[BlerOn] Logging 설정

Published: by Creative Commons Licence

BlerOn Logging 설정

BlerOn Admin Backend(Spring Boot 3.3.4 / Java 21) 프로젝트의 로깅 설정을 정리한 글입니다. 운영 환경에서 로그는 "장애 분석의 1차 자료"이기 때문에, 아래 3가지 관점으로 나누어 설계했습니다.

  1. profile(local/dev/production)별 로깅 설정 : 환경마다 다른 로그 레벨/출력 대상
  2. 로깅 관련 라이브러리와 설정 : Logback, SLF4J, log4jdbc, Micrometer Tracing 등
  3. LogAspect(AOP) 설정 : 모든 컨트롤러의 요청/응답을 공통으로 기록

1. 로깅 아키텍처 한눈에 보기

[HTTP 요청]
   │
   ▼
TraceIdMdcFilter   ──▶ MDC 에 traceId, clientIp 저장
   │
   ▼
LoggingAspect(AOP) ──▶ MDC 에 userId, memberSeq 저장 + REQ/RES 로그 기록
   │
   ▼
Controller → Service → Repository
   │                      │
   │                      ▼
   │                  log4jdbc ──▶ 실제 실행 SQL 로그 (바인딩 값 포함)
   ▼
Logback (logback-spring.xml)
   ├─ CONSOLE Appender
   ├─ FILE Appender (롤링)
   └─ ERROR_FILE Appender (ERROR만)
  • SLF4J : 로깅 API(인터페이스) 역할. 코드에서는 log.info() 형태로만 사용
  • Logback : SLF4J의 실제 구현체. Spring Boot의 기본 로깅 엔진
  • MDC(Mapped Diagnostic Context) : 스레드별 보관소. traceId/userId 같은 "요청 단위 정보"를 로그 패턴에 자동으로 끼워 넣음

핵심 아이디어: 코드에서는 SLF4J로만 로그를 찍고, 출력 형식/대상/레벨은 전부 logback-spring.xmlapplication-{profile}.yml에서 제어한다.


2. 로깅 관련 라이브러리

build.gradle에서 로깅과 직접 관련된 의존성은 다음과 같습니다.

// Spring Boot 기본 starter에 Logback + SLF4J가 포함되어 있음
implementation 'org.springframework.boot:spring-boot-starter-web'

// AOP (LoggingAspect 동작에 필요)
implementation 'org.springframework.boot:spring-boot-starter-aop'

// 분산 추적(traceId 발급) - Micrometer Tracing + OpenTelemetry
implementation 'io.micrometer:micrometer-tracing-bridge-otel'

// JDBC SQL 로깅 (바인딩된 실제 SQL 출력)
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

// Logback EvaluatorFilter(조건식 필터)용 - 특정 로그 메시지 필터링에 필요
implementation 'org.codehaus.janino:janino:3.1.12'
라이브러리 역할
spring-boot-starter (내부 logback-classic, slf4j-api) 로깅 엔진 + API
spring-boot-starter-aop LoggingAspect가 컨트롤러를 가로채기 위한 AOP
micrometer-tracing-bridge-otel 요청마다 고유 traceId를 발급 → 로그 추적용
log4jdbc-log4j2 JPA/Hibernate가 실행하는 실제 SQL(바인딩 값 포함)을 로그로 출력
janino Logback의 EvaluatorFilter에서 자바 표현식(message.contains(...))을 평가하기 위한 필수 라이브러리

2-1. log4jdbc 설정

JPA만 사용하면 ? 자리표시자(placeholder)가 들어간 SQL만 보이지만, log4jdbc를 쓰면 실제 바인딩된 값이 채워진 SQL을 볼 수 있어 디버깅이 훨씬 편합니다.

src/main/resources/log4jdbc.log4j2.properties:

