catsridingCATSRIDING|OCEANWAVES
Dev

Spring AOP 공통 관심사 분리하기

jynn@catsriding.com
Apr 07, 2024
Published byJynn
999
Spring AOP 공통 관심사 분리하기

Aspect Oriented Programming in Spring

AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)는 애플리케이션에서 공통적으로 발생하는 관심사를 모듈화하는 프로그래밍 패러다임입니다. 코드의 다양한 부분에서 반복적으로 등장하는 이러한 횡단 관심사는 적절히 다루지 않으면 코드의 중복성을 증가시키고 유지보수를 어렵게 만듭니다. Spring AOP는 이 횡단 관심사를 담당하는 코드를 캡슐화하여 중복을 줄이는 강력한 방법을 제공합니다.

Spring Data JPA의 @Transactional 어노테이션은 트랜잭션 관리라는 횡단 관심사를 효과적으로 해결한 사례입니다. 이와 같이, 핵심 기능 외에 여러 모듈에서 공통적으로 필요로 하는 부가 기능들을 해결하기 위해 AOP를 도입하여 어떻게 효과적으로 처리할 수 있는지 알아보겠습니다.

Prerequisites

API 처리 과정에 로그 추적기를 도입하여 이러한 횡단 관심사를 어떻게 다루는지 살펴보려고 합니다. 이를 위해 먼저 프로젝트를 설정해야 합니다. Spring Initializr를 사용하여 프로젝트를 생성하고 필요한 의존성을 추가합니다:

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
  • Java 21
  • Spring Boot 3.3
  • Gradle 8
  • Dependencies:
    • Spring Web
    • Lombok

Adding REST API

이제 간단한 REST API를 하나 추가하겠습니다. 먼저 HTTP 요청을 핸들링하는 OrderController 클래스를 구현합니다:

OrderController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/orders")
public class OrderController {

    private final OrderService service;

    @PostMapping
    public ResponseEntity<?> order(@RequestBody OrderRequest request) {
        service.process(request);
        return ResponseEntity
                .ok("Successfully completed request!");
    }
}

OrderController 클래스는 /orders 경로로 들어오는 POST 요청을 처리하며, 요청 본문을 OrderRequest 객체로 받아 OrderServiceprocess() 메서드를 호출합니다. 처리 후에는 성공 메시지를 반환합니다.

다음으로 실제 비즈니스 로직을 수행하는 OrderService 클래스를 작성합니다:

OrderService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository repository;

    public void process(OrderRequest request) {
        repository.save(request);
    }
}

OrderService 클래스는 process() 메서드를 통해 OrderRequest 객체를 받아 리포지토리의 save() 메서드를 호출합니다. 이 클래스는 서비스 계층의 역할을 하며, 비즈니스 로직을 처리하는 부분입니다.

마지막으로 데이터베이스와의 상호작용을 담당하는 OrderRepository 클래스를 작성합니다:

OrderRepository.java
@Slf4j
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    public void save(OrderRequest request) {
        validate(request.isApproved());
        persist(request.getItemId());
    }

    private static void validate(boolean isApproved) {
        if (!isApproved) {
            throw new IllegalArgumentException("exceptions!");
        }
    }

    private static void persist(String itemId) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

OrderRepository 클래스는 save() 메서드를 통해 데이터를 저장합니다. 저장하기 전에 요청이 승인되었는지 검증하고, 데이터를 실제로 저장하는 persist() 메서드를 호출합니다. 여기서 persist() 메서드는 데이터 저장을 시뮬레이션하기 위해 1초 동안 대기하도록 코드를 작성하였습니다.

API가 정상적으로 동작하는지 확인하기 위해 테스트 코드를 작성하고 실행합니다:

OrderControllerTest.java
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {

    @Autowired
    protected MockMvc mockMvc;
    @Autowired
    protected ObjectMapper objectMapper;

    @Test
    @DisplayName("success")
    void shouldSuccess() throws Exception {

        // Given
        OrderRequest request = OrderRequest.builder()
                .itemId(UUID.randomUUID().toString())
                .approved(true)
                .build();

        // When
        String content = objectMapper.writeValueAsString(request);
        ResultActions actions = mockMvc.perform(
                post("/orders")
                        .content(content)
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .header(ACCEPT_LANGUAGE, KOREA));

        // Then
        actions
                .andExpect(status().isOk());
    }
}

이제 샘플 API가 준비되었습니다. 다음 단계에서는 API 로깅을 위한 로그 추적기를 구현해보겠습니다.

Implementing Log Tracer

프로세스 로깅을 위한 TraceIdTraceStatus 클래스를 구현합니다. TraceId 클래스는 각 요청에 대한 고유한 트레이스 ID와 호출 스택의 깊이를 표현하는 트레이스 레벨을 관리합니다.

TraceId.java
@Slf4j
@Getter
public class TraceId {

    private final String id;
    private final int level;

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

    @Builder
    public TraceId(String id, int level) {
        this.id = id;
        this.level = level;
    }

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

