이전 포스팅들에서 스프링 컨테이너가 객체를 스캔하고, 조립하고, 생명주기를 통제하는 DI(의존성 주입)의 핵심 원리를 알아보았다.

 

하지만 엔터프라이즈 시스템은 비즈니스 로직만으로 굴러가지 않는다. 데이터베이스 트랜잭션을 열고 닫거나, 실행 시간을 측정하고, 권한을 검증하는 등의 인프라성 코드가 반드시 수반된다.

 

이번 포스팅 부터는 이러한 인프라 로직을 핵심 비즈니스 로직에서 완벽하게 분리해 내는 AOP(관점 지향 프로그래밍)의 내부 구현 원리를 다룬다. 이번 포스팅에서는 원본 코드를 건드리지 않고 런타임에 부가 기능을 주입하는 동적 프록시(Dynamic Proxy)에 대해 알아본다.

 

1. 횡단 관심사(Cross-cutting Concerns)

시스템을 개발하다 보면 애플리케이션의 여러 계층(Controller, Service, Repository)에 걸쳐 공통적으로 반복되는 로직이 발생한다. 이를 횡단 관심사라고 부른다.

 

만약 모든 서비스 클래스의 실행 시간을 측정해야 한다는 요구사항이 주어졌을 때, 비즈니스 로직 내부에서 시간 측정 코드를 직접 작성하면 심각한 아키텍쳐 결함이 발생한다. 핵심 도메인 로직은 단 한 줄인데, 시간을 측정하는 인프라 코드가 메서드의 시작과 끝을 둘러싸면서 코드의 가독성이 떨어지고 단일 책임 원칙(SRP)이 붕괴된다.

 

2. [Bad vs Good Code] 프록시 패턴을 활용한 인프라 로직 분리

프레임워크는 이 문제를 해결하기 위해 프록시(Proxy) 패턴을 사용한다. 클라이언트가 실제 객체를 직접 호출하는 대신, 프록시 객체를 먼저 거치도록 설계하여 그 안에서 인프라 로직을 처리하는 방식이다.

 

[Bad Code] 핵심 로직과 인프라 로직이 강하게 결합된 순수 코드

원본 도메인 클래스가 인프라 코드에 직접 오염되어 유지보수가 불가능해진다.

 

public class OrderServiceImpl implements OrderService {
    @Override
    public void placeOrder(String item) {
        long startTime = System.currentTimeMillis(); // 인프라 로직 침투
        
        try {
            System.out.println("[Domain] 주문 처리: " + item); // 실제 비즈니스 로직
        } finally {
            long endTime = System.currentTimeMillis(); // 인프라 로직 침투
            System.out.println("실행 시간: " + (endTime - startTime) + "ms");
        }
    }
}

 

[Good Code] JDK Dynamic Proxy를 활용하여 관심사를 분리한 아키텍처

스프링 프레임워크가 내부적으로 사용하는 AOP 제어 흐름을 이해하기 위해, 자바 표준 API인 JDK Dynamic Proxy를 직접 구현한 학습용 예제를 살펴보자. 원본 도메인 코드는 인프라 로직을 모두 제거하고 순수하게 유지된다.

 

// 1. 공통 관심사(성능 측정)를 처리할 범용 핸들러 작성
public class PerformanceInvocationHandler implements InvocationHandler {
    private final Object target;

    public PerformanceInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long startTime = System.currentTimeMillis(); // 인프라 로직 (전처리)
        
        try {
            return method.invoke(target, args); // 실제 원본 객체(Target)의 로직 실행 위임
        } finally {
            long endTime = System.currentTimeMillis(); // 인프라 로직 (후처리)
            System.out.println(method.getName() + " 실행 시간: " + (endTime - startTime) + "ms");
        }
    }
}

 

// 2. 런타임에 동적으로 프록시 객체를 생성하여 클라이언트에게 제공
public static void main(String[] args) {
    OrderService target = new OrderServiceImpl(); // 원본 객체
    
    // 메모리 상에서 OrderService 인터페이스를 구현한 가짜 프록시 객체를 동적으로 생성
    OrderService proxy = (OrderService) Proxy.newProxyInstance(
            OrderService.class.getClassLoader(),
            new Class[]{OrderService.class},
            new PerformanceInvocationHandler(target)
    );
    
    proxy.placeOrder("MacBook Pro");
}

 

이 구조에서 클라이언트는 자신이 프록시를 호출하는지, 실제 타겟을 호출하는지 전혀 알지 못한다. 프레임워크는 컨테이너에 빈을 등록할 때 원본 객체 대신 이 프록시 객체를 덮어씌워 주입(@Autowired) 함으로써, 개발자가 작성한 원본 코드를 수정하지 않고도 시스템 전반에 인프라 로직을 적용한다.

 

3. 프록시 생성의 두 가지 표준: JDK Proxy와 CGLIB

스프링 프레임워크가 프록시 객체를 동적으로 생성할 때 사용하는 기술은 크게 두 가지로 나뉜다.

 

 - JDK Dynamic Proxy : 자바 표준 API다. 내부적으로 리플렉션을 사용하며, 인터페이스 기반으로 프록시를 생성한다. 타겟 클래스가 반드시 하나 이상의 인터페이스를 구현하고 있어야만 적용이 가능하다는 제약이 있다.

 

 - CGLIB(Code Generation Library) : 바이트코드 조작 라이브러리다. 인터페이스가 없어도 구체 클래스를 상속받아 자식 클래스 형태로 프록시를 생성한다. 클래스나 메서드에 final 키워드가 붙어있으면 상속 및 오버라이딩이 불가능하므로 프록시를 적용할 수 없다는 한계가 있다.

 

4. 결론

과거 스프링은 타겟 클래스가 인터페이스를 구현하고 있으면 JDK Dynamic Proxy를, 그렇지 않으면 CGLIB를 혼용하여 사용했다. 하지만 최근의 Spring Boot 환경에서는 바이트코드 조작 기술이 발전함에 따라, 인터페이스 유무와 상관없이 예외 발생 확률이 적고 성능이 우수한 CGLIB를 기본 프록시 생성 전략으로 채택하고 있다. 우리가 매일 사용하는 @Transactional 이나 @Async 어노테이션이 바로 이 CGLIB 프록시 위에서 동작한다.

 

지금까지 동적 프록시 기술을 통해 런타임에 원본 객체를 감싸는 방법에 대해 알아보았다. 다음 포스팅에서는 포인트컷(Pointcut)과 어드바이스(Advice)가 어떻게 프록시로 직조(Weaving)되는지 알아본다.

+ Recent posts