장애가 터졌다. 로그를 봤다. System.out.println('여기 옴'). 실화다. 운영 서버에서 이런 로그를 발견했을 때의 허탈함이란. '누가 이렇게 짠 거야?'라고 git blame을 돌렸더니... 6개월 전의 내 커밋이었다. 그날부터 로깅 전략을 제대로 세우기 시작했다.

로깅의 기본 원칙

1. 적절한 로그 레벨 사용

로그 레벨을 올바르게 사용하지 않으면, 중요한 정보가 대량의 불필요한 로그에 묻히게 됩니다.

// ERROR: 즉시 대응이 필요한 시스템 장애
log.error("DB 연결 실패: {}", e.getMessage(), e);

// WARN: 잠재적 문제, 비정상 동작이지만 시스템은 동작
log.warn("API 응답 시간 초과: {}ms (임계값: {}ms)", elapsed, threshold);

// INFO: 주요 비즈니스 이벤트 기록
log.info("주문 생성 완료: orderId={}, userId={}, amount={}",
    order.getId(), userId, order.getTotalAmount());

// DEBUG: 개발/디버깅 시 필요한 상세 정보
log.debug("쿼리 파라미터: {}", params);

// TRACE: 매우 상세한 실행 흐름 (운영에서는 거의 사용하지 않음)
log.trace("메서드 진입: processOrder({})", orderId);

2. 구조화된 로그 메시지

로그 메시지에 검색 가능한 키-값 쌍을 포함하면 로그 분석이 훨씬 용이합니다.

// 잘못된 예시 - 검색/파싱이 어려움
log.info("사용자 " + userId + "가 상품 " + productId + "를 주문했습니다.");

// 올바른 예시 - 구조화된 메시지
log.info("주문 처리 완료 [userId={}, productId={}, orderId={}, amount={}]",
    userId, productId, orderId, amount);

MDC(Mapped Diagnostic Context) 활용

MDC는 같은 요청에 대한 모든 로그를 하나의 추적 ID로 연결해주는 기능입니다. 분산 환경에서 특정 요청의 전체 처리 흐름을 추적할 때 필수적입니다.

@Component
public class RequestTraceFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {

        String traceId = UUID.randomUUID().toString().substring(0, 8);
        String userId = extractUserId((HttpServletRequest) request);

        MDC.put("traceId", traceId);
        MDC.put("userId", userId != null ? userId : "anonymous");

        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();  // 반드시 정리!
        }
    }
}

Logback 설정에서 MDC 값을 로그 패턴에 포함합니다:

<!-- logback-spring.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{userId}] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>
</configuration>

이렇게 설정하면 모든 로그에 traceId가 포함되어, 특정 요청의 전체 처리 과정을 한눈에 볼 수 있습니다:

2025-04-01 10:30:15.123 [http-nio-8080-exec-1] [a1b2c3d4] [user123] INFO  OrderController - 주문 요청 수신 [productId=100, quantity=2]
2025-04-01 10:30:15.145 [http-nio-8080-exec-1] [a1b2c3d4] [user123] DEBUG OrderService - 재고 확인 [productId=100, stock=50]
2025-04-01 10:30:15.200 [http-nio-8080-exec-1] [a1b2c3d4] [user123] INFO  OrderService - 주문 생성 완료 [orderId=5001, amount=50000]

운영 환경 Logback 설정

환경별로 다른 로그 설정을 적용하는 것이 중요합니다. Spring Profile을 활용하여 환경별 설정을 분리합니다.

<configuration>
  <!-- 공통 설정 -->
  <property name="LOG_PATH" value="./logs" />
  <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n" />

  <!-- 개발 환경 -->
  <springProfile name="local,dev">
    <root level="DEBUG">
      <appender-ref ref="CONSOLE" />
    </root>
  </springProfile>

  <!-- 운영 환경 -->
  <springProfile name="prod">
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
      <file>${LOG_PATH}/application.log</file>
      <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>
        <maxHistory>30</maxHistory>
        <totalSizeCap>3GB</totalSizeCap>
      </rollingPolicy>
      <encoder>
        <pattern>${LOG_PATTERN}</pattern>
      </encoder>
    </appender>

    <!-- ERROR 전용 파일 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
      <filter class="ch.qos.logback.classic.filter.LevelFilter">
        <level>ERROR</level>
        <onMatch>ACCEPT</onMatch>
        <onMismatch>DENY</onMismatch>
      </filter>
      <file>${LOG_PATH}/error.log</file>
      <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
        <maxFileSize>50MB</maxFileSize>
        <maxHistory>90</maxHistory>
      </rollingPolicy>
      <encoder>
        <pattern>${LOG_PATTERN}</pattern>
      </encoder>
    </appender>

    <root level="INFO">
      <appender-ref ref="FILE" />
      <appender-ref ref="ERROR_FILE" />
    </root>
  </springProfile>