    public TraceId createNextId() {
        return new TraceId(id, level + 1);
    }

    public TraceId createPreviousId() {
        return new TraceId(id, level - 1);
    }

    public boolean isFirstLevel() {
        return level == 0;
    }
}

TraceId 클래스는 트레이스 ID를 생성하고, 현재 레벨에서 다음 또는 이전 레벨로 이동할 수 있는 메서드를 제공합니다. 각 요청은 고유한 트레이스 ID를 가지며, 메서드 호출 계층에 따라 트레이스 레벨이 증가하거나 감소합니다.

다음으로 TraceStatus 클래스를 구현합니다. 이 클래스는 트레이스 ID, 시작 시간, 메시지를 포함하여 로깅 상태를 관리합니다.

TraceStatus.java
@Slf4j
@Getter
public class TraceStatus {

    private final TraceId traceId;
    private final Long startTimeMs;
    private final String message;

    @Builder
    public TraceStatus(TraceId traceId, Long startTimeMs, String message) {
        this.traceId = traceId;
        this.startTimeMs = startTimeMs;
        this.message = message;
    }
}

TraceStatus 클래스는 트레이스 ID와 시작 시간을 저장하며, 트레이스 메시지를 포함합니다. 이를 통해 각 요청의 시작과 끝, 그리고 예외 발생 시점을 로깅할 수 있습니다.

이제 로깅의 주요 로직을 담당하는 LogTraceImpl 클래스를 작성합니다. 이 클래스는 LogTrace 인터페이스를 구현하여 로깅 기능을 제공하며, 인터페이스를 사용함으로써 추후 다른 객체로 쉽게 교체할 수 있도록 설계되었습니다:

LogTraceImpl.java
@Slf4j
@Component
@RequiredArgsConstructor
public class LogTraceImpl implements LogTrace {

    private static final String START_PREFIX = "-->";
    private static final String COMPLETE_PREFIX = "<--";
    private static final String EX_PREFIX = "<X-";
    private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder.get();
        Long startTimeMs = System.currentTimeMillis();
        TraceStatus status = new TraceStatus(traceId, startTimeMs, message);
        onStart(status);
        return status;
    }

    @Override
    public void end(TraceStatus status) {
        Long stopTimeMs = System.currentTimeMillis();
        long resultTimeMs = stopTimeMs - status.getStartTimeMs();
        onSuccess(status, resultTimeMs);
        releaseTraceId();
    }

    @Override
    public void exception(TraceStatus status, Exception e) {
        Long stopTimeMs = System.currentTimeMillis();
        long resultTimeMs = stopTimeMs - status.getStartTimeMs();
        onFailure(status, resultTimeMs, e);
        releaseTraceId();
    }

    private void syncTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId == null) {
            traceIdHolder.set(new TraceId());
        } else {
            traceIdHolder.set(traceId.createNextId());
        }
    }

    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId.isFirstLevel()) {
            traceIdHolder.remove();
        } else {
            traceIdHolder.set(traceId.createPreviousId());
        }
    }

    private void onStart(TraceStatus status) {
        log.info("[{}] {}{}",
                 status.getTraceId().getId(),
                 addSpace(START_PREFIX, status.getTraceId().getLevel()),
                 status.getMessage());
    }

    private void onSuccess(TraceStatus status, long resultTimeMs) {
        log.info("[{}] {}{} time={}ms",
                 status.getTraceId().getId(),
                 addSpace(COMPLETE_PREFIX, status.getTraceId().getLevel()),
                 status.getMessage(),
                 resultTimeMs);
    }

    private void onFailure(TraceStatus status, long resultTimeMs, Exception e) {
        log.info("[{}] {}{} time={}ms ex={}",
                 status.getTraceId().getId(),
                 addSpace(EX_PREFIX, status.getTraceId().getLevel()),
                 status.getMessage(),
                 resultTimeMs,
                 e.toString());
    }

    private static 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();
    }
}

