이론 학습으로 돌아가기
Spring FrameworkPart 0: 디자인 패턴

Spring 02: 디자인 패턴 (2) - Proxy, Decorator, Observer

Spring AOP의 핵심인 Proxy 패턴과 이벤트 기반 설계의 Observer 패턴을 학습합니다. JDK Dynamic Proxy와 CGLIB의 차이점, @Transactional의 동작 원리를 이해합니다.

약 60분9개 섹션

학습 목표

  • -Proxy 패턴의 원리와 Spring AOP에서의 활용 이해
  • -JDK Dynamic Proxy와 CGLIB의 차이점과 선택 기준
  • -@Transactional, @Async, @Cacheable의 동작 원리
  • -Decorator 패턴으로 기능을 동적으로 확장하는 방법
  • -Observer 패턴과 Spring Event를 활용한 느슨한 결합
  • -Adapter와 Facade 패턴으로 시스템 통합 및 복잡성 관리

1. Proxy 패턴 - Spring AOP의 핵심

"Proxy는 다른 객체에 대한 접근을 제어하는 대리자 역할을 한다."

— Gang of Four

Proxy 패턴은 Spring Framework에서 가장 중요한 패턴입니다. @Transactional, @Async, @Cacheable, @Retryable, AOP 등 Spring의 핵심 기능들이 모두 Proxy를 통해 구현됩니다. Proxy 패턴을 이해하면 Spring의 "마법"처럼 보이는 동작들이 어떻게 작동하는지 명확하게 이해할 수 있습니다.

1.1 Proxy 패턴이란?

Proxy는 "대리인"이라는 뜻입니다. 실제 객체를 대신하여 클라이언트의 요청을 받아 처리하는 객체입니다. 클라이언트는 실제 객체를 직접 호출하는 것처럼 보이지만, 실제로는 Proxy 객체가 중간에서 요청을 가로채어 부가적인 작업을 수행합니다.

Proxy의 세 가지 주요 역할

접근 제어 (Protection Proxy)

권한 검사, 지연 로딩(Lazy Loading), 캐싱 등을 통해 실제 객체에 대한 접근을 제어합니다.

  • - 권한이 없으면 접근 차단
  • - 필요할 때까지 객체 생성 지연
  • - 결과를 캐싱하여 재사용
부가 기능 (Decorator-like)

로깅, 트랜잭션 관리, 성능 측정 등 핵심 로직과 분리된 부가 기능을 추가합니다.

  • - 메서드 호출 전후 로깅
  • - 트랜잭션 시작/커밋/롤백
  • - 실행 시간 측정
원격 호출 (Remote Proxy)

원격 서버의 객체를 마치 로컬 객체처럼 사용할 수 있게 합니다.

  • - RPC, RMI 구현
  • - 네트워크 통신 추상화
  • - 분산 시스템 투명성

1.2 왜 Proxy가 필요한가?

비즈니스 로직과 부가 기능(횡단 관심사)을 분리하기 위해 Proxy가 필요합니다. 로깅, 트랜잭션, 보안, 캐싱 같은 기능은 여러 클래스에 걸쳐 반복적으로 나타납니다. 이런 코드를 각 클래스에 직접 작성하면 코드 중복이 발생하고, 비즈니스 로직이 부가 기능 코드에 묻혀 가독성이 떨어집니다.

Proxy 없이 - 로깅 코드가 비즈니스 로직에 침투

public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    
    public Order createOrder(Long customerId, List<OrderItem> items) {
        // 로깅 - 시작
        long startTime = System.currentTimeMillis();
        log.info("[OrderService.createOrder] 시작 - customerId: {}, items: {}", 
                 customerId, items.size());
        
        try {
            // 트랜잭션 시작 (수동)
            TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition());
            
            try {
                // === 실제 비즈니스 로직 시작 ===
                Customer customer = customerRepository.findById(customerId)
                    .orElseThrow(() -> new CustomerNotFoundException(customerId));
                
                Order order = Order.create(customer, items);
                
                // 재고 확인 및 차감
                for (OrderItem item : items) {
                    inventoryService.decreaseStock(
                        item.getProductId(), 
                        item.getQuantity()
                    );
                }
                
                // 주문 저장
                Order savedOrder = orderRepository.save(order);
                // === 실제 비즈니스 로직 끝 ===
                
                // 트랜잭션 커밋
                transactionManager.commit(status);
                
                // 로깅 - 성공
                log.info("[OrderService.createOrder] 성공 - orderId: {}", 
                         savedOrder.getId());
                
                return savedOrder;
                
            } catch (Exception e) {
                // 트랜잭션 롤백
                transactionManager.rollback(status);
                throw e;
            }
            
        } catch (Exception e) {
            // 로깅 - 실패
            log.error("[OrderService.createOrder] 실패 - customerId: {}", 
                      customerId, e);
            throw e;
            
        } finally {
            // 로깅 - 소요시간
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[OrderService.createOrder] 소요시간: {}ms", elapsed);
        }
    }
    
    public void cancelOrder(Long orderId) {
        // 또 같은 패턴의 로깅, 트랜잭션 코드 반복...
        long startTime = System.currentTimeMillis();
        log.info("[OrderService.cancelOrder] 시작 - orderId: {}", orderId);
        
        try {
            TransactionStatus status = transactionManager.getTransaction(
                new DefaultTransactionDefinition());
            
            try {
                // 비즈니스 로직
                Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new OrderNotFoundException(orderId));
                order.cancel();
                
                // 재고 복구
                for (OrderItem item : order.getItems()) {
                    inventoryService.increaseStock(
                        item.getProductId(), 
                        item.getQuantity()
                    );
                }
                
                transactionManager.commit(status);
                log.info("[OrderService.cancelOrder] 성공 - orderId: {}", orderId);
                
            } catch (Exception e) {
                transactionManager.rollback(status);
                throw e;
            }
            
        } catch (Exception e) {
            log.error("[OrderService.cancelOrder] 실패 - orderId: {}", orderId, e);
            throw e;
            
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[OrderService.cancelOrder] 소요시간: {}ms", elapsed);
        }
    }
}

문제점: 비즈니스 로직(약 10줄)이 로깅/트랜잭션 코드(약 30줄)에 묻혀있습니다. 새로운 메서드를 추가할 때마다 같은 패턴을 반복해야 합니다.

Proxy 패턴 적용 - 관심사 분리

// 1. 인터페이스 정의
public interface OrderService {
    Order createOrder(Long customerId, List<OrderItem> items);
    void cancelOrder(Long orderId);
    Order getOrder(Long orderId);
    List<Order> getOrdersByCustomer(Long customerId);
}

// 2. 실제 구현 - 순수한 비즈니스 로직만 포함
@Service
public class OrderServiceImpl implements OrderService {
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final InventoryService inventoryService;
    
    @Override
    public Order createOrder(Long customerId, List<OrderItem> items) {
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
        
        Order order = Order.create(customer, items);
        
        for (OrderItem item : items) {
            inventoryService.decreaseStock(item.getProductId(), item.getQuantity());
        }
        
        return orderRepository.save(order);
    }
    
    @Override
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        order.cancel();
        
        for (OrderItem item : order.getItems()) {
            inventoryService.increaseStock(item.getProductId(), item.getQuantity());
        }
    }
    
    @Override
    public Order getOrder(Long orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }
    
    @Override
    public List<Order> getOrdersByCustomer(Long customerId) {
        return orderRepository.findByCustomerId(customerId);
    }
}

3. 로깅 Proxy - 부가 기능 담당

public class LoggingOrderServiceProxy implements OrderService {
    private final OrderService target;
    private final Logger log = LoggerFactory.getLogger(LoggingOrderServiceProxy.class);
    
    public LoggingOrderServiceProxy(OrderService target) {
        this.target = target;
    }
    
