로그를 남기는 건 개발자의 기본 습관이지만, "어떻게 남기느냐"는 의외로 깊은 주제다. System.out.println으로 디버깅하던 습관이 프로덕션에서 어떤 문제를 만드는지, 파사드와 구현체는 왜 분리해야 하는지 — 로깅의 기본부터 실무 전략까지 한 번 정리해보자.

로깅 파사드 vs 구현체 — SLF4J는 왜 필요한가

로깅에는 두 가지 레이어가 있습니다.

  • 파사드(facade): 로깅 API를 정의하는 인터페이스 계층이에요. 대표적으로 SLF4J 가 있습니다.
  • 구현체(implementation): 실제 로그를 기록하는 엔진이에요. Logback, Log4j2, java.util.logging 등이 있습니다.
PLAINTEXT
┌─────────────────────────────────────┐
│         애플리케이션 코드            │
│    Logger log = LoggerFactory...    │
└──────────────┬──────────────────────┘
               │  SLF4J API (파사드)

┌──────────────────────────────────────┐
│   Logback  |  Log4j2  |  JUL  ...  │  ← 구현체 (교체 가능)
└──────────────────────────────────────┘

이 구조가 왜 중요한지 실무 예시로 볼게요. 프로젝트에서 Log4j를 쓰다가 보안 이슈(Log4Shell)로 Logback으로 바꿔야 하는 상황이 오면, SLF4J를 파사드로 쓰고 있었다면 ** 애플리케이션 코드는 한 줄도 안 바꿔도 됩니다.** 구현체 의존성만 교체하면 끝이에요.

JAVA
// 애플리케이션 코드 — 구현체가 바뀌어도 이 코드는 그대로
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    // SLF4J 로거 생성 — 클래스 이름을 카테고리로 사용
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void placeOrder(String orderId) {
        log.info("주문 접수: orderId={}", orderId);
    }
}

SLF4J는 "로깅 라이브러리"가 아니라 ** 파사드 패턴을 적용한 로깅 추상화 레이어 **다. 이 구분이 중요하다.

로그 레벨 — TRACE부터 ERROR까지

로그 레벨은 메시지의 중요도를 나타냅니다. 낮은 레벨일수록 상세하고, 높은 레벨일수록 심각해요.

레벨용도예시
TRACE가장 상세한 디버깅 정보메서드 진입/종료, 변수 값 추적
DEBUG개발 중 디버깅용 정보SQL 쿼리, 요청 파라미터
INFO일반적인 운영 정보서버 시작, 주문 완료, 배치 실행
WARN잠재적 문제 경고재시도 발생, 캐시 미스 빈발
ERROR오류 발생예외 발생, 외부 API 호출 실패

중요한 건 ** 레벨을 설정하면 그 레벨 이상만 출력된다 **는 점입니다.

JAVA
// 로그 레벨이 INFO로 설정된 경우
log.trace("이건 안 나옴");  // 출력 안 됨
log.debug("이것도 안 나옴"); // 출력 안 됨
log.info("이건 나옴");       // 출력됨
log.warn("이것도 나옴");     // 출력됨
log.error("이것도 나옴");    // 출력됨

개발 환경 vs 운영 환경 전략

로그 레벨 전략은 환경에 따라 달라야 합니다.

  • ** 로컬/개발 **: DEBUG — 쿼리, 파라미터 등 상세 정보가 필요해요.
  • ** 스테이징 **: INFO — 운영과 동일한 조건에서 테스트하되, 기본 흐름은 확인합니다.
  • ** 프로덕션 **: INFO 또는 WARN — 불필요한 로그는 성능과 디스크 비용에 직결돼요.
XML
<!-- logback-spring.xml에서 프로파일별 분리 -->
<springProfile name="local">
    <root level="DEBUG">
        <appender-ref ref="CONSOLE" />
    </root>
</springProfile>

<springProfile name="prod">
    <root level="INFO">
        <appender-ref ref="FILE" />
    </root>
</springProfile>

** 팁:** 프로덕션에서 갑자기 디버그 로그가 필요하면? Spring Boot Actuator의 /loggers 엔드포인트로 ** 런타임에 로그 레벨을 변경 **할 수 있어요. 재배포 없이 문제를 추적할 수 있어서 실무에서 굉장히 유용합니다.

