Spring AOP에서 내부 메서드 호출이 동작하지 않는 이유는 무엇인가요?
꼬리 질문
- Self Invocation이란 무엇인가요?
- 프록시 패턴이 Spring AOP에서 어떻게 작동하나요?
- 내부 메서드 호출 문제를 해결하는 방법들은 무엇인가요?
- CGLIB 프록시와 JDK 다이나믹 프록시의 차이점은 무엇인가요?
- 트랜잭션 전파(Propagation)와 Self Invocation의 관계는?
- 비동기(@Async) 메서드에서도 같은 문제가 발생하나요?
- Self Invocation 문제를 어떻게 디버깅하나요?
- 실무에서 자주 실수하는 패턴은?
답변 보기
-
핵심 정리 📌
- Spring AOP는 프록시 패턴 기반이므로 외부 호출만 가로챕니다
- Self Invocation 문제는
this
를 통한 내부 호출이 프록시를 거치지 않아 발생합니다 - 해결 방법 우선순위:
- 1순위: 리팩토링으로 별도 빈 분리 ⭐
- 2순위: Self-Injection
- 3순위: AopContext.currentProxy()
- 4순위: AspectJ 위빙 (복잡한 경우만)
- Spring Boot 2.x부터는 CGLIB 프록시가 기본값입니다
- 프록시 동작을 이해하면 트랜잭션, 캐싱, 비동기, 보안 등의 문제를 쉽게 해결할 수 있습니다
- 디버깅 시 AopUtils, 로깅, Aspect를 활용하여 프록시 적용 여부를 확인합니다
- 실무 주의사항: private/final 메서드, 초기화 시점, 람다 표현식, 상속 관계에서 특히 주의가 필요합니다
-
Spring AOP는 프록시 기반으로 동작하여, 객체의 메서드 호출을 가로채고 부가 기능을 적용합니다
- 외부에서 프록시를 통해 메서드를 호출할 때만 AOP가 적용됩니다
- 내부에서
this
를 통한 메서드 호출은 프록시를 거치지 않아 AOP가 적용되지 않습니다 - 이를 Self Invocation(자기 호출) 문제라고 합니다 🔄
-
왜 트랜잭션이 적용되지 않는지 상세 설명
- Spring이 빈을 생성할 때 실제로 일어나는 일:
// 1. Spring이 UserService 빈을 생성할 때 실제 구조 // 원본 UserService 클래스 public class UserService { @Transactional public void updateUser(Long userId) { this.deleteUserCache(userId); // 여기서 this는 프록시가 아닌 실제 객체 } @CacheEvict(value = "users", key = "#userId") public void deleteUserCache(Long userId) { // 캐시 삭제 로직 } } // 2. Spring이 생성하는 프록시 (개념적 표현) public class UserService$$EnhancerBySpringCGLIB extends UserService { private UserService target; // 실제 UserService 인스턴스 private TransactionInterceptor txInterceptor; private CacheInterceptor cacheInterceptor; @Override public void updateUser(Long userId) { // 트랜잭션 시작 TransactionStatus tx = txInterceptor.startTransaction(); try { // 실제 메서드 호출 target.updateUser(userId); // 트랜잭션 커밋 txInterceptor.commit(tx); } catch (Exception e) { // 트랜잭션 롤백 txInterceptor.rollback(tx); throw e; } } @Override public void deleteUserCache(Long userId) { // 캐시 제거 처리 cacheInterceptor.evictCache("users", userId); // 실제 메서드 호출 target.deleteUserCache(userId); } }
- 실제 호출 흐름 분석:
// 3. 외부에서 호출할 때 (정상 동작) @RestController public class UserController { @Autowired private UserService userService; // 실제로는 프록시가 주입됨 @PostMapping("/users/{id}") public void updateUser(@PathVariable Long id) { // userService는 프록시이므로 AOP가 적용됨 userService.updateUser(id); /* 실제 호출 순서: 1. UserController → UserService$$Proxy.updateUser() 2. Proxy가 트랜잭션 시작 3. Proxy → 실제 UserService.updateUser() 4. 실제 UserService 내부에서 this.deleteUserCache() 호출 → 이때 this는 프록시가 아닌 실제 객체! 5. 프록시를 거치지 않고 직접 deleteUserCache() 호출 → @CacheEvict 미적용! */ } } // 4. 내부 호출 시 문제 발생 과정 public class UserService { @Transactional public void updateUser(Long userId) { log.info("updateUser 실행 - this의 클래스: {}", this.getClass().getName()); // 출력: UserService (프록시가 아님!) // this는 프록시가 아닌 실제 UserService 인스턴스 this.deleteUserCache(userId); // 위 호출은 아래와 동일: // UserService.deleteUserCache(userId) - 직접 호출 // 프록시의 deleteUserCache()가 아님! } }
-
꼬리질문: Self Invocation이란 무엇인가요?
- 같은 클래스 내에서 한 메서드가 다른 메서드를 직접 호출하는 것을 의미합니다
- 프록시 패턴의 한계로 인해 발생하는 문제입니다
- 시각적 표현:
[외부 호출 - 정상 동작] Client → Proxy → AOP처리 → Target Object [내부 호출 - AOP 미적용] Target Object 내부에서 this.method() → 같은 객체의 메서드 직접 호출 → 프록시를 거치지 않음!
-
꼬리질문: 프록시 패턴이 Spring AOP에서 어떻게 작동하나요?
- Spring은 대상 객체를 감싸는 프록시 객체를 생성합니다
- 클라이언트는 실제 객체가 아닌 프록시 객체를 주입받습니다
- 프록시는 메서드 호출을 가로채서 부가 기능을 실행한 후 실제 객체에 위임합니다
// Spring이 프록시를 생성하는 과정 시뮬레이션 @Configuration public class ProxySimulation { @Bean public UserService userService() { UserService target = new UserService(); // Spring이 실제로 하는 일 (개념적 표현) ProxyFactory factory = new ProxyFactory(target); // 트랜잭션 어드바이저 추가 factory.addAdvisor(new TransactionAdvisor()); // 캐시 어드바이저 추가 factory.addAdvisor(new CacheAdvisor()); // 프록시 생성 및 반환 return (UserService) factory.getProxy(); } }
-
꼬리질문: 내부 메서드 호출 문제를 해결하는 방법들은 무엇인가요?
- 방법 1: AopContext.currentProxy() 사용 ⚡
@Service @Slf4j @EnableAspectJAutoProxy(exposeProxy = true) // 중요! public class UserService { @Transactional public void updateUser(Long userId) { log.info("updateUser 메서드 실행"); // 현재 프록시를 가져와서 호출 UserService proxy = (UserService) AopContext.currentProxy(); proxy.deleteUserCache(userId); // 프록시를 통한 호출! } @CacheEvict(value = "users", key = "#userId") public void deleteUserCache(Long userId) { log.info("캐시 삭제: {}", userId); } }
- 장점: 간단한 해결책
- 단점: Spring AOP에 종속적, 테스트 어려움
- 방법 2: 자기 주입(Self-Injection) 💉
@Service @Slf4j public class UserService { @Autowired @Lazy // 순환 참조 방지 필수! private UserService self; @Transactional public void updateUser(Long userId) { log.info("updateUser 메서드 실행"); log.info("this 클래스: {}", this.getClass().getSimpleName()); log.info("self 클래스: {}", self.getClass().getSimpleName()); // this: UserService // self: UserService$$EnhancerBySpringCGLIB self.deleteUserCache(userId); // 프록시를 통한 호출 } @CacheEvict(value = "users", key = "#userId") public void deleteUserCache(Long userId) { log.info("캐시 삭제: {}", userId); } }
- 장점: AopContext보다 테스트 용이
- 단점: 순환 참조 위험, 다소 부자연스러운 구조
- 방법 3: 리팩토링 (권장) 🔨
// 1. 캐시 관련 기능을 별도 서비스로 분리 @Service @Slf4j public class UserCacheService { @CacheEvict(value = "users", key = "#userId") public void evictUserCache(Long userId) { log.info("사용자 {} 캐시 삭제", userId); } @Cacheable(value = "users", key = "#userId") public User getUserFromCache(Long userId) { log.info("사용자 {} 캐시 조회", userId); return userRepository.findById(userId); } } // 2. UserService에서 주입받아 사용 @Service @Slf4j @RequiredArgsConstructor public class UserService { private final UserCacheService cacheService; private final UserRepository userRepository; @Transactional public void updateUser(Long userId, UserDto dto) { log.info("사용자 {} 정보 업데이트", userId); // 사용자 정보 업데이트 User user = userRepository.findById(userId); user.update(dto); // 캐시 삭제 - 별도 서비스의 프록시를 통해 호출 cacheService.evictUserCache(userId); } }
- 장점: 단일 책임 원칙 준수, 테스트 용이, 명확한 구조
- 단점: 클래스가 늘어남
- 방법 4: AspectJ 컴파일 타임 위빙 🛠️
<!-- pom.xml 설정 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-aspects</artifactId> </dependency> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>aspectj-maven-plugin</artifactId> <configuration> <complianceLevel>1.8</complianceLevel> <aspectLibraries> <aspectLibrary> <groupId>org.springframework</groupId> <artifactId>spring-aspects</artifactId> </aspectLibrary> </aspectLibraries> </configuration> </plugin>
- 장점: Self Invocation 문제 완전 해결
- 단점: 설정 복잡, 빌드 시간 증가
- 방법 1: AopContext.currentProxy() 사용 ⚡
-
꼬리질문: CGLIB 프록시와 JDK 다이나믹 프록시의 차이점은 무엇인가요?
-
프록시 생성 방식의 차이
// JDK 다이나믹 프록시 - 인터페이스 필수 public interface UserService { void updateUser(Long userId); } @Service public class UserServiceImpl implements UserService { @Transactional public void updateUser(Long userId) { // 구현 } } // CGLIB 프록시 - 클래스 상속 @Service public class UserService { // 인터페이스 불필요 @Transactional public void updateUser(Long userId) { // 구현 } }
-
프록시 타입 확인 및 설정
@Component public class ProxyChecker implements ApplicationRunner { @Autowired private UserService userService; @Override public void run(ApplicationArguments args) { // 프록시 정보 출력 System.out.println("=== 프록시 정보 ==="); System.out.println("Bean class: " + userService.getClass().getName()); System.out.println("Is AOP Proxy? " + AopUtils.isAopProxy(userService)); System.out.println("Is CGLIB? " + AopUtils.isCglibProxy(userService)); System.out.println("Is JDK Dynamic? " + AopUtils.isJdkDynamicProxy(userService)); System.out.println("Target class: " + AopUtils.getTargetClass(userService)); // 출력 예시 (Spring Boot 2.x 기본값): // Bean class: com.example.UserService$$EnhancerBySpringCGLIB$$12345678 // Is AOP Proxy? true // Is CGLIB? true // Is JDK Dynamic? false // Target class: class com.example.UserService } }
-
-
꼬리질문: 트랜잭션 전파(Propagation)와 Self Invocation의 관계는?
- Self Invocation 시 트랜잭션 전파 속성이 완전히 무시됩니다
- 실제 동작 비교:
@Service @Slf4j public class PaymentService { @Autowired private PaymentService self; @Transactional public void processPayment(Long orderId) { log.info("결제 처리 시작 - TX: {}", TransactionSynchronizationManager.getCurrentTransactionName()); // 방법 1: 내부 호출 (문제 발생) this.savePaymentHistory(orderId); // 같은 트랜잭션에서 실행됨 // 방법 2: 프록시를 통한 호출 (정상 동작) self.savePaymentHistory(orderId); // 새 트랜잭션에서 실행됨 } @Transactional(propagation = Propagation.REQUIRES_NEW) public void savePaymentHistory(Long orderId) { log.info("결제 이력 저장 - TX: {}", TransactionSynchronizationManager.getCurrentTransactionName()); // REQUIRES_NEW가 작동하려면 프록시를 통해 호출되어야 함 } }
-
꼬리질문: 비동기(@Async) 메서드에서도 같은 문제가 발생하나요?
- 네,
@Async
도 AOP 기반이므로 동일한 문제가 발생합니다 - 문제 상황과 해결책:
@Service @EnableAsync @Slf4j public class NotificationService { @Autowired private NotificationService self; public void handleOrder(Order order) { // 주문 처리 processOrder(order); // 잘못된 호출 - 동기 실행됨 this.sendEmailNotification(order.getId()); // ❌ // 올바른 호출 - 비동기 실행됨 self.sendEmailNotification(order.getId()); // ✅ log.info("주문 처리 완료"); } @Async public CompletableFuture<String> sendEmailNotification(Long orderId) { log.info("이메일 전송 시작 - Thread: {}", Thread.currentThread().getName()); // 시간이 오래 걸리는 작업 try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("Email sent for order: " + orderId); } }
- 네,
-
꼬리질문: Self Invocation 문제를 어떻게 디버깅하나요?
- AOP 프록시 적용 여부 확인 방법:
@Component @Slf4j public class AopDebugger { @PostConstruct public void debugProxyInfo() { // 1. 클래스 이름으로 확인 log.info("Class name: {}", this.getClass().getName()); // 프록시인 경우: $$EnhancerBySpringCGLIB$$ 포함 // 2. AopUtils 활용 log.info("Is AOP proxy: {}", AopUtils.isAopProxy(this)); // 3. 메서드 실행 시 로그 추가 StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); Arrays.stream(stackTrace) .limit(10) .forEach(element -> log.info("Stack: {}", element)); } // 4. Aspect를 이용한 디버깅 @Aspect @Component public static class DebugAspect { @Before("@annotation(org.springframework.transaction.annotation.Transactional)") public void debugTransaction(JoinPoint joinPoint) { log.info("=== Transaction Debug ==="); log.info("Target: {}", joinPoint.getTarget().getClass().getName()); log.info("Proxy: {}", joinPoint.getThis().getClass().getName()); log.info("Method: {}", joinPoint.getSignature().getName()); log.info("========================"); } } }
- 런타임 프록시 정보 확인:
@RestController @RequiredArgsConstructor public class DebugController { private final ApplicationContext context; @GetMapping("/debug/proxy/{beanName}") public Map<String, Object> getProxyInfo(@PathVariable String beanName) { Object bean = context.getBean(beanName); Map<String, Object> info = new HashMap<>(); info.put("beanClass", bean.getClass().getName()); info.put("isAopProxy", AopUtils.isAopProxy(bean)); info.put("isCglibProxy", AopUtils.isCglibProxy(bean)); info.put("isJdkProxy", AopUtils.isJdkDynamicProxy(bean)); info.put("targetClass", AopUtils.getTargetClass(bean).getName()); if (AopUtils.isAopProxy(bean)) { // 프록시된 메서드 목록 Method[] methods = AopUtils.getTargetClass(bean).getDeclaredMethods(); List<String> proxiedMethods = Arrays.stream(methods) .filter(m -> m.isAnnotationPresent(Transactional.class) || m.isAnnotationPresent(Cacheable.class) || m.isAnnotationPresent(Async.class)) .map(Method::getName) .collect(Collectors.toList()); info.put("proxiedMethods", proxiedMethods); } return info; } }
-
꼬리질문: 실무에서 자주 실수하는 패턴은?
- 실수 1: private 메서드에 AOP 어노테이션 사용
@Service public class CommonMistakes { @Transactional // ❌ 작동하지 않음! private void privateMethod() { // private 메서드는 프록시될 수 없음 } @Transactional // ❌ 작동하지 않음! final void finalMethod() { // final 메서드도 CGLIB 프록시 불가 } @Transactional // ✅ 정상 작동 public void publicMethod() { // public 메서드만 프록시 가능 } }
- 실수 2: 생성자나 @PostConstruct에서 AOP 메서드 호출
@Service @Slf4j public class InitializationMistake { @Autowired private InitializationMistake self; @PostConstruct public void init() { // ❌ 빈 초기화 중에는 프록시가 아직 준비되지 않음 this.loadCache(); // ❌ self도 아직 완전히 초기화되지 않음 // self.loadCache(); } // 해결책: ApplicationListener 사용 @Component public static class CacheInitializer implements ApplicationListener<ContextRefreshedEvent> { @Autowired private InitializationMistake service; @Override public void onApplicationEvent(ContextRefreshedEvent event) { service.loadCache(); // ✅ 컨텍스트 초기화 완료 후 호출 } } @Cacheable("config") public String loadCache() { log.info("Loading cache..."); return "config-data"; } }
- 실수 3: 조건부 AOP 적용 시 실수
@Service @Slf4j public class ConditionalService { @Value("${cache.enabled:true}") private boolean cacheEnabled; public void processData(String key) { if (cacheEnabled) { // ❌ 조건부로 호출해도 Self Invocation String data = this.getCachedData(key); } else { String data = this.getDirectData(key); } } // 해결책: 조건을 메서드 내부로 이동 @Cacheable(value = "data", condition = "@conditionalService.cacheEnabled") public String getData(String key) { return fetchDataFromDb(key); } }
- 실수 4: 람다나 스트림에서 AOP 메서드 참조
@Service public class StreamMistake { @Transactional(readOnly = true) public User findUser(Long id) { return userRepository.findById(id); } public List<User> findUsers(List<Long> ids) { // ❌ 메서드 참조는 프록시를 거치지 않음 return ids.stream() .map(this::findUser) // Self Invocation! .collect(Collectors.toList()); // ✅ 해결책: 별도 서비스로 분리하거나 직접 구현 return ids.stream() .map(id -> userRepository.findById(id)) .collect(Collectors.toList()); } }
- 실수 5: 상속 관계에서의 AOP 적용
@Service public class ParentService { @Transactional public void parentMethod() { childMethod(); // ❌ Self Invocation } protected void childMethod() { // 트랜잭션 적용 안됨 } } @Service public class ChildService extends ParentService { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) protected void childMethod() { // ❌ protected 메서드는 프록시되지 않음 // ❌ 부모에서 호출 시 Self Invocation } }