LogTraceImpl 클래스는 로깅의 주요 로직을 구현합니다. 이 클래스는 begin(), end(), exception() 메서드를 통해 각 요청의 시작과 끝, 그리고 예외 발생 시점을 로깅합니다.

  • traceIdHolder: 각 쓰레드마다 고유한 TraceId를 저장하기 위해 ThreadLocal을 사용합니다. 이는 다중 쓰레드 환경에서 안전하게 트레이스 정보를 관리하기 위함입니다. 각 쓰레드는 자신만의 TraceId를 가지므로 동시성 문제를 방지할 수 있습니다.
  • begin(): 요청이 시작될 때 호출됩니다. syncTraceId()를 통해 트레이스 ID를 동기화하고, 시작 시간을 기록한 TraceStatus 객체를 생성하여 반환합니다.
  • end(): 요청이 정상적으로 끝날 때 호출됩니다. 종료 시간을 기록하고, 처리 시간을 계산하여 로그를 남긴 후, releaseTraceId()를 호출하여 트레이스 ID를 해제합니다.
  • exception(): 요청 중 예외가 발생할 때 호출됩니다. 종료 시간을 기록하고, 처리 시간을 계산하여 예외 정보를 포함한 로그를 남긴 후, releaseTraceId()를 호출합니다.
  • syncTraceId(): 현재 쓰레드의 트레이스 ID를 가져와서 존재하지 않으면 새로 생성하고, 이미 존재하면 트레이스 레벨을 증가시킵니다.
  • releaseTraceId(): 현재 쓰레드의 트레이스 ID를 가져와서 첫 번째 레벨이면 제거하고, 그렇지 않으면 트레이스 레벨을 감소시킵니다. ThreadLocal을 사용하므로 쓰레드의 자원을 적절히 해제해야합니다.
  • onStart(): 요청이 시작될 때 로그를 남깁니다. 트레이스 ID와 메시지를 포함하여 로그를 기록합니다.
  • onSuccess(): 요청이 성공적으로 끝날 때 로그를 남깁니다. 트레이스 ID, 메시지, 처리 시간을 포함하여 로그를 기록합니다.
  • onFailure(): 요청 중 예외가 발생했을 때 로그를 남깁니다. 트레이스 ID, 메시지, 처리 시간, 예외 정보를 포함하여 로그를 기록합니다.
  • addSpace(): 로그 메시지에 트레이스 레벨에 따라 들여쓰기를 추가합니다. 각 레벨에 맞는 접두사를 추가하여 가독성을 높입니다. 예를 들어, 호출 스택 레벨이 증가하면 --> 화살표를 추가하고, 호출 스택이 레벨이 감소하면 <-- 화살표를, 예외가 발생하여 처리가 실패한 경우에는 <X- 화살표를 추가합니다.

이와 같이 LogTraceImpl 클래스는 각 요청의 시작, 종료, 예외 발생 시점을 로깅하여 실행 흐름을 추적할 수 있게 합니다.

Integrating Log Tracer

추가한 로그 추적기를 API에 통합하여 메서드의 처리 시간을 측정하고 실행 흐름을 추적해 보겠습니다. 단순한 접근법으로 시작해 점진적으로 문제를 해결하고 개선하면서, 횡단 관심사를 효과적으로 처리하는 방법을 살펴보도록 하겠습니다.

Applying Logging Directly

먼저, OrderController 클래스의 order() 메서드에 로깅 기능을 추가합니다. 이 작업을 통해 메서드의 시작, 종료, 예외 발생 시점을 기록할 수 있습니다. LogTrace 객체를 주입받아 메서드의 시작과 끝, 그리고 예외 발생 시점을 로깅합니다:

OrderController.java
private final OrderService service;
private final LogTrace trace;

@PostMapping
public ResponseEntity<?> order(@RequestBody OrderRequest request) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderController.order()");

        service.process(request);

        trace.end(status);

        return ResponseEntity
                .ok("Successfully completed request!");
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}

다음으로, OrderService 클래스의 process() 메서드에도 동일한 방식으로 로깅을 추가하여 비즈니스 로직의 시작과 끝, 그리고 예외 발생 시점을 로깅하여 처리되는 전체 흐름을 기록합니다:

OrderService.java
private final OrderRepository repository;
private final LogTrace trace;

public void process(OrderRequest request) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderService.process()");

        repository.save(request);

        trace.end(status);
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}

마지막으로, OrderRepository 클래스의 save() 메서드에도 로깅을 추가하여 메서드의 실행 흐름을 기록합니다:

OrderRepository.java
private final LogTrace trace;

public void save(OrderRequest request) {
    TraceStatus status = null;
    try {
        status = trace.begin("OrderRepository.save()");

        validate(request.isApproved());
        persist(request.getItemId());

        trace.end(status);
    } catch (Exception e) {
        trace.exception(status, e);
        throw e;
    }
}

이제 HTTP 요청이 처리되고 반환되는 전체 프로세스에서 로그 추적기를 사용하여 처리 과정을 기록할 수 있게 되었습니다.

정상적으로 로그가 기록되는지 확인하기 위해 API를 호출하는 테스트 코드를 실행해보겠습니다. 이를 통해 로그 추적기가 정상적으로 동작하며 각 메서드의 실행 시간과 과정을 로깅하는 것을 확인할 수 있습니다:

Console
[33701d92] OrderController.order()
[33701d92] |-->OrderService.process()
[33701d92] |   |-->OrderRepository.save()
[33701d92] |   |<--OrderRepository.save() time=1005ms
[33701d92] |<--OrderService.process() time=1006ms
[33701d92] OrderController.order() time=1008ms
> Task :test
BUILD SUCCESSFUL in 3s

이와 같이 각 핵심 로직에 로그 추적기 관련 코드를 추가함으로써, API의 처리 과정을 성공적으로 추적할 수 있게 되었습니다.

그러나 이러한 기능은 비즈니스 로직의 핵심이 아니라 개발자를 위한 부가적인 기능입니다. 운영 코드에 직접 추가하는 것은 유지보수나 가독성 측면에서 적절하지 않습니다. 또한, 많은 API에 이 기능을 적용하려면 대량의 코드를 수정해야 하는 반복 작업이 필요합니다. 이는 매우 비효율적이고 비현실적인 접근 방식입니다.

