Observability · Operations

Observability는
나중에 덧붙이는 게 아니라 설계 결정이다

로깅 라이브러리를 붙이는 건 쉽다. 어려운 건 무엇을 어느 레벨로, 어느 계층에서 남길지, 요청 하나를 십여 줄의 로그 사이에서 어떻게 따라갈지 정하는 일이다. 장애 원인을 5분 만에 찾느냐 5시간 걸려 찾느냐는 여기서 갈린다.

Observability는 흔히 나중에 붙이는 인프라로 여겨진다. 로깅 라이브러리 하나 고르고 대시보드를 연결하면 끝이라는 식이다. 그런데 정말 중요한 건 도구보다 결정이다. 무엇을 어느 레벨로 남길지, 어느 계층이 무엇을 로깅할지 정해야 한다. 요청 하나가 여러 프로세스의 로그 수십 줄로 흩어진 뒤에도 그 요청이 어떻게 흘러갔는지 따라갈 수 있어야 한다.

레벨은 다섯 개, 엄격하게 지킨다

error는 요청 처리 실패와 외부 시스템 장애에 쓴다. DB 연결 실패, 외부 API의 5xx 응답, 처리되지 않은 예외가 여기에 든다. warn은 정상 동작이지만 눈여겨봐야 하는 경우다. deprecated 엔드포인트 호출, 재시도, 임계치에 가까워지는 상황 같은 것이다. log는 주요 비즈니스 이벤트와 상태 변화를 남긴다. 주문 생성, 결제 완료, 앱 시작과 종료가 그렇다. debug는 쿼리 파라미터나 중간 계산 결과처럼 개발할 때 보는 상세 정보다. verbose는 요청과 응답 페이로드 전체처럼 가장 자세한 정보다.

운영 환경에서는 error, warn, log만 남기고, 개발과 스테이징에서는 전부 남긴다. 운영에서 쓸데없는 로그를 남기면 돈만 드는 게 아니다. 가장 빨리 찾아야 할 순간에 중요한 로그가 노이즈에 묻힌다.

누가 무엇을 로깅하나

Interface 계층(Controller)은 catch 블록에서 잡은 요청 에러를 로깅한다. Application 계층은 비즈니스 이벤트와 외부 시스템 호출 결과를 로깅한다. Infrastructure 계층은 외부 연동 실패와 재시도, 비정상적인 쿼리 성능을 로깅한다. Domain 계층은 어떤 경우에도 로깅하지 않는다. 프레임워크에 기대지 않아야 하기 때문이다. 도메인 로직의 결과는 그걸 호출한 한 단계 위, Application 계층이 로깅한다.

// forbidden — using a logger/framework in the Domain layer
import org.slf4j.Logger;          // forbidden
import org.slf4j.LoggerFactory;   // forbidden

public class Order {
    private static final Logger log = LoggerFactory.getLogger(Order.class);  // forbidden

    public void cancel(String reason) {
        log.info("Order cancelled");  // forbidden
        ...
    }
}

순수성을 위한 순수성이 아니다. Domain 계층이 로깅을 하면 가지면 안 되는 프레임워크 의존성이 생긴다. 그러면 도메인 단위 테스트마다 로거를 모킹하거나, 원하지도 않은 로그 노이즈를 견뎌야 한다.

구조화된 로그와 필드 이름

Datadog, CloudWatch, Grafana Loki 같은 외부 모니터링 시스템과 연동한다면 로그는 구조화된 JSON이어야 하고, 필드 이름은 snake_case로 쓴다.

// a business-event log
log.info("Order created", kv("order_id", orderId), kv("user_id", userId), kv("amount", amount));

// an error log
log.error("SQS send failed", kv("event_id", event.getEventId()), e);

camelCase 대신 굳이 snake_case를 쓰는 이유는 멋은 없지만 분명하다. 대부분의 모니터링 플랫폼이 기본으로 snake_case 필드를 파싱한다. 필드 이름이 안 맞으면 보기에 들쭉날쭉한 것으로 끝나지 않는다. 인덱싱이 에러 없이 깨지고, 특정 order_id의 로그를 전부 찾아야 할 쿼리가 아무것도 돌려주지 않는다.

