Java

Java 17~21 새 기능 총정리 - 실무에서 바로 쓸 수 있는 핵심 기능들

백엔드 개발자 김승원 2026. 3. 25. 12:51

들어가며

Java 17 LTS에서 Java 21 LTS로 넘어오면서 정말 많은 변화가 있었습니다. 단순히 문법 설탕(syntax sugar) 수준이 아니라, 아키텍처 설계 방식 자체를 바꿀 수 있는 기능들이 대거 추가되었는데요. 이 글에서는 실무 백엔드 개발자 관점에서 당장 프로젝트에 적용할 수 있는 핵심 기능 5가지를 정리합니다.

각 기능마다 Before/After 코드를 함께 보여드리니, 마이그레이션을 고민 중이시라면 참고해 주세요.

1. Record 클래스 - DTO 보일러플레이트 제거

Record는 Java 16에서 정식 도입되었고, Java 17 LTS부터 안정적으로 사용할 수 있습니다. 불변 데이터 캐리어를 간결하게 정의하는 문법인데, 실무에서는 DTO(Data Transfer Object)를 대체하는 데 가장 많이 쓰입니다.

Before: 기존 DTO 클래스

public class UserResponse {
    private final String name;
    private final String email;
    private final int age;

    public UserResponse(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }

    @Override
    public boolean equals(Object o) { /* 생략 */ }
    @Override
    public int hashCode() { /* 생략 */ }
    @Override
    public String toString() { /* 생략 */ }
}

After: Record로 변환

public record UserResponse(String name, String email, int age) {
    // equals, hashCode, toString, 접근자 메서드 모두 자동 생성
}

단 한 줄로 위의 모든 코드가 대체됩니다. Record의 핵심 특징을 정리하면 다음과 같습니다.

  • 모든 필드가 private final로 선언됩니다 (불변 보장)
  • equals(), hashCode(), toString()이 자동 생성됩니다
  • 필드명과 동일한 접근자 메서드가 만들어집니다 (getXxx가 아닌 xxx() 형태)
  • Compact Constructor로 유효성 검증을 넣을 수 있습니다

실무 팁: Compact Constructor로 검증 추가

public record CreateUserRequest(String name, String email, int age) {
    public CreateUserRequest {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("나이가 유효하지 않습니다");
        }
    }
}

Spring Boot에서 @RequestBody로 Record를 바로 바인딩할 수 있고, Jackson 직렬화/역직렬화도 정상 동작합니다. 다만 JPA Entity로는 사용할 수 없습니다. Entity는 기본 생성자와 setter가 필요하기 때문이죠. Record는 DTO, VO, 이벤트 페이로드 용도로 사용하는 것이 적절합니다.

2. Sealed Classes - 타입 계층의 완전한 통제

Sealed Classes는 Java 17에서 정식 도입되었습니다. 어떤 클래스가 자신을 상속할 수 있는지를 명시적으로 제한하는 기능입니다. 이게 왜 중요하냐면, 컴파일러가 모든 하위 타입을 알 수 있으므로 switch 표현식에서 완전성 검사(exhaustiveness check)가 가능해지기 때문입니다.

실무 예시: 결제 수단 모델링

public sealed interface PaymentMethod
        permits CreditCard, BankTransfer, MobilePayment {
}

public record CreditCard(String cardNumber, String expiry) implements PaymentMethod {}
public record BankTransfer(String bankCode, String accountNumber) implements PaymentMethod {}
public record MobilePayment(String phoneNumber, String provider) implements PaymentMethod {}

이렇게 정의하면 PaymentMethod를 구현할 수 있는 클래스는 딱 세 가지뿐입니다. 누군가 새로운 결제 수단을 추가하면 permits 절을 수정해야 하고, 그 순간 모든 switch 문에서 컴파일 에러가 발생하여 누락을 방지합니다.

