BindProject

AOP와 ThreadLocal로 구현하는 로그 추적기 (MSA 환경 완벽 대비)

dding-shark 2025. 8. 2. 14:30
728x90

왜 로그 추적기가 필요한가?

현대의 애플리케이션은 여러 개의 독립적인 서비스들이 서로 통신하며 하나의 큰 비즈니스 로직을 완성하는 MSA 구조를 채택하는 경우가 많습니다. 예를 들어, 사용자의 '결제 요청' 하나를 처리하기 위해 BFF(Backend for Frontend) -> 결제 서비스 -> 외부 PG사 API -> 주문 서비스 -> 메시지 큐 등 수많은 단계를 거칠 수 있습니다.

이런 환경에서 장애가 발생하거나 성능 저하가 의심될 때, 분산된 로그들을 하나하나 뒤져가며 원인을 찾는 것은 엄청난 시간과 노력을 소모하는 일입니다.

로그 추적기는 이러한 분산된 호출들을 하나의 트랜잭션 ID(Trace ID)로 묶고, 호출 깊이를 시각적으로 표현하여 전체 요청의 흐름과 각 단계의 소요 시간을 명확하게 보여줍니다. 이를 통해 개발자는 시스템의 동작을 직관적으로 이해하고 문제의 원인을 신속하게 파악할 수 있습니다.

이제, 우리 프로젝트에 딱 맞는 맞춤형 로그 추적기를 만들어 봅시다!

 

 

 

 


 

 

 

1단계: 프로젝트 구조 설계 - 재사용 가능한 공용 모듈 생성

가장 먼저, 로그 추적 기능을 특정 서비스에 종속시키지 않고 어떤 서비스에서든 가져다 쓸 수 있도록 별도의 공용 모듈로 분리하는 것이 중요합니다. 이는 프로젝트 전체의 일관성을 유지하고 재사용성을 극대화하는 좋은 설계입니다.

저희 프로젝트는 Gradle 멀티 모듈 구조를 사용하고 있으며, settings.gradle에 다음과 같이 public:logtracer라는 공용 모듈을 추가했습니다.

settings.gradle

// ... 생략
rootProject.name = 'backend'

// 공용 모듈
include 'public:logtracer'

// 애플리케이션 및 서비스 모듈
include 'application:bff'
include 'service:payment'
// ... 기타 모듈 생략

logtracer 모듈은 AOP 기능이 핵심이므로, build.gradle에 관련 의존성을 추가합니다.

public/logtracer/build.gradle

dependencies {
    // Spring Boot AOP 스타터
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    // Lombok (보일러플레이트 코드 감소)
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

이제 payment 서비스와 같은 다른 모듈에서는 이 logtracer 모듈을 의존성에 추가하기만 하면 됩니다.

service/payment/build.gradle

dependencies {
    // ... 기타 의존성
    implementation project(':public:logtracer')
}

기반 공사가 끝났으니, 이제 본격적으로 내부 로직을 구현해 보겠습니다.

 

 

 

 


 

 

 

2단계: 추적 로직의 핵심, TraceIdThreadLocal 설계

로그 추적기의 핵심 요구사항은 다음과 같습니다.

  1. Trace ID: 하나의 요청 사이클 동안 발생하는 모든 로그를 묶어주는 고유 식별자.
  2. 호출 깊이(Level): 메서드 호출 스택의 깊이를 표현하여 계층 구조를 시각화.
  3. 동시성 제어: 여러 사용자의 요청이 동시에 들어와도 각 요청의 로그가 섞이지 않도록 완벽히 격리.

여기서 가장 중요한 것이 바로 동시성 제어입니다. Spring Boot 애플리케이션은 기본적으로 요청마다 별도의 스레드를 할당하여 처리하므로, 각 스레드의 추적 정보(Trace ID, Level)를 안전하게 보관할 장치가 필요합니다. 이 문제에 대한 완벽한 해답은 바로 ThreadLocal입니다. ThreadLocal은 각 스레드마다 고유한 데이터 저장소를 제공하여 다른 스레드로부터 완벽하게 격리된 상태를 보장합니다.

이 개념을 바탕으로 추적 정보의 상태를 관리할 클래스들을 설계해 보겠습니다.

public/logtracer/src/main/java/logtracer/TraceId.java

package logtracer;

import lombok.Getter;
import java.util.UUID;

@Getter
public class TraceId {
    private String id;
    private int level;

    public TraceId() {
        this.id = createId();
        this.level = 0;
    }

    private TraceId(String id, int level) {
        this.id = id;
        this.level = level;
    }

    private String createId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }

    // 다음 호출 깊이를 표현하는 새로운 TraceId 객체 생성
    public TraceId createNextId() {
        return new TraceId(id, level + 1);
    }

    // 이전 호출 깊이를 표현하는 새로운 TraceId 객체 생성
    public TraceId createPreviousId() {
        return new TraceId(id, level - 1);
    }

    // 현재가 첫번째 호출인지 확인
    public boolean isFirstLevel() {
        return level == 0;
    }
}

public/logtracer/src/main/java/logtracer/TraceStatus.java

package logtracer;

