Blog
Anki
Spring

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 문제 완전 해결
      • 단점: 설정 복잡, 빌드 시간 증가
  • 꼬리질문: 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
        }
    }