⚙️
🌱
💻
🚀
Skip to content

JDK 17 vs JDK 21 비교 - 임대운영 서비스 관점에서





서론

최근 팀에서 JDK 버전 업그레이드 검토 회의를 했습니다. 현재 JDK 11을 쓰고 있는데, 17로 갈지 21로 갈지 의견이 나뉘더군요.

사실 이전 회사(DAU 10만 메신저 서비스)에서는 이런 고민이 단순했습니다. “성능 좋은 거 쓰면 되지” 였죠. 실시간 메시징이라 동시 접속자 수천 명을 감당하려면 스레드 관리가 생명이었으니까요.

하지만 지금은 다릅니다. 저희는 임대운영 서비스업을 하는 회사입니다. MAU 2,000 정도의 B2B+B2C 복합 서비스인데, TPS는 10-20 수준입니다. 대신 금융/계약 데이터를 다루다 보니 데이터 정합성이 생명입니다.

“우리 같은 작은 서비스에 JDK 21이 필요할까?” 솔직히 이런 의문이 들었습니다.

그래도 고민 끝에 스테이징 환경에 두 버전을 테스트해봤습니다. 이번 포스팅에서는 작은 규모 서비스 관점에서의 JDK 선택에 대해 솔직하게 이야기해보겠습니다.





JDK 버전 전략 이해하기

먼저 Java 릴리즈 전략을 간단히 짚고 넘어가겠습니다. Oracle이 6개월마다 새 버전을 내놓는데, 그중 일부만 LTS(Long Term Support)로 지정됩니다.

LTS 버전:

  • JDK 11 (2018년 9월)
  • JDK 17 (2021년 9월)
  • JDK 21 (2023년 9월)

실무에서는 거의 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

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

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초 이상 걸립니다.

테스트 환경에 가상 스레드를 적용했습니다. 소규모 부하 테스트 결과:

측정 결과:

  • 평균 응답 시간: 약 70% 개선
  • 95 percentile: 약 65% 개선
  • 톰캣 스레드 풀: 설정 불필요
  • CPU 사용률: 약 40% 감소

놀란 건 CPU 사용률이 오히려 줄었다는 겁니다. 기존에는 스레드가 외부 API 응답 기다리면서 블록되어 있었는데, 가상 스레드는 그 시간에 다른 일을 하니까요.

특히 좋았던 건 확장성입니다.

저희는 소규모 서비스지만 향후 5-10배 확장 계획이 있습니다. 가상 스레드 덕분에 트래픽이 늘어도 스레드 풀 걱정은 안 해도 될 것 같습니다.

다만 함정도 있었습니다.

가상 스레드를 적용하면서 예상치 못한 이슈가 있었습니다.

1. 데이터베이스 커넥션 풀 설정

기존에는 스레드 풀 크기에 맞춰서 DB 커넥션 풀도 50개 정도로 설정했었습니다. 그런데 가상 스레드는 수천 개가 동시에 생성될 수 있거든요.

# 기존 설정
spring:
  datasource:
    hikari:
      maximum-pool-size: 50

# 가상 스레드 적용 후
spring:
  datasource:
    hikari:
      maximum-pool-size: 100

초반에 이걸 모르고 테스트하다가 “커넥션 풀 고갈” 에러가 연발했습니다. 가상 스레드가 많아도 결국 DB 커넥션은 물리적 자원이니까요.

2. 외부 API 타임아웃 전략

가상 스레드 덕분에 동시에 많은 외부 API 호출을 할 수 있게 되었는데, 이게 오히려 문제였습니다.

// 타임아웃 설정 필수
RestClient client = RestClient.builder()
    .requestFactory(new JdkClientHttpRequestFactory())
    .build();

// 이후 설정 추가
RestClient client = RestClient.builder()
    .requestFactory(factory)
    .defaultRequest(spec -> spec
        .timeout(Duration.ofSeconds(3))
    )
    .build();

외부 시스템이 느려지면 가상 스레드가 수백 개씩 대기하면서 메모리를 잡아먹더군요. 타임아웃을 짧게 설정하고 서킷 브레이커를 추가했습니다.

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 호출이 많고, 느린 레거시 시스템과 연동해야 하고, 확장 계획이 있다면 충분히 가치가 있습니다.



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 체크도 자연스럽게 할 수 있어서 안전합니다. 외부에서 들어오는 데이터라 null이 올 수 있거든요.



Record Patterns

record BookingInfo(Long id, String name, LocalDateTime time) {}

public void processBooking(Object obj) {
    if (obj instanceof BookingInfo(Long id, String name, LocalDateTime time)) {
        System.out.println(
            String.format("%s(%d) 예약: %s", name, id, time)
        );
    }
}

Record와 Pattern Matching이 결합된 기능입니다. 데이터 처리 로직에서 유용할 것 같은데, 아직 많이 안 써봤습니다.





