Aspect-Oriented Programming 분해

GreatCloud·2025년 12월 18일

백엔드

목록 보기
7/14

이전에 Proxy가 무엇인지 자세히 한번 알아보았었다.
오늘은 AOP의 기본 원리인 프록시 패턴과 데코레이터 패턴의 차이를 명확하게 이해하고 Spring이 프록시를 만드는 2가지 방법인 JDK Dynamic Proxy, CGLIB, 그리고 AOP의 한계를 이해할 수 있는 @Transactional이 동작하지 않는 케이스들을 통해 알아볼 것이다


프록시 패턴 vs 데코레이터 패턴

Proxy는 "객체를 감싸는"패턴이라고도 이야기 한다.
그러나 "객체를 감싸는" 패턴은 하나가 아니다.
바로 데코레이터 패턴이 있으며 이 데코레이터 패턴과 프록시 패턴의 차이점을 확인해보자.

프록시 패턴

이전 프록시 패턴의 목적은 핵심 로직외의 부가적인 작업들에 대한 중복 작성을 피하기 위함이라고 했다.
그리고 Proxy는 이러한 부가적인 작업을 이용한 몇가지의 목적을 더 가지고 있다.

원본 객체로의 직접 접근을 막고 제어
아래의 코드는 이미지 로딩 시나리오에 대입하여 접근제어, 지연로딩에 대한 예시를 보여주고 있다.

public interface Image {
    void display();
}

// 진짜 객체 (무거움)
public class RealImage implements Image {
    private String filename;
    
    public RealImage(String filename) {
        this.filename = filename;
        loadFromDisk();  // 디스크에서 로딩 (느림)
    }
    
    private void loadFromDisk() {
        System.out.println("디스크에서 " + filename + " 로딩 중...");
    }
    
    @Override
    public void display() {
        System.out.println(filename + " 표시");
    }
}

// 프록시 (접근 제어)
public class ImageProxy implements Image {
    private String filename;
    private RealImage realImage;  // 실제 객체는 필요할 때만 생성
    
    public ImageProxy(String filename) {
        this.filename = filename;
    }
    
    @Override
    public void display() {
        if (realImage == null) {
            realImage = new RealImage(filename);  // 지연 로딩
        }
        realImage.display();
    }
}

// 사용
Image image = new ImageProxy("photo.jpg");
// 이 시점엔 아직 디스크에서 안 읽음

image.display();  // 이 시점에 디스크에서 읽음

여기서 핵심은 원본 객체에 대한 대리 역할이다. 이 예시에서는 접근 제어(지연 로딩)를 위해 사용되었다.


데코레이터 패턴

데코레이터의 패턴의 목적은 기능 추가 및 확장인데 이름에서 짐작 가능한데 기존 객체를 꾸며주는 것이다.
커피로 예를 들어보자

public interface Coffee {
    int cost();
    String description();
}

// 기본 커피
public class SimpleCoffee implements Coffee {
    @Override
    public int cost() {
        return 1000;
    }
    
    @Override
    public String description() {
        return "커피";
    }
}

// 데코레이터 1: 우유 추가
public class MilkDecorator implements Coffee {
    private Coffee coffee;
    
    public MilkDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
    
    @Override
    public int cost() {
        return coffee.cost() + 500;  // 기능 추가
    }
    
    @Override
    public String description() {
        return coffee.description() + " + 우유";  // 기능 추가
    }
}

// 데코레이터 2: 시럽 추가
public class SyrupDecorator implements Coffee {
    private Coffee coffee;
    
    public SyrupDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
    
    @Override
    public int cost() {
        return coffee.cost() + 300;  // 기능 추가
    }
    
    @Override
    public String description() {
        return coffee.description() + " + 시럽";  // 기능 추가
    }
}

// 사용
Coffee coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SyrupDecorator(coffee);

System.out.println(coffee.description());  // "커피 + 우유 + 시럽"
System.out.println(coffee.cost());         // 1800

핵심: "원본 기능을 유지하면서 무언가를 추가한다"


그래서 AOP는 어느 패턴 쓰는데?

정답은 둘 다 사용한다.

  • 프록시 패턴의 역할: 원본 객체 대신 프록시를 반환한다(접근 제어)
  • 데코레이터 패턴: 기능 추가

Spring AOP = 프록시 패턴 + 데코레이터 패턴
이라고 볼 수 있다.


JDK Dynamic Proxy, CGLIB

Spring은 프록시를 어떻게 만들어요?

일단 프록시를 만드는 방법 JDK Dynamic Proxy와 CGLIB 두가지를 살펴보자

1. JDK DynamicProxy(인터페이스 기반)

프록시 원본 객체

// 인터페이스
public interface UserService {
    void createUser(User user);
}

// 구현체
@Service
public class UserServiceImpl implements UserService {
    @Override
    public void createUser(User user) {
        System.out.println("사용자 생성: " + user.getName());
    }
}

JDK Dynamic Proxy 생성

