로깅(Observability) 완전 정리
장애가 나면 어디부터 봐야 할까?
운영 환경에서는 디버거를 붙일 수 없습니다. 결국 우리에게 남는 건 로그, 메트릭, 트레이스입니다. 왜 로깅이 필요한지, 언제 무엇을 봐야 하는지, 실무에서 어떻게 써야 하는지를 백엔드 개발자 관점에서 한 번에 정리합니다.
왜 Observability가 필요한가
금요일 오후 6시, 퇴근 직전 슬랙에 메시지가 옵니다.
이때 무작정 코드를 뒤지는 것부터 시작하면 시간이 오래 걸립니다. 운영 환경에서는 브레이크포인트를 걸고 한 줄씩 따라갈 수 없기 때문입니다. 대신 우리는 시스템이 남긴 흔적을 따라가야 합니다.
무슨 일이 있었는가
에러 메시지, 주문 번호, 사용자 ID, 처리 시간처럼 사건의 맥락을 보여줍니다.
지금 이상한가
에러율, 응답시간, 트래픽, CPU처럼 숫자로 장애 징후를 빠르게 감지합니다.
어디서 느렸는가
한 요청이 어떤 서비스와 구간을 지나며 어디서 병목이 났는지 보여줍니다.
결국 Observability의 핵심은 단순합니다. 어디까지 성공했고, 어디서 실패했는지 빠르게 좁히는 것입니다.
Monitoring vs Observability
면접에서도 자주 나오는 질문이죠. 많은 분들이 둘을 비슷하게 생각하지만, 실무에서는 분명한 차이가 있습니다.
| 구분 | Monitoring | Observability |
|---|---|---|
| 핵심 질문 | 지금 문제가 있는가? | 왜 문제가 생겼는가? |
| 주요 데이터 | 메트릭, 알람 | 메트릭, 로그, 트레이스 |
| 대표 예시 | 에러율 5% 초과 알람 | PG timeout이 어디서 났는지 추적 |
| 목적 | 감지 | 원인 분석 |
🔔 Monitoring
자동차 계기판의 경고등과 비슷합니다. "문제가 있다"는 사실은 알려주지만, 왜 생겼는지는 알려주지 못합니다.
🔬 Observability
정비사가 엔진, 팬, 오일 상태를 보며 원인을 좁혀가는 과정과 비슷합니다. 문제의 내부 상태를 이해할 수 있게 해주는 능력입니다.
Metrics / Logs / Traces는 언제 써야 할까?
실무에서는 세 가지를 경쟁 관계로 보지 않습니다. 각자 답하는 질문이 다르기 때문에 함께 써야 합니다.
| 도구 | 한마디로 | 답하는 질문 | 언제 가장 유용한가 |
|---|---|---|---|
| Metrics | 숫자 | 얼마나 느린가? 얼마나 실패하는가? | 장애 징후를 가장 먼저 볼 때 |
| Logs | 사건 기록 | 그 순간 무슨 일이 있었나? | 실패 요청의 맥락과 에러 원인을 확인할 때 |
| Traces | 요청 경로 | 어느 구간에서 오래 걸렸나? | MSA, 외부 API 호출, 병목 구간 분석 시 |
- 에러율이 갑자기 튀었는지 보고 싶을 때
- 응답시간이 평소보다 느려졌는지 확인할 때
- CPU, 메모리, DB 커넥션이 포화 상태인지 볼 때
- 어떤 주문, 어떤 사용자에서 실패했는지 확인할 때
- 실제 에러 메시지와 에러 코드를 보고 싶을 때
- 스택 트레이스로 코드 레벨 원인을 좁힐 때
- Order → Payment → PG 중 어디가 느린지 알고 싶을 때
- MSA에서 서비스 간 호출 흐름을 한눈에 보고 싶을 때
- 외부 API, Kafka, 비동기 구간 병목을 분석할 때
장애 분석은 위에서 아래로 내려갑니다
평균보다 p95, p99를 보는 이유
평균 응답시간은 종종 거짓말을 합니다. 95명은 200ms에 응답받고, 5명은 10초를 기다려도 평균은 "그럭저럭"처럼 보일 수 있습니다. 그래서 실무에서는 p95, p99 같은 백분위수를 더 중요하게 봅니다.
| 지표 | 의미 | 실무 해석 |
|---|---|---|
| p50 | 50% 사용자가 이 안에 응답받음 | 보통 사용자 경험 |
| p95 | 95% 사용자가 이 안에 응답받음 | 느린 사용자 5%의 경계 |
| p99 | 99% 사용자가 이 안에 응답받음 | 최악에 가까운 사용자 경험 |
좋은 로그는 어떻게 남겨야 할까?
로그는 많이 찍는 게 핵심이 아닙니다. 검색 가능하게, 맥락 있게, 원인을 좁힐 수 있게 남겨야 합니다.
❌ 나쁜 로그
try {
payment.process(order);
} catch (Exception e) {
log.error("결제 실패");
}
- 어떤 주문인지 모릅니다
- 어떤 사용자인지 모릅니다
- 왜 실패했는지 모릅니다
- 얼마나 걸렸는지 모릅니다
✅ 좋은 로그
try {
payment.process(order);
} catch (PaymentException e) {
log.error(
"Payment failed. orderId={}, userId={}, errorCode={}, elapsedMs={}",
order.getId(), order.getUserId(),
e.getErrorCode(), elapsed, e
);
}
- 업무 맥락(orderId, userId)이 있습니다
- 원인 분류(errorCode)가 가능합니다
- 성능 분석(elapsedMs)이 가능합니다
구조화 로그를 쓰면 더 좋아집니다
{
"level": "ERROR", "service": "payment-service",
"traceId": "trc-20260515-001", "orderId": "ORD-1004",
"userId": "U-77", "message": "External payment API timeout",
"elapsedMs": 3200, "errorCode": "PG_TIMEOUT"
}
좋은 로그의 5가지 조건
| 조건 | 왜 필요한가 | 예시 필드 |
|---|---|---|
| 식별 가능 | 검색해서 원하는 요청을 찾아야 함 | traceId, requestId |
| 맥락 보유 | 어떤 업무에서 발생했는지 알아야 함 | userId, orderId |
| 출처 명확 | 어느 서비스의 문제인지 알아야 함 | serviceName |
| 원인 분류 | 알람/통계/패턴 분석에 필요 | errorCode |
| 시간 측정 | 느린 요청과 성능 저하를 잡아야 함 | elapsedMs |
로그 레벨 구분
| 레벨 | 언제 쓰는가 | 예시 |
|---|---|---|
| ERROR | 사용자 요청이 실제로 실패했을 때 | 결제 실패, DB 연결 실패 |
| WARN | 실패는 아니지만 비정상 패턴일 때 | 재시도 후 성공, 임계치 근접 |
| INFO | 중요한 비즈니스 이벤트를 남길 때 | 주문 생성, 결제 완료 |
| DEBUG | 개발/테스트 환경에서 내부 흐름을 볼 때 | 변수 값, 상세 분기 로직 |
장애가 났을 때 어디부터 봐야 할까?
실무에서 가장 중요한 건 순서입니다. 요청 흐름을 따라 어디까지 성공했는지 확인해야 합니다.
요청이 들어왔는지 확인
access log, API Gateway, Load Balancer 로그를 봅니다.
규모 파악
Metrics에서 에러율, p95 응답시간, 특정 인스턴스/API 문제인지 먼저 봅니다.
문제 요청 식별
traceId, requestId, orderId, userId 중 하나를 확보합니다.
흐름 따라 좁히기
Controller → Service → DB → 외부 API → 응답 순서대로 확인합니다.
MSA라면 서비스 간 로그 연결
traceId로 order-service, payment-service 로그를 한 화면에서 이어 봅니다.
원인 후보 정리
코드, DB, 외부 API, 네트워크, 메시징, 리소스 포화 중 원인을 확정합니다.
결제 실패 시나리오 예시
[14:23:01] POST /orders 수신
[14:23:01] order-service: 주문 생성 시작 (orderId=ORD-1004)
[14:23:01] order-service: 주문 저장 성공 (120ms)
[14:23:04] payment-service: 외부 PG API 호출 시작
[14:23:07] payment-service: PG_TIMEOUT (3,200ms)
[14:23:07] order-service: 결제 실패 응답
MSA에서 Trace ID가 왜 중요한가?
모놀리식에서는 한 서버의 로그만 보면 됐지만, MSA에서는 같은 요청의 로그가 여러 서비스에 흩어집니다.
Client → Backend → DB
로그가 한곳에 모여 있어 비교적 추적이 쉽습니다.
Client → Order → Payment → Coupon → Notification
같은 요청의 로그가 서비스별로 분산되어 공통 식별자가 필수입니다.
이때 필요한 것이 Trace ID입니다. 사용자 요청 하나가 시작될 때 고유 ID를 만들고, 그 요청이 거치는 모든 서비스 로그에 같은 값을 남깁니다.
order-service | traceId=trc-001 | 주문 생성
payment-service | traceId=trc-001 | 결제 실패
coupon-service | traceId=trc-001 | 쿠폰 사용
notification-service | traceId=trc-001 | 알림 발송 실패
표준 관점에서는 W3C Trace Context의 traceparent 헤더를 많이 사용합니다. 표준을 따르면 도구 간 연동이 쉬워집니다.
Spring Boot에서 MDC로 traceId 자동 부착하기
매번 로그를 찍을 때마다 traceId를 직접 넘기면 코드가 지저분해집니다. 이때 사용하는 것이 MDC (Mapped Diagnostic Context)입니다.
요청 단위 공통 값 자동 출력
traceId, requestId 등을 스레드 컨텍스트에 넣어두고 로그 패턴에서 자동으로 출력합니다.
MSA 로그 연결이 필요할 때
Spring Boot API 서버에서 요청 단위 추적이 필요할 때 매우 유용합니다.
// 1. 요청 입구에서 traceId 생성 또는 이어받기
MDC.put("traceId", UUID.randomUUID().toString());
// 2. 이후 로그에는 자동으로 traceId 부착
log.info("Order created. orderId={}", order.getId());
// 3. 요청 종료 시 반드시 정리
MDC.clear();
Filter에서 처리하는 예시
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(
HttpServletRequest req, HttpServletResponse res, FilterChain chain
) throws ServletException, IOException {
try {
String traceId = req.getHeader(TRACE_HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString();
}
MDC.put("traceId", traceId);
res.setHeader(TRACE_HEADER, traceId);
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}
@Async, CompletableFuture, Kafka Consumer 같은 구간에서는 traceId 전파 전략을 별도로 고려해야 합니다.traceId만으로 부족할 때: Span
Trace ID: trc-001
├─ 주문 조회 20ms
├─ 쿠폰 검증 80ms
├─ 결제 요청 3200ms ← 병목
│ ├─ 카드사 통신 3150ms
│ └─ 결과 저장 30ms
└─ 알림 발송 90ms
Kafka도 관찰 대상입니다
- 메시지 발행 시 traceId를 헤더에 담아 전파
- Consumer 로그에도 같은 traceId 기록
- lag 증가, 재처리 횟수, DLQ 적재량도 함께 모니터링
절대 로그에 남기면 안 되는 정보
특히 개인정보와 인증 정보는 절대 평문으로 남기면 안 됩니다.
- 비밀번호
- 주민등록번호
- 카드번호 전체 / CVC / CVV
- JWT, Access Token, Refresh Token
- 세션 ID
- 계좌번호, 의료 정보
log.info("Request received: {}", requestDto); // ← 절대 금지
요청 객체를 통째로 찍으면 password, token, cardNumber 같은 민감 정보가 함께 남을 수 있습니다.
안전한 방식: 필요한 값만 선택적으로 기록
log.info("Payment request received. traceId={}, orderId={}, userId={}, amount={}",
traceId, orderId, maskedUserId, order.getAmount());
마스킹 예시
// 원본: 1234-5678-9012-3456 → 결과: 1234-****-****-3456
String masked = cardNumber.substring(0, 4) + "-****-****-" + cardNumber.substring(15);
실무에서는 어떤 도구를 조합할까?
중요한 건 도구 이름을 외우는 게 아니라, 각 도구가 어떤 역할을 맡는지 이해하는 것입니다.
| 영역 | 대표 도구 | 역할 |
|---|---|---|
| 계측 표준 | OpenTelemetry | 로그, 메트릭, 트레이스를 표준 방식으로 생성/전달 |
| 메트릭 저장소 | Prometheus | 시계열 지표 저장 |
| 로그 저장소 | Loki, ELK | 로그 검색/집계/분석 |
| 트레이스 저장소 | Jaeger, Zipkin, Tempo | 분산 추적 및 병목 확인 |
| 시각화 | Grafana, Kibana | 대시보드, 검색, 알람 |
| 에러 트래킹 | Sentry | 에러 그룹화, 스택 추적, 영향 범위 확인 |
| 클라우드 기본 도구 | CloudWatch, Azure Monitor | 기본 로그/메트릭/알람 운영 |
회사 규모별로 자주 보이는 조합
실무 체크리스트
운영 환경에서 바로 써먹을 수 있는 기준만 짧게 정리합니다.
- traceId / requestId가 있는가
- userId / orderId 같은 업무 맥락이 있는가
- serviceName, errorCode가 있는가
- elapsedMs를 남기고 있는가
- Exception 객체를 마지막 인자로 넘기고 있는가
- 민감 정보를 마스킹하거나 제외했는가
- 요청이 실제로 들어왔는지 먼저 확인
- Metrics로 범위와 규모를 파악
- 문제 요청 하나를 선정
- traceId로 관련 로그 연결
- 어디까지 성공했는지 순서대로 확인
- DB / 외부 API / 네트워크 / 코드 중 원인 확정
마무리
운영 환경에서 로그는 단순한 출력문이 아닙니다. 메트릭은 문제가 있다고 알려주고, 트레이스는 어디가 느린지 보여주며, 로그는 정확히 무슨 일이 있었는지 말해줍니다.
백엔드 개발자가 성장할수록 중요한 건 기능 구현 속도만이 아닙니다. 문제가 생겼을 때 빠르게 복구할 수 있는 시스템을 만드는 능력, 그게 바로 Observability입니다.
'MSA' 카테고리의 다른 글
| Terraform 입문클릭으로 만들던 인프라를 코드로 관리하기 (0) | 2026.05.15 |
|---|---|
| OpenFeign 공식 문서 ,선언적 HTTP 클라이언트의 모든 것 (0) | 2026.05.15 |
| API 게이트웨이 (0) | 2026.04.15 |
| 서킷브레이커 (1) | 2026.04.14 |
| 로드밸런싱 (0) | 2026.04.14 |