# SQL 앞뒤 공백/줄바꿈 정리
log4jdbc.trim.sql=true
# SQL 한 줄 최대 길이 제한 해제 (0 = 무제한)
log4jdbc.dump.sql.maxlinelength=0
# 감시할 JDBC 드라이버 지정
log4jdbc.drivers=com.mysql.cj.jdbc.Driver
# 인기 드라이버 자동 로드 비활성화 (명시한 드라이버만 사용)
log4jdbc.auto.load.popular.drivers=false
# 로그 출력을 커스텀 클래스에 위임 (SQL 포맷팅 + 결과셋 마스킹)
log4jdbc.spylogdelegator.name=kr.co.bler.infrastructure.config.logging.CustomLogDelegator

여기서 CustomLogDelegator는 log4jdbc의 기본 출력 동작을 확장한 클래스입니다.

public class CustomLogDelegator extends Slf4jSpyLogDelegator {

    @Override
    public void sqlOccurred(Spy spy, String methodCall, String sql) {
        // Hibernate 포맷터로 SQL을 보기 좋게 정렬
        BasicFormatterImpl formatter = new BasicFormatterImpl();
        sql = formatter.format(sql);
        super.sqlOccurred(spy, methodCall, sql);
    }

    @Override
    public void resultSetCollected(ResultSetCollector resultSetCollector) {
        // 조회 결과 중 비밀번호/이메일 등 민감 컬럼은 마스킹 후 출력
        ResultSetCollector maskingCollector =
                new MaskingResultSetCollector(resultSetCollector, MASK_COLUMN_NAMES, REPLACE_COLUMN_NAMES);
        String resultsToPrint = new ResultSetCollectorPrinter().getResultSetToPrint(maskingCollector);
        org.slf4j.LoggerFactory.getLogger("jdbc.resultsettable").info(resultsToPrint);
    }
}

이렇게 하면 SQL 로그가 자동으로 포맷팅 + 결과셋 민감정보 마스킹되어 출력됩니다.

log4jdbc는 다음과 같은 로거(logger) 이름으로 출력 종류를 세분화할 수 있습니다.

로거 이름 설명
jdbc.sqlonly 바인딩 값이 채워진 SQL만 출력
jdbc.sqltiming SQL + 실행 시간 출력
jdbc.audit JDBC 호출 전체(매우 상세, 보통 OFF)
jdbc.resultset 결과셋의 각 row 호출(상세, 보통 OFF)
jdbc.resultsettable 조회 결과를 표 형태로 출력
jdbc.connection 커넥션 open/close 추적

운영에서는 SQL 로그가 성능과 보안에 영향을 줄 수 있으므로 필요한 로거만 켜는 것이 중요합니다. (뒤의 profile별 설정 참고)


3. profile별 로깅 설정

이 프로젝트는 로그 설정을 두 파일에서 나눠서 관리합니다.

  • application-{profile}.yml : 로그 레벨(level) 위주 (어떤 패키지를 어느 레벨까지 볼지)
  • logback-spring.xml : 로그 출력 대상(Appender)과 출력 형식(pattern) 위주

3-1. application.yml (공통 기본값)

logging:
  level:
    root: info
    kr.co.bler: DEBUG
    org.springframework.security: DEBUG
    # Spring Boot 자동 설정 관련 로그 비활성화 (불필요한 로그 줄이기)
    org.springframework.boot.autoconfigure: WARN
  # Logback 설정 파일 위치 지정
  config: classpath:logback-spring.xml
  • root: info : 기본 레벨은 INFO
  • kr.co.bler: DEBUG : 우리 애플리케이션 코드만 DEBUG까지
  • logging.config로 Logback 설정 파일을 명시

3-2. logback-spring.xml의 <springProfile>

logback-spring.xml<springProfile name="..."> 태그로 profile마다 다른 Appender/패턴/레벨을 적용할 수 있습니다. (파일명이 logback-spring.xml이어야 <springProfile>을 사용할 수 있습니다. logback.xml은 불가)

local 프로파일 (개발자 PC)

