들어가며
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에서 언마운트됩니다
- 기존
ThreadAPI와 호환됩니다
코드 비교
// 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 등)을 다뤄보겠습니다.
'Java' 카테고리의 다른 글
| Clean Code 실전 - 레거시 코드를 리팩토링하는 7가지 패턴 (0) | 2026.04.13 |
|---|---|
| Java 디자인 패턴 실전 - 실무에서 자주 쓰는 10가지 패턴 (0) | 2026.04.07 |
| JVM 메모리 구조와 GC 튜닝 - G1, ZGC, Shenandoah 완벽 비교 (0) | 2026.04.07 |
| Java 22~26 새 기능 총정리 - Structured Concurrency부터 Primitive Types까지 (0) | 2026.04.07 |
| Java 동시성 프로그래밍 완벽 가이드 - synchronized부터 Virtual Threads까지 (0) | 2026.03.30 |