실제 성능 비교

스테이징 환경에서 동일한 애플리케이션(어드민 사이트)을 JDK 17과 21로 각각 돌려봤습니다.


애플리케이션 시작 시간

JDK 17: 평균 7.8초 JDK 21: 평균 7.1초

약 9% 정도 빨라졌습니다. 저희는 MSA 환경이 아니라 모놀리스라서 재시작이 자주 있진 않지만, 그래도 개발 중에는 체감됩니다.


메모리 사용량

G1GC 기준으로 JDK 21이 약간 더 효율적이었습니다. Full GC 시간도 평균 180ms에서 140ms로 줄었습니다.

작은 차이지만 계약 처리 중 GC가 발생하면 응답이 지연되는 경우가 있었는데, 21로 전환하고 그런 일이 줄었습니다.


처리량

이건 가상 스레드 사용 여부에 따라 극명하게 갈립니다.

가상 스레드 미사용 시:

  • 거의 차이 없음 (오차 범위 내)

가상 스레드 사용 시:

  • 외부 API 호출이 많은 계약 처리: 약 2.8배 향상
  • IoT 제어 API: 약 2.3배 향상
  • 순수 DB 처리: 오히려 약간 느림 (컨텍스트 스위칭 오버헤드)




프로덕션 적용 시 고려사항


안정성

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단계: 스테이징 테스트

  • 부하 테스트로 성능 확인
  • 가상 스레드 효과 측정
  • 메모리 사용량 모니터링

4단계: 프로덕션 배포

  • 메인 코어 프로젝트부터 적용
  • 일주일 모니터링 후 문제 없으면 완료

작은 팀이라 거창하게 하진 않았습니다. 실용적으로 필요한 것만 테스트하고 바로 적용했습니다.





어떤 걸 선택할까?


JDK 17이 나은 경우

안정성이 최우선일 때:

  • 금융 데이터, 계약 데이터처럼 절대 틀리면 안 되는 경우
  • 레거시 시스템과 연동이 많은 경우
  • 검증 시간이 부족한 경우

빠른 마이그레이션이 필요할 때:

  • JDK 11 이하를 쓰고 있어서 당장 올려야 하는 상황
  • 소규모 팀에서 안정적인 운영이 우선일 때
  • 충분한 테스트 리소스가 없는 경우

트래픽이 정말 적을 때:

  • 소규모 사용자
  • 낮은 TPS
  • 확장 계획이 전혀 없는 내부 시스템

저희 회사 내부 어드민은 17로 유지하기로 했습니다. 소수 사용자만 쓰고, 안정성이 최우선이거든요.


JDK 21이 나은 경우

신규 프로젝트 (규모 무관):

  • 어차피 처음부터 시작하는 거라면 21로 가는 게 맞습니다
  • 향후 3-4년은 쓸 건데 최신 LTS가 유리합니다
  • 작은 규모라도 나중을 생각하면 21이 낫습니다

외부 API 호출이 많을 때:

  • 인증, 결제, 외부 시스템 연동처럼 외부 호출이 많은 서비스
  • 외부 시스템이 느려서 응답 대기 시간이 긴 경우
  • 가상 스레드의 효과를 제대로 볼 수 있습니다

서비스 확장을 계획 중일 때:

  • 현재는 작지만 트래픽 증가가 예상되는 경우
  • 향후 5-10배 확장 계획이 있는 경우
  • 비즈니스 성장이 예상되는 경우
  • 나중에 다시 마이그레이션 하느니 지금 하는 게 낫습니다

B2C 서비스를 계획 중일 때:

  • 현재 B2B만 하지만 B2C 확장을 고민 중이라면
  • B2C는 트래픽 패턴이 다르니까 미리 대비

저희 팀은 메인 API 서버를 21로 전환했습니다. 현재 트래픽은 적지만, 외부 연동이 많고 향후 확장 계획이 있어서요.





결론

기술적으로만 보면 JDK 21이 압승입니다. 가상 스레드 하나만으로도 충분한 가치가 있습니다.

그리고 솔직히 말하면, 써보고 싶었습니다.

저희 팀 회의에서 JDK 21 이야기가 나왔을 때, 가장 큰 이유는 “Virtual Thread를 실무에 적용해보고 싶다”였습니다.

물론 성능 개선, 확장성 같은 합리적인 이유도 있었지만, 개발자라면 누구나 새로운 기술을 써보고 싶잖아요. 특히 가상 스레드는 Java 진영에서 정말 큰 변화니까요.

저희는 작은 팀이지만 기술 선택에 자율성이 있습니다. 덕분에 이런 실험적인 시도를 할 수 있었죠.


작은 회사에서 배운 것

기술 선택은 기술력만으로 안 됩니다.

5년 전 이전 회사(대용량 트래픽 서비스)에 있었다면 “JDK 21이 최신이고 성능도 좋으니까 당연히 써야지”라고 생각했을 겁니다. 하지만 지금은 다릅니다.