import lombok.AllArgsConstructor;
import lombok.Getter;

// 로그의 상태 정보를 담는 클래스.
// begin() 시점에 생성되어 end() 시점까지 데이터를 전달하는 역할을 한다.
@Getter
@AllArgsConstructor
public class TraceStatus {
    private TraceId traceId;    // 트랜잭션 ID와 레벨 정보
    private Long startTimeMs;   // 로그 시작 시간
    private String message;     // 로그 메시지
}

 

 

 

 

 


 

 

 

3단계: LogTrace 서비스 구현 - ThreadLocal을 품다

이제 본격적으로 로그를 시작하고, 종료하고, 예외를 처리하는 핵심 서비스를 ThreadLocal 기반으로 구현할 차례입니다. 먼저 유연한 설계를 위해 인터페이스를 정의합니다.

public/logtracer/src/main/java/logtracer/LogTrace.java

package logtracer;

public interface LogTrace {
    // 로그 추적 시작
    TraceStatus begin(String message);
    // 정상 종료 시 로그 처리
    void end(TraceStatus status);
    // 예외 발생 시 로그 처리
    void exception(TraceStatus status, Exception e);
}

그리고 이 인터페이스의 ThreadLocal 기반 구현체를 만듭니다.

public/logtracer/src/main/java/logtracer/ThreadLocalLogTrace.java

package logtracer;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ThreadLocalLogTrace implements LogTrace {

    private static final String START_PREFIX = "-->";
    private static final String COMPLETE_PREFIX = "<--";
    private static final String EX_PREFIX = "<X-";

    // TraceId를 동기화하기 위한 ThreadLocal 필드
    private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder.get();
        long startTimeMs = System.currentTimeMillis();
        // [TraceId] 레벨에 맞는 접두사 + 메시지
        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTimeMs, message);
    }

    @Override
    public void end(TraceStatus status) {
        complete(status, null);
    }

    @Override
    public void exception(TraceStatus status, Exception e) {
        complete(status, e);
    }

    private void complete(TraceStatus status, Exception e) {
        long stopTimeMs = System.currentTimeMillis();
        long resultTimeMs = stopTimeMs - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();
        if (e == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs);
        } else {
            log.warn("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs, e.toString());
        }
        releaseTraceId();
    }

    // ThreadLocal에서 TraceId를 관리 (신규 생성 또는 레벨 증가)
    private void syncTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId == null) {
            traceIdHolder.set(new TraceId());
        } else {
            traceIdHolder.set(traceId.createNextId());
        }
    }

    // ThreadLocal의 TraceId를 해제 (레벨 감소 또는 완전 제거)
    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId.isFirstLevel()) {
            traceIdHolder.remove(); // 첫 레벨이면 ThreadLocal에서 완전히 제거
        } else {
            traceIdHolder.set(traceId.createPreviousId());
        }
    }

    // 호출 깊이에 따라 로그의 접두사와 공백을 만들어주는 헬퍼 메서드
    private String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|   ");
        }
        return sb.toString();
    }
}

syncTraceId()releaseTraceId()가 이 구현의 핵심입니다. ThreadLocalTraceId가 없으면 새로 생성하고, 있으면 레벨을 하나 올립니다. 처리가 끝나면 반대로 레벨을 내리다가, 첫 레벨(level 0)의 처리가 끝나면 remove()를 호출하여 ThreadLocal에 저장된 값을 완전히 제거합니다. 이는 스레드 풀 환경에서 스레드를 재사용할 때 이전 요청의 정보가 남아있는 것을 방지하는 중요한 처리입니다.

 

 

 

 


 

 

4단계: AOP로 비즈니스 로직에 생명 불어넣기

이제 우리가 만든 LogTrace 서비스를 비즈니스 코드에 적용할 시간입니다. 여기서 AOP(관점 지향 프로그래밍)가 마법을 부립니다. AOP를 사용하면 '로깅'이라는 횡단 관심사(cross-cutting concern)를 비즈니스 로직 코드로부터 완벽하게 분리하여 원하는 코드 위치에 주입할 수 있습니다.

public/logtracer/src/main/java/logtracer/LogTraceAspect.java

package logtracer;

import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import java.util.Arrays;

@Aspect
@RequiredArgsConstructor
public class LogTraceAspect {

    private final LogTrace logTrace;

    /**
     * 로그 추적을 적용할 포인트컷입니다.
     * - Controller, Service, Repository, Producer, Client 등 핵심 아키텍처 계층을 포함합니다.
     * - Entity, Mapper 등 과도한 로그를 유발할 수 있는 계층은 제외하여 최적화합니다.
     */
    @Pointcut("execution(* *..controller..*.*(..)) || " +
            "execution(* *..service..*.*(..)) || " +
            "execution(* *..repository..*.*(..)) || " +
            "execution(* *..producer..*.*(..)) || " +
            "execution(* *..client..*.*(..))")
    public void coreArchitectureLayer() {}