다음은 이러한 접근 방식의 한계에 대해 논의하고 이를 극복하는 방법을 살펴보겠습니다.

Isolating Auxiliary with Proxies

로그 추적기를 운영 코드의 각 메서드마다 직접 추가하는 접근 방식은 비효율적이며 코드의 가독성을 저하시키고 유지보수의 어려움을 초래합니다. 이러한 접근 방식의 한계로 인해 해결해야 하는 문제는 다음 두 가지로 요약할 수 있습니다:

  • 핵심 비즈니스 로직에 부가기능 코드가 혼재되는 현상
  • 각 컴포넌트마다 부가기능을 수동으로 추가해야 하는 비효율성

이 문제를 해결하기 위해 프록시 객체를 도입하는 방법을 고려해 볼 수 있습니다. 프록시 객체를 사용하면 실제 로직을 호출하기 전후에 추가적인 작업을 수행할 수 있어, 운영 코드를 수정하지 않고도 로그 추적기를 도입할 수 있습니다. Spring Framework는 기본적으로 Spring AOP 모듈을 내장하고 있어서 이를 활용하면 간편하게 프록시 객체를 구성할 수 있습니다.

먼저, Spring AOP 모듈에서 제공하는 MethodInterceptor 인터페이스를 구현하여 메서드 호출 전후에 로그 추적기를 추가하는 LogTraceAdvice 클래스를 작성합니다.

LogTraceAdvice.java
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
@RequiredArgsConstructor
public class LogTraceAdvice implements MethodInterceptor {