이전 회사에서는 성능이 전부였습니다. 대용량 트래픽을 감당하려면 가상 스레드 같은 기술이 절실했죠.

하지만 현재 회사(소규모 B2B 서비스)에서는 완전히 다릅니다:

  • 데이터 정합성: 결제 데이터, 비즈니스 데이터는 절대 틀리면 안 됩니다
  • 트랜잭션 설계: 복잡한 비즈니스 로직에서 동시성 제어가 중요합니다
  • 비즈니스 로직: 상태 전이 규칙이 복잡합니다
  • 장애 대응: 문제 생기면 고객사 항의 전화가 바로 옵니다

가상 스레드를 쓴다고 해서 비즈니스 가치가 올라가진 않습니다. 그래도 21을 선택한 건 “미래를 위한 투자”입니다.


솔직하게 말하면

트래픽이 적다면 JDK 17로 충분합니다.

소규모 서비스에서 JDK 17도 과분합니다. 성능 문제는 거의 없고, 데이터 정합성이나 비즈니스 로직이 훨씬 중요합니다.

이전 회사(대용량 트래픽)에서는 가상 스레드가 정말 절실했을 겁니다. 동시 접속자가 많으면 스레드 관리가 생명이니까요.

하지만 지금 같은 규모에서는 가상 스레드보다 트랜잭션 설계, DB 인덱싱, 쿼리 최적화가 더 중요합니다. 데이터가 꼬이거나 중복 처리되는 게 훨씬 큰 문제거든요.

그런데도 저희가 21을 선택한 이유:

  1. 확장 계획: 향후 5-10배 확장 목표
  2. 외부 연동: 외부 API 호출이 많음
  3. 미래 대비: 나중에 다시 마이그레이션 하기 싫음

저희는 향후 서비스 확장 계획이 있습니다. 그때 가서 다시 JDK 마이그레이션 하느니, 지금 21로 시작하는 게 낫다고 판단했습니다.


한 가지 더 - 메인 코어만 21로

처음부터 모든 레파지토리를 21로 올리진 않았습니다.

이전 회사(대용량 트래픽)는 개발자가 많았습니다. 기술 전환해도 시행착오를 나눠서 감당할 수 있었죠.

하지만 지금은 소규모 백엔드 팀입니다. 제가 잘못 판단하면 팀 전체가 고생합니다.

그래서 전략적으로 접근했습니다:

메인 코어 프로젝트만 JDK 21:

  • 메인 API 서버
  • 핵심 비즈니스 로직
  • Virtual Thread 효과를 제대로 볼 수 있는 부분

서포트 레파지토리는 JDK 17 유지:

  • 배치 처리 (야간 작업, 통계)
  • 스케줄러 (알림, 정산)
  • 유틸리티 라이브러리
  • 굳이 21이 필요 없는 부분

이렇게 하면 리스크를 줄이면서도 virtual thread를 써볼 수 있습니다. 메인 프로젝트에서 문제가 생기면 롤백하고, 서포트 레파지토리는 안정적으로 유지할 수 있으니까요.

솔직히 배치나 스케줄러는 성능이 중요하지 않습니다. 밤에 돌아가는데 몇 초 빠른 건 의미 없거든요. 핵심 API만 빠르면 됩니다.


마지막으로

결국 정답은 없습니다.

  • 안정성이 최우선이라면 JDK 17
  • 성능과 미래를 보려면 JDK 21
  • 레거시가 많다면 JDK 17
  • 신규 프로젝트라면 무조건 JDK 21
  • 작은 규모지만 확장 계획이 있다면 JDK 21
  • 외부 API 연동이 많다면 JDK 21
  • 데이터 정합성이 최우선이라면 JDK 17로 시작해도 됨

저희 팀은:

  • 메인 코어 프로젝트 (API 서버): JDK 21
  • 서포트 레파지토리 (배치, 스케줄러, 유틸리티): JDK 17

핵심 비즈니스 로직은 21로, 부가적인 것들은 17로 유지하고 있습니다.

B2B 서비스를 운영하시는 분들께:

트래픽보다 데이터 정합성이 중요하다면, 무리해서 21로 갈 필요 없습니다. 17도 훌륭합니다.

다만 확장 계획이 있거나, 외부 연동이 많거나, 신규 프로젝트라면 21을 적극 추천합니다. 3년 후 후회하지 않으려면요.

다음 포스팅에서는 가상 스레드를 실제 B2B 서비스에 적용하면서 겪은 삽질들을 더 자세히 공유하겠습니다. DB 커넥션 풀 튜닝, 외부 API 타임아웃 전략, 로깅 최적화 등 실전 노하우를 담아보겠습니다.





참고 :

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




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#My Portfolio#Blog OpenSource Github#Blog OpenSource Demo Site