    @Around("coreArchitectureLayer()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;
        try {
            // 로그 메시지: "클래스명.메서드명() | args=[...]"
            String message = joinPoint.getSignature().toShortString() +
                             " | args=" + Arrays.toString(joinPoint.getArgs());
            status = logTrace.begin(message);

            // 실제 비즈니스 로직 호출
            Object result = joinPoint.proceed();

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e; // 예외를 다시 던져주어야 정상 흐름에 영향을 주지 않는다.
        }
    }
}

@Pointcut 설정이 매우 중요합니다. 여기서는 애플리케이션의 주요 흐름을 파악할 수 있는 핵심 계층들(controller, service, repository, client, producer)을 대상으로 지정했습니다.

주의: entity, mapper 와 같은 계층을 포인트컷에 포함시키면 JPA가 내부적으로 호출하는 수많은 getter, setter나 단순 데이터 변환 로직까지 모두 로깅 대상이 되어 로그의 양이 폭증하고, 가독성과 성능이 심각하게 저하될 수 있으므로 반드시 제외하는 것을 권장합니다.

 

 

 

 


 

 

5단계: Spring Bean으로 등록하여 최종 조립

마지막으로 우리가 만든 LogTrace 구현체와 AOP Aspect를 Spring이 인식하고 관리할 수 있도록 @Configuration을 사용하여 Bean으로 등록합니다.

public/logtracer/src/main/java/logtracer/config/LogTraceConfig.java

package logtracer.config;

import logtracer.LogTrace;
import logtracer.LogTraceAspect;
import logtracer.ThreadLocalLogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new ThreadLocalLogTrace();
    }

    @Bean
    public LogTraceAspect logTraceAspect(LogTrace logTrace) {
        // LogTrace 의존성 주입
        return new LogTraceAspect(logTrace);
    }
}

이제 모든 준비가 끝났습니다. logtracer 모듈을 의존하는 Spring Boot 애플리케이션을 실행하면, Spring이 LogTraceConfig를 자동으로 읽어 ThreadLocalLogTraceLogTraceAspect를 Bean으로 등록하고, AOP가 마법처럼 동작하기 시작합니다.

 

 

 

 


 

 

최종 결과: 실제 예시로 확인하는 로그 추적

결제 서비스에서 Toss PG 결제를 승인하는 API를 호출한다고 가정해 봅시다. 이 때의 흐름은 대략 다음과 같을 것입니다.

PaymentController -> PaymentService.confirmTossPayment() -> TossPaymentApiClient (외부 API 호출) -> PaymentService.recordNewTossPayment() -> PaymentRepository (DB 저장)

우리가 만든 로그 추적기를 적용하면, 애플리케이션 로그에 다음과 같은 결과가 출력됩니다.

[c5a1b8d3] -->PaymentController.confirmTossPayment() | args=[paymentKey-abc, order-123, 15000]
[c5a1b8d3] |   -->PaymentService.confirmTossPayment() | args=[paymentKey-abc, order-123, 15000]
[c5a1b8d3] |   |   -->TossPaymentApiClient.confirmPayment() | args=[paymentKey-abc, order-123, 15000]
[c5a1b8d3] |   |   <--TossPaymentApiClient.confirmPayment() time=185ms
[c5a1b8d3] |   |   -->PaymentService.recordNewTossPayment() | args=[TossPaymentSuccessDto(...)]
[c5a1b8d3] |   |   |   -->PaymentRepository.save() | args=[TossPayment(...)]
[c5a1b8d3] |   |   |   <--PaymentRepository.save() time=25ms
[c5a1b8d3] |   |   <--PaymentService.recordNewTossPayment() time=30ms
[c5a1b8d3] |   <--PaymentService.confirmTossPayment() time=220ms
[c5a1b8d3] <--PaymentController.confirmTossPayment() time=225ms

어떤가요? c5a1b8d3라는 동일한 Trace ID로 모든 로그가 묶여있고, 호출 깊이에 따라 들여쓰기가 되어 있어 전체 흐름을 한눈에 파악할 수 있습니다. 각 메서드의 시작(-->)과 끝(<--), 그리고 소요 시간(time=...ms)까지 명확하게 표시되어 어느 단계에서 시간이 많이 소요되는지 쉽게 분석할 수 있습니다. 만약 중간에 예외가 발생했다면 <X- 접두사와 함께 로그가 남을 것입니다.

마치며

지금까지 AOP와 ThreadLocal을 활용하여 비즈니스 코드를 침해하지 않는 강력하고 재사용 가능한 로그 추적 모듈을 구현해 보았습니다. 이 모듈은 복잡한 MSA 환경에서 시스템의 동작을 이해하고, 신속하게 문제를 진단하며, 성능 병목 지점을 찾아내는 데 필수적인 도구가 될 것입니다.

여기서 더 나아가, 로그에 찍히는 파라미터 중 민감한 정보를 마스킹 처리하거나, 추적 로그를 OpenTelemetry와 같은 표준 분산 추적 시스템과 연동하여 Jaeger나 Zipkin 같은 대시보드에서 시각화하는 등 다양한 방법으로 기능을 확장해 볼 수도 있습니다.

긴 글 읽어주셔서 감사합니다. 여러분의 프로젝트에도 멋진 로그 추적기를 도입하여 개발 경험을 한 단계 업그레이드해 보시길 바랍니다

728x90