System.out.println 대신 로거를 쓰는 이유

1. 레벨 제어가 안 됩니다

println은 항상 출력됩니다. 로거는 레벨 설정으로 개발/운영 환경에 따라 출력을 제어할 수 있어요.

2. 출력 대상을 바꿀 수 없습니다

println은 콘솔(System.out)에만 출력됩니다. 로거는 파일, 네트워크, 데이터베이스 등 다양한 대상(Appender)으로 보낼 수 있어요.

3. 성능 문제

System.out.printlnsynchronized 메서드 입니다. 멀티스레드 환경에서 호출할 때마다 락을 잡기 때문에 병목이 될 수 있어요.

JAVA
// java.io.PrintStream 내부 — 출력할 때마다 동기화
public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

반면 Logback은 비동기 Appender 를 지원해서, 로그 기록이 애플리케이션 스레드를 블로킹하지 않도록 할 수 있습니다.

4. 포맷과 메타데이터

로거는 타임스탬프, 스레드 이름, 클래스 이름, 로그 레벨 등을 자동으로 포함합니다. println으로 이걸 매번 직접 작성하는 건 비현실적이에요.

PLAINTEXT
// println 출력
주문 접수됨

// 로거 출력
2026-03-19 14:30:00.123 [http-nio-8080-exec-1] INFO  c.e.s.OrderService - 주문 접수: orderId=ORD-001

Logback 설정 — Appender, Encoder, Rolling Policy

Logback은 Spring Boot의 기본 로깅 구현체입니다. 핵심 구성 요소 세 가지를 알아볼게요.

Appender — 어디에 출력할 것인가

XML
<!-- 콘솔 출력 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <!-- 패턴: 시간 [스레드] 레벨 로거 - 메시지 -->
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
</appender>

<!-- 파일 출력 (롤링) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/app.log</file>
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <!-- 날짜별 + 크기별 롤링 -->
        <fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>       <!-- 파일당 최대 크기 -->
        <maxHistory>30</maxHistory>            <!-- 최대 보관 일수 -->
        <totalSizeCap>3GB</totalSizeCap>       <!-- 전체 로그 최대 용량 -->
    </rollingPolicy>
</appender>

Encoder — 어떤 형태로 출력할 것인가

패턴 문자열에서 자주 쓰는 변환어를 정리하면 다음과 같습니다.

변환어의미
%d날짜/시간
%thread스레드 이름
%-5level로그 레벨 (5자리 좌측 정렬)
%logger{36}로거 이름 (최대 36자)
%msg로그 메시지
%n줄바꿈
%X{key}MDC 값

Rolling Policy — 언제 파일을 나눌 것인가

프로덕션에서 로그 파일 관리를 안 하면 디스크가 꽉 차서 서버가 죽는 일이 실제로 발생합니다. SizeAndTimeBasedRollingPolicy가 가장 실용적이에요.

  • **시간 기준 **: 날짜가 바뀌면 새 파일 생성
  • ** 크기 기준 **: 파일이 maxFileSize를 넘으면 새 파일 생성
  • ** 자동 압축 **: .gz 확장자로 지정하면 이전 파일을 자동 압축
  • ** 자동 삭제 **: maxHistorytotalSizeCap으로 오래된 파일 자동 정리

비동기 로깅 — AsyncAppender

로그를 동기로 기록하면 I/O 대기 시간이 애플리케이션 스레드에 영향을 줄 수 있습니다. 특히 파일이나 네트워크로 로그를 보낼 때 그래요.

XML
<!-- 비동기 Appender 설정 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>1024</queueSize>           <!-- 내부 큐 크기 -->
    <discardingThreshold>0</discardingThreshold> <!-- 0이면 로그를 버리지 않음 -->
    <neverBlock>false</neverBlock>         <!-- true면 큐가 꽉 차도 블로킹하지 않음 (로그 유실 가능) -->
    <appender-ref ref="FILE" />
</appender>

** 주의점:** 비동기 로깅은 빠르지만, 큐가 가득 차면 로그가 유실될 수 있어요. discardingThreshold를 0으로 설정하면 INFO 이상의 로그는 버리지 않지만, 큐가 꽉 차면 블로킹이 발생할 수 있습니다. 트레이드오프를 이해하고 설정해야 해요.