    @Override
    public Order createOrder(Long customerId, List<OrderItem> items) {
        String methodName = "createOrder";
        long startTime = System.currentTimeMillis();
        
        log.info("[{}] 시작 - customerId: {}, itemCount: {}", 
                 methodName, customerId, items.size());
        
        try {
            Order result = target.createOrder(customerId, items);
            
            log.info("[{}] 성공 - orderId: {}, totalAmount: {}", 
                     methodName, result.getId(), result.getTotalAmount());
            
            return result;
            
        } catch (Exception e) {
            log.error("[{}] 실패 - customerId: {}, error: {}", 
                      methodName, customerId, e.getMessage(), e);
            throw e;
            
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[{}] 소요시간: {}ms", methodName, elapsed);
        }
    }
    
    @Override
    public void cancelOrder(Long orderId) {
        String methodName = "cancelOrder";
        long startTime = System.currentTimeMillis();
        
        log.info("[{}] 시작 - orderId: {}", methodName, orderId);
        
        try {
            target.cancelOrder(orderId);
            log.info("[{}] 성공 - orderId: {}", methodName, orderId);
            
        } catch (Exception e) {
            log.error("[{}] 실패 - orderId: {}, error: {}", 
                      methodName, orderId, e.getMessage(), e);
            throw e;
            
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[{}] 소요시간: {}ms", methodName, elapsed);
        }
    }
    
    @Override
    public Order getOrder(Long orderId) {
        log.debug("[getOrder] orderId: {}", orderId);
        return target.getOrder(orderId);
    }
    
    @Override
    public List<Order> getOrdersByCustomer(Long customerId) {
        log.debug("[getOrdersByCustomer] customerId: {}", customerId);
        return target.getOrdersByCustomer(customerId);
    }
}

4. 트랜잭션 Proxy - 트랜잭션 관리 담당

public class TransactionalOrderServiceProxy implements OrderService {
    private final OrderService target;
    private final PlatformTransactionManager transactionManager;
    
    public TransactionalOrderServiceProxy(
            OrderService target, 
            PlatformTransactionManager transactionManager) {
        this.target = target;
        this.transactionManager = transactionManager;
    }
    
    @Override
    public Order createOrder(Long customerId, List<OrderItem> items) {
        // 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(
            new DefaultTransactionDefinition());
        