<springProfile name="local">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 컬러를 입혀 가독성을 높인 콘솔 전용 패턴 -->
            <pattern>%yellow(%d{yyyy-MM-dd HH:mm:ss.SSS}) %highlight(%-5level)
                %green([%-20thread]) %cyan([%X{traceId}])
                %magenta([%-30logger{0}])
                %cyan([%X{userId}][%X{memberSeq}][%X{ip}]) - %yellow(%msg) %n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <logger name="kr.co.bler" level="DEBUG"/>
    <!-- log4jdbc: 바인딩된 SQL만 보이도록 -->
    <logger name="jdbc.sqlonly" level="DEBUG"/>
    <logger name="jdbc.sqltiming" level="OFF"/>
    <!-- Hibernate 원시 SQL은 OFF (log4jdbc와 중복 방지) -->
    <logger name="org.hibernate.SQL" level="OFF"/>

    <root level="DEBUG">
        <appender-ref ref="CONSOLE"/>
    </root>
</springProfile>
  • 콘솔에만 출력, 컬러 패턴으로 가독성 강화
  • 파일 출력은 주석 처리(로컬에선 불필요)
  • kr.co.blerjdbc.sqlonly를 DEBUG로 → 코드 흐름 + 실제 SQL 모두 확인
  • Hibernate 기본 SQL 로그는 OFF → log4jdbc 로그와 중복 방지

dev 프로파일 (개발 서버)

<springProfile name="dev">
    <appender name="CONSOLE" .../>

    <!-- 전체 로그 파일 (날짜+용량 기준 롤링) -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${logging.file.path:-logs}/bler-admin-dev.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${logging.file.path:-logs}/bler-admin-dev.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>200MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%-20thread] [%X{traceId}]
                [%-30logger{0}] [%X{userId}][%X{memberSeq}][%X{ip}] - %msg%n</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>${logging.file.path:-logs}/bler-admin-dev-error.log</file>
        ...
    </appender>

    <logger name="kr.co.bler" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>
    <logger name="org.springframework" level="WARN"/>

    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
</springProfile>
  • 콘솔 + 파일 + 에러전용 파일 3개 Appender 사용
  • RollingFileAppender + SizeAndTimeBasedRollingPolicy
    • 날짜가 바뀌거나(%d{yyyy-MM-dd}) 파일이 일정 크기(maxFileSize)를 넘으면(%i) 새 파일로 분리
    • maxHistory로 보관 일수 제한 → 디스크 가득 차는 사고 방지
  • ERROR_FILE: LevelFilter로 ERROR만 골라 별도 파일에 저장 → 장애 발생 시 에러 로그만 빠르게 확인
  • 로컬보다 로그 레벨을 한 단계 올림(kr.co.bler: INFO)

application-dev.yml의 레벨 보강:

logging:
  level:
    jdbc.sqlonly: OFF
    jdbc.sqltiming: WARN   # 닫힌 커넥션 관련 오류만 경고로 확인
    kr.co.bler: DEBUG

production 프로파일 (운영 서버)

<springProfile name="production">
    <appender name="CONSOLE" .../>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${logging.file.path:-logs}/bler-admin-prod.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>...bler-admin-prod.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>500MB</maxFileSize>
            <maxHistory>90</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>           <!-- 전체 로그 총량 상한 -->
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>
        ...
    </appender>
    <appender name="ERROR_FILE" .../>  <!-- maxHistory 180일, totalSizeCap 5GB -->

    <logger name="kr.co.bler" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>
    <logger name="org.springframework" level="WARN"/>

    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
</springProfile>

운영은 dev와 구조는 같지만 보관 정책을 강화한 것이 포인트입니다.

항목 dev production
일반 로그 maxFileSize 200MB 500MB
일반 로그 maxHistory 30일 90일
에러 로그 maxHistory 90일 180일
totalSizeCap (총량 제한) 없음 10GB / 에러 5GB
cleanHistoryOnStart 없음 true (시작 시 오래된 로그 정리)
  • 운영은 SQL 상세 로그(jdbc)를 OFF → 성능/보안 보호
  • totalSizeCapcleanHistoryOnStart로 디스크 용량을 안전하게 관리

3-3. profile별 요약 비교

