[Spring] 첫번째 요청에서 발생하는 지연 시간을 개선해보자

Hocaron·1일 전
1

트러블슈팅

목록 보기
13/14

Spring Boot 애플리케이션을 처음 실행할 때 첫 요청이 느린 건 한 번쯤 경험해봤을 것이다. 첫 요청에서 지연 시간이 발생하는 이유와 줄일 수 있는 방법에 대해서 알아보자.

첫번째 요청 처리 지연은 왜 발생할까?

Spring Boot 초기화

  • Lazy Loading 전략
    • 실제 빈은 요청 시점에 생성
  • JPA/Hibernate
    • Repository 인터페이스에 대한 프록시 객체를 생성
  • 커넥션 풀 관리
    • 초기 커넥션 생성 지연
    • 실제 요청 시점에 커넥션 확보
  • 내장 톰캣
    • 서블릿 컨테이너 및 스레드 풀 초기화
  • 외부 서비스 연결
    • FeignClient/WebClient 연결
    • Redis, Kafka 연결 및 초기 데이터 로딩

JVM 워밍업

  • 클래스 로딩 클래스 파일 로드 및 검증
  • JIT 컴파일 인터프리터 → 기계어 변환
  • 메모리 초기화 힙, 메타스페이스 설정

첫번째 요청 처리 지연이 어떤 문제가 있을까?

즉각적 영향

  • 요청 큐 적체
  • 리소스 경합 발생
  • 시스템 부하 증가

연쇄 효과

  • 후속 요청 대기 시간 증가
  • DB 커넥션 및 스레드 풀 고갈
  • GC 부하 증가
  • 클라이언트 타임아웃 발생
  • 재시도로 인한 부하 가중

성능 테스트 구성

시스템 환경

구분항목내용
런타임 환경JVMOpenJDK 17
프레임워크Spring Boot 3.2.x
운영체제Ubuntu 22.04 LTS
데이터베이스DBMSMySQL 8.0

시스템 리소스

구분항목내용
리소스 제한메모리2GB
CPU2 cores

테스트 도구 및 모니터링 구성

구분항목내용
Grafana 대시보드JVM 모니터링• 힙 메모리 사용량
• 가비지 컬렉션 통계
• 스레드 상태
HTTP 성능 지표• 응답 시간
• 처리량
테스트 도구성능 테스트k6

단계별 시나리오

  1. 초기 부하 (0-10초):
    • 0에서 100명까지 가상 사용자 즉시 생성
  2. 지속 부하 (10초-3분):
    • 100명의 가상 사용자가 지속적으로 API 호출
  3. 부하 감소 (3분-3분 10초):
    • 100명에서 0명으로 점진적 감소

테스트 대상 코드

  • 단순 DB 조회 작업을 수행하는 REST API
  • JPA를 사용한 기본적인 조회 작업

Spring Boot 애플리케이션의 첫 요청이 느린데, 어떤 부분들을 살펴봐야 할까?

기본 설정 (Base Line)

응답시간 메트릭JVM 메트릭
상단 JVM하단 JVM

초기 상태의 스프링 부트 애플리케이션은 다음과 같은 성능을 보여주었다.

  • 평균 응답 시간: 201.91ms
  • 초당 요청 처리량: 400-600 requests/second

아무런 최적화를 하지 않은 상태의 기본적인 성능이다. 이후 적용하는 각각의 최적화 방안들은 이전 설정을 유지한 상태에서 추가로 적용하여 성능 측정한 결과를 정리하였다.

🔑 1. Tomcat Thread Pool은 충분할까?

문제 상황

첫 요청이 들어왔을 때 워커 스레드를 생성하는 과정에서 지연이 발생할 수 있다. 기본값인 10개의 워커 스레드로는 갑작스러운 요청 증가에 대응하기 어렵고, 추가 스레드 생성에 따른 오버헤드가 발생할 수 있다.

NIO 동작원리

