Blog
Anki
Spring

Apache Tomcat의 동작 방식에 대해서 설명해주세요.

꼬리 질문
  • Tomcat은 요청마다 새로운 스레드를 생성하는가?
  • Thread Pool은 어떻게 동작하는가?
  • Acceptor 스레드와 Worker 스레드의 차이는?
  • 멀티스레드로 동작한다면 어떻게 동시 요청을 처리하는가?
답변 보기
  • 핵심 포인트 💡:

    • Tomcat은 멀티스레드 기반 Thread Pool 방식으로 요청 처리
    • 요청마다 스레드를 생성하지 않고 미리 생성된 Thread Pool에서 재사용
    • Acceptor 스레드(1-2개)가 연결 수락, Worker 스레드(기본 200개)가 실제 요청 처리
    • 여러 Worker 스레드가 동시에 병렬로 각자 다른 요청을 처리
  • ❌ 잘못된 이해

    • "싱글 스레드가 Socket 연결을 대기한다" → Acceptor 스레드(1-2개)가 연결 수락
    • "요청마다 새 스레드를 생성한다" → Thread Pool에서 Worker 스레드 재사용
    • "RequestHandler 스레드를 생성한다" → Worker 스레드가 정확한 명칭
  • ✅ 실제 동작 방식

  1. 서버 시작 시 Thread Pool에 Worker 스레드 미리 생성 (기본 200개)
  2. Acceptor 스레드가 클라이언트 소켓 연결 수락
  3. 요청 발생 시 Thread Pool에서 유휴 Worker 스레드 할당
  4. Worker 스레드가 HTTP 요청 처리 (여러 스레드 병렬 처리)
  5. 처리 완료 후 Worker 스레드는 Pool로 반환되어 재사용
  • 동시 요청 처리 예시
  Thread Pool (max=200개)
  ├─ Worker Thread 1 → 요청 A 처리 중
  ├─ Worker Thread 2 → 요청 B 처리 중
  ├─ Worker Thread 3 → 요청 C 처리 중
  ├─ Worker Thread 4 → 요청 D 처리 중
  ...
  └─ Worker Thread 200 → 대기 중
  
  → 100개 요청 동시 도착 시 100개 스레드가 동시에 병렬 처리

Tomcat의 BIO Connector와 NIO Connector의 차이는?

꼬리 질문
  • BIO Connector는 언제 제거되었는가?
  • NIO Connector가 더 효율적인 이유는?
  • Poller 스레드의 역할은 무엇인가?
  • 연결(Connection)과 요청(Request)의 차이는?
답변 보기
  • 핵심 포인트 💡:

    • BIO는 연결당 스레드 1개 점유, NIO는 Poller로 여러 연결 모니터링
    • BIO는 Tomcat 8.5부터 완전히 제거
    • NIO는 Keep-Alive 연결을 효율적으로 관리하여 적은 스레드로 많은 연결 처리
    • 요청 처리 자체는 BIO/NIO 모두 멀티스레드 방식 동일, 차이는 연결 관리 방식
  • BIO (Blocking I/O) - Tomcat 8.0 이전 기본값

    • 동작 방식
    1. Acceptor 스레드: 소켓 연결 수락
    2. Worker 스레드: 연결에 할당 (연결 종료까지 점유)
    3. HTTP Keep-Alive 중에도 스레드가 유휴 상태로 대기 (비효율)
  • 문제점

    • 유휴 연결이 스레드를 낭비
    • 동시 연결 수 = 스레드 수로 제한
    • 많은 동시 연결 처리 시 많은 스레드 필요 → 메모리 부족
  • Tomcat 8.0에서 deprecated, Tomcat 8.5에서 완전 제거

  • NIO (Non-blocking I/O) - Tomcat 8.0부터 기본값

    • 동작 방식
    1. Acceptor 스레드: 소켓 연결 수락
    2. Poller 스레드: Selector로 여러 연결 동시 모니터링
    3. 데이터 준비된 연결만 감지
    4. Worker 스레드: 실제 요청 처리 (처리 후 즉시 Pool 반환)
    5. Keep-Alive 연결은 Poller가 관리 (Worker 스레드 소비 안 함)
  • 핵심 개념

    • 연결(Connection) ≠ 요청(Request)
    • HTTP 연결은 대부분 시간 동안 유휴 상태 (Keep-Alive)
    • BIO: 유휴 연결도 스레드 소비 → 비효율
    • NIO: 유휴 연결은 Poller가 관리, 요청 시에만 Worker 사용 → 효율
  • 성능 비교

    • 같은 스레드 수(200개)로 처리 가능한 동시 연결 수
      • BIO: 최대 200개 연결
      • NIO: 수천~수만 개 연결 가능 (유휴 연결은 Poller가 관리)
    • 메모리 사용량
      • BIO: 연결 수 증가 → 스레드 수 증가 → 메모리 증가
      • NIO: 연결 수 증가 → Poller만 관리 → 메모리 효율적
  • Spring Boot와의 관계

    • Spring Boot 2.0 이상: 모두 Tomcat 8.5+ 사용 → NIO 기반
    • Spring Boot 1.4 이상: Tomcat 8.5 → BIO 사용 불가
  • 출처: Apache Tomcat Migration Guide 8.5 (opens in a new tab)

  • 출처: Stack Overflow - BIO vs NIO Connector (opens in a new tab)

