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 } }
ShedLock이란 무엇이고 분산 환경에서 왜 필요한가요?
꼬리 질문
- ShedLock과 Quartz의 차이점은 무엇인가요?
- lockAtMostFor와 lockAtLeastFor의 적절한 설정 기준은?
- RDBMS vs Redis 기반 락 제공자의 성능 차이는?
답변 보기
-
핵심 포인트 💡:
- 분산 락: 여러 인스턴스에서 동일한 스케줄 작업이 중복 실행되지 않도록 보장하는 라이브러리
- 외부 저장소 기반: RDBMS, Redis, MongoDB 등을 통해 락 상태를 인스턴스 간 공유
- 시간 기반 안전장치: lockAtMostFor로 노드 장애 시 무한 락 방지
- Row 업데이트 방식: 새로운 레코드 생성 대신 기존 락 레코드를 업데이트하여 DB 부하 최소화
-
ShedLock의 정의와 특징
- ShedLock은 스케줄된 작업이 동시에 최대 한 번만 실행되도록 보장하며, 외부 저장소를 사용하여 조정 (출처: ShedLock GitHub (opens in a new tab))
- ✅ Skip 방식: 락을 획득하지 못한 노드는 대기하지 않고 작업을 건너뛰어 성능상 이점
- ✅ 다양한 저장소 지원: Mongo, JDBC database, Redis, Hazelcast, ZooKeeper 등 (출처: ShedLock GitHub (opens in a new tab))
- ✅ Row 업데이트 방식: 스케줄링 작업 실행 시마다 새로운 레코드를 생성하지 않고 해당 작업의 잠금 레코드를 업데이트하여 데이터베이스 부하를 줄임 (출처: dkswnkk 기술블로그 (opens in a new tab))
- ✅ 인메모리 캐시: ShedLock이 기존 락 row들을 인메모리 캐시로 관리하여 효율적 동작 (출처: ShedLock GitHub (opens in a new tab))
- ❌ 완전한 스케줄러 아님: ShedLock은 단순한 락이며, 분산 스케줄러가 필요하다면 db-scheduler나 JobRunr 사용 권장 (출처: ShedLock GitHub (opens in a new tab))
- ⚠️ 수동 삭제 금지: 락 row를 수동으로 삭제하면 애플리케이션 재시작 전까지 자동 재생성되지 않음 (출처: ShedLock GitHub (opens in a new tab))
-
분산 환경에서 필요한 이유와 해결 방법
- 문제 상황: 다중 인스턴스 환경에서 동일한 @Scheduled 작업이 각 인스턴스에서 동시 실행되어 중복 처리 발생
- 📧 동일한 알림 메시지 중복 발송
- 📊 중복 데이터 생성 및 리소스 낭비
- 🔄 데이터 정합성 문제 발생
- ShedLock 해결 방법: 외부 저장소를 통한 분산 락으로 한 번에 하나의 인스턴스에서만 실행 보장
@Component public class ScheduledTasks { @Scheduled(cron = "0 0 1 * * *") // 매일 새벽 1시 @SchedulerLock( name = "dailyReport", // 고유한 락 이름 lockAtMostFor = "PT25M", // 최대 25분 유지 (안전장치) lockAtLeastFor = "PT5M" // 최소 5분 유지 (중복 방지) ) public void generateDailyReport() { LockAssert.assertLocked(); // 락 확인 log.info("일일 리포트 생성 중..."); // 실제 비즈니스 로직 } }
- 문제 상황: 다중 인스턴스 환경에서 동일한 @Scheduled 작업이 각 인스턴스에서 동시 실행되어 중복 처리 발생
-
ShedLock과 Quartz의 차이점
- ShedLock 특징:
- ✅ 가벼운 분산 락 메커니즘만 제공 (출처: TAV Technologies Medium (opens in a new tab))
- ✅ Spring과의 완벽 호환, 간단한 설정 (출처: TAV Technologies Medium (opens in a new tab))
- ✅ 다양한 저장소 지원으로 유연성 제공
- ❌ 스케줄링 기능 없음 (기존 @Scheduled 사용)
- Quartz 특징:
- ✅ 완전한 스케줄러 + 분산 락 기능
- ✅ 복잡한 스케줄링 요구사항 지원
- ❌ 현대적 대안들과 비교했을 때 성능이 현저히 낮음 (row level locking과 복잡한 설정 때문) (출처: Foojay.io (opens in a new tab))
- ❌ 복잡하고 무거운 솔루션, 높은 구현 및 배포 비용 (출처: TAV Technologies Medium (opens in a new tab))
- ShedLock 특징:
-
RDBMS vs Redis 기반 락 제공자의 성능 차이
- Redis 성능 특성:
- ✅ 인메모리 key-value 저장소로 대부분의 경우 최고 성능 (출처: Profil Software (opens in a new tab))
- ✅ 단순한 CRUD 작업에서 RDBMS보다 현저히 빠름
- ✅ NoSQL 특성상 거의 일정한 시간으로 동작 (출처: Profil Software (opens in a new tab))
- ❌ Redis 마스터 장애 시 락 메커니즘이 안정적이지 못할 수 있음 (출처: ShedLock GitHub (opens in a new tab))
- RDBMS 성능 특성:
- ✅ ACID 트랜잭션으로 높은 안정성과 일관성 보장
- ✅ 기존 애플리케이션 DB 활용으로 별도 인프라 불필요
- ❌ 레코드 수 증가에 따라 시간이 급격히 증가 (출처: Profil Software (opens in a new tab))
- ❌ 디스크 I/O로 인한 성능 제약
- Redis 성능 특성:
-
lockAtMostFor와 lockAtLeastFor 설정 기준
- lockAtMostFor: 예상 실행 시간의 2-3배로 설정 (예: 5분 작업 → 15분) (출처: ShedLock GitHub (opens in a new tab))
- lockAtLeastFor: 스케줄 간격과 동일하거나 약간 짧게 설정하여 중복 실행 방지 (출처: ShedLock GitHub (opens in a new tab))