[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 메트릭
  • 평균 응답 시간: 217.46ms
  • 큰 효과는 없는 것 같다.
  • 스레드 풀의 확장/축소 오버헤드가 감소했다.
    • JVM 메트릭을 보면 스레드가 요청이 들어오기 전부터 미리 생성되어있는 것을 알 수 있다.

🔑 2. DB 커넥션이 뷰 렌더링까지 유지되고 있진 않을까?

문제 상황

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 메트릭
  • 평균 응답 시간: 166.11ms
  • 데이터베이스 커넥션 사용 시간 감소
  • 트랜잭션 처리 최적화

🔑 3. DB 커넥션 풀 사이즈는 적절할까?

문제 상황

HikariCP의 기본 maximum-pool-size는 20개로 설정되어 있어, 동시에 많은 요청이 들어올 경우 커넥션 풀 고갈로 인한 병목 현상이 발생할 수 있다. 특히 첫 요청 시 커넥션 생성에 따른 오버헤드가 추가된다.

해결 방안

spring:
  datasource:
    hikari:
      maximum-pool-size: 50

결과

응답시간 메트릭JVM 메트릭
  • 평균 응답 시간: 182.68ms
  • 더 많은 동시 데이터베이스 연결 처리 가능
  • 안정적인 커넥션 풀 운영

HikariCp 의 커넥션 초기화 및 획득시 발생하는 경합으로 인해 지연시간이 발생할 수 있다

Connection Pool 획득 과정에서 발생하는 지연시간 개선하기 을 참고해보자.

ALB의 slow start, Lambda Concurrency 설정이 적용된 경우에는 초기 트래픽을 제한할 수 있어 이러한 경합 상황이 덜 발생하게 된다.

설정설명예시
ALB Slow Start새로운 타겟이 등록되면 트래픽을 점진적으로 증가30초~900초 동안 트래픽을 0%에서 100%까지 천천히 증가
Lambda Provisioned Concurrency초기에 동시 실행될 수 있는 Lambda 인스턴스 수를 제한초기 동시성을 5로 설정하면 최대 5개의 요청만 동시 처리

🔑 4. JPA 엔티티는 미리 초기화해둘 수 없을까?

문제 상황

JPA는 애플리케이션 시작 후 첫 쿼리 실행 시 많은 초기화 작업을 수행합니다. 엔티티 매핑 정보 로드, SQL 쿼리 생성 및 파싱, 영속성 컨텍스트 초기화 등의 작업으로 인해 첫 요청에서 상당한 지연이 발생할 수 있다.

해결 방안

@Component
public class JpaWarmer {
    @Autowired
    private TestRepository testRepository;
    
    @PostConstruct
    public void warmup() {
        testRepository.findById(1L);
    }
}

결과

응답시간 메트릭JVM 메트릭
  • 평균 응답 시간: 139.83ms
  • 초기 응답 시간 지연 개선

🔑 5. 서블릿 필터 체인도 미리 초기화할 수 있지 않을까?

문제 상황

Spring Boot 애플리케이션의 첫 요청에서는 서블릿 필터 체인의 초기화가 필요하다. 보안, 로깅, 인증 등 다양한 필터들이 처음 요청될 때 초기화되면서 지연이 발생하게 된다.

해결 방안

@Component
public class ServletWarmer {
    @Autowired
    private RestTemplate restTemplate;
    
    @PostConstruct
    public void warmup() {
        restTemplate.getForEntity("/api/v1/health", String.class);
    }
}

결과

응답시간 메트릭JVM 메트릭
  • 평균 응답 시간: 132.38ms
  • 필터 체인 처리 시간 감소

🔑 6. JIT 컴파일 최적화를 좀 더 빨리 할 순 없을까?

문제 상황

JVM은 처음에 바이트코드를 인터프리터로 실행하다가, 자주 실행되는 코드를 기계어로 컴파일하여 성능을 최적화한다. 하지만 JIT 컴파일 과정이 실제 요청이 들어올 때 이루어지면서 초기 성능 저하가 발생할 수 있다. 기본 CompileThreshold 값(10,000)은 코드가 충분히 실행된 후에야 JIT 컴파일이 시작되도록 설정되어 있어, 초기 요청들은 최적화되지 않은 상태로 실행되기 때문이다.

Tiered Compilation 동작 방식

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 메트릭
  • 평균 응답 시간: 118.03ms
  • JIT 컴파일러 최적화
  • 전반적인 애플리케이션 성능 향상

코드가 C2 컴파일러에 의해 최적화된건지 확인해보자

웜업 적용 X웜업 적용 O
java -XX:+UnlockDiagnosticVMOptions
     -XX:+LogCompilation

위 옵션을 추가하면 로그 파일로 어떤 메서드가 최적화될 예정인지/되었는지 알 수 있다.


컨트롤러에서 인터페이스 없이 직접 구현 클래스를 사용했기 때문에, CGLIB 프록시 클래스가 네이티브 머신 코드로 변환된 것을 알 수 있다.

최종 결과

초기 상태 대비 약 41%의 성능 향상을 달성했다. 각 단계별 최적화를 통해 점진적으로 성능이 개선되었으며, 특히 코드 웜업과 JIT 컴파일러 최적화가 도움이 되었다.

테스트 프로젝트는 아래 링크에서 확인할 수 있다

https://github.com/hocaron/spring-study/tree/main/warm-up

References

profile
기록을 통한 성장을

0개의 댓글