        try {
            // 실제 비즈니스 로직 위임
            Order result = target.createOrder(customerId, items);
            
            // 성공 시 커밋
            transactionManager.commit(status);
            
            return result;
            
        } catch (Exception e) {
            // 실패 시 롤백
            transactionManager.rollback(status);
            throw e;
        }
    }
    
    @Override
    public void cancelOrder(Long orderId) {
        TransactionStatus status = transactionManager.getTransaction(
            new DefaultTransactionDefinition());
        
        try {
            target.cancelOrder(orderId);
            transactionManager.commit(status);
            
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
    
    // 읽기 전용 메서드는 트랜잭션 없이 위임
    @Override
    public Order getOrder(Long orderId) {
        return target.getOrder(orderId);
    }
    
    @Override
    public List<Order> getOrdersByCustomer(Long customerId) {
        return target.getOrdersByCustomer(customerId);
    }
}

5. Proxy 체인 구성

@Configuration
public class OrderServiceConfig {
    
    @Bean
    public OrderService orderService(
            OrderRepository orderRepository,
            CustomerRepository customerRepository,
            InventoryService inventoryService,
            PlatformTransactionManager transactionManager) {
        
        // 1. 실제 구현체 생성
        OrderService realService = new OrderServiceImpl(
            orderRepository, customerRepository, inventoryService);
        
        // 2. 트랜잭션 Proxy로 감싸기
        OrderService transactionalProxy = new TransactionalOrderServiceProxy(
            realService, transactionManager);
        
        // 3. 로깅 Proxy로 감싸기 (가장 바깥)
        OrderService loggingProxy = new LoggingOrderServiceProxy(transactionalProxy);
        
        return loggingProxy;
    }
}

// 호출 흐름:
// Client -> LoggingProxy -> TransactionalProxy -> RealService
//
// createOrder() 호출 시:
// 1. LoggingProxy: 로그 시작
// 2. TransactionalProxy: 트랜잭션 시작
// 3. RealService: 비즈니스 로직 실행
// 4. TransactionalProxy: 트랜잭션 커밋 (또는 롤백)
// 5. LoggingProxy: 로그 종료, 소요시간 기록
Proxy 패턴의 장점:
  • - 비즈니스 로직과 부가 기능의 완전한 분리
  • - 각 Proxy는 단일 책임만 가짐 (SRP)
  • - 새로운 부가 기능 추가 시 기존 코드 수정 불필요 (OCP)
  • - Proxy 조합으로 다양한 기능 구성 가능
  • - 테스트 시 각 계층을 독립적으로 테스트 가능

1.3 수동 Proxy의 한계

위에서 본 것처럼 수동으로 Proxy를 만들면 관심사 분리는 되지만, 여전히 문제가 있습니다.

수동 Proxy의 문제점

1. 인터페이스의 모든 메서드 구현 필요

OrderService에 메서드가 10개 있다면, LoggingProxy도 10개 메서드를 모두 구현해야 합니다. 대부분은 단순 위임 코드입니다.

2. 서비스마다 Proxy 클래스 필요

OrderService, ProductService, CustomerService 각각에 대해 LoggingProxy, TransactionalProxy를 만들어야 합니다. 서비스가 100개면 Proxy도 200개가 필요합니다.

3. 인터페이스 변경 시 Proxy도 수정

OrderService에 새 메서드가 추가되면 모든 Proxy 클래스도 수정해야 합니다.

이 문제를 해결하기 위해 Java는 런타임에 Proxy를 동적으로 생성하는 기능을 제공합니다. 다음 섹션에서 JDK Dynamic Proxy와 CGLIB에 대해 알아보겠습니다.

2. JDK Dynamic Proxy

Java는 java.lang.reflect.Proxy 클래스를 통해 런타임에 동적으로 Proxy 객체를 생성하는 기능을 제공합니다. 이를 JDK Dynamic Proxy라고 합니다. 인터페이스 기반으로 동작하며, Spring AOP의 기본 Proxy 생성 방식입니다.

2.1 InvocationHandler 이해하기

JDK Dynamic Proxy의 핵심은 InvocationHandler 인터페이스입니다. Proxy 객체의 모든 메서드 호출은 InvocationHandler의 invoke() 메서드로 전달됩니다.

InvocationHandler 인터페이스

public interface InvocationHandler {
    /**
     * Proxy 인스턴스의 메서드 호출을 처리합니다.
     *
     * @param proxy  - Proxy 인스턴스 자체
     * @param method - 호출된 메서드의 Method 객체
     * @param args   - 메서드에 전달된 인자들
     * @return 메서드 호출 결과
     */
    Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

2.2 로깅 InvocationHandler 구현

public class LoggingInvocationHandler implements InvocationHandler {
    
    private final Object target;  // 실제 객체
    private final Logger log = LoggerFactory.getLogger(LoggingInvocationHandler.class);
    
    public LoggingInvocationHandler(Object target) {
        this.target = target;
    }
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String className = target.getClass().getSimpleName();
        String methodName = method.getName();
        
        // Object 클래스의 메서드는 로깅하지 않음
        if (method.getDeclaringClass() == Object.class) {
            return method.invoke(target, args);
        }
        
        long startTime = System.currentTimeMillis();
        
        log.info("[{}.{}] 시작 - args: {}", className, methodName, formatArgs(args));
        
        try {
            // 실제 메서드 호출
            Object result = method.invoke(target, args);
            
            log.info("[{}.{}] 성공 - result: {}", className, methodName, formatResult(result));
            
            return result;
            
        } catch (InvocationTargetException e) {
            // 실제 예외를 꺼내서 로깅
            Throwable cause = e.getTargetException();
            log.error("[{}.{}] 실패 - error: {}", className, methodName, cause.getMessage());
            throw cause;  // 원래 예외를 다시 던짐
            
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[{}.{}] 소요시간: {}ms", className, methodName, elapsed);
        }
    }
    
    private String formatArgs(Object[] args) {
        if (args == null || args.length == 0) {
            return "없음";
        }
        return Arrays.stream(args)
            .map(arg -> arg == null ? "null" : arg.toString())
            .collect(Collectors.joining(", "));
    }
    
    private String formatResult(Object result) {
        if (result == null) {
            return "null";
        }
        if (result instanceof Collection) {
            return "Collection(size=" + ((Collection<?>) result).size() + ")";
        }
        return result.toString();
    }
}

2.3 Dynamic Proxy 생성 및 사용

public class ProxyFactory {
    
    /**
     * 로깅 기능이 추가된 Proxy를 생성합니다.
     */
    @SuppressWarnings("unchecked")
    public static <T> T createLoggingProxy(T target, Class<T> interfaceType) {
        return (T) Proxy.newProxyInstance(
            interfaceType.getClassLoader(),           // 클래스 로더
            new Class<?>[] { interfaceType },         // 구현할 인터페이스들
            new LoggingInvocationHandler(target)      // 호출 핸들러
        );
    }
    
    /**
     * 여러 인터페이스를 구현하는 Proxy 생성
     */
    public static Object createProxy(Object target, InvocationHandler handler) {
        // target이 구현한 모든 인터페이스 가져오기
        Class<?>[] interfaces = target.getClass().getInterfaces();
        
        return Proxy.newProxyInstance(
            target.getClass().getClassLoader(),
            interfaces,
            handler
        );
    }
}

// 사용 예시
public class Application {
    
    public static void main(String[] args) {
        // 1. 실제 서비스 생성
        OrderService realService = new OrderServiceImpl(
            orderRepository, customerRepository, inventoryService);
        
        // 2. 로깅 Proxy 생성
        OrderService proxyService = ProxyFactory.createLoggingProxy(
            realService, OrderService.class);
        
        // 3. Proxy를 통해 호출 - 자동으로 로깅됨
        Order order = proxyService.createOrder(1L, items);
        
        // 출력:
        // [OrderServiceImpl.createOrder] 시작 - args: 1, [OrderItem(...)]
        // [OrderServiceImpl.createOrder] 성공 - result: Order(id=1, ...)
        // [OrderServiceImpl.createOrder] 소요시간: 45ms
    }
}

2.4 트랜잭션 InvocationHandler

public class TransactionalInvocationHandler implements InvocationHandler {
    
    private final Object target;
    private final PlatformTransactionManager transactionManager;
    private final Set<String> transactionalMethods;
    
    public TransactionalInvocationHandler(
            Object target,
            PlatformTransactionManager transactionManager,
            String... methodNames) {
        this.target = target;
        this.transactionManager = transactionManager;
        this.transactionalMethods = new HashSet<>(Arrays.asList(methodNames));
    }
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        
        // 트랜잭션이 필요한 메서드인지 확인
        if (!transactionalMethods.contains(methodName)) {
            return method.invoke(target, args);
        }
        
        // 트랜잭션 시작
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
        
        TransactionStatus status = transactionManager.getTransaction(def);
        
        try {
            Object result = method.invoke(target, args);
            transactionManager.commit(status);
            return result;
            
        } catch (InvocationTargetException e) {
            transactionManager.rollback(status);
            throw e.getTargetException();
            
        } catch (Exception e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}

// 사용 예시
OrderService transactionalProxy = (OrderService) Proxy.newProxyInstance(
    OrderService.class.getClassLoader(),
    new Class<?>[] { OrderService.class },
    new TransactionalInvocationHandler(
        realService,
        transactionManager,
        "createOrder", "cancelOrder", "updateOrder"  // 트랜잭션 적용할 메서드들
    )
);

2.5 여러 InvocationHandler 체이닝

/**
 * 여러 InvocationHandler를 체인으로 연결하는 핸들러
 */
public class ChainedInvocationHandler implements InvocationHandler {
    
    private final Object target;
    private final List<InvocationHandler> handlers;
    
    public ChainedInvocationHandler(Object target, List<InvocationHandler> handlers) {
        this.target = target;
        this.handlers = handlers;
    }
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 체인의 첫 번째 핸들러부터 시작
        return invokeChain(0, proxy, method, args);
    }
    
    private Object invokeChain(int index, Object proxy, Method method, Object[] args) 
            throws Throwable {
        if (index >= handlers.size()) {
            // 모든 핸들러를 거쳤으면 실제 메서드 호출
            return method.invoke(target, args);
        }
        
        // 현재 핸들러에게 다음 핸들러를 호출하는 책임 위임
        // (실제로는 더 복잡한 구현이 필요하지만 개념 설명용)
        return handlers.get(index).invoke(proxy, method, args);
    }
}

// 실제 Spring AOP는 이보다 훨씬 정교한 체이닝 메커니즘을 사용합니다.
// MethodInterceptor와 MethodInvocation을 통해 구현됩니다.

2.6 JDK Dynamic Proxy의 제약사항

1. 인터페이스 필수

JDK Dynamic Proxy는 인터페이스를 구현한 클래스만 프록시할 수 있습니다. 인터페이스 없이 클래스만 있는 경우 사용할 수 없습니다.

// 가능 - 인터페이스 있음
public interface OrderService { ... }
public class OrderServiceImpl implements OrderService { ... }

// 불가능 - 인터페이스 없음
public class OrderService { ... }  // JDK Dynamic Proxy 사용 불가
2. final 메서드 프록시 불가

인터페이스에 정의된 메서드만 프록시됩니다. 구현 클래스에만 있는 메서드는 프록시를 통해 호출할 수 없습니다.

3. 성능 오버헤드

리플렉션을 사용하므로 직접 호출보다 약간의 성능 오버헤드가 있습니다. 하지만 대부분의 애플리케이션에서는 무시할 수 있는 수준입니다.

JDK Dynamic Proxy 정리:
  • - 인터페이스 기반으로 런타임에 Proxy 생성
  • - InvocationHandler로 모든 메서드 호출을 가로챔
  • - Spring AOP의 기본 Proxy 생성 방식
  • - 인터페이스가 없으면 CGLIB 사용 필요

3. CGLIB Proxy

CGLIB(Code Generation Library)는 바이트코드 조작을 통해 클래스를 상속받는 Proxy를 생성합니다. 인터페이스 없이도 Proxy를 만들 수 있어 JDK Dynamic Proxy의 한계를 극복합니다. Spring Boot 2.0부터는 CGLIB가 기본 Proxy 생성 방식입니다.

3.1 CGLIB의 동작 원리

JDK Dynamic Proxy vs CGLIB

JDK Dynamic Proxy
  • - 인터페이스 기반
  • - 인터페이스를 구현하는 Proxy 생성
  • - java.lang.reflect.Proxy 사용
  • - 인터페이스 필수
interface OrderService
    ^
    |
OrderServiceImpl  <-- Proxy
(implements)          (implements)
CGLIB
  • - 클래스 기반
  • - 대상 클래스를 상속받는 Proxy 생성
  • - 바이트코드 조작 (ASM 라이브러리)
  • - 인터페이스 불필요
OrderService
    ^
    |
OrderService$$EnhancerByCGLIB
(extends, Proxy)

3.2 CGLIB MethodInterceptor

import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

/**
 * CGLIB의 MethodInterceptor - JDK의 InvocationHandler와 유사한 역할
 */
public class LoggingMethodInterceptor implements MethodInterceptor {
    
    private final Logger log = LoggerFactory.getLogger(LoggingMethodInterceptor.class);
    
    /**
     * @param obj       - Proxy 객체 (CGLIB가 생성한 서브클래스 인스턴스)
     * @param method    - 호출된 메서드
     * @param args      - 메서드 인자
     * @param proxy     - 부모 클래스의 메서드를 호출할 수 있는 MethodProxy
     */
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) 
            throws Throwable {
        
        String methodName = method.getName();
        
        // Object 클래스의 메서드는 그냥 통과
        if (method.getDeclaringClass() == Object.class) {
            return proxy.invokeSuper(obj, args);
        }
        
        long startTime = System.currentTimeMillis();
        log.info("[{}] 시작 - args: {}", methodName, Arrays.toString(args));
        
        try {
            // 부모 클래스(실제 클래스)의 메서드 호출
            // proxy.invokeSuper()는 리플렉션보다 빠름
            Object result = proxy.invokeSuper(obj, args);
            
            log.info("[{}] 성공 - result: {}", methodName, result);
            return result;
            
        } catch (Throwable e) {
            log.error("[{}] 실패 - error: {}", methodName, e.getMessage());
            throw e;
            
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[{}] 소요시간: {}ms", methodName, elapsed);
        }
    }
}