UserService proxy = (UserService) Proxy.newProxyInstance(
    UserService.class.getClassLoader(),
    new Class[]{UserService.class},  // 인터페이스
    new InvocationHandler() {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) {
            System.out.println("메서드 실행 전");
            
            Object result = method.invoke(realService, args);
            
            System.out.println("메서드 실행 후");
            return result;
        }
    }
);

특징:

  • 인터페이스 기반
  • 인터페이스 없으면 사용 불가
  • Java 표준 (java.lang.reflect.Proxy)

2. CGLIB(클래스 기반)

인터페이스가 아닌 일반 클래스

@Service
public class UserService {
    public void createUser(User user) {
        System.out.println("사용자 생성: " + user.getName());
    }
}

CGLIB 프록시 생성:

Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);  // 클래스 상속
enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) {
        System.out.println("메서드 실행 전");
        
        Object result = proxy.invokeSuper(obj, args);
        
        System.out.println("메서드 실행 후");
        return result;
    }
});

UserService proxy = (UserService) enhancer.create();

특징:

  • 클래스 상속 기반
  • 인터페이스 없어도 사용 가능
  • final 클래스/메소드 사용 불가
  • private 메소드 사용 불가

그래서 Spring은 어떤 것을 사용하는데?

Spring Boot 2.0 이후 (기본값)
CGLIB를 기본으로 사용

@Service
public class UserService {  // 인터페이스 없음
    @Transactional
    public void createUser(User user) {
        // ...
    }
}

// Spring이 CGLIB으로 프록시 생성
UserService$$EnhancerBySpringCGLIB$$12345

혹시 인터페이스가 있으면 JDK DynamicProxy 사용하나?

//Spring Boot 2.0 이상
public interface UserService {
    void createUser(User user);
}

@Service
public class UserServiceImpl implements UserService {
    @Transactional
    @Override
    public void createUser(User user) {
        // ...
    }
}

응 아니야
2.0 이후에는 CGLIB를 사용한다
강제로 JDK DynamicProxy를 사용하는 방법이 있긴한데
굳이 지금 알아둘 필요는 없을 것 같다.


@Transactional이 동작하지 않는 케이스

1. private 메소드

@Service
public class UserService {
    
    @Transactional  // ❌ 안 먹힘!
    private void createUser(User user) {
        userRepository.save(user);
    }
}

CGLIB은 상속 기반이므로 private 메소드는 오버라이드 불가능하다. Transactional을 사용하려면 public을 사용하자

2. final 메소드

@Service
public class UserService {
    
    @Transactional  // ❌ 안 먹힘!
    public final void createUser(User user) {
        userRepository.save(user);
    }
}

마찬가지로 final 메소드는 오버라이드가 불가능하다 final을 제거하도록 하자.

3. 내부 메소드 호출(self-Invocation)

@Service
public class UserService {
    
    public void outerMethod() {
        innerMethod();  // ❌ 프록시를 안 거침!
    }
    
    @Transactional
    public void innerMethod() {
        userRepository.save(user);
    }
}

내부 메소드는 원본을 호출하므로 트랜잭션 범위에 포함되지 않는다.

/ 프록시
public class UserService$$Proxy extends UserService {
    @Override
    public void outerMethod() {
        super.outerMethod();  // 원본 호출
    }
    
    @Override
    public void innerMethod() {
        // 트랜잭션 시작
        super.innerMethod();
        // 트랜잭션 종료
    }
}

// 원본
public class UserService {
    public void outerMethod() {
        this.innerMethod();  // ← this = 원본 객체, 프록시 아님!
    }
}

해결 방법 1: 별도의 Bean으로 분리

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserInternalService internalService;
    
    public void outerMethod() {
        internalService.innerMethod();  // ✅ 프록시 거침
    }
}

@Service
public class UserInternalService {
    @Transactional
    public void innerMethod() {
        userRepository.save(user);
    }
}

해결 방법 2: 자기 자신 주입

@Service
public class UserService {
    @Autowired
    private UserService self;  // 자기 자신 (프록시)
    
    public void outerMethod() {
        self.innerMethod();  // ✅ 프록시 거침
    }
    
    @Transactional
    public void innerMethod() {
        userRepository.save(user);
    }
}

@Transactional이 없는 클래스

당연히 Bean이 아니면 프록시 생성이 안된다.

public class UserService {  // ← @Service 없음!
    
    @Transactional  // ❌ 안 먹힘!
    public void createUser(User user) {
        userRepository.save(user);
    }
}

Checked Exception

@Service
public class UserService {
    
    @Transactional  // ❌ 롤백 안 됨!
    public void createUser(User user) throws Exception {
        userRepository.save(user);
        throw new Exception("에러 발생");  // Checked Exception
    }
}

@Transactional은 기본적으로 RuntimeException만 롤백한다.

profile
모르는 것에 부끄러워 말고 아는 것에 자만하지 말 것

0개의 댓글