Spring Boot 버전에 따라 내장 Tomcat 버전은 어떻게 다른가?

꼬리 질문
  • Spring Boot 2.x와 3.x의 Tomcat 버전 차이는?
  • Jakarta EE 전환이란 무엇인가?
  • Spring Boot 3.3.1 이상의 특별한 요구사항은?
  • Servlet 버전은 어떻게 달라지는가?
답변 보기
  • 핵심 포인트 💡:

    • Spring Boot 2.x는 Tomcat 9.0.x (Servlet 4.0, Java 8-17)
    • Spring Boot 3.x는 Tomcat 10.1.x (Servlet 6.0, Java 17+)
    • Spring Boot 3.0부터 Jakarta EE 전환 (javax.* → jakarta.*)
    • Spring Boot 3.3.1 이상은 Tomcat 10.1.25 이상 필수
  • Spring Boot 2.x 계열

    • Tomcat 9.0.x 사용
    • Servlet 4.0 스펙
    • Java 8-17 지원
    • NIO Connector 기본
    • Java EE 패키지 (javax.*)
    • 예시
    // Spring Boot 2.x
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
  • Spring Boot 3.x 계열 (현재 권장)
    • Tomcat 10.1.x 사용 (10.1.25 이상 권장)
    • Servlet 6.0 스펙 (Jakarta EE 10)
    • Java 17 최소 요구, Java 24까지 호환
    • Spring Framework 6.x 기반
    • Jakarta EE 패키지 (jakarta.*)
    • 예시
    // Spring Boot 3.x
    import jakarta.servlet.http.HttpServlet;
    import jakarta.servlet.http.HttpServletRequest;
  • 버전별 매핑

    • Spring Boot 2.7.x (최종): Tomcat 9.0.x, Java 8-17
    • Spring Boot 3.0.x: Tomcat 10.1.x, Java 17-19
    • Spring Boot 3.3.x: Tomcat 10.1.25+, Java 17-22
    • Spring Boot 3.5.x (최신): Tomcat 10.1.46, Java 17-24
  • ⚠️ Spring Boot 3.3.1+ 중요 변경사항

    • Tomcat 10.1.25 이상 필수
    • 이전 버전 사용 시 오류 발생
    AbstractProtocol.setMaxQueueSize(int) 메서드 없음 오류
  • 원인: Spring Boot 3.3.1에서 추가된 기능이 Tomcat 10.1.25+에서만 지원

  • Jakarta EE 전환 (Spring Boot 3.0+)

    • 패키지명 변경
      • javax.servlet.* → jakarta.servlet.*
      • javax.persistence.* → jakarta.persistence.*
      • javax.validation.* → jakarta.validation.*
    • Spring Boot가 대부분 자동 처리
    • 직접 javax.* 사용하는 코드만 수정 필요
    • 외부 라이브러리도 Jakarta EE 호환 버전으로 업그레이드 필요
  • 마이그레이션 고려사항

    • Spring Boot 2 → 3 업그레이드 시
      • Java 17 이상 필수
      • javax.* → jakarta.* 패키지 변경
      • 일부 deprecated API 제거
    • Tomcat 9 → 10 직접 업그레이드 시
      • 패키지명 변경으로 호환성 깨짐
      • 애플리케이션 코드 수정 필요
  • 출처: Spring Boot System Requirements (opens in a new tab)

  • 출처: Spring Boot Version History (opens in a new tab)

  • 출처: GitHub Issue #42849 (opens in a new tab)

NIO Connector에서 Acceptor, Poller, Worker 스레드의 역할은?