3.3 CGLIB Proxy 생성

public class CglibProxyFactory {
    
    /**
     * CGLIB를 사용하여 Proxy 생성
     */
    @SuppressWarnings("unchecked")
    public static <T> T createProxy(Class<T> targetClass, MethodInterceptor interceptor) {
        Enhancer enhancer = new Enhancer();
        
        // 상속받을 클래스 지정
        enhancer.setSuperclass(targetClass);
        
        // 메서드 호출을 가로챌 인터셉터 지정
        enhancer.setCallback(interceptor);
        
        // Proxy 인스턴스 생성
        return (T) enhancer.create();
    }
    
    /**
     * 생성자 인자가 있는 경우
     */
    @SuppressWarnings("unchecked")
    public static <T> T createProxy(
            Class<T> targetClass, 
            MethodInterceptor interceptor,
            Class<?>[] argumentTypes,
            Object[] arguments) {
        
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(targetClass);
        enhancer.setCallback(interceptor);
        
        // 생성자 인자와 함께 생성
        return (T) enhancer.create(argumentTypes, arguments);
    }
}

// 사용 예시 - 인터페이스 없는 클래스도 프록시 가능
public class OrderService {  // 인터페이스 없음!
    
    private final OrderRepository orderRepository;
    
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
    
    public Order createOrder(Long customerId, List<OrderItem> items) {
        // 비즈니스 로직
        return orderRepository.save(new Order(customerId, items));
    }
}

// Proxy 생성
OrderService proxy = CglibProxyFactory.createProxy(
    OrderService.class,
    new LoggingMethodInterceptor(),
    new Class<?>[] { OrderRepository.class },
    new Object[] { orderRepository }
);

// 호출 - 로깅이 자동으로 적용됨
Order order = proxy.createOrder(1L, items);

3.4 CGLIB의 제약사항

1. final 클래스/메서드 프록시 불가

CGLIB는 상속을 통해 Proxy를 생성하므로, final 클래스나 final 메서드는 프록시할 수 없습니다.

// 불가능 - final 클래스
public final class OrderService { ... }

// 불가능 - final 메서드
public class OrderService {
    public final Order createOrder(...) { ... }  // 프록시 안됨
}
2. 기본 생성자 필요 (Spring 4.0 이전)

Spring 4.0 이전에는 CGLIB Proxy 생성 시 기본 생성자가 필요했습니다. Spring 4.0부터는 Objenesis 라이브러리를 사용하여 이 제약이 해결되었습니다.

3. 생성자가 두 번 호출됨 (Spring 4.0 이전)

CGLIB는 상속을 사용하므로 Proxy 생성 시 부모 클래스의 생성자가 호출됩니다. Spring 4.0부터는 Objenesis로 이 문제도 해결되었습니다.

3.5 Spring에서의 Proxy 선택

// application.yml에서 설정
spring:
  aop:
    proxy-target-class: true   # true: CGLIB 강제 (기본값)
                               # false: 인터페이스 있으면 JDK Dynamic Proxy

// 또는 Java Config에서 설정
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)  // CGLIB 강제
public class AopConfig {
}

// Spring Boot 2.0 이후 기본 동작:
// 1. 인터페이스가 있어도 CGLIB 사용 (proxyTargetClass=true가 기본)
// 2. 이유: 일관된 동작, 인터페이스 없는 클래스도 프록시 가능

// 확인 방법
@Service
public class DebugService {
    
    @Autowired
    private OrderService orderService;
    
    public void checkProxy() {
        System.out.println("Proxy 클래스: " + orderService.getClass().getName());
        // 출력 예: com.shop.service.OrderService$$EnhancerBySpringCGLIB$$abc123
        
        System.out.println("CGLIB Proxy인가? " + 
            orderService.getClass().getName().contains("CGLIB"));
    }
}
CGLIB 정리:
  • - 클래스 상속 기반으로 Proxy 생성
  • - 인터페이스 없이도 Proxy 가능
  • - final 클래스/메서드는 프록시 불가
  • - Spring Boot 2.0부터 기본 Proxy 방식
  • - MethodProxy.invokeSuper()로 빠른 메서드 호출

4. Spring에서 Proxy가 사용되는 곳

Spring Framework의 많은 기능들이 Proxy를 통해 구현됩니다. @Transactional, @Async, @Cacheable 등의 어노테이션이 동작하는 원리를 이해하면 Spring을 더 효과적으로 사용할 수 있습니다.

4.1 @Transactional의 동작 원리

@Service
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    
    @Transactional  // Proxy가 이 메서드를 감싸서 트랜잭션 처리
    public Order createOrder(Long customerId, List<OrderItem> items) {
        Order order = Order.create(customerId, items);
        orderRepository.save(order);
        paymentService.processPayment(order);
        return order;
    }
}

// Spring이 생성하는 Proxy의 동작 (개념적 코드)
public class OrderService$$SpringCGLIB extends OrderService {
    
    private final OrderService target;
    private final PlatformTransactionManager txManager;
    
    @Override
    public Order createOrder(Long customerId, List<OrderItem> items) {
        // 1. @Transactional 어노테이션 확인
        TransactionAttribute txAttr = getTransactionAttribute(method);
        
        // 2. 트랜잭션 시작
        TransactionStatus status = txManager.getTransaction(txAttr);
        
        try {
            // 3. 실제 메서드 호출
            Order result = super.createOrder(customerId, items);
            
            // 4. 커밋
            txManager.commit(status);
            return result;
            
        } catch (RuntimeException e) {
            // 5. 롤백 (RuntimeException은 기본적으로 롤백)
            txManager.rollback(status);
            throw e;
            
        } catch (Exception e) {
            // 6. Checked Exception은 기본적으로 커밋
            txManager.commit(status);
            throw e;
        }
    }
}
@Transactional이 동작하지 않는 경우

Proxy를 거치지 않으면 @Transactional이 적용되지 않습니다.

@Service
public class OrderService {
    
    public void processOrder(Long orderId) {
        // 같은 클래스 내에서 this로 호출 - Proxy를 거치지 않음!
        this.createOrder(orderId);  // @Transactional 적용 안됨
    }
    
    @Transactional
    public void createOrder(Long orderId) {
        // 트랜잭션이 시작되지 않음!
        orderRepository.save(...);
    }
}

// 해결 방법 1: 별도 클래스로 분리
@Service
public class OrderService {
    private final OrderTransactionService txService;
    
    public void processOrder(Long orderId) {
        txService.createOrder(orderId);  // Proxy를 통해 호출
    }
}

@Service
public class OrderTransactionService {
    @Transactional
    public void createOrder(Long orderId) {
        // 트랜잭션 정상 동작
    }
}

// 해결 방법 2: Self-injection (권장하지 않음)
@Service
public class OrderService {
    @Autowired
    private OrderService self;  // Proxy가 주입됨
    
    public void processOrder(Long orderId) {
        self.createOrder(orderId);  // Proxy를 통해 호출
    }
}

4.2 @Async의 동작 원리

@Service
public class NotificationService {
    
    @Async  // Proxy가 별도 스레드에서 실행
    public CompletableFuture<Void> sendEmailAsync(String to, String content) {
        // 이메일 발송 (시간이 오래 걸림)
        emailSender.send(to, content);
        return CompletableFuture.completedFuture(null);
    }
    