</configuration>

API 요청/응답 로깅

API의 요청과 응답을 로깅하면 문제 추적이 훨씬 수월합니다. 다만 민감한 정보는 마스킹해야 합니다.

@Aspect
@Component
@Slf4j
public class ApiLoggingAspect {

    @Around("@within(org.springframework.web.bind.annotation.RestController)")
    public Object logApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().toShortString();

        log.info("API 요청: {} args={}", methodName,
            maskSensitiveData(joinPoint.getArgs()));

        try {
            Object result = joinPoint.proceed();
            long elapsed = System.currentTimeMillis() - startTime;

            log.info("API 응답: {} elapsed={}ms", methodName, elapsed);

            if (elapsed > 3000) {
                log.warn("느린 API 감지: {} elapsed={}ms", methodName, elapsed);
            }

            return result;
        } catch (Exception e) {
            long elapsed = System.currentTimeMillis() - startTime;
            log.error("API 오류: {} elapsed={}ms error={}",
                methodName, elapsed, e.getMessage());
            throw e;
        }
    }
}

로그에 남기면 안 되는 정보

보안과 개인정보보호를 위해 다음 정보는 절대 로그에 남기면 안 됩니다:

// 민감 데이터 마스킹 예시
private String maskSensitiveData(Object[] args) {
    return Arrays.stream(args)
        .map(arg -> {
            if (arg == null) return "null";
            String str = arg.toString();
            // 이메일 마스킹
            str = str.replaceAll("([\\w.]+)@([\\w.]+)", "***@$2");
            // 전화번호 마스킹
            str = str.replaceAll("\\d{3}-\\d{4}-\\d{4}", "***-****-****");
            return str;
        })
        .collect(Collectors.joining(", "));
}
실무 경험 공유: 구조화 로깅을 도입하기 전과 후의 차이는 숫자로 명확하게 나타났습니다. 이전에는 장애가 발생하면 여러 서버의 로그 파일을 grep으로 뒤지면서 원인을 파악하는 데 평균 2시간이 걸렸습니다. trace ID 기반의 구조화 로깅을 도입한 이후에는 Kibana에서 trace ID 하나만 검색하면 해당 요청의 전체 흐름이 한눈에 보이게 되었고, 장애 원인 파악 시간이 평균 15분으로 줄었습니다. 물론 구조화된 로그 형식 때문에 로그 용량이 기존 대비 약 30% 늘어났지만, 장애 대응 시간 단축과 디버깅 효율성을 생각하면 충분히 그만한 가치가 있었습니다. 팀 내에서 "로그는 미래의 나를 위한 편지"라는 말이 유행할 정도로, 로깅에 대한 인식이 완전히 바뀌었습니다.

로깅 전략이 장애 대응 시간을 줄여준 이야기

좋은 로그는 운영 안정성의 핵심입니다. 프로젝트 초기에 로깅 전략을 수립하고, 팀 전체가 일관된 로깅 규칙을 따르면 장애 대응 시간을 크게 단축할 수 있습니다.

특히 MDC를 활용한 요청 추적은 분산 환경에서 반드시 도입해야 할 패턴입니다. 한 가지 당부드리고 싶은 것은, 로깅 규칙을 문서로만 만들어놓지 말고 코드 리뷰 시 실제로 체크하는 항목에 포함시키라는 것입니다. 저희 팀에서는 PR 체크리스트에 "주요 비즈니스 로직에 적절한 로그가 포함되어 있는가"를 추가한 이후로 로그 품질이 눈에 띄게 좋아졌습니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글