꼬리 질문
  • 각 스레드는 몇 개씩 생성되는가?
  • Poller가 없으면 어떤 문제가 발생하는가?
  • Keep-Alive 연결은 어떻게 처리되는가?
  • Producer-Consumer 패턴이란?
답변 보기
  • 핵심 포인트 💡:

    • Acceptor(1-2개): 클라이언트 소켓 연결 수락
    • Poller(1-2개): Selector로 여러 연결 모니터링, 요청 준비된 연결 감지
    • Worker(기본 200개): 실제 HTTP 요청 처리, 처리 후 즉시 Pool 반환
    • Event Queue를 통한 Producer-Consumer 패턴으로 통신
  • Acceptor 스레드의 역할

    • 개수: 1-2개
    • 역할
    1. 서버 소켓에서 클라이언트 연결 대기 (accept)
    2. 새 연결 수락
    3. NioChannel 객체 생성
    4. PollerEvent로 캡슐화
    5. Event Queue에 추가 (Producer)
  • 실제 코드 구조
    public class Acceptor implements Runnable {
        @Override
        public void run() {
            while(!stopCalled) {
                Socket socket = endpoint.serverSocketAccept();
                if (!stopCalled && !endpoint.isPaused()) {
                    endpoint.setSocketOptions(socket);
                }
            }
        }
    }
  • Poller 스레드의 역할
    • 개수: 1-2개
    • 역할
    1. Event Queue에서 PollerEvent 가져옴 (Consumer)
    2. Selector에 소켓 등록
    3. 여러 소켓을 동시에 모니터링
    4. 읽기/쓰기 가능한 소켓 감지 (실제 데이터 도착)
    5. Worker 스레드에게 전달
  • Selector의 역할

    • 하나의 스레드로 여러 소켓 동시 모니터링
    • Non-blocking 방식으로 효율적
    • 데이터가 준비된 소켓만 반환
  • Worker 스레드의 역할

    • 개수: Thread Pool에서 관리 (기본 200개)
    • 역할
    1. Poller로부터 요청 받음
    2. HTTP 요청 파싱
    3. Servlet 실행 (비즈니스 로직)
    4. HTTP 응답 생성 및 전송
    5. 즉시 Thread Pool로 반환 (재사용)
  • 전체 흐름 (Producer-Consumer 패턴)
  Acceptor (Producer)

  [Event Queue]

  Poller (Consumer → Producer)

  데이터 준비된 소켓만 선별

  Worker Thread Pool (Consumer)

  HTTP 요청 처리

  Thread Pool로 반환
  • Keep-Alive 연결 처리
    • BIO 방식
    연결 수락 → Worker 스레드 할당
    → 요청 처리 → 응답 전송
    → Keep-Alive 시간 동안 스레드 대기 (비효율)
    → 연결 종료 후 스레드 반환
  • NIO 방식
    연결 수락 → Poller에 등록
    → 요청 도착 시에만 Worker 스레드 할당
    → 요청 처리 → 응답 전송 → 즉시 스레드 반환 (효율)
    → Keep-Alive 연결은 Poller가 계속 모니터링

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
        }
    }

ShedLock이란 무엇이고 분산 환경에서 왜 필요한가요?

꼬리 질문
  • ShedLock과 Quartz의 차이점은 무엇인가요?
  • lockAtMostFor와 lockAtLeastFor의 적절한 설정 기준은?
  • RDBMS vs Redis 기반 락 제공자의 성능 차이는?
답변 보기
  • 핵심 포인트 💡:

    • 분산 락: 여러 인스턴스에서 동일한 스케줄 작업이 중복 실행되지 않도록 보장하는 라이브러리
    • 외부 저장소 기반: RDBMS, Redis, MongoDB 등을 통해 락 상태를 인스턴스 간 공유
    • 시간 기반 안전장치: lockAtMostFor로 노드 장애 시 무한 락 방지
    • Row 업데이트 방식: 새로운 레코드 생성 대신 기존 락 레코드를 업데이트하여 DB 부하 최소화
  • ShedLock의 정의와 특징

  • 분산 환경에서 필요한 이유와 해결 방법

    • 문제 상황: 다중 인스턴스 환경에서 동일한 @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("일일 리포트 생성 중...");
              // 실제 비즈니스 로직
          }
      }
  • ShedLock과 Quartz의 차이점

  • RDBMS vs Redis 기반 락 제공자의 성능 차이

  • lockAtMostFor와 lockAtLeastFor 설정 기준