    @Async("customExecutor")  // 특정 Executor 사용
    public void sendSmsAsync(String phoneNumber, String message) {
        smsSender.send(phoneNumber, message);
    }
}

// Spring이 생성하는 Proxy의 동작 (개념적 코드)
public class NotificationService$$SpringCGLIB extends NotificationService {
    
    private final AsyncTaskExecutor executor;
    
    @Override
    public CompletableFuture<Void> sendEmailAsync(String to, String content) {
        // 별도 스레드에서 실행
        return CompletableFuture.supplyAsync(() -> {
            return super.sendEmailAsync(to, content);
        }, executor);
    }
}

// 설정
@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean("customExecutor")
    public Executor customExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

4.3 @Cacheable의 동작 원리

@Service
public class ProductService {
    
    @Cacheable(value = "products", key = "#productId")
    public Product getProduct(Long productId) {
        log.info("DB에서 상품 조회: {}", productId);
        return productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
    }
    
    @CacheEvict(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    @CachePut(value = "products", key = "#result.id")
    public Product createProduct(CreateProductCommand command) {
        Product product = Product.create(command);
        return productRepository.save(product);
    }
}

// Spring이 생성하는 Proxy의 동작 (개념적 코드)
public class ProductService$$SpringCGLIB extends ProductService {
    
    private final CacheManager cacheManager;
    
    @Override
    public Product getProduct(Long productId) {
        Cache cache = cacheManager.getCache("products");
        String key = String.valueOf(productId);
        
        // 1. 캐시에서 먼저 조회
        Product cached = cache.get(key, Product.class);
        if (cached != null) {
            return cached;  // 캐시 히트 - DB 조회 안함
        }
        
        // 2. 캐시 미스 - 실제 메서드 호출
        Product result = super.getProduct(productId);
        
        // 3. 결과를 캐시에 저장
        cache.put(key, result);
        
        return result;
    }
}

// 설정
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10)));
        return cacheManager;
    }
}

4.4 Spring이 Proxy를 사용하는 기능 정리

어노테이션Proxy가 하는 일활성화 설정
@Transactional트랜잭션 시작/커밋/롤백@EnableTransactionManagement
@Async별도 스레드에서 비동기 실행@EnableAsync
@Cacheable캐시 조회/저장@EnableCaching
@Retryable실패 시 재시도@EnableRetry
@Secured권한 검사@EnableGlobalMethodSecurity
@Validated메서드 파라미터 검증자동 활성화
핵심 포인트:
  • - Spring의 선언적 기능들은 대부분 Proxy로 구현됨
  • - Proxy를 거쳐야 어노테이션이 동작함
  • - 같은 클래스 내 this 호출은 Proxy를 거치지 않음
  • - private 메서드는 Proxy 적용 불가

5. Decorator 패턴 - 기능의 동적 확장

Decorator 패턴은 객체에 동적으로 새로운 책임을 추가합니다. Proxy와 구조는 비슷하지만 목적이 다릅니다. Proxy는 접근 제어가 목적이고, Decorator는 기능 확장이 목적입니다.

5.1 Proxy vs Decorator

Proxy 패턴
  • - 접근 제어가 주 목적
  • - 대상 객체의 존재를 숨김
  • - 클라이언트는 Proxy인지 모름
  • - 보통 하나의 Proxy만 사용
  • - 예: 지연 로딩, 권한 검사, 캐싱
Decorator 패턴
  • - 기능 확장이 주 목적
  • - 여러 개를 중첩하여 사용
  • - 조합으로 다양한 기능 구성
  • - 런타임에 동적으로 기능 추가/제거
  • - 예: Java I/O, 필터 체인

5.2 E-commerce 예제: 배송비 계산

배송비 계산에는 여러 규칙이 적용됩니다: 기본 배송비, 무료 배송 조건, 도서산간 추가 요금, 회원 등급 할인 등. Decorator 패턴으로 이 규칙들을 조합할 수 있습니다.

기본 인터페이스와 구현

// 배송비 계산 인터페이스
public interface ShippingCalculator {
    Money calculate(Order order);
}

// 기본 구현 - 거리 기반 배송비
@Component
@Primary
public class BasicShippingCalculator implements ShippingCalculator {
    
    private static final Money BASE_FEE = Money.of(3000);
    private static final Money PER_KM_FEE = Money.of(100);
    
    @Override
    public Money calculate(Order order) {
        Address address = order.getShippingAddress();
        int distanceKm = calculateDistance(address);
        
        if (distanceKm <= 10) {
            return BASE_FEE;
        }
        
        // 10km 초과 시 km당 100원 추가
        int extraKm = distanceKm - 10;
        return BASE_FEE.add(PER_KM_FEE.multiply(extraKm));
    }
    
    private int calculateDistance(Address address) {
        // 실제로는 지도 API를 사용하여 거리 계산
        return 15;  // 예시
    }
}

Decorator 추상 클래스

// Decorator 기본 클래스
public abstract class ShippingCalculatorDecorator implements ShippingCalculator {
    
    protected final ShippingCalculator delegate;
    
    protected ShippingCalculatorDecorator(ShippingCalculator delegate) {
        this.delegate = delegate;
    }
    
    @Override
    public Money calculate(Order order) {
        return delegate.calculate(order);
    }
}

구체적인 Decorator들

// Decorator 1: 무료 배송 조건 적용
public class FreeShippingDecorator extends ShippingCalculatorDecorator {
    
    private final Money freeShippingThreshold;
    
    public FreeShippingDecorator(ShippingCalculator delegate, Money threshold) {
        super(delegate);
        this.freeShippingThreshold = threshold;
    }
    
    @Override
    public Money calculate(Order order) {
        // 주문 금액이 기준 이상이면 무료 배송
        if (order.getTotalAmount().isGreaterThanOrEqual(freeShippingThreshold)) {
            return Money.ZERO;
        }
        return delegate.calculate(order);
    }
}

// Decorator 2: 도서산간 추가 요금
public class RemoteAreaDecorator extends ShippingCalculatorDecorator {
    
    private static final Money REMOTE_AREA_FEE = Money.of(3000);
    private static final Set<String> REMOTE_AREAS = Set.of(
        "제주특별자치도", "울릉군", "옹진군"
    );
    
    public RemoteAreaDecorator(ShippingCalculator delegate) {
        super(delegate);
    }
    
    @Override
    public Money calculate(Order order) {
        Money baseFee = delegate.calculate(order);
        
        // 이미 무료 배송이면 도서산간 요금만 부과
        if (baseFee.equals(Money.ZERO)) {
            if (isRemoteArea(order.getShippingAddress())) {
                return REMOTE_AREA_FEE;
            }
            return Money.ZERO;
        }
        
        // 도서산간 지역이면 추가 요금
        if (isRemoteArea(order.getShippingAddress())) {
            return baseFee.add(REMOTE_AREA_FEE);
        }
        
        return baseFee;
    }
    
    private boolean isRemoteArea(Address address) {
        return REMOTE_AREAS.stream()
            .anyMatch(area -> address.getRegion().contains(area));
    }
}

// Decorator 3: 회원 등급별 배송비 할인
public class MemberDiscountDecorator extends ShippingCalculatorDecorator {
    
    public MemberDiscountDecorator(ShippingCalculator delegate) {
        super(delegate);
    }
    
    @Override
    public Money calculate(Order order) {
        Money baseFee = delegate.calculate(order);
        
        if (baseFee.equals(Money.ZERO)) {
            return Money.ZERO;
        }
        
        Customer customer = order.getCustomer();
        double discountRate = getDiscountRate(customer.getGrade());
        
        return baseFee.multiply(1 - discountRate);
    }
    
    private double getDiscountRate(CustomerGrade grade) {
        return switch (grade) {
            case VIP -> 0.5;      // 50% 할인
            case GOLD -> 0.3;     // 30% 할인
            case SILVER -> 0.1;   // 10% 할인
            default -> 0.0;       // 할인 없음
        };
    }
}

