장애가 터졌다. 로그를 봤다. 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;
}
}
}
로그에 남기면 안 되는 정보
보안과 개인정보보호를 위해 다음 정보는 절대 로그에 남기면 안 됩니다:
- 비밀번호, JWT 토큰 전체 값
- 신용카드 번호, 주민등록번호
- 개인 연락처, 상세 주소
- API 키, Secret Key
// 민감 데이터 마스킹 예시
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(", "));
}
로깅 전략이 장애 대응 시간을 줄여준 이야기
좋은 로그는 운영 안정성의 핵심입니다. 프로젝트 초기에 로깅 전략을 수립하고, 팀 전체가 일관된 로깅 규칙을 따르면 장애 대응 시간을 크게 단축할 수 있습니다.
특히 MDC를 활용한 요청 추적은 분산 환경에서 반드시 도입해야 할 패턴입니다. 한 가지 당부드리고 싶은 것은, 로깅 규칙을 문서로만 만들어놓지 말고 코드 리뷰 시 실제로 체크하는 항목에 포함시키라는 것입니다. 저희 팀에서는 PR 체크리스트에 "주요 비즈니스 로직에 적절한 로그가 포함되어 있는가"를 추가한 이후로 로그 품질이 눈에 띄게 좋아졌습니다.
10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.
GitHub →
💬 댓글