우리 팀은 왜 JDK 21을 선택했나 - 작은 서비스의 관점
TL;DR
- 문제: JDK 11을 쓰고 있는데 17로 갈지 21로 갈지 결정 필요 (작은 규모 B2B 서비스, TPS 10-20)
- 원인: 대용량 서비스는 성능 중심 선택하면 되는데, 작은 서비스는 “우리에게 필요한가?” 고민
- 해결: 스테이징 3개월 테스트 → JDK 21 선택 (Virtual Thread, Pattern Matching, Record 등)
- 효과: 개발 생산성 ↑ (Record), 코드 가독성 ↑ (Pattern Matching), 미래 대비
- 한계: 운영팀 설득 3개월 소요, 검증 덜 된 버전 리스크, 17 대비 학습 곡선, 당장 체감 효과 적음
글 머리말
최근 팀에서 JDK 버전 업그레이드 검토 회의를 했습니다.
현재 JDK 11을 쓰고 있는데, 17로 갈지 21로 갈지 의견이 나뉘더군요. 사실 이전 회사(DAU 10만 메신저 서비스)에서는 이런 고민이 단순했습니다. “성능 좋은 거 쓰면 되지” 였죠. 실시간 메시징이라 동시 접속자 수천 명을 감당하려면 스레드 관리가 생명이었으니까요.
하지만 지금은 다릅니다. 저희는 임대운영 서비스업을 하는 회사입니다. MAU 2,000 정도의 B2B+B2C 복합 서비스인데, TPS는 10-20 수준입니다. 대신 금융/계약 데이터를 다루다 보니 데이터 정합성이 생명입니다.
“우리 같은 작은 서비스에 JDK 21이 필요할까?” 솔직히 이런 의문이 들었습니다.
그래도 고민 끝에 스테이징 환경에 두 버전을 테스트해봤습니다. 이번 포스팅에서는 작은 규모 서비스 관점에서의 JDK 선택에 대해 솔직하게 이야기해보겠습니다.
배경: Java LTS 전략 이해하기
LTS (Long Term Support)란
Oracle이 6개월마다 새 버 전을 내놓는데, 그중 일부만 LTS(Long Term Support)로 지정됩니다.
- JDK 11 (2018년 9월) - 4년 사용
- JDK 17 (2021년 9월) - 현재 가장 인기
- JDK 21 (2023년 9월) - 최신 LTS
실무에서는 거의 LTS만 씁니다. 저희처럼 작은 팀은 더더욱 그렇습니다. 6개월짜리 버전 쫓아가다간 개발은 언제 하냐는 얘기죠.
LTS는 최소 3년 지원이 보장되니까 안정적으로 운영할 수 있습니다.
JDK 17 주요 기능
JDK 17은 현재 가장 많이 쓰이는 버전입니다. 주변 회사들 물어보면 대부분 17을 쓰고 있더군요.
Sealed Classes: 상속 제한
배경
특정 비즈니스 규칙에서 허용된 타입만 사용하도록 강제하고 싶을 때 유용합니다.
코드 예시
public sealed class PaymentMethod
permits CreditCard, BankTransfer, MobilePay {
}
public final class CreditCard extends PaymentMethod {
private final String cardNumber;
private final String cardType;
}
public final class BankTransfer extends PaymentMethod {
private final String bankCode;
private final String accountNumber;
}실무 적용
저희 팀에서 도메인 모델링할 때 써봤는데 꽤 유용했습니다. 누군가 마음대로 새로운 타입을 추가해서 비즈니스 로직을 망가뜨리는 걸 원천 차단할 수 있습니다.
B2B 서비스는 비즈니스 규칙이 정말 중요하니까 도메인 규칙을 코드 레벨에서 강제할 수 있다는 게 큰 장점입니다.
Pattern Matching for instanceof
코드 예시
// 타입별 처리
public BigDecimal calculateDiscount(Payment payment) {
if (payment instanceof CreditCardPayment card) {
return card.getAmount().multiply(new BigDecimal("0.02"));
} else if (payment instanceof BankTransferPayment bank) {
return bank.getAmount().multiply(new BigDecimal("0.01"));
}
throw new IllegalArgumentException("Unknown payment type");
}개선점
형변환 코드가 사라져서 깔끔해집니다. 타입별로 처리하는 로직이 많은데, 이전에는 타입 체크하고 형변환하고 번거로웠는데, 이제는 한 줄로 해결됩니다.
Records: 불변 DTO
코드 예시
public record UserDto(
Long id,
String name,
String email,
LocalDateTime createdAt,
UserType userType
) {}
// 사용
UserDto user = new UserDto(
1L,
"홍길동",
"user@example.com",
LocalDateTime.now(),
UserType.PREMIUM
);실무 가치
DTO 만들 때 정말 편합니다. 롬복 없이도 깔끔하게 불변 객체를 만들 수 있거든요.
저희는 API 응답 DTO가 정말 많습니다. 이런 것들을 전부 Record로 작성하니까 코드가 훨씬 간결해졌습니다. 특히 데이터 정합성이 중요한 서비스라 불변 객체를 선호하는데, Record가 딱 맞습니다.
Text Blocks: SQL 작성 개선
코드 예시
String reportQuery = """
SELECT u.user_id, u.user_name, u.email,
o.order_date, o.total_amount,
p.payment_status
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
LEFT JOIN payments p ON o.order_id = p.order_id
WHERE u.status = 'ACTIVE'
AND o.order_date >= ?
ORDER BY o.order_date DESC
""";개선점
SQL 쿼리 작성할 때 진짜 편합니다. 저희는 JPA 쓰지만 복잡한 리포팅이나 통계 조회는 네이티브 쿼리를 쓸 수밖에 없습니다.
이전에는 \n 붙이고 + 연산자로 이어붙이느라 정신없었는데, 이제는 그냥 자연스럽게 여러 줄로 쓸 수 있습니다.
JDK 21의 변화
JDK 21은 2023년 9월에 나온 최신 LTS입니다. 솔직히 17에서 21로 넘어가는 게 11에서 17로 넘어가는 것보다 체감이 더 큽니다.
Virtual Threads - 작은 서비스에도 필요할까?
이게 JDK 21의 핵심입니다. 처음 들었을 때는 “Go의 고루틴 같은 거네?”라고 생각했는데, 과연 우리 서비스에 필요한가 고민이 많았습니다.
// 기존 플랫폼 스레드
ExecutorService executor = Executors.newFixedThreadPool(50);
// JDK 21 가상 스레드
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
// 외부 API 호출
String result = callExternalAPI();
return processResult(result);
});이전 회사 경험 (DAU 10만 메신저):
- 피크 타임 동시 접속자 수천 명
- 스레드 풀 200개로도 부족
- 복잡한 비동기 처리 구현
- 가상 스레드가 있었다면 훨씬 간단했을 것
현재 회사 상황 (임대운영 서비스):
- 평소 TPS 10-20
- 피크 타임에도 50 미만
- “우리 정도 트래픽이면 오버킬 아닌가?”
그런데 테스트를 해보니 의외였습니다
저희 서비스는 외부 호출이 생각보다 많습니다:
| 외부 연동 | 평균 응답 시간 |
|---|---|
| 인증 API | 800ms |
| 외부 시스템 연동 | 1초 이상 |
| 결제 처리 | 1.5초 |
| 알림 발송 | 500ms |
건수는 적지만 하나하나가 정말 느립니다. 특히 레거시 시스템 연동이 응답이 느려서 평균 1초 이상 걸립니다.
테스트 결과
스테이징 환경에 가상 스레드를 적용했습니다.
graph LR
A[JDK 17<br/>Platform Thread] -->|응답 시간| B[2.5초]
C[JDK 21<br/>Virtual Thread] -->|응답 시간| D[0.75초]
B -->|CPU 사용률| E[65%]
D -->|CPU 사용률| F[40%]
style A fill:#ffebee
style B fill:#ffcdd2
style C fill:#e8f5e9
style D fill:#c8e6c9
style E fill:#ffcdd2
style F fill:#c8e6c9성능 개선:
- 평균 응답 시간: 70% 개선 (2.5초 → 0.75초)
- 95 percentile: 65% 개선
- CPU 사용률: 40% 감소 (65% → 40%)
놀란 건 CPU 사용률이 오히려 줄었다는 겁니다. 기존에는 스레드가 외부 API 응답 기다리면서 블록되어 있었는데, 가상 스레드는 그 시간에 다른 일을 하니까요.
예상치 못한 문제들
가상 스레드를 적용하면서 몇 가지 함정이 있었습니다.
문제 1: DB 커넥션 풀 고갈
문제 2: 외부 API 타임아웃 전략
문제 3: 로깅 성능 이슈
문제 상황
가상 스레드가 많아지니까 로그가 폭발적으로 증가했습니다. 기존 로깅 설정으로는 감당이 안 되더군요.
해결 방법
<!-- Logback 비동기 로깅으로 전환 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>512</queueSize>
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="FILE" />
</appender>이 작업들에 약 2주가 걸렸습니다. 다행히 스테이징에서 발견해서 프로덕션 장애는 피했습니다.
솔직한 결론
TPS가 낮은 서비스에서 가상 스레드의 극적인 효과를 보기는 어렵습니다. 이전 회사처럼 대용량 트래픽이 있어야 진가를 발휘합니다.
하지만 저희처럼:
- 외부 API 호출이 많고
- 느린 레거시 시스템과 연동해야 하고
- 확장 계획이 있다면
충분히 가치가 있습니다.
기타 JDK 21 기능들
Sequenced Collections
// 최근 목록 조회
List<Order> orders = orderRepository.findRecent();
Order latest = orders.getFirst(); // 가장 최근 주문
Order oldest = orders.getLast(); // 가장 오래된 주문
// 역순 정렬
List<Order> reversed = orders.reversed();솔직히 이건 “이제야?”라는 생각이 듭니다. 다른 언어에서는 당연히 되던 게 자바는 이제 지원되네요.
그래도 orders.get(0), orders.get(orders.size()-1) 이런 식으로 안 써도 돼서 코드가 명확해집니다.
Pattern Matching for switch
public String processCommand(Command command) {
return switch (command) {
case StartCommand start ->
"시작: " + start.getName();
case StopCommand stop ->
"종료: " + stop.getReason();
case UpdateCommand update ->
"업데이트: " + update.getVersion();
case null -> "명령 없음";
default -> "알 수 없는 명령";
};
}JDK 17에서는 프리뷰였는데 21에서 정식 채택되었습니다.
저희는 외부 시스템 연동에서 다양한 명령 타입을 처리하는데, 이걸 if-else 지옥으로 처리하다가 이제는 깔끔하게 정리했습니다.
null 체크도 자연스럽게 할 수 있어서 안전합니다.
실제 성능 비교
스테이징 환경에서 동일한 애플리케이션(어드민 사이트)을 JDK 17과 21로 각각 돌려봤습니다.
측정 결과
| 항목 | JDK 17 | JDK 21 | 개선율 |
|---|---|---|---|
| 앱 시작 시간 | 7.8초 | 7.1초 | 9% ↓ |
| Full GC 시간 | 180ms | 140ms | 22% ↓ |
| 외부 API 처리 | 2.5초 | 0.75초 | 70% ↓ |
| 순수 DB 처리 | 50ms | 55ms | 10% ↑ (느림) |
| CPU 사용률 | 65% | 40% | 38% ↓ |
가상 스레드 미사용 시:
- 거의 차이 없음 (오차 범위 내)
가상 스레드 사용 시:
- 외부 API 호출이 많은 계약 처리: 약 2.8배 향상
- IoT 제어 API: 약 2.3배 향상
- 순수 DB 처리: 오히려 약간 느림 (컨텍스트 스위칭 오버헤드)
프로덕션 적용 시 고려사항
안정성
| 항목 | JDK 17 | JDK 21 |
|---|---|---|
| 검증 기간 | 4년 | 1년 |
| 라이브러리 호환성 | 거의 완벽 | 대부분 OK, 일부 경고 |
| 레거시 연동 | 문제 없음 | 확인 필요 |
JDK 17은 이미 4년 가까이 검증되었습니다. 웬만한 라이브러리는 다 호환됩니다.
JDK 21은 아직 1년 조금 넘었습니다. 대부분 잘 동작하지만, 저희가 쓰던 오래된 외부 연동 라이브러리가 21에서 경고를 뿜어댔습니다. 다행히 라이브러리 버전을 올려서 해결했지만, 레거시 시스템과 연동이 많다면 호환성 체크가 필수입니다.
Spring 호 환성
- Spring Boot 3.0 이상: JDK 17 필수
- Spring Boot 3.2 이상: JDK 21 정식 지원
저희는 Spring Boot 3.2를 쓰고 있어서 21 적용이 문제없었습니다. Spring Boot 3.2에서 가상 스레드 지원이 제대로 들어왔는데, 설정 하나면 됩니다:
spring:
threads:
virtual:
enabled: true이거 하나로 톰캣이 가상 스레드를 쓰도록 바뀝니다. 정말 간단합니다.
마이그레이션 전략
저희 팀은 이렇게 진행했습니다.
1단계: 로컬 검증 (며칠)
- 빌드, 단위 테스트 확인
- 주요 기능 수동 테스트
- 외부 라이브러리 호환성 확인
2단계: 개발 서버 (일주일)
- 통합 테스트 수행
- 외부 API 연동 테스트
- DB 커넥션 풀, 타임아웃 설정 조정
3단계: 스테이징 테스트 (2주)
- 부하 테스트로 성능 확인
- 가상 스레드 효과 측정
- 메모리 사용량 모니터링
4단계: 프로덕션 배포
- 메인 코어 프로젝트부터 적용
- 일주일 모니터링 후 문제 없으면 완료
작은 팀이라 거창하게 하진 않았습니다. 실용적으로 필요한 것만 테스트하고 바로 적용했습니다.
어떤 걸 선택할까?
JDK 17이 나은 경우
추천 대상:
- 금융 데이터, 계약 데이터처럼 절대 틀리면 안 되는 경우
- 레거시 시스템과 연동이 많은 경우
- 검증 시간이 부족한 경우
추천 상황:
- JDK 11 이하를 쓰고 있어서 당장 올려야 하는 상황
- 소규모 팀에서 안정적인 운영이 우선일 때
- 충분한 테스트 리소스가 없는 경우
트래픽이 정말 적을 때:
- 소규모 사용자
- 낮은 TPS
- 확장 계획이 전혀 없는 내부 시스템
저희 회사 내부 어드민은 17로 유지하기로 했습니다. 소수 사용자만 쓰고, 안정성이 최우선이거든요.
JDK 21이 나은 경우
신규 프로젝트 (규모 무관):
- 어차피 처음부터 시작하는 거라면 21로 가는 게 맞습니다
- 향후 3-4년은 쓸 건데 최신 LTS가 유리합니다
- 작은 규모라도 나중을 생각하면 21이 낫습니다
외부 API 호출이 많을 때:
- 인증, 결제, 외부 시스템 연동처럼 외부 호출이 많은 서비스
- 외부 시스템이 느려서 응답 대기 시간이 긴 경우
- 가상 스레드의 효과를 제대로 볼 수 있습니다
서비스 확장을 계획 중일 때:
- 현재는 작지만 트래픽 증가가 예상되는 경우
- 향후 5-10배 확장 계획이 있는 경우
- 비즈니스 성장이 예상되는 경우
- 나중에 다시 마이그레이션 하느니 지금 하는 게 낫습니다
저희 팀은 메인 API 서버를 21로 전환했습니다. 현재 트래픽은 적지만, 외부 연동이 많고 향후 확장 계획이 있어서요.
내 프로젝트에 바로 적용하기
체크리스트
- 현재 JDK 버전과 Spring Boot 버전 확인
- 라이브러리 호환성 확인 (특히 외부 연동)
- 테스트 환경 준비 (개발 → 스테이징 → 프로덕션)
- 가상 스레드 사용 시 DB 커넥션 풀 설정 검토
- 외부 API 타임아웃 전략 수립
주의사항
❌ 프로덕션에 바로 적용
- 반드시 스테이징에서 먼저 테스트
❌ 가상 스레드만 믿고 DB 커넥션 풀 미조정
- 가상 스레드 != 무한 DB 커넥션
- 커넥션 풀 크기는 반드시 증가시켜야 함
❌ 외부 API 타임아웃 미설정
- 가상 스레드가 많아지면 타임아웃 필수
- 서킷 브레이커 추가 권장
❌ 모든 프로젝트에 무조건 적용
- 배치, 스케줄러 등은 17로 유지 가능
- 핵심 API 서버만 21로 전환
추천 설정
가상 스레드 적용 시:
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 100 # 기존 50에서 증가외부 API 타임아웃:
RestClient.builder()
.defaultRequest(spec -> spec
.timeout(Duration.ofSeconds(3)) # 짧게 설정
)
.build();비동기 로깅:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>512</queueSize>
</appender>트러블슈팅
Q. “가상 스레드 적용 후 DB 커넥션 풀 고갈 에러가 나요”
- HikariCP maximum-pool-size를 100으로 증가
- 또는 가상 스레드 비활성화
Q. “외부 API 호출 시 메모리가 급증해요”
- 타임아웃 설정 확인 (3초 권장)
- 서킷 브레이커 추가
Q. “순수 DB 작업이 오히려 느려졌어요”
- 이건 정상입니다 (컨텍스트 스위칭 오버헤드)
- DB 작업만 하는 배치는 가상 스레드 미적용 권장
시스템 점검 체크리스트
저도 JDK 버전 업그레이드할 때 이 항목들을 확인합니다. JDK 선택을 고려한다면 참고하시면 좋을 것 같습니다.
- 스테이징 검증: 최소 3개월 이상 스테이징 환경에서 안정성을 검증했는가?
- 라이브러리 호환성: 사용 중인 모든 라이브러리가 새 JDK 버전을 지원하는가?
- 운영팀 동의: 새 버전의 리스크를 운영팀과 충분히 논의했는가?
- 롤백 계획: 문제 발생 시 이전 버전으로 롤백하는 계획이 있는가?
- 모니터링 강화: JVM 메트릭스(GC, 메모리, 스레드)를 주의 깊게 모니터링하는가?
마무리
결국 정답은 없습니다.
기술 선택은 기술력만으로 안 됩니다. 5년 전 이전 회사(대용량 트래픽 서비스)에 있었다면 “JDK 21이 최신이고 성능도 좋으니까 당연히 써야지”라고 생각했을 겁니다. 하지만 지금은 다릅니다.
이전 회사에서는 성능이 전부였습니다. 대용량 트래픽을 감당하려면 가상 스레드 같은 기술이 절실했죠. 하지만 현재 회사(소규모 B2B 서비스)에서는 완전히 다릅니다:
- 데이터 정합성: 결제 데이터, 비즈니스 데이터는 절대 틀리면 안 됩니다
- 트랜잭션 설계: 복잡한 비즈니스 로직에서 동시성 제어가 중요합니다
- 비즈니스 로직: 상태 전이 규칙이 복잡합니다
- 장애 대응: 문제 생기면 고객사 항의 전화가 바로 옵니다
솔직하게 말하면, 트래픽이 적다면 JDK 17로 충분합니다. 소규모 서비스에서 JDK 17도 과분합니다. 성능 문제는 거의 없고, 데이터 정합성이나 비즈니스 로직이 훨씬 중요합니다.
하지만 저희가 21을 선택한 이유:
- 확장 계획: 향후 5-10배 확장 목표
- 외부 연동: 외부 API 호출이 많음
- 미래 대비: 나중에 다시 마이그레이션 하기 싫음
우리 팀의 전략: 선택적 적용
처음부터 모든 레파지토리를 21로 올리진 않았습니다.
메인 코어 프로젝트만 JDK 21:
- 메인 API 서버
- 핵심 비즈니스 로직
- Virtual Thread 효과를 제대로 볼 수 있는 부분
서포트 레파지토리는 JDK 17 유지:
- 배치 처리 (야간 작업, 통계)
- 스케줄러 (알림, 정산)
- 유틸리티 라이브러리
- 굳이 21이 필요 없는 부분
솔직히 배치나 스케줄러는 성능이 중요하지 않습니다. 밤에 돌아가는데 몇 초 빠른 건 의미 없거든요. 핵심 API만 빠르면 됩니다.
B2B 서비스를 운영하시는 분들께: 트래픽보다 데이터 정합성이 중요하다면, 무리해서 21로 갈 필요 없습니다. 17도 훌륭합니다.
다만 확장 계획이 있거나, 외부 연동이 많거나, 신규 프로젝트라면 21을 적극 추천합니다. 3년 후 후회하지 않으려면요.
참고 :
https://openjdk.org/projects/jdk/17/
https://openjdk.org/projects/jdk/21/
https://docs.oracle.com/en/java/javase/21/docs/api/
https://spring.io/blog/2023/09/09/all-together-now-spring-boot-3-2-graalvm-native-images-java-21-and-virtual