왜 유용한가?

  • 도메인 모델의 닫힌 타입 계층을 표현할 수 있습니다
  • Pattern Matching switch와 결합하면 default 분기가 불필요합니다
  • 새로운 하위 타입 추가 시 컴파일 타임에 누락을 잡아줍니다
  • enum보다 유연합니다 (각 타입이 서로 다른 필드를 가질 수 있으므로)

3. Pattern Matching - instanceof와 switch의 진화

Pattern Matching은 Java 17~21에 걸쳐 단계적으로 강화되었습니다. 크게 두 가지로 나눌 수 있습니다.

3-1. Pattern Matching for instanceof (Java 16+)

// Before
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// After
if (obj instanceof String s) {
    System.out.println(s.length());
}

타입 검사와 캐스팅을 한 번에 처리합니다. 사소해 보이지만 실무에서 반복되는 코드를 상당히 줄여줍니다.

3-2. Pattern Matching for switch (Java 21 정식)

이 기능이 진짜 게임 체인저입니다. 앞서 정의한 Sealed Class와 결합하면 매우 강력해집니다.

public BigDecimal calculateFee(PaymentMethod method) {
    return switch (method) {
        case CreditCard card    -> card.cardNumber().startsWith("4")
                ? new BigDecimal("0.02")   // VISA 2%
                : new BigDecimal("0.03");  // 기타 3%
        case BankTransfer bt    -> new BigDecimal("0.01");  // 계좌이체 1%
        case MobilePayment mp   -> new BigDecimal("0.015"); // 모바일 1.5%
        // default 불필요! sealed이므로 컴파일러가 완전성을 보장
    };
}

Guard Pattern (when 절)

return switch (method) {
    case CreditCard card when card.cardNumber().startsWith("4")
        -> new BigDecimal("0.02");
    case CreditCard card
        -> new BigDecimal("0.03");
    case BankTransfer bt
        -> new BigDecimal("0.01");
    case MobilePayment mp
        -> new BigDecimal("0.015");
};

when 키워드로 조건을 더 세밀하게 분기할 수 있습니다. if-else 체인 대비 가독성이 훨씬 좋고, 실수로 분기를 빠뜨릴 가능성도 줄어듭니다.

4. Virtual Threads (Project Loom) - 동시성의 패러다임 전환

개인적으로 Java 21에서 가장 임팩트 있는 기능이라고 생각합니다. Virtual Thread는 JVM이 관리하는 경량 스레드로, OS 스레드(Platform Thread)와 1:1로 매핑되지 않습니다.

기존 Platform Thread의 한계

  • OS 스레드 하나당 약 1MB의 스택 메모리를 소비합니다
  • 컨텍스트 스위칭 비용이 큽니다
  • 동시 요청이 수천 개를 넘으면 스레드 풀이 포화되어 대기열이 쌓입니다
  • I/O 블로킹 시 스레드가 점유되어 자원이 낭비됩니다

Virtual Thread의 특징

  • JVM 레벨에서 스케줄링되어 수십만 개를 동시에 생성할 수 있습니다
  • 스택 메모리를 필요에 따라 동적으로 할당/해제합니다
  • I/O 블로킹 시 자동으로 carrier thread에서 언마운트됩니다
  • 기존 Thread API와 호환됩니다

코드 비교

// Before: Platform Thread Pool
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
    executor.submit(() -> {
        // DB 조회 등 I/O 작업
        Thread.sleep(Duration.ofSeconds(1));
        return "완료";
    });
}
// 200개 스레드로 10,000개 작업 처리 → 약 50초 소요

// After: Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return "완료";
        });
    }
}
// 10,000개 가상 스레드가 동시 실행 → 약 1~2초 소요

Spring Boot 3.2+에서 적용하기

# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 한 줄이면 톰캣의 요청 처리 스레드가 Virtual Thread로 전환됩니다. WebFlux 같은 리액티브 프로그래밍 없이도 높은 동시성을 확보할 수 있다는 점이 핵심입니다.