// Decorator 4: 특정 상품 무료 배송
public class ProductFreeShippingDecorator extends ShippingCalculatorDecorator {
    
    private final Set<Long> freeShippingProductIds;
    
    public ProductFreeShippingDecorator(
            ShippingCalculator delegate, 
            Set<Long> freeShippingProductIds) {
        super(delegate);
        this.freeShippingProductIds = freeShippingProductIds;
    }
    
    @Override
    public Money calculate(Order order) {
        // 무료 배송 상품이 포함되어 있으면 무료
        boolean hasFreeShippingProduct = order.getItems().stream()
            .anyMatch(item -> freeShippingProductIds.contains(item.getProductId()));
        
        if (hasFreeShippingProduct) {
            return Money.ZERO;
        }
        
        return delegate.calculate(order);
    }
}

5.3 Decorator 조합 설정

@Configuration
public class ShippingConfig {
    
    @Bean
    public ShippingCalculator shippingCalculator(
            @Value("${shipping.free-threshold:50000}") long freeThreshold,
            @Value("${shipping.free-products:}") Set<Long> freeShippingProducts) {
        
        // Decorator 체인 구성 (안쪽부터 바깥쪽 순서로)
        // 실행 순서: 회원할인 -> 도서산간 -> 상품무료 -> 금액무료 -> 기본계산
        
        ShippingCalculator calculator = new BasicShippingCalculator();
        
        // 1. 금액 기준 무료 배송
        calculator = new FreeShippingDecorator(
            calculator, 
            Money.of(freeThreshold)
        );
        
        // 2. 특정 상품 무료 배송
        if (!freeShippingProducts.isEmpty()) {
            calculator = new ProductFreeShippingDecorator(
                calculator, 
                freeShippingProducts
            );
        }
        
        // 3. 도서산간 추가 요금
        calculator = new RemoteAreaDecorator(calculator);
        
        // 4. 회원 등급 할인 (가장 마지막에 적용)
        calculator = new MemberDiscountDecorator(calculator);
        
        return calculator;
    }
}

// 사용하는 곳에서는 조합을 몰라도 됨
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final ShippingCalculator shippingCalculator;
    
    public Order createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        
        // 배송비 계산 - 모든 규칙이 자동 적용됨
        Money shippingFee = shippingCalculator.calculate(order);
        order.setShippingFee(shippingFee);
        
        return orderRepository.save(order);
    }
}

5.4 Java I/O의 Decorator 패턴

Java의 InputStream/OutputStream은 Decorator 패턴의 대표적인 예입니다. 기본 스트림에 버퍼링, 압축, 암호화 등의 기능을 동적으로 추가할 수 있습니다.

// Java I/O Decorator 체인
InputStream is = new BufferedInputStream(      // 버퍼링 추가
    new GZIPInputStream(                        // 압축 해제 추가
        new FileInputStream("data.gz")          // 기본 파일 읽기
    )
);

// 읽기 - 모든 Decorator가 순서대로 적용됨
// FileInputStream -> GZIPInputStream -> BufferedInputStream -> 사용자
byte[] data = is.readAllBytes();

// Spring의 비슷한 패턴
RestTemplate restTemplate = new RestTemplateBuilder()
    .interceptors(new LoggingInterceptor())     // 로깅 추가
    .interceptors(new RetryInterceptor())       // 재시도 추가
    .interceptors(new AuthInterceptor())        // 인증 추가
    .build();
Decorator 패턴 정리:
  • - 상속 없이 객체에 동적으로 기능 추가
  • - 여러 Decorator를 조합하여 다양한 기능 구성
  • - 단일 책임 원칙 준수 - 각 Decorator는 하나의 기능만
  • - 개방-폐쇄 원칙 준수 - 새 기능 추가 시 기존 코드 수정 불필요
  • - Spring의 Interceptor, Filter 체인이 대표적인 예

6. Observer 패턴 - 이벤트 기반 설계

Observer 패턴은 객체 간의 일대다 의존 관계를 정의합니다. 한 객체의 상태가 변경되면 의존하는 모든 객체에게 자동으로 알림이 갑니다. Spring의 ApplicationEvent가 대표적인 구현입니다.

6.1 왜 Observer 패턴이 필요한가?

Observer 없이 - 강한 결합

@Service
public class OrderService {
    // 너무 많은 의존성 - 주문 완료 후 해야 할 일이 늘어날 때마다 추가됨
    private final OrderRepository orderRepository;
    private final EmailService emailService;
    private final SmsService smsService;
    private final PushNotificationService pushService;
    private final InventoryService inventoryService;
    private final PointService pointService;
    private final AnalyticsService analyticsService;
    private final RecommendationService recommendationService;
    private final PartnerNotificationService partnerService;
    
    @Transactional
    public Order completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        order.complete();
        orderRepository.save(order);
        
        // 주문 완료 후 해야 할 일들이 모두 여기에 나열됨
        // 새로운 기능 추가할 때마다 이 클래스를 수정해야 함!
        
        // 1. 알림 발송
        emailService.sendOrderConfirmation(order);
        smsService.sendOrderNotification(order);
        pushService.sendOrderPush(order);
        
        // 2. 재고 차감
        for (OrderItem item : order.getItems()) {
            inventoryService.decreaseStock(item.getProductId(), item.getQuantity());
        }
        
        // 3. 포인트 적립
        int points = calculatePoints(order);
        pointService.addPoints(order.getCustomerId(), points);
        
        // 4. 분석 데이터 전송
        analyticsService.trackOrderCompleted(order);
        
        // 5. 추천 시스템 업데이트
        recommendationService.updatePurchaseHistory(order);
        
        // 6. 파트너사 알림 (마켓플레이스인 경우)
        if (order.hasPartnerProducts()) {
            partnerService.notifyPartners(order);
        }
        
        return order;
    }
}

문제점: OrderService가 너무 많은 책임을 가지고 있습니다. 새로운 후처리 로직이 추가될 때마다 OrderService를 수정해야 합니다. 또한 하나의 후처리가 실패하면 전체 트랜잭션이 롤백됩니다.

Spring Event 적용 - 느슨한 결합

// 1. 이벤트 정의
public record OrderCompletedEvent(
    Long orderId,
    Long customerId,
    Money totalAmount,
    List<OrderItemInfo> items,
    LocalDateTime completedAt
) {
    // 이벤트에 필요한 정보만 포함
    // Order 엔티티를 직접 전달하지 않음 (영속성 컨텍스트 문제 방지)
    
    public record OrderItemInfo(
        Long productId,
        int quantity,
        Money price
    ) {}
    
    public static OrderCompletedEvent from(Order order) {
        List<OrderItemInfo> itemInfos = order.getItems().stream()
            .map(item -> new OrderItemInfo(
                item.getProductId(),
                item.getQuantity(),
                item.getPrice()
            ))
            .toList();
        
        return new OrderCompletedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            itemInfos,
            LocalDateTime.now()
        );
    }
}

// 2. 이벤트 발행 - OrderService는 이벤트만 발행
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    
    @Transactional
    public Order completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        order.complete();
        Order savedOrder = orderRepository.save(order);
        
        // 이벤트 발행 - 누가 처리하는지 몰라도 됨
        eventPublisher.publishEvent(OrderCompletedEvent.from(savedOrder));
        
        return savedOrder;
    }
}

3. 이벤트 리스너들 - 각자 독립적으로 처리

// 알림 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderNotificationListener {
    
    private final EmailService emailService;
    private final SmsService smsService;
    private final CustomerRepository customerRepository;
    
    @EventListener
    public void sendEmail(OrderCompletedEvent event) {
        log.info("주문 완료 이메일 발송 - orderId: {}", event.orderId());
        
        Customer customer = customerRepository.findById(event.customerId())
            .orElseThrow();
        
        emailService.sendOrderConfirmation(
            customer.getEmail(),
            event.orderId(),
            event.totalAmount()
        );
    }
    
    @EventListener
    public void sendSms(OrderCompletedEvent event) {
        log.info("주문 완료 SMS 발송 - orderId: {}", event.orderId());
        
        Customer customer = customerRepository.findById(event.customerId())
            .orElseThrow();
        
        smsService.send(
            customer.getPhoneNumber(),
            String.format("주문이 완료되었습니다. 주문번호: %d", event.orderId())
        );
    }
}