MDC — 요청 추적의 핵심

MDC(Mapped Diagnostic Context) 는 스레드 단위로 컨텍스트 정보를 저장하는 메커니즘입니다. 내부적으로 ThreadLocal을 사용해요.

MSA 환경이나 동시 요청이 많은 서비스에서, 로그가 뒤섞이면 특정 요청의 흐름을 추적하기가 불가능해집니다. MDC를 쓰면 요청별로 고유 ID를 부여해서 로그를 필터링할 수 있어요.

필터에서 MDC 설정

JAVA
public class RequestIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws Exception {
        try {
            String traceId = ((HttpServletRequest) request).getHeader("X-Trace-Id");
            if (traceId == null) {
                traceId = UUID.randomUUID().toString().substring(0, 8);
            }
            MDC.put("traceId", traceId);
            chain.doFilter(request, response);
        } finally {
            MDC.clear(); // 반드시 clear — ThreadPool 환경에서 이전 요청의 MDC가 남으면 안 된다
        }
    }
}

Logback 패턴에 MDC 포함

XML
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>

출력 결과:

PLAINTEXT
14:30:00.123 [http-nio-8080-exec-1] [a3f2b1c8] INFO  c.e.s.OrderService - 주문 접수: orderId=ORD-001
14:30:00.456 [http-nio-8080-exec-1] [a3f2b1c8] INFO  c.e.s.PaymentService - 결제 처리: orderId=ORD-001
14:30:00.789 [http-nio-8080-exec-2] [e7d4c9a1] INFO  c.e.s.OrderService - 주문 접수: orderId=ORD-002

traceId로 필터링하면 특정 요청의 전체 흐름을 한눈에 볼 수 있습니다.

MSA 환경에서 요청을 추적하려면 MDC + 분산 추적(Zipkin, Jaeger)을 함께 쓴다. Spring Cloud Sleuth(현재는 Micrometer Tracing)가 자동으로 MDC에 traceId를 넣어준다.

프로덕션 로깅 모범 사례

1. 구조화된 로그 (Structured Logging)

텍스트 로그는 사람이 읽기엔 좋지만, 로그 분석 시스템(ELK, Datadog 등)에서 파싱하기 어렵습니다. JSON 형식으로 출력하면 검색과 집계가 훨씬 쉬워져요.

XML
<!-- logstash-logback-encoder 사용 -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <!-- MDC 필드가 자동으로 JSON에 포함된다 -->
    </encoder>
    <!-- rolling policy 생략 -->
</appender>

출력 결과:

JSON
{
  "@timestamp": "2026-03-19T14:30:00.123+09:00",
  "level": "INFO",
  "thread_name": "http-nio-8080-exec-1",
  "logger_name": "com.example.service.OrderService",
  "message": "주문 접수: orderId=ORD-001",
  "traceId": "a3f2b1c8"
}

2. 민감 정보 마스킹

로그에 개인정보나 인증 정보가 그대로 찍히면 보안 사고입니다. 로그를 남기기 전에 마스킹해야 해요.

JAVA
// 잘못된 예 — 비밀번호가 로그에 그대로 노출
log.info("로그인 시도: email={}, password={}", email, password);

// 올바른 예 — 민감 정보 마스킹
log.info("로그인 시도: email={}", maskEmail(email));

private String maskEmail(String email) {
    // 앞 3자리만 보여주고 나머지는 마스킹
    if (email == null || email.length() < 3) return "***";
    int atIndex = email.indexOf('@');
    if (atIndex <= 3) return email.substring(0, 1) + "***" + email.substring(atIndex);
    return email.substring(0, 3) + "***" + email.substring(atIndex);
}

마스킹이 필요한 대표적인 정보:

  • 비밀번호, 토큰, API 키
  • 주민등록번호, 카드번호
  • 이메일, 전화번호 (부분 마스킹)

3. 로그 메시지 작성 팁

JAVA
// 나쁜 예 — 무슨 일이 일어났는지 알 수 없다
log.error("에러 발생");

// 나쁜 예 — 문자열 연결은 로그 레벨과 무관하게 항상 실행된다
log.debug("주문 상세: " + order.toString());