주의사항

  • synchronized 블록 내부에서 블로킹 I/O를 하면 carrier thread가 pinning됩니다. ReentrantLock으로 대체하세요.
  • CPU 바운드 작업에는 이점이 없습니다. Virtual Thread는 I/O 바운드 작업에 최적화되어 있습니다.
  • ThreadLocal을 과도하게 사용하면 메모리 이슈가 발생할 수 있습니다. ScopedValue(Preview) 도입을 고려해 보세요.

5. SequencedCollection - 순서가 있는 컬렉션의 통일된 인터페이스

Java 21에서 추가된 SequencedCollection은 작지만 실용적인 변화입니다. 기존에는 컬렉션에서 첫 번째/마지막 요소에 접근하는 방법이 제각각이었습니다.

Before: 일관성 없는 API

// List
list.get(0);                           // 첫 번째
list.get(list.size() - 1);             // 마지막

// Deque
deque.getFirst();                      // 첫 번째
deque.getLast();                        // 마지막

// SortedSet
sortedSet.first();                     // 첫 번째
sortedSet.last();                      // 마지막

// LinkedHashSet
linkedHashSet.iterator().next();       // 첫 번째
// 마지막? ... 방법 없음 (직접 순회해야 함)

After: SequencedCollection 통일 API

SequencedCollection<String> collection = ...;

collection.getFirst();      // 첫 번째 요소
collection.getLast();       // 마지막 요소
collection.addFirst(e);     // 맨 앞에 추가
collection.addLast(e);      // 맨 뒤에 추가
collection.removeFirst();   // 맨 앞 제거
collection.removeLast();    // 맨 뒤 제거
collection.reversed();      // 역순 뷰 반환

List, Deque, LinkedHashSet, SortedSet 등이 모두 이 인터페이스를 구현합니다. 특히 reversed() 메서드는 새 컬렉션을 생성하는 게 아니라 뷰를 반환하므로 성능상 부담이 없습니다.

실무 활용: 최신 데이터 조회

// 시간순 정렬된 주문 목록에서 최신/최초 주문 조회
SequencedCollection<Order> orders = orderRepository.findByUserIdOrderByCreatedAt(userId);

Order firstOrder = orders.getFirst();   // 최초 주문
Order latestOrder = orders.getLast();   // 최신 주문

// 최신순으로 뒤집어서 처리
for (Order order : orders.reversed()) {
    processOrder(order);
}

6. 실무 마이그레이션 체크리스트

위 기능들을 실무에 도입할 때 고려할 사항을 정리합니다.

기능 적용 난이도 추천 적용 범위
Record 낮음 신규 DTO부터 점진적 적용
Sealed Classes 중간 도메인 이벤트, 상태 모델링
Pattern Matching 낮음 instanceof 사용처부터 리팩터링
Virtual Threads 중간~높음 I/O 집약 서비스 우선 적용, 부하테스트 필수
SequencedCollection 매우 낮음 새 코드 작성 시 자연스럽게 사용

마무리

Java 17에서 21로의 전환은 단순한 버전 업그레이드가 아닙니다. Record와 Sealed Classes로 도메인 모델링이 더 명확해지고, Pattern Matching으로 분기 로직이 안전해지며, Virtual Threads로 동시성 처리 방식이 근본적으로 변화합니다.

특히 Virtual Threads는 기존의 "스레드 풀 크기를 얼마로 잡을까"라는 고민 자체를 없애줄 수 있는 기능입니다. Spring Boot 3.2 이상을 사용 중이라면 설정 한 줄로 체험해 볼 수 있으니, 스테이징 환경에서 먼저 테스트해 보시길 권장합니다.

다음 글에서는 Java 21의 Preview 기능 중 주목할 만한 것들(String Templates, Structured Concurrency 등)을 다뤄보겠습니다.