Correlation ID로 요청 하나를 끝까지 따라가기

요청 하나를 여러 서비스의 로그에 걸쳐 따라가려면 모든 로그 항목에 Correlation ID가 들어가야 한다. 클라이언트가 x-correlation-id 헤더를 보내면 그 값을 그대로 쓰고, 없으면 서버가 만든다. 이 헤더는 하위 호출마다 넘겨주고, 응답에도 실어 보낸다.

ID는 요청이 들어오는 지점, 즉 Interface 계층의 Servlet Filter에서 만들거나 꺼낸다. 그리고 SLF4J의 MDC(Mapped Diagnostic Context)에 넣어 전파한다. MDC는 ThreadLocal 기반의 맵이고, Logback JSON 인코더가 알아서 읽어 간다. 그래서 뒤쪽 계층 어디서든 메서드 시그니처에 인자를 하나도 늘리지 않고 현재 요청의 Correlation ID를 쓸 수 있다.

// at request entry — a Filter in the Interface layer
String correlationId = Optional.ofNullable(request.getHeader("X-Correlation-Id"))
        .orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
MDC.put("correlation_id", correlationId);
try {
    chain.doFilter(request, response);
} finally {
    MDC.remove("correlation_id");
}

// when logging, anywhere downstream — no argument needed, MDC is read automatically
log.info("Order created");

인증에서 쓰는 request-scoped 사용자 컨텍스트 패턴과 똑같은 모양이다. 값은 요청 입구에서 딱 한 번 만들고, 나머지 모든 곳에서는 request 객체를 넘겨받지 않고 스토리지에서 읽는다. 현재 사용자가 누구인지와 이 로그를 만든 요청이 무엇인지는 서로 다른 문제다. 그래도 둘을 같은 방식으로 푼 건 의도한 것이다.

메트릭과 트레이싱은 방향만 정한다

특정 스택 하나에 묶을 얘기는 아니지만, 어떤 스택을 쓰든 알림을 걸어 둘 만한 지표가 몇 가지 있다. HTTP 5xx 비율, p99 응답 시간, DB 커넥션 풀 포화도, 0보다 커진 메시지 큐 DLQ 적재량, 그리고 큐의 ApproximateAgeOfOldestMessage다. 마지막 지표는 요청이 실패하고 있다는 걸 누가 알아채기 훨씬 전에 멈춘 consumer를 잡아낸다.

트레이싱은 OpenTelemetry auto-instrumentation을 쓰면 손을 거의 대지 않고 HTTP, DB, 메시지 큐 span을 모을 수 있다. Task Queue나 Integration Event 같은 비동기 경계에서는 Outbox 페이로드에 traceparent를 넣으면 trace context가 그 틈을 건너간다. 그러면 HTTP 요청과 몇 초, 몇 분 뒤에 일어난 이벤트 처리가 따로 노는 두 trace로 갈라지지 않고 하나의 trace로 이어진다. 로그 레코드에 trace_id를 넣어 두면 trace에서 그 로그로 바로 건너갈 수 있다. "뭔가 느려졌다"에서 멈추느냐, "어느 쿼리가 느려졌는지"까지 보이느냐가 대개 여기서 갈린다.

모든 걸 묶는 원칙 하나

예외를 다시 던지기(rethrow) 전에 catch 블록에서 반드시 로깅한다. 어차피 위로 올라갈 예외라고 해서 로그 없이 삼키면 안 된다. 호출하는 쪽에서 보면, 말없이 삼킨 예외와 제대로 다시 던진 예외는 똑같아 보인다. 나중에 뭔가 잘못됐었다는 걸 알려 주는 건 로그뿐이다.

더 볼 자료

docs/architecture/observability.md(같은 백엔드 설계(DDD, CQRS, Outbox)를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트의 로그 레벨 정책 전체와 메트릭·트레이싱 메모) · docs/architecture/cross-cutting-concerns.md(요청 파이프라인 어디에서 Correlation ID를 넣는지)