// 좋은 예 — 플레이스홀더 사용, 로그 레벨이 맞을 때만 문자열 생성
log.debug("주문 상세: orderId={}, amount={}", order.getId(), order.getAmount());

// 좋은 예 — 예외는 마지막 인자로 전달하면 스택트레이스가 출력된다
try {
    processPayment(orderId);
} catch (PaymentException e) {
    log.error("결제 실패: orderId={}", orderId, e);
}

** 플레이스홀더({}) 방식 **을 쓰는 이유는 성능 때문입니다. 문자열 연결(+)은 로그 레벨과 관계없이 항상 실행되지만, 플레이스홀더는 해당 레벨이 활성화되었을 때만 문자열을 생성해요.

4. 예외 로깅 주의사항

JAVA
// 나쁜 예 — 스택트레이스가 사라진다
catch (Exception e) {
    log.error("처리 실패: " + e.getMessage());
}

// 좋은 예 — 예외 객체를 넘기면 스택트레이스가 자동 출력
catch (Exception e) {
    log.error("처리 실패: orderId={}", orderId, e);
}

e.getMessage()만 로깅하면 원인을 추적할 수 없습니다. 예외 객체 자체를 마지막 인자로 넘겨야 스택트레이스가 포함돼요. 이건 실무에서 정말 자주 겪는 실수입니다.

전체 Logback 설정 예시

Spring Boot 프로젝트에서 쓸 수 있는 실전 설정입니다. 먼저 Appender를 정의할게요.

XML
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 콘솔 Appender -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 파일 Appender (롤링) -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>3GB</totalSizeCap>
        </rollingPolicy>
    </appender>

비동기 Appender를 추가하고, 환경별로 분리합니다. 로컬에서는 콘솔에 DEBUG, 프로덕션에서는 파일에 INFO(비동기)로 출력해요.

XML
    <!-- 비동기 Appender -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <queueSize>1024</queueSize>
        <discardingThreshold>0</discardingThreshold>
        <appender-ref ref="FILE" />
    </appender>

    <springProfile name="local">
        <root level="DEBUG">
            <appender-ref ref="CONSOLE" />
        </root>
    </springProfile>

    <springProfile name="prod">
        <logger name="com.example.payment" level="DEBUG" />
        <root level="INFO">
            <appender-ref ref="ASYNC_FILE" />
        </root>
    </springProfile>
</configuration>

주의할 점

e.getMessage()만 로깅하면 원인을 추적할 수 없습니다

예외 객체 자체를 마지막 인자로 넘겨야 스택트레이스가 포함돼요. 이건 실무에서 정말 자주 겪는 실수입니다.

비동기 로깅은 로그 유실 가능성이 있습니다

AsyncAppender의 큐가 가득 차면 로그가 유실될 수 있어요. discardingThreshold를 0으로 설정하면 INFO 이상의 로그는 보존하지만, 큐가 꽉 차면 블로킹이 발생할 수 있습니다. 트레이드오프를 이해하고 설정해야 해요.

민감 정보를 마스킹하지 않으면 보안 사고입니다

비밀번호, 토큰, API 키, 주민등록번호, 카드번호가 로그에 그대로 찍히는 경우가 생각보다 많아요. 로그를 남기기 전에 반드시 마스킹 처리해야 합니다.

정리

개념핵심
SLF4J파사드 패턴을 적용한 로깅 추상화. 구현체를 바꿔도 코드 변경 없음
로그 레벨TRACE → DEBUG → INFO → WARN → ERROR. 설정한 레벨 이상만 출력
println 대신 로거레벨 제어, 출력 대상 분리, 비동기 처리, 메타데이터 자동 포함
MDCThreadLocal 기반으로 요청별 컨텍스트를 로그에 포함. MSA 환경에서 필수
구조화된 로그JSON 형식으로 출력하면 ELK, Datadog 등에서 검색과 집계가 쉬움
Rolling Policy시간 + 크기 기반 롤링으로 디스크 관리. 미설정 시 디스크 풀로 서버 장애
플레이스홀더{}는 레벨이 활성화되었을 때만 문자열 생성. + 연결보다 성능 우수
댓글 로딩 중...