구분 local dev production
출력 대상 콘솔 콘솔 + 파일 + 에러파일 콘솔 + 파일 + 에러파일
콘솔 컬러 O X X
kr.co.bler 레벨 DEBUG INFO(xml)/DEBUG(yml) INFO
SQL 로그 jdbc.sqlonly=DEBUG 대부분 OFF jdbc 전체 OFF
로그 보관 안 함 30~90일 90~180일 + 총량 제한

4. LogAspect(LoggingAspect) 설정

LoggingAspectAOP를 이용해 모든 컨트롤러의 요청/응답을 자동으로 로그에 남기는 공통 컴포넌트입니다. 컨트롤러마다 일일이 로그 코드를 넣지 않아도 되고, 형식도 통일됩니다.

4-1. 전체 구조

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class LoggingAspect {

    private final ObjectMapper objectMapper;

    // presentation.controller 패키지 하위 전체를 대상으로 지정
    @Pointcut("within(kr.co.bler.presentation.controller..*)")
    public void traceMethod() {}

    @Around("traceMethod()")
    public Object traceMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        String className  = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();
        Object[] args     = joinPoint.getArgs();

        // 1) 파라미터를 JSON 문자열로 직렬화
        String argsString = ...;

        // 2) MDC에 인증 정보 저장 (로그 패턴의 %X{...} 자리에 출력됨)
        if (ObjectUtils.isNotEmpty(SecurityUtil.getCurrentMemberSeq())) {
            MDC.put(USER_ID, SecurityUtil.getCurrentMemberId());
            MDC.put(MEMBER_SEQ, String.valueOf(SecurityUtil.getCurrentMemberSeq()));
        } else {
            MDC.put(USER_ID, "anonymous");
            MDC.put(MEMBER_SEQ, null);
        }
        MDC.put(IP, SecurityUtil.getCurrentClientIpAddress());

        // 3) 요청 로그
        log.info("[{}][{}.{}] Param:{}", REQUEST, className, methodName, argsString);

        try {
            Object result = joinPoint.proceed();      // 실제 컨트롤러 실행
            // 4) 응답 로그
            log.info("[{}][{}.{}] Result:{}", RESPONSE, className, methodName, result);
            return result;
        } finally {
            // 5) 요청 끝나면 MDC 정리 (스레드 재사용 시 값 오염 방지)
            MDC.remove(USER_ID);
            MDC.remove(MEMBER_SEQ);
            MDC.remove(IP);
        }
    }
}

4-2. 어노테이션 의미

어노테이션 의미
@Aspect 이 클래스가 AOP의 부가기능(Aspect)임을 선언
@Component 스프링 빈으로 등록 (AOP가 동작하려면 빈이어야 함)
@Pointcut("within(...)") 어디에 적용할지(대상) 지정 — 여기선 컨트롤러 패키지 전체
@Around 대상 메서드 실행 전후 모두 가로채는 advice. 요청/응답 둘 다 로깅 가능

4-3. @Around 동작 순서

@AroundjoinPoint.proceed()를 기준으로 앞쪽 = 실행 전, 뒤쪽 = 실행 후로 나뉩니다.

1. 컨트롤러 호출 직전 → 파라미터 직렬화 + MDC 세팅 + REQ 로그
2. joinPoint.proceed() → 실제 컨트롤러 메서드 실행
3. 컨트롤러 정상 반환 → RES 로그
4. finally → MDC 정리 (반드시 실행)

4-4. 안전한 파라미터 직렬화 (OOM 방지)

요청 파라미터를 무조건 JSON으로 바꾸면 파일 업로드(MultipartFile)나 byte[] 같은 대용량 데이터에서 메모리 부족(OOM)이 발생할 수 있습니다. 그래서 직렬화 대상이 위험한 타입이면 요약 문자열로 대체합니다.

