Spring Boot 애플리케이션을 처음 실행할 때 첫 요청이 느린 건 한 번쯤 경험해봤을 것이다. 첫 요청에서 지연 시간이 발생하는 이유와 줄일 수 있는 방법에 대해서 알아보자.
구분 | 항목 | 내용 |
---|---|---|
런타임 환경 | JVM | OpenJDK 17 |
프레임워크 | Spring Boot 3.2.x | |
운영체제 | Ubuntu 22.04 LTS | |
데이터베이스 | DBMS | MySQL 8.0 |
구분 | 항목 | 내용 |
---|---|---|
리소스 제한 | 메모리 | 2GB |
CPU | 2 cores |
구분 | 항목 | 내용 |
---|---|---|
Grafana 대시보드 | JVM 모니터링 | • 힙 메모리 사용량 • 가비지 컬렉션 통계 • 스레드 상태 |
HTTP 성능 지표 | • 응답 시간 • 처리량 | |
테스트 도구 | 성능 테스트 | k6 |
응답시간 메트릭 | JVM 메트릭 |
---|---|
초기 상태의 스프링 부트 애플리케이션은 다음과 같은 성능을 보여주었다.
아무런 최적화를 하지 않은 상태의 기본적인 성능이다. 이후 적용하는 각각의 최적화 방안들은 이전 설정을 유지한 상태에서 추가로 적용하여 성능 측정한 결과를 정리하였다.
첫 요청이 들어왔을 때 워커 스레드를 생성하는 과정에서 지연이 발생할 수 있다. 기본값인 10개의 워커 스레드로는 갑작스러운 요청 증가에 대응하기 어렵고, 추가 스레드 생성에 따른 오버헤드가 발생할 수 있다.
[Acceptor Thread]
│
├─ 새로운 연결 수락
│ ├─ 클라이언트로부터 TCP 연결 요청이 들어옴
│ ├─ ServerSocketChannel.accept()를 통해 연결 수락
│ ├─ 새로운 SocketChannel 생성
│ └─ 생성된 채널을 논블로킹 모드로 설정
│
[Poller Threads]
│
├─ 이벤트 감지
│ ├─ Selector가 등록된 채널들의 이벤트를 감시
│ ├─ read/write 준비된 채널 감지
│ └─ 준비된 이벤트가 있으면 해당 채널 처리
│
├─ 논블로킹 I/O 처리
│ ├─ 데이터 읽기/쓰기가 가능한 상태인지 확인
│ ├─ ByteBuffer를 사용하여 데이터 읽기/쓰기
│ └─ SocketProcessor 생성하여 Worker Thread Pool에 전달
│
[⭐️ Worker Threads] - min-spare: 100 개로 설정시 100개가 항상 대기 (기본 10개)
│
├─ HTTP 파싱
│ ├─ 요청 라인 파싱 (METHOD, URI, HTTP VERSION)
│ ├─ 헤더 파싱
│ └─ 바디 파싱 (Content-Length 체크)
│
├─ 서블릿 실행
│ ├─ 필터 체인 실행
│ ├─ 서블릿 컨테이너에 요청 전달
│ └─ 실제 비즈니스 로직 수행
│
└─ 응답 전송
├─ 응답 헤더 생성
├─ 응답 바디 생성
└─ 클라이언트로 응답 전송
server:
tomcat:
threads:
min-spare: 100 # 기본값 10개에서 100개로 증가
응답시간 메트릭 | JVM 메트릭 |
---|---|
Spring의 OSIV(Open Session In View) 기본 설정(true)으로 인해 영속성 컨텍스트가 뷰 렌더링이 끝날 때까지 유지된다. 이는 불필요하게 DB 커넥션을 오래 점유하게 되며, 특히 첫 요청 시 트랜잭션 및 커넥션 설정에 추가 시간이 소요된다.
트랜잭션 시작/종료 시 set autocommit=0/set autocommit=1 쿼리가 실행되며 추가적인 시간이 소요된다.
1. 요청 발생 -> 커넥션 풀에서 사용 가능한 커넥션 확인
2. 사용 가능한 커넥션이 있는 경우 -> 즉시 할당
3. 사용 가능한 커넥션이 없는 경우
- 풀 사이즈가 maximum-pool-size 미만 -> 새로운 커넥션 생성
- 풀 사이즈가 maximum-pool-size에 도달 -> ⭐️ 대기
[커넥션 생명주기]
요청 시작 -> 커넥션 획득 -> 쿼리 실행 -> 커넥션 반환 -> 풀에 유지
🔥 커넥션이 유지된 상태에서 긴 IO 작업이 일어나지 않도록 주의하자
OSIV가 활성화된 상태에서 뷰 렌더링 중에 외부 API를 호출하거나 트랜잭션 내에서 외부 API를 호출하는 경우, DB 커넥션이 불필요하게 길게 유지되어 커넥션 풀 고갈로 이어질 수 있다.
다른 요청들이 DB 커넥션을 얻지 못해 대기하게 만들고, 전체적인 시스템의 응답 시간 증가와 타임아웃 발생으로 이어질 수 있으므로 주의가 필요하다.
spring:
jpa:
open-in-view: false
datasource:
hikari:
auto-commit: false
응답시간 메트릭 | JVM 메트릭 |
---|---|
HikariCP의 기본 maximum-pool-size는 20개로 설정되어 있어, 동시에 많은 요청이 들어올 경우 커넥션 풀 고갈로 인한 병목 현상이 발생할 수 있다. 특히 첫 요청 시 커넥션 생성에 따른 오버헤드가 추가된다.
spring:
datasource:
hikari:
maximum-pool-size: 50
응답시간 메트릭 | JVM 메트릭 |
---|---|
Connection Pool 획득 과정에서 발생하는 지연시간 개선하기 을 참고해보자.
ALB의 slow start, Lambda Concurrency 설정이 적용된 경우에는 초기 트래픽을 제한할 수 있어 이러한 경합 상황이 덜 발생하게 된다.
설정 | 설명 | 예시 |
---|---|---|
ALB Slow Start | 새로운 타겟이 등록되면 트래픽을 점진적으로 증가 | 30초~900초 동안 트래픽을 0%에서 100%까지 천천히 증가 |
Lambda Provisioned Concurrency | 초기에 동시 실행될 수 있는 Lambda 인스턴스 수를 제한 | 초기 동시성을 5로 설정하면 최대 5개의 요청만 동시 처리 |
JPA는 애플리케이션 시작 후 첫 쿼리 실행 시 많은 초기화 작업을 수행합니다. 엔티티 매핑 정보 로드, SQL 쿼리 생성 및 파싱, 영속성 컨텍스트 초기화 등의 작업으로 인해 첫 요청에서 상당한 지연이 발생할 수 있다.
@Component
public class JpaWarmer {
@Autowired
private TestRepository testRepository;
@PostConstruct
public void warmup() {
testRepository.findById(1L);
}
}
응답시간 메트릭 | JVM 메트릭 |
---|---|
Spring Boot 애플리케이션의 첫 요청에서는 서블릿 필터 체인의 초기화가 필요하다. 보안, 로깅, 인증 등 다양한 필터들이 처음 요청될 때 초기화되면서 지연이 발생하게 된다.
@Component
public class ServletWarmer {
@Autowired
private RestTemplate restTemplate;
@PostConstruct
public void warmup() {
restTemplate.getForEntity("/api/v1/health", String.class);
}
}
응답시간 메트릭 | JVM 메트릭 |
---|---|
JVM은 처음에 바이트코드를 인터프리터로 실행하다가, 자주 실행되는 코드를 기계어로 컴파일하여 성능을 최적화한다. 하지만 JIT 컴파일 과정이 실제 요청이 들어올 때 이루어지면서 초기 성능 저하가 발생할 수 있다. 기본 CompileThreshold 값(10,000)은 코드가 충분히 실행된 후에야 JIT 컴파일이 시작되도록 설정되어 있어, 초기 요청들은 최적화되지 않은 상태로 실행되기 때문이다.
Level 0 (Interpreter)
- 모든 코드는 처음에 인터프리터로 실행
- 실행 빈도와 성능 데이터를 수집
Level 1~3 (C1 Compiler)
- Level 1: 빠른 컴파일, 기본적인 최적화
- Level 2: 메소드의 일부는 인터프리터로 실행하면서 프로파일링 데이터 수집
- Level 3: C1 수준에서 가능한 모든 최적화 적용
Level 4 (C2 Compiler)
- 수집된 프로파일링 데이터 기반으로 공격적인 최적화 수행
- 가장 느린 컴파일 시간, 하지만 가장 빠른 실행 속도
java -XX:-TieredCompilation \ # 단계별 컴파일 비활성화
-XX:CompileThreshold=500 \ # JIT 컴파일 임계값 낮춤
-XX:+AlwaysPreTouch \ # 메모리 사전 할당
-jar application.jar
# 옵션 설명
# -XX:TieredCompilation:
# - 단계별 컴파일을 비활성화하고 C2 Compiler 컴파일러만 사용
# - 초기 컴파일 오버헤드 감소
# - 단, 워밍업 시간이 좀 더 필요할 수 있음
# -XX:CompileThreshold=500:
# - 메서드가 500번 호출되면 컴파일 시작 (기본값 10,000)
# - 더 빠른 JIT 컴파일로 초기 성능 향상
# -XX:+AlwaysPreTouch:
# - JVM 힙 메모리를 시작 시점에 모두 할당
# - 런타임에서의 메모리 할당 지연 방지
응답시간 메트릭 | JVM 메트릭 |
---|---|
웜업 적용 X | 웜업 적용 O |
---|---|
java -XX:+UnlockDiagnosticVMOptions
-XX:+LogCompilation
위 옵션을 추가하면 로그 파일로 어떤 메서드가 최적화될 예정인지/되었는지 알 수 있다.
컨트롤러에서 인터페이스 없이 직접 구현 클래스를 사용했기 때문에, CGLIB 프록시 클래스가 네이티브 머신 코드로 변환된 것을 알 수 있다.
초기 상태 대비 약 41%의 성능 향상을 달성했다. 각 단계별 최적화를 통해 점진적으로 성능이 개선되었으며, 특히 코드 웜업과 JIT 컴파일러 최적화가 도움이 되었다.
https://github.com/hocaron/spring-study/tree/main/warm-up