    private final LogTrace trace;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TraceStatus status = null;
        try {
            Method method = invocation.getMethod();
            String message = String.format("%s.%s()", method.getDeclaringClass().getSimpleName(), method.getName());
            status = trace.begin(message);

            Object result = invocation.proceed();

            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

이 클래스는 프록시 객체 생성 시 활용되며, 대상 클래스의 메서드 실행 전후에 로깅을 포함한 부가 기능을 삽입하는 데 사용됩니다.

  • MethodInterceptor: Spring AOP에서 제공하는 인터페이스로, 메서드 호출을 가로채어 추가적인 처리를 할 수 있습니다. 이 인터페이스는 하나의 메서드 invoke()만을 정의하고 있으며, 이는 메서드 호출을 가로채는 데 사용됩니다.
  • invoke(): MethodInterceptor 인터페이스의 유일한 메서드로, 프록시 객체의 메서드가 호출될 때마다 실행됩니다. 이 메서드 안에서 추가적인 로직을 수행할 수 있으며, MethodInvocation 객체를 통해 실제 메서드를 호출합니다.
  • MethodInvocation: 현재 호출된 메서드와 해당 메서드의 인수를 캡슐화하는 객체입니다. 이 객체를 사용하여 실제 메서드를 호출하고, 호출 전후에 필요한 로직을 추가할 수 있습니다. MethodInvocation 객체는 다음과 같은 중요한 메서드와 정보를 포함하고 있습니다:
    • proceed(): 대상 메서드를 호출하여 실제 로직을 실행합니다.
    • getMethod(): 호출된 메서드의 이름, 반환 타입, 매개변수 타입과 같은 메타데이터를 반환합니다.
    • getArguments(): 메서드 호출 시 전달된 인수들을 배열로 반환합니다.
    • getThis(): 현재 실행 중인 프록시 객체를 반환합니다.

다음으로, 구성 클래스를 추가하고 Spring AOP에서 제공하는 프록시 팩토리를 통해 각 대상 클래스마다 프록시 객체를 생성하고 LogTraceAdvice를 적용합니다:

LogTraceConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class LogTraceConfig {

    @Bean
    public OrderController orderController(LogTrace trace) {
        OrderController target = new OrderController(orderService(trace));
        ProxyFactory factory = new ProxyFactory(target);
        factory.addAdvisor(createAdvisor(trace));
        OrderController proxy = (OrderController) factory.getProxy();
        log.info("orderController: proxy={}, target={}", proxy.getClass(), target.getClass());
        return proxy;
    }

    @Bean
    public OrderService orderService(LogTrace trace) {
        OrderService target = new OrderService(orderRepository(trace));
        ProxyFactory factory = new ProxyFactory(target);
        factory.addAdvisor(createAdvisor(trace));
        OrderService proxy = (OrderService) factory.getProxy();
        log.info("orderService: proxy={}, target={}", proxy.getClass(), target.getClass());
        return proxy;
    }

    @Bean
    public OrderRepository orderRepository(LogTrace trace) {
        OrderRepository target = new OrderRepository();
        ProxyFactory factory = new ProxyFactory(target);
        factory.addAdvisor(createAdvisor(trace));
        OrderRepository proxy = (OrderRepository) factory.getProxy();
        log.info("orderRepository: proxy={}, target={}", proxy.getClass(), target.getClass());
        return proxy;
    }

    @Bean
    public LogTrace trace() {
        return new LogTraceImpl();
    }

    public Advisor createAdvisor(LogTrace trace) {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("order*", "process*", "save*");
        LogTraceAdvice advice = new LogTraceAdvice(trace);
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

이와 같이 프록시 객체를 Spring Bean으로 등록함으로써, 운영 코드를 직접 수정하지 않고도 부가 기능을 추가할 수 있습니다.

  • ProxyFactory: Spring AOP에서 제공하는 유틸리티 클래스입니다. 이 클래스는 프록시 객체를 생성하는 데 사용되며, 인터페이스 유무에 따라 자동으로 CGLIB나 JDK Dynamic Proxy를 선택하여 프록시를 생성합니다. 프록시 객체는 실제 객체에 대한 대리자 역할을 하여, 메서드 호출 전후에 추가적인 로직을 삽입할 수 있게 합니다.
  • Advice: AOP에서 부가 기능을 정의하는 모듈입니다. AOP의 핵심 개념으로, 실제 비즈니스 로직에 영향을 주지 않으면서 부가 기능을 추가할 수 있게 해줍니다. LogTraceAdviceMethodInterceptor 인터페이스를 구현하여 메서드 호출 전후에 로깅을 수행하도록 하였는데, 바로 이 MethodInterceptorAdvice 인터페이스를 상속받고 있습니다.
  • Pointcut: 어떤 메서드에 어드바이스를 적용할지 정의하는 조건을 설정하는 역할을 하는 클래스입니다. 여기서는 NameMatchMethodPointcut을 사용하여 특정 이름 패턴에 맞는 메서드에만 어드바이스를 적용합니다. 예를 들어, 메서드 이름이 "order", "process", "save"로 시작하는 메서드에만 로깅을 추가할 수 있습니다.
  • Advisor: 포인트컷과 어드바이스를 결합한 객체로, 어떤 메서드에 어떤 어드바이스를 적용할지 결정합니다. 여기서는 DefaultPointcutAdvisor를 사용하여 포인트컷과 어드바이스를 결합합니다. Advisor는 AOP 설정에서 매우 중요한 역할을 하며, 특정 조건에 맞는 메서드에 어드바이스를 적용할 수 있게 합니다.

이제 프록시를 통해 로깅이 처리되기 때문에 이전 단계에서 각각의 대상 클래스에 추가한 로그 추적기 관련 코드를 모두 제거하여 핵심 비즈니스 로직만 남겨두는 것이 가능해졌습니다:

Targets
// OrderController.java
private final OrderService service;

@PostMapping
public ResponseEntity<?> order(@RequestBody OrderRequest request) {
    service.process(request);
    return ResponseEntity
            .ok("Successfully completed request!");
}

// OrderService.java
private final OrderRepository repository;

public void process(OrderRequest request) {
    repository.save(request);
}

// OrderRepository.java
public void save(OrderRequest request) {
    validate(request.isApproved());
    persist(request.getItemId());
}

테스트 코드를 실행해보면 프록시 객체를 통한 로그 추적기가 정상적으로 동작하여 각 메서드의 실행 시간과 프로세스의 과정을 로깅하는 것을 확인할 수 있습니다:

Console
[33701d92] OrderController.order()
[33701d92] |-->OrderService.process()
[33701d92] |   |-->OrderRepository.save()
[33701d92] |   |<--OrderRepository.save() time=1005ms
[33701d92] |<--OrderService.process() time=1006ms
[33701d92] OrderController.order() time=1008ms
> Task :test
BUILD SUCCESSFUL in 3s

프록시 객체를 도입하여 로그 추적기를 적용함으로써, 운영 코드를 수정하지 않고도 로그 추적기를 도입할 수 있게 되었습니다. 이는 Spring AOP(Aspect-Oriented Programming)를 활용하여 메서드 호출을 가로채고 부가 기능을 추가하는 전형적인 AOP 패턴을 구현한 것입니다. Spring AOP를 사용하면 핵심 비즈니스 로직과 부가 기능을 효과적으로 분리할 수 있습니다.

하지만 이를 통해서도 여전히 해결되지 않은 문제가 하나 남아있습니다. 바로 LogTraceConfig 구성 클래스에서 확인할 수 있듯이 모든 대상 클래스에 대한 프록시 객체를 직접 등록해야 하는 작업입니다. 지금은 학습을 위한 간단한 API이기 때문에 로직이 단순하지만, 실제 운영 레벨에서는 매우 복잡해질 수 있습니다. 또한, 이렇게 하나만 적용하는 것이 아니라 수많은 프로세스에 로그 추적기를 도입해야 할 수도 있습니다.

이렇게 다양한 레이어를 횡단하는 관심사를 효율적으로 개선하기 위해 AOP를 더 적극적으로 활용해 보겠습니다.

Enhancing with AspectJ

AOP를 더 효율적으로 사용하기 위해서 AspectJ 라이브러리를 도입합니다. 이를 위해 spring-boot-starter-aop 의존성을 build.gradle에 추가합니다.

build.gradle
dependencies {
+   implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

이렇게 Spring Boot Starter AOP 의존성을 추가하면 Spring AOP와 AspectJ의 기능을 모두 활용하여 효율적으로 AOP를 적용할 수 있습니다. Spring AOP의 단순성과 AspectJ의 강력함을 결합하여 보다 유연하고 강력한 AOP 설정을 할 수 있습니다.

Spring AOP와 AspectJ는 모두 AOP를 구현하기 위한 프레임워크이지만, 서로 다른 방식과 특징을 가지고 있습니다.

  • Spring AOP:
    • Spring AOP는 Spring 프레임워크에서 제공하는 AOP 구현체입니다.
    • 주로 런타임 프록시를 사용하여 AOP를 구현하며, JDK 동적 프록시와 CGLIB 기반 프록시를 지원합니다.
    • Spring 컨테이너에 의해 관리되는 Spring Bean에 대해서만 AOP를 적용할 수 있습니다.
    • AOP의 적용 범위는 주로 메서드 실행입니다.
    • @AspectJ 스타일의 어노테이션을 사용하여 AOP를 설정할 수 있습니다.
    • 보다 단순하고 간편한 AOP 기능을 제공합니다.
  • AspectJ:
    • AspectJ는 독립적인 AOP 프레임워크로, 더 강력하고 유연한 AOP 기능을 제공합니다.
    • 컴파일 시점, 로드 시점, 런타임 시점에서 부가 기능을 적용할 수 있습니다.
    • 바이트코드 조작을 통해 AOP를 구현하며, 더 정교한 포인트컷과 어드바이스를 지원합니다.
    • 스프링 컨테이너 외부의 객체에도 AOP를 적용할 수 있습니다.
    • 복잡한 포인트컷 표현식과 다양한 어드바이스 타입을 지원합니다.

이제 AspectJ에서 제공하는 강력한 AOP 기능을 활용하여 로그 추적기 통합 기능을 개선해보겠습니다. 위에서 작업한 LogTraceAdvice와 관련 구성을 모두 제거하고, LogTraceAspect 클래스를 추가하여 AspectJ를 활용한 로그 추적기능을 구현합니다.

LogTraceAspect.java
@Slf4j
@Aspect
@RequiredArgsConstructor
public class LogTraceAspect {

    private final LogTrace trace;

    @Around("execution(* app.catsriding.aop.order..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;
        try {
            String message = joinPoint.getSignature().toShortString();
            status = trace.begin(message);

            Object result = joinPoint.proceed();

            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

AspectJ에서 제공하는 강력한 포인트컷 표현식을 사용하여 프록시가 적용될 대상을 더욱 정교하게 지정할 수 있습니다. 다음은 AspectJ에서 자주 사용하는 주요 기능들입니다:

  • @Aspect: 이 어노테이션은 해당 클래스가 AspectJ의 관점(Aspect)임을 나타냅니다. 관점이란 AOP에서 로깅, 트랜잭션 관리와 같은 부가 기능을 모듈화하는 단위입니다.
  • @Pointcut: 포인트컷을 정의하고 재사용할 수 있게 합니다. 포인트컷은 AOP 적용 대상 메서드나 위치를 지정하는 표현식입니다.
  • @Around: 포인트컷 표현식에 해당하는 메서드 호출을 가로채어 그 전후로 부가 기능을 수행합니다. 예를 들어, 메서드 실행 전후에 로깅을 추가하거나 실행 시간을 측정할 수 있습니다.
  • @Before: 포인트컷 표현식에 해당하는 메서드가 실행되기 전에 부가 기능을 수행합니다. 예를 들어, 메서드 호출 전에 로그를 남기거나 사전 조건을 검사할 수 있습니다.
  • @After: 포인트컷 표현식에 해당하는 메서드가 실행된 후에 부가 기능을 수행합니다. 메서드 실행 후에 정리 작업을 하거나 로그를 남길 수 있습니다.
  • @AfterReturning: 포인트컷 표현식에 해당하는 메서드가 정상적으로 종료된 후에 부가 기능을 수행합니다. 메서드가 성공적으로 반환된 후에 로그를 남기거나 결과를 처리할 수 있습니다.
  • @AfterThrowing: 포인트컷 표현식에 해당하는 메서드 실행 중 예외가 발생한 후에 부가 기능을 수행합니다. 예외가 던져졌을 때 예외 정보를 로깅하거나 다른 처리를 할 수 있습니다.
  • ProceedingJoinPoint: 메서드 호출을 캡슐화하며, 실제 메서드 호출을 제어할 수 있습니다. proceed() 메서드를 호출하여 원래의 메서드를 실행할 수 있으며, 실행 전후로 부가 기능을 삽입할 수 있습니다.
  • @DeclareParents: 기존의 클래스에 새로운 메서드나 인터페이스를 동적으로 추가할 수 있습니다. 이는 믹스인(Mixin) 스타일의 프로그래밍을 가능하게 합니다.
  • @DeclarePrecedence: 여러 Aspect가 적용될 때 우선 순위를 지정할 수 있습니다.
  • cflow(): 특정 포인트컷 표현식의 제어 흐름 안에 있는 조인 포인트를 지정합니다.

AspectJ에서는 다양한 포인트컷 표현식을 사용하여 특정 메서드나 클래스에 AOP를 적용할 수 있습니다. AspectJ에서 자주 사용하는 포인트컷 표현식의 문법은 다음과 같습니다:

Pointcut-Expressions
# 메서드 실행 조인 포인트 매칭
execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?)
# 특정 타입 내의 조인 포인트 매칭
within(type-pattern)
# 프록시 객체의 타입을 기준으로 조인 포인트 매칭
this(type)
# 실제 대상 객체의 타입을 기준으로 조인 포인트 매칭
target(type)
# 메서드 인수의 타입을 기준으로 조인 포인트 매칭
args(types)
# 주어진 어노테이션이 타입 선언에 있는 조인 포인트 매칭
@within(annotation)
# 주어진 어노테이션이 타입 선언에 있는 실제 대상 객체의 조인 포인트 매칭
@target(annotation)
# 주어진 어노테이션이 메서드 인수에 있는 조인 포인트 매칭
@args(annotation)
# 주어진 어노테이션이 메서드에 있는 조인 포인트 매칭
@annotation(annotation)

- `modifiers-pattern`: 접근 제한자 
    - `public`
    - `private`
    - `protected`
    - `static`
    - 선택사항
- `ret-type-pattern`: 반환 타입
    - `*`: 모든 반환 타입
    - `void`
    - 내장 클래스: `int`, `String`
    - 사용자 정의 타입
- `declaring-type-pattern`: 선언 타입
- `name-pattern`: 메서드 이름
    - `*`: 모든 메서드
    - 특정 메서드 이름 또는 패턴: `get*`, `set*`
- `param-pattern`: 매개변수 패턴
    - `( .. )`: 모든 매개변수 타입과 수
    - `(String, int)`: 특정 매개변수 타입과 수
- `throws-pattern`: 예외 패턴
- `type-pattern`: 클래스나 패키지의 전체 경로
- `types`: 인수 타입
- `annotation`: 어노테이션

이러한 표현식들은 특정한 조건을 만족하는 메서드 호출을 대상으로 하여 AOP 어드바이스를 적용하는 역할을 합니다. 이를 통해 보다 정교하게 AOP를 적용할 수 있습니다.

다음으로, 이 LogTraceAspect를 Spring Bean에 등록하여 자동으로 AOP를 적용할 수 있도록 구성 클래스를 작성합니다:

LogTraceConfig.java
@Slf4j
@Configuration
public class LogTraceConfig {

    @Bean
    public LogTraceAspect logTraceAspect(LogTrace trace) {
        return new LogTraceAspect(trace);
    }

    @Bean
    public LogTrace trace() {
        return new LogTraceImpl();
    }
}

이와 같이 LogTraceAspect를 Spring Bean으로 등록하면, 애플리케이션이 구동될 때 해당 포인트컷 표현식에 맞는 모든 대상 객체를 찾아서 프록시를 생성합니다. 이 과정은 다음과 같습니다:

  • LogTraceAspect 클래스에서 정의한 포인트컷 표현식에 해당하는 모든 메서드를 가진 대상 객체를 스캔합니다. 여기서는 order 패키지 하위 클래스의 메서드가 대상입니다.
  • Spring은 이 대상 객체들을 프록시로 감쌉니다. 프록시 객체는 실제 메서드 호출 전후에 추가적인 작업을 수행할 수 있게 합니다.
  • 프록시 객체는 대상 객체의 메서드를 호출할 때마다 LogTraceAspect에 정의된 어드바이스 execute() 함수를 실행합니다.
  • 이 어드바이스는 메서드 호출 전후에 로그를 남기고, 예외 발생 시 예외 정보를 기록하는 등의 부가 기능을 수행합니다.
  • 실제 메서드 호출은 joinPoint.proceed()를 통해 이루어집니다.

이 과정을 통해 개발자는 핵심 비즈니스 로직에 영향을 주지 않으면서도 효율적으로 로깅과 같은 부가 기능을 적용할 수 있습니다. 이렇게 설정한 AOP가 올바르게 작동하는지 테스트합니다:

Console
[c5c7ac3e] OrderController.order(..)
[c5c7ac3e] |-->OrderService.process(..)
[c5c7ac3e] |   |-->OrderRepository.save(..)
[c5c7ac3e] |   |<--OrderRepository.save(..) time=1005ms
[c5c7ac3e] |<--OrderService.process(..) time=1006ms
[c5c7ac3e] OrderController.order(..) time=1010ms
> Task :test
BUILD SUCCESSFUL in 3s

Spring AOP는 어노테이션을 사용하여 AOP를 적용할 수도 있습니다. @Transactional 어노테이션이 이에 대한 대표적인 사례입니다. 이는 메서드나 클래스에 붙여 트랜잭션 관리 기능을 쉽게 추가할 수 있게 합니다. 이제 유사한 방식으로, 로깅을 위해 @Trace 어노테이션을 정의하고 이를 활용하여 각 클래스의 메서드에 AOP를 적용해보겠습니다. 이를 통해 @Transactional 어노테이션이 어떻게 동작하는지에 대한 이해도 넓힐 수 있을 것입니다.

Trace.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {

}

LogTraceAspect 클래스에서 포인트컷을 변경하여 @Trace 어노테이션이 붙은 메서드만 로깅하도록 수정합니다:

LogTraceAspect.java
@Slf4j
@Aspect
@RequiredArgsConstructor
public class LogTraceAspect {

    private final LogTrace trace;

-   @Around("execution(* app.catsriding.aop.order..*(..))")
+   @Around("@annotation(app.catsriding.aop.annotation.Trace)")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;
        try {
            String message = joinPoint.getSignature().toShortString();
            status = trace.begin(message);

            Object result = joinPoint.proceed();

            trace.end(status);
            return result;
        } catch (Exception e) {
            trace.exception(status, e);
            throw e;
        }
    }
}

포인트컷 표현식을 @annotation() 구문으로 변경하고 대상 어노테이션인 @Trace를 지정함으로써, 이 어노테이션이 추가된 메서드에서 로깅 기능이 활성화되도록 하였습니다. 이렇게 하면 적용하려는 대상에 어노테이션을 추가해야 하지만, 패키지명을 지정하는 것보다 더 유연하며, 운영 코드에 명시적으로 어노테이션을 추가함으로써 AOP 기능을 개발자에게 명확하게 알릴 수 있는 이점이 있습니다.

그러면 이제 이 어노테이션을 대상에 추가해보겠습니다. 먼저, OrderController 클래스의 order() 메서드에 @Trace 어노테이션을 추가합니다:

OrderController.java
@Trace
@PostMapping
public ResponseEntity<?> order(@RequestBody OrderRequest request) {
    service.process(request);
    return ResponseEntity
            .ok("Successfully completed request!");
}

다음으로, OrderService 클래스의 process() 메서드에도 동일하게 @Trace 어노테이션을 추가합니다:

OrderService.java
@Trace
public void process(OrderRequest request) {
    repository.save(request);
}

이제 process() 메서드가 호출될 때 이 메서드의 실행 흐름도 로그에 기록됩니다. 이는 OrderControllerorder() 메서드에서 시작된 로그 트레이스를 이어받아, 서비스 계층의 처리 과정을 추적할 수 있게 합니다.

마지막으로, OrderRepository 클래스의 save() 메서드에도 @Trace 어노테이션을 추가합니다:

OrderRepository.java
@Trace
public void save(OrderRequest request) {
    validate(request.isApproved());
    persist(request.getItemId());
}

save() 메서드 역시 @Trace 어노테이션을 통해 리포지토리 계층의 실행 흐름을 로그에 기록합니다. 이렇게 하면 컨트롤러, 서비스, 리포지토리 계층을 아우르는 전체 처리가 하나의 일련의 과정으로 로그에 기록되어, 실행 흐름을 쉽게 추적할 수 있습니다.

테스트를 실행하여 각 메서드 호출 시 로그가 제대로 기록되는지 확인해봅니다. 이를 통해 @Trace 어노테이션을 통해 적용된 AOP가 의도한 대로 작동하는지 검증할 수 있습니다:

Console
[ce326104] OrderController.order(..)
[ce326104] |-->OrderService.process(..)
[ce326104] |   |-->OrderRepository.save(..)
[ce326104] |   |<--OrderRepository.save(..) time=1005ms
[ce326104] |<--OrderService.process(..) time=1006ms
[ce326104] OrderController.order(..) time=1009ms
> Task :test
BUILD SUCCESSFUL in 7s

AOP는 이러한 어노테이션을 기반으로 프록시 객체를 생성하여 각 메서드 호출 시 자동으로 로깅을 처리합니다. 이를 통해 코드의 중복을 줄이고, 유지보수성을 크게 향상시킬 수 있습니다.

Divide and Conquer

AOP를 활용하여 애플리케이션 프로세스 로그 추적과 같은 횡단 관심사를 모듈화하면서, 핵심 비즈니스 로직과 부가적인 기능을 분리함으로써 코드의 복잡성을 줄이고 더 나은 코드 구조와 효율적인 관심사 분리를 달성할 수 있었습니다. 이러한 AOP 방식은 많은 잘 알려진 모니터링 도구에서도 널리 사용되고 있습니다. 실무에서는 어느 정도 규모가 있지 않은 이상 AOP를 직접 구현하여 활용하는 경우는 드문듯 하지만, AOP는 Spring 컨테이너와 함께 Spring Framework의 핵심 기능 중 하나로, 프레임워크를 효과적으로 사용하기 위해 반드시 이해할 필요가 있는 중요한 개념인 것 같습니다.


  • Spring