// 응답 스트림 객체는 직렬화하지 않음
if (arg instanceof HttpServletResponse) {
    return "<HttpServletResponse>";
}
// 파일 업로드는 메타정보만 출력
if (arg instanceof MultipartFile multipartFile) {
    return String.format("<MultipartFile: name=%s, size=%d bytes, contentType=%s>",
            multipartFile.getOriginalFilename(),
            multipartFile.getSize(),
            multipartFile.getContentType());
}
// byte[]는 길이만 (Base64 등 대용량 → OOM 유발)
if (arg instanceof byte[] bytes) {
    return String.format("<byte[]: length=%d>", bytes.length);
}

실무에서 의외로 자주 만나는 장애가 "로그 때문에 OOM"입니다. 로깅 코드도 방어 로직이 필요합니다.

4-5. MDC와 로그 패턴의 연결

LoggingAspectMDC.put(USER_ID, ...)로 넣은 값은 logback-spring.xml의 패턴에서 %X{userId}로 출력됩니다.

MDC.put("userId", "master1")   ─┐
MDC.put("memberSeq", "1024")    ├─▶  패턴:  [%X{userId}][%X{memberSeq}][%X{ip}]
MDC.put("ip", "10.0.0.5")      ─┘     출력:  [master1][1024][10.0.0.5]
  • traceId, ip : TraceIdMdcFilter(서블릿 필터)에서 요청 진입 시 세팅
  • userId, memberSeq : LoggingAspect에서 인증 정보로 세팅

이 덕분에 로그 한 줄만 봐도 "어떤 요청(traceId)에서, 어떤 사용자(userId)가, 어떤 IP에서" 호출했는지 추적할 수 있습니다.

4-6. 실제 로그 출력 예시

2026-01-05 14:22:01.532 INFO  [http-nio-8080-exec-3] [a1b2c3d4e5] [ClassController] [master1][1024][10.0.0.5] - [REQ][ClassController.list] Param:{"page":0,"size":20}
2026-01-05 14:22:01.610 DEBUG [http-nio-8080-exec-3] [a1b2c3d4e5] [jdbc.sqlonly]    [master1][1024][10.0.0.5] - select c1_0.class_seq, ... from class c1_0 ...
2026-01-05 14:22:01.655 INFO  [http-nio-8080-exec-3] [a1b2c3d4e5] [ClassController] [master1][1024][10.0.0.5] - [RES][ClassController.list] Result:ApiResponse(success=true, ...)

같은 traceId(a1b2c3d4e5)로 요청 → SQL → 응답이 한 흐름으로 묶이는 것을 볼 수 있습니다.


5. 민감정보 마스킹

로그에는 비밀번호, 휴대폰 번호 같은 개인정보가 노출되면 안 됩니다. 이 프로젝트는 두 계층에서 마스킹합니다.

  1. REQ 파라미터(JSON) 마스킹 : LoggingParamMaskProperties에 지정한 필드명을 마스킹
    # config/util/util-{profile}.yml
    util:
      logging:
        param-mask:
          json-field-names:
            - password
            - mobile
    
  2. SQL 결과셋 마스킹 : 앞서 본 CustomLogDelegator + MaskingResultSetCollector가 조회 결과의 민감 컬럼을 가려서 출력

6. 정리

  • 코드는 SLF4J로만, 출력 제어는 logback-spring.xml + application-{profile}.yml로 분리
  • profile별 전략
    • local : 콘솔 컬러 로그 + 상세 SQL → 개발 편의 우선
    • dev : 파일 롤링 + 에러 분리 + 적당한 보관 기간
    • production : 보관 정책 강화(totalSizeCap, cleanHistoryOnStart) + SQL 로그 OFF
  • log4jdbc로 바인딩된 실제 SQL을 보기 좋게 출력 (CustomLogDelegator로 포맷팅/마스킹)
  • LoggingAspect(AOP)로 모든 컨트롤러의 요청/응답을 공통 포맷으로 기록
  • MDC + TraceId로 요청 단위 추적(traceId/userId/ip) 가능
  • 마스킹으로 개인정보 노출 방지

로깅은 "잘 만들어두면 평소엔 안 보이지만, 장애가 났을 때 가장 먼저 찾게 되는 자산"입니다. 환경별로 레벨/출력/보관 정책을 명확히 구분해두는 것이 핵심입니다.