// 재고 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class InventoryEventListener {
    
    private final InventoryService inventoryService;
    
    @EventListener
    public void decreaseStock(OrderCompletedEvent event) {
        log.info("재고 차감 시작 - orderId: {}", event.orderId());
        
        for (OrderCompletedEvent.OrderItemInfo item : event.items()) {
            inventoryService.decrease(item.productId(), item.quantity());
        }
        
        log.info("재고 차감 완료 - orderId: {}", event.orderId());
    }
}

// 포인트 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class PointEventListener {
    
    private final PointService pointService;
    
    @EventListener
    public void addPoints(OrderCompletedEvent event) {
        // 결제 금액의 1% 포인트 적립
        int points = event.totalAmount().getValue().intValue() / 100;
        
        log.info("포인트 적립 - customerId: {}, points: {}", 
                 event.customerId(), points);
        
        pointService.add(event.customerId(), points);
    }
}

// 분석 리스너 - 비동기 처리
@Component
@RequiredArgsConstructor
@Slf4j
public class AnalyticsEventListener {
    
    private final AnalyticsService analyticsService;
    
    @Async  // 별도 스레드에서 비동기 실행
    @EventListener
    public void trackOrder(OrderCompletedEvent event) {
        log.info("분석 데이터 전송 - orderId: {}", event.orderId());
        
        analyticsService.track("order_completed", Map.of(
            "orderId", event.orderId(),
            "customerId", event.customerId(),
            "amount", event.totalAmount().getValue(),
            "itemCount", event.items().size()
        ));
    }
}

6.2 @TransactionalEventListener

@EventListener는 이벤트 발행 시점에 즉시 실행됩니다. 트랜잭션이 롤백되어도 이미 실행된 리스너는 취소되지 않습니다. @TransactionalEventListener를 사용하면 트랜잭션 상태에 따라 실행 시점을 제어할 수 있습니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class TransactionalOrderEventListener {
    
    private final NotificationService notificationService;
    private final ExternalApiClient externalApiClient;
    
    // 트랜잭션 커밋 후에 실행 (기본값)
    // 주문이 확실히 저장된 후에 알림 발송
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendNotificationAfterCommit(OrderCompletedEvent event) {
        log.info("트랜잭션 커밋 후 알림 발송 - orderId: {}", event.orderId());
        
        // 이 시점에서는 주문이 확실히 DB에 저장됨
        notificationService.send(event);
    }
    
    // 트랜잭션 롤백 후에 실행
    // 주문 실패 시 보상 처리
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleRollback(OrderCompletedEvent event) {
        log.warn("트랜잭션 롤백 - orderId: {}", event.orderId());
        
        // 롤백 시 필요한 보상 처리
        // 예: 외부 시스템에 취소 요청
    }
    
    // 트랜잭션 완료 후에 실행 (커밋/롤백 무관)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void cleanup(OrderCompletedEvent event) {
        log.info("트랜잭션 완료 후 정리 - orderId: {}", event.orderId());
        
        // 임시 데이터 정리 등
    }
    
    // 트랜잭션 커밋 전에 실행
    // 같은 트랜잭션 내에서 추가 작업 필요할 때
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void validateBeforeCommit(OrderCompletedEvent event) {
        log.info("커밋 전 검증 - orderId: {}", event.orderId());
        
        // 외부 시스템 검증 등
        // 여기서 예외 발생 시 트랜잭션 롤백됨
    }
}

// TransactionPhase 정리
// BEFORE_COMMIT: 커밋 직전 (같은 트랜잭션)
// AFTER_COMMIT: 커밋 후 (기본값, 가장 많이 사용)
// AFTER_ROLLBACK: 롤백 후
// AFTER_COMPLETION: 완료 후 (커밋/롤백 무관)

6.3 이벤트 순서 제어

@Component
public class OrderedEventListeners {
    
    @EventListener
    @Order(1)  // 가장 먼저 실행
    public void validateFirst(OrderCompletedEvent event) {
        log.info("1. 검증 - orderId: {}", event.orderId());
        
        // 필수 검증 로직
        if (event.items().isEmpty()) {
            throw new IllegalStateException("주문 항목이 없습니다");
        }
    }
    
    @EventListener
    @Order(2)
    public void processSecond(OrderCompletedEvent event) {
        log.info("2. 핵심 처리 - orderId: {}", event.orderId());
        
        // 핵심 비즈니스 로직
    }
    
    @EventListener
    @Order(3)  // 가장 나중에 실행
    public void notifyLast(OrderCompletedEvent event) {
        log.info("3. 알림 - orderId: {}", event.orderId());
        
        // 알림은 마지막에
    }
}

// 조건부 이벤트 처리
@Component
public class ConditionalEventListener {
    
    // SpEL 조건으로 필터링
    @EventListener(condition = "#event.totalAmount.value > 100000")
    public void handleHighValueOrder(OrderCompletedEvent event) {
        log.info("고가 주문 처리 - orderId: {}, amount: {}", 
                 event.orderId(), event.totalAmount());
        
        // VIP 고객 처리 등
    }
    
    // 특정 조건에서만 실행
    @EventListener(condition = "#event.items.size() >= 5")
    public void handleBulkOrder(OrderCompletedEvent event) {
        log.info("대량 주문 처리 - orderId: {}, itemCount: {}", 
                 event.orderId(), event.items().size());
    }
}
Observer 패턴 (Spring Event) 정리:
  • - 발행자와 구독자 간의 느슨한 결합
  • - 새로운 리스너 추가 시 기존 코드 수정 불필요
  • - @TransactionalEventListener로 트랜잭션과 연계
  • - @Async와 조합하여 비동기 처리 가능
  • - @Order로 실행 순서 제어
  • - condition으로 조건부 처리

7. Adapter 패턴 - 인터페이스 변환

Adapter 패턴은 호환되지 않는 인터페이스를 가진 클래스들이 함께 동작할 수 있게 합니다. 외부 라이브러리나 레거시 시스템을 통합할 때 자주 사용됩니다. 기존 코드를 수정하지 않고 새로운 시스템과 연동할 수 있습니다.

7.1 E-commerce 예제: 결제 게이트웨이 통합

여러 PG사(Payment Gateway)를 통합해야 하는 상황을 생각해봅시다. 각 PG사마다 SDK의 인터페이스가 다릅니다. Adapter 패턴으로 우리 시스템의 인터페이스에 맞게 변환할 수 있습니다.

우리 시스템의 결제 인터페이스

// 우리 시스템에서 사용할 통합 결제 인터페이스
public interface PaymentGateway {
    PaymentResult process(PaymentRequest request);
    PaymentStatus checkStatus(String transactionId);
    RefundResult refund(String transactionId, Money amount);
}

public record PaymentRequest(
    String orderId,
    Money amount,
    String customerName,
    String customerEmail,
    PaymentMethod method
) {}

public record PaymentResult(
    String transactionId,
    PaymentStatus status,
    LocalDateTime approvedAt,
    String message
) {}

외부 PG사 SDK (Toss Payments) - 우리 인터페이스와 다름

public class TossPaymentsSdk {
    public TossPaymentResponse requestPayment(TossPaymentRequest request) { }
    public TossStatusResponse getPaymentByPaymentKey(String paymentKey) { }
    public TossCancelResponse cancelPayment(String paymentKey, TossCancelRequest req) { }
}

public class TossPaymentRequest {
    private int amount;
    private String orderId;
    private String orderName;
    private String successUrl;
    private String failUrl;
}

Toss Payments Adapter - 인터페이스 변환

@Component("tossPaymentGateway")
@RequiredArgsConstructor
public class TossPaymentsAdapter implements PaymentGateway {
    
