Spring 기반 Java 애플리케이션에서 로깅을 구현할 때 SLF4J와 Logback 조합이 표준으로 자리 잡았다. Logback은 유연한 설정과 성능으로 많은 개발자들이 선택하는 로깅 프레임워크다. 이번 글에서는 로그가 실제 출력되기 전에 내용을 가공하는 방법을 살펴본다. 특히 개인정보 보호를 위한 마스킹 처리를 예시로 다룬다.
Logback 동작 흐름 분석
개발자가 log.info("사용자 이메일: user@example.com") 같은 코드를 실행하면 Logback 내부에서는 다음과 같은 단계를 거친다.
1단계: 터보 필터 체인 실행
전역적으로 적용되는 TurboFilter가 먼저 동작한다. Marker, Level, Logger 이름, 메시지 내용, 예외 객체 등을 기준으로 로그 이벤트를 선별한다. DENY 응답이면 즉시 기, ACCEPT면 3단계로 건너뛰고, NEUTRAL이면 다음 단계로 진행한다.
2단계: 로거 레벨 비교
요청된 로그 레벨이 로거의 유효 레벨보다 낮으면 이벤트가 버려진다.
3단계: LoggingEvent 객체 생성
필터를 통과한 이벤트는 ch.qos.logback.classic.LoggingEvent 객체로 포장된다. 이 객체에는 타임스탬프, 스레드 정보, MDC 컨텍스트, 예외 스택트레이스 등이 포함된다.
4단계: Appender 호출
Logger에 연결된 모든 Appender의 doAppend() 메서드가 순차적으로 실행된다. 이 시점에서 Appender 전용 필터가 추가로 적용될 수 있다.
5단계: 레이아웃 포맷팅
Appender는 Layout에게 이벤트 포맷팅을 위임한다. PatternLayout을 사용하는 경우 PatternLayoutEncoder가 LoggingEvent를 문자열로 변환한다.
6단계: 최종 출력
완성된 문자열이 파일, 콘솔, 원격 서버 등 지정된 목적지로 전송된다.
메시지 변환 지점 식별
로그 메시지가 최종 문자열로 변환되는 5단계에서 핵심 역할을 하는 것이 ClassicConverter의 하위 클래스들이다. 기본 메시지 변환은 MessageConverter가 담당한다.
public class MessageConverter extends ClassicConverter {
@Override
public String convert(ILoggingEvent evt) {
return evt.getFormattedMessage();
}
}
이 클래스는 단순히 원본 메시지를 그대로 반환한다. 여기서 상속을 받아 convert 메서드를 재정의하면 출력 직전에 임의의 변환 로직을 주입할 수 있다.
마스킹 변환기 구현
다음은 실제 운영 환경에서 사용할 수 있는 마스킹 변환기 구현이다. 정규식 기반 패턴 매칭으로 민감 데이터를 탐지하고, 긴 로그에 대한 길이 제한도 함께 적용한다.
public class PrivacySafeConverter extends ClassicConverter {
private static final int MAX_LOG_LENGTH = 8192;
private final Pattern mobilePattern = Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})");
private final Pattern emailPattern = Pattern.compile("(\\w{2})\\w+(@\\w+\\.\\w+)");
private final Pattern idCardPattern = Pattern.compile("(\\d{6})\\d{8}(\\d{4})");
@Override
public String convert(ILoggingEvent evt) {
String rawMessage = evt.getFormattedMessage();
if (rawMessage == null) {
return "";
}
try {
String truncated = truncateIfNeeded(rawMessage);
return applyMasking(truncated);
} catch (Exception ex) {
// 변환 실패 시 원본 반환으로 graceful degradation
return rawMessage;
}
}
private String truncateIfNeeded(String text) {
if (text.length() <= MAX_LOG_LENGTH) {
return text;
}
return text.substring(0, MAX_LOG_LENGTH) + "...[truncated]";
}
private String applyMasking(String text) {
String masked = mobilePattern.matcher(text)
.replaceAll("$1****$2");
masked = emailPattern.matcher(masked)
.replaceAll("$1***$2");
masked = idCardPattern.matcher(masked)
.replaceAll("$1********$2");
return masked;
}
}
비동기 Appender를 사용하더라도 변환 로직은 로깅 스레드에서 실행된다. Logback의 AsyncAppenderBase는 BlockingQueue에 이벤트를 넣기 전에 이미 포맷팅이 완료된 문자열을 사용하지 않는다. 따라서 무거운 정규식 연산이 로그 출력 지연을 유발할 수 있으므로, 복잡한 패턴은 피하거나 캐싱을 고려해야 한다.
설정 파일 등록
커스텀 변환기를 활성화하려면 XML 설정 파일의 <configuration> 루트 하위에 <conversionRule> 태그를 추가한다. 이 선언은 Appender 정의보다 앞에 위치해야 한다.
<configuration>
<!-- 반드시 상단에 배치: 로딩 순서 문제 방지 -->
<conversionRule
conversionWord="safeMsg"
converterClass="com.example.logging.PrivacySafeConverter"/>
<property name="LOG_PATH" value="/var/log/application"/>
<appender name="asyncFile" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>5000</queueSize>
<discardingThreshold>20</discardingThreshold>
<appender-ref ref="rollingFile"/>
</appender>
<appender name="rollingFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/app.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %safeMsg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="asyncFile"/>
</root>
</configuration>
conversionWord에 정의한 safeMsg를 패턴 문자열에서 %safeMsg 형태로 참조하면 된다. 기존 %msg 대신 사용하면서도 다른 변환 지시자들과 자유롭게 조합할 수 있다.
확장 가능한 구조 설계
마스킹 규이 늘어날 경우 전략 패턴을 적용해 유연하게 확장할 수 있다.
public interface MaskingStrategy {
String mask(String input);
}
@Component
public class CompositeMaskingConverter extends ClassicConverter {
private final List<MaskingStrategy> strategies;
public CompositeMaskingConverter(List<MaskingStrategy> strategies) {
this.strategies = strategies;
}
@Override
public String convert(ILoggingEvent evt) {
String result = evt.getFormattedMessage();
for (MaskingStrategy strategy : strategies) {
result = strategy.mask(result);
}
return result;
}
}
이 방식으로 금융 정보, 의료 정보 등 도메인별 마스킹 로직을 모듈화하고, Spring의 구성 가능한 빈 주입을 통해 런타임에 조합할 수 있다.