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개 스레드가 동시에 병렬 처리-
Thread Pool 방식의 장점
- 스레드 생성/소멸 비용 절감
- 메모리 효율성 (스레드 수 제한)
- 리소스 제어 가능 (최대 동시 처리 수 설정)
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만 관리 → 메모리 효율적
- 같은 스레드 수(200개)로 처리 가능한 동시 연결 수
-
Spring Boot와의 관계
- Spring Boot 2.0 이상: 모두 Tomcat 8.5+ 사용 → NIO 기반
- Spring Boot 1.4 이상: Tomcat 8.5 → BIO 사용 불가
-
출처: 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 2 → 3 업그레이드 시
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가 계속 모니터링-
효율성 비교
- 1000개 Keep-Alive 연결, 실제 요청은 10개/초
- BIO: 1000개 스레드 필요 (대부분 유휴)
- NIO: Poller 2개 + Worker 10-20개면 충분
-
출처: BIO, NIO Connector Architecture in Tomcat (opens in a new tab)
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))