    private final TossPaymentsSdk tossSdk;
    
    @Override
    public PaymentResult process(PaymentRequest request) {
        // 1. 우리 요청을 Toss 형식으로 변환
        TossPaymentRequest tossRequest = TossPaymentRequest.builder()
            .amount(request.amount().getValue().intValue())
            .orderId(request.orderId())
            .orderName("주문 " + request.orderId())
            .build();
        
        // 2. Toss SDK 호출
        TossPaymentResponse response = tossSdk.requestPayment(tossRequest);
        
        // 3. Toss 응답을 우리 형식으로 변환
        return new PaymentResult(
            response.getPaymentKey(),
            mapTossStatus(response.getStatus()),
            parseDateTime(response.getApprovedAt()),
            "결제 완료"
        );
    }
    
    @Override
    public PaymentStatus checkStatus(String transactionId) {
        TossStatusResponse response = tossSdk.getPaymentByPaymentKey(transactionId);
        return mapTossStatus(response.getStatus());
    }
    
    @Override
    public RefundResult refund(String transactionId, Money amount) {
        TossCancelResponse response = tossSdk.cancelPayment(
            transactionId, 
            new TossCancelRequest("고객 요청")
        );
        return new RefundResult(
            Money.of(response.getCancelAmount()),
            response.getCanceledAt()
        );
    }
    
    private PaymentStatus mapTossStatus(String tossStatus) {
        return switch (tossStatus) {
            case "DONE" -> PaymentStatus.COMPLETED;
            case "CANCELED" -> PaymentStatus.REFUNDED;
            case "WAITING_FOR_DEPOSIT" -> PaymentStatus.PENDING;
            default -> PaymentStatus.FAILED;
        };
    }
}

7.2 Spring MVC의 HandlerAdapter

// Spring MVC의 HandlerAdapter 인터페이스
public interface HandlerAdapter {
    boolean supports(Object handler);
    ModelAndView handle(HttpServletRequest request, 
                       HttpServletResponse response, 
                       Object handler) throws Exception;
}

// @Controller 메서드를 처리하는 Adapter
public class RequestMappingHandlerAdapter implements HandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return handler instanceof HandlerMethod;
    }
    
    @Override
    public ModelAndView handle(...) {
        // @RequestMapping 메서드 호출, 파라미터 바인딩 등
    }
}
Adapter 패턴 정리:
  • - 호환되지 않는 인터페이스를 연결
  • - 외부 라이브러리/레거시 시스템 통합에 유용
  • - 기존 코드 수정 없이 새로운 시스템 연동
  • - Spring의 HandlerAdapter가 대표적인 예

8. Facade 패턴 - 복잡성 숨기기

Facade 패턴은 복잡한 서브시스템에 대한 단순화된 인터페이스를 제공합니다. 클라이언트는 복잡한 내부 구조를 알 필요 없이 Facade만 사용하면 됩니다. DDD의 Application Service가 대표적인 Facade입니다.

8.1 E-commerce 예제: 주문 처리 Facade

Facade 없이 - 컨트롤러가 모든 서비스를 알아야 함

@RestController
public class OrderController {
    // 너무 많은 의존성
    private final CustomerService customerService;
    private final ProductService productService;
    private final InventoryService inventoryService;
    private final PricingService pricingService;
    private final DiscountService discountService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;
    private final OrderRepository orderRepository;
    
    @PostMapping("/orders")
    public OrderResponse createOrder(@RequestBody OrderRequest request) {
        // 컨트롤러가 비즈니스 로직을 조율해야 함
        Customer customer = customerService.findById(request.getCustomerId());
        List<Product> products = productService.findByIds(request.getProductIds());
        
        for (Product product : products) {
            if (!inventoryService.isAvailable(product.getId(), request.getQuantity())) {
                throw new OutOfStockException(product.getId());
            }
        }
        
        Money subtotal = pricingService.calculate(products);
        Money discount = discountService.apply(customer, subtotal);
        Money shipping = shippingService.calculate(request.getAddress());
        Money total = subtotal.subtract(discount).add(shipping);
        
        PaymentResult payment = paymentService.process(customer, total);
        
        Order order = new Order(...);
        orderRepository.save(order);
        inventoryService.decrease(...);
        
        return new OrderResponse(order);
    }
}

Facade 적용 - 단순화된 인터페이스

// Facade - 복잡한 주문 프로세스를 단순화
@Service
@RequiredArgsConstructor
public class OrderFacade {
    private final CustomerService customerService;
    private final ProductService productService;
    private final InventoryService inventoryService;
    private final PricingService pricingService;
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;
    
    @Transactional
    public OrderResult placeOrder(OrderCommand command) {
        // 1. 고객 및 상품 조회
        Customer customer = customerService.getById(command.customerId());
        List<OrderLine> orderLines = createOrderLines(command.items());
        
        // 2. 재고 확인
        validateStock(orderLines);
        
        // 3. 가격 계산
        PriceCalculation price = calculatePrice(customer, orderLines);
        
        // 4. 결제 처리
        PaymentResult payment = processPayment(customer, price.total());
        
        // 5. 주문 생성 및 저장
        Order order = createAndSaveOrder(customer, orderLines, price, payment);
        
        // 6. 재고 차감
        decreaseInventory(orderLines);
        
        return OrderResult.success(order);
    }
    
    private PriceCalculation calculatePrice(Customer customer, List<OrderLine> lines) {
        Money subtotal = pricingService.calculate(lines);
        Money discount = pricingService.applyDiscount(customer, subtotal);
        return new PriceCalculation(subtotal, discount);
    }
}

// Controller는 Facade만 의존
@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderFacade orderFacade;
    
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        OrderCommand command = request.toCommand();
        OrderResult result = orderFacade.placeOrder(command);
        return ResponseEntity.ok(OrderResponse.from(result));
    }
}

8.2 Facade가 하는 일과 하지 않는 일

Facade가 하는 일
  • - 트랜잭션 경계 관리
  • - 여러 서비스/도메인 조율
  • - 입력 검증 (기본적인)
  • - DTO와 Domain 객체 변환
  • - 이벤트 발행
Facade가 하지 않는 일
  • - 비즈니스 로직 구현
  • - 도메인 규칙 검증
  • - 복잡한 계산
  • - 상태 관리
  • - 직접 DB 접근
Facade 패턴 정리:
  • - 복잡한 서브시스템에 단순한 인터페이스 제공
  • - 클라이언트와 서브시스템 간의 결합도 감소
  • - Application Service가 대표적인 Facade
  • - 계층 간 경계를 명확하게 정의

9. 정리 및 다음 단계

9.1 이번 세션에서 배운 패턴

패턴목적Spring 적용
Proxy접근 제어, 부가 기능@Transactional, AOP, @Async
JDK Dynamic Proxy인터페이스 기반 ProxyInvocationHandler
CGLIB클래스 기반 ProxySpring Boot 기본 방식
Decorator동적 기능 확장Interceptor, Filter
Observer이벤트 기반 통신ApplicationEvent
Adapter인터페이스 변환HandlerAdapter
Facade복잡성 숨기기Application Service

9.2 핵심 포인트

Proxy는 Spring의 핵심

@Transactional, @Async, @Cacheable 등 대부분의 Spring 기능이 Proxy로 동작합니다. Proxy를 이해하면 Spring의 동작 원리가 보입니다.

Self-invocation 주의

같은 클래스 내에서 this.method()로 호출하면 Proxy를 거치지 않아 @Transactional 등이 동작하지 않습니다.

이벤트로 결합도 낮추기

Observer 패턴(Spring Event)을 사용하면 서비스 간 의존성을 제거하고 확장성 있는 구조를 만들 수 있습니다.

Facade로 복잡성 관리

Application Service를 Facade로 사용하여 컨트롤러와 도메인 사이의 복잡성을 관리합니다.

다음 세션 예고: Spring 03에서는 IoC/DI의 원리와 Spring Container의 동작 방식을 깊이 있게 다룹니다.