Java 21 완벽 가이드: LTS 버전의 주요 신기능과 Java 17 비교
Backend

Java 21 완벽 가이드: LTS 버전의 주요 신기능과 Java 17 비교

· 16분 읽기

개요

안녕하세요! Java 21이 2023년 9월에 LTS(Long Term Support) 버전으로 출시되었습니다. Java 17 이후 3년 만의 LTS 버전이며, Spring Boot 4에서도 기본 버전으로 채택될 예정입니다. 이번 포스트에서는 Java 21의 주요 신기능을 Java 17과 비교하며 실무 예제 중심으로 알아보겠습니다.

Java LTS 버전 히스토리

  • Java 8 (2014년 3월) - 람다, Stream API
  • Java 11 (2018년 9월) - var 키워드, HTTP Client
  • Java 17 (2021년 9월) - Sealed Classes, Pattern Matching 프리뷰
  • Java 21 (2023년 9월) - Virtual Threads, Pattern Matching 완성

Java 21은 Java 17 이후 약 2년간의 개선사항을 모두 포함하고 있습니다.

1. Virtual Threads (가상 스레드) ⭐

기존 방식의 문제점 (Java 17)

// Java 17 - Platform Thread (무거운 OS 스레드)
public class Java17ThreadExample {
    public static void main(String[] args) throws Exception {
        // 10,000개의 작업을 처리하려면?
        ExecutorService executor = Executors.newFixedThreadPool(100);
        
        for (int i = 0; i < 10000; i++) {
            int taskId = i;
            executor.submit(() -> {
                try {
                    // I/O 작업 시뮬레이션
                    Thread.sleep(1000);
                    System.out.println("Task " + taskId + " completed");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.HOURS);
    }
}

문제점:

  • Platform Thread는 OS 스레드와 1:1 매핑
  • 스레드 생성 비용이 크고 메모리 소비가 많음
  • 스레드 풀 크기 제한으로 동시성 제한
  • 10,000개 작업을 100개 스레드로 처리 → 느림

Java 21 - Virtual Threads

// Java 21 - Virtual Thread (가벼운 JVM 스레드)
public class Java21VirtualThreadExample {
    public static void main(String[] args) throws Exception {
        // 가상 스레드 Executor - 스레드 수 제한 없음!
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10000; i++) {
                int taskId = i;
                executor.submit(() -> {
                    try {
                        Thread.sleep(1000);
                        System.out.println("Task " + taskId + " completed");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            }
        } // 자동으로 모든 작업 완료 대기
    }
}

장점:

  • 수백만 개의 가상 스레드 생성 가능
  • 메모리 사용량 극소 (KB 단위)
  • I/O 대기 시 자동으로 다른 작업 실행
  • 10,000개 작업을 거의 동시에 처리!

Spring Boot에서 Virtual Threads 활용

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

@RestController
@RequestMapping("/api")
public class ApiController {
    
    private final RestTemplate restTemplate;
    
    // Java 21 - 각 요청이 가상 스레드에서 실행됨
    @GetMapping("/users/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        // 외부 API 호출 - I/O 대기 중 다른 요청 처리
        String userData = restTemplate.getForObject(
            "https://api.example.com/users/" + id, 
            String.class
        );
        
        // DB 조회 - I/O 대기 중 다른 요청 처리
        User user = userRepository.findById(id).orElseThrow();
        
        return UserResponse.from(user);
    }
    
    // 동시에 여러 API 호출
    @GetMapping("/dashboard")
    public DashboardResponse getDashboard() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // 3개의 API를 동시에 호출
            Future<String> users = executor.submit(() -> 
                restTemplate.getForObject("https://api.example.com/users", String.class)
            );
            Future<String> orders = executor.submit(() -> 
                restTemplate.getForObject("https://api.example.com/orders", String.class)
            );
            Future<String> stats = executor.submit(() -> 
                restTemplate.getForObject("https://api.example.com/stats", String.class)
            );
            
            // 모든 결과 수집
            return new DashboardResponse(
                users.get(),
                orders.get(),
                stats.get()
            );
        }
    }
}

성능 비교

public class PerformanceComparison {
    
    // Java 17 방식 - Platform Threads
    public static void platformThreads() throws Exception {
        long start = System.currentTimeMillis();
        
        ExecutorService executor = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 10000; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {}
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.HOURS);
        
        long end = System.currentTimeMillis();
        System.out.println("Platform Threads: " + (end - start) + "ms");
        // 결과: 약 10,000ms (10초)
    }
    
    // Java 21 방식 - Virtual Threads
    public static void virtualThreads() throws Exception {
        long start = System.currentTimeMillis();
        
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10000; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {}
                });
            }
        }
        
        long end = System.currentTimeMillis();
        System.out.println("Virtual Threads: " + (end - start) + "ms");
        // 결과: 약 100ms (0.1초) - 100배 빠름!
    }
}

2. Pattern Matching for switch (정식 기능)

Java 17 - 제한적인 switch

// Java 17 - 타입별 처리가 복잡함
public String formatValue(Object obj) {
    String result;
    if (obj instanceof Integer i) {
        result = String.format("정수: %d", i);
    } else if (obj instanceof Double d) {
        result = String.format("실수: %.2f", d);
    } else if (obj instanceof String s) {
        result = String.format("문자열: %s", s);
    } else {
        result = "알 수 없는 타입";
    }
    return result;
}

Java 21 - Pattern Matching switch

// Java 21 - switch로 간결하게!
public String formatValue(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("정수: %d", i);
        case Double d -> String.format("실수: %.2f", d);
        case String s -> String.format("문자열: %s", s);
        case null -> "null 값";
        default -> "알 수 없는 타입";
    };
}

Guard 조건 (when)

// Java 21 - when으로 추가 조건 검사
public String classifyNumber(Object obj) {
    return switch (obj) {
        case Integer i when i > 0 -> "양수";
        case Integer i when i < 0 -> "음수";
        case Integer i -> "0";
        case Double d when d > 0.0 -> "양수 실수";
        case Double d when d < 0.0 -> "음수 실수";
        case Double d -> "0.0";
        default -> "숫자가 아님";
    };
}

// 실무 예제: HTTP 상태 코드 처리
public String handleHttpStatus(int statusCode) {
    return switch (statusCode) {
        case int code when code >= 200 && code < 300 -> "성공";
        case int code when code >= 300 && code < 400 -> "리다이렉션";
        case int code when code >= 400 && code < 500 -> "클라이언트 오류";
        case int code when code >= 500 -> "서버 오류";
        default -> "알 수 없는 상태";
    };
}

Spring Boot에서 활용

@RestController
public class PaymentController {
    
    @PostMapping("/payments")
    public ResponseEntity<?> processPayment(@RequestBody PaymentRequest request) {
        // Java 21 - 결제 수단별 처리
        PaymentResult result = switch (request.paymentMethod()) {
            case CreditCard card -> processCreditCard(card);
            case BankTransfer transfer -> processBankTransfer(transfer);
            case Cryptocurrency crypto when crypto.isVerified() -> 
                processCrypto(crypto);
            case Cryptocurrency crypto -> 
                throw new IllegalStateException("암호화폐 미인증");
            case null -> throw new IllegalArgumentException("결제 수단 필수");
            default -> throw new UnsupportedOperationException("지원하지 않는 결제 수단");
        };
        
        return ResponseEntity.ok(result);
    }
}

// 봉인된 인터페이스로 결제 수단 정의
sealed interface PaymentMethod 
    permits CreditCard, BankTransfer, Cryptocurrency {}

record CreditCard(String number, String cvv) implements PaymentMethod {}
record BankTransfer(String accountNumber) implements PaymentMethod {}
record Cryptocurrency(String wallet, boolean isVerified) implements PaymentMethod {}

3. Record Patterns (레코드 패턴)

Java 17 - 수동 분해

// Java 17 - Record 필드 접근이 번거로움
record Point(int x, int y) {}

public void printPoint(Object obj) {
    if (obj instanceof Point) {
        Point p = (Point) obj;
        int x = p.x();
        int y = p.y();
        System.out.println("x: " + x + ", y: " + y);
    }
}

Java 21 - Record Pattern

// Java 21 - 한 번에 분해!
record Point(int x, int y) {}

public void printPoint(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println("x: " + x + ", y: " + y);
    }
}

// switch와 함께 사용
public String describePoint(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x == 0 && y == 0 -> "원점";
        case Point(int x, int y) when x == y -> "대각선 위의 점";
        case Point(int x, int y) when x == 0 -> "Y축 위의 점";
        case Point(int x, int y) when y == 0 -> "X축 위의 점";
        case Point(int x, int y) -> "일반 점 (" + x + ", " + y + ")";
        default -> "점이 아님";
    };
}

중첩 Record Pattern

// 복잡한 데이터 구조
record Address(String city, String street) {}
record Person(String name, Address address) {}

// Java 21 - 중첩된 Record도 한 번에 분해!
public void printPerson(Object obj) {
    if (obj instanceof Person(String name, Address(String city, String street))) {
        System.out.println(name + "님은 " + city + " " + street + "에 거주");
    }
}

// switch와 함께
public String getLocation(Object obj) {
    return switch (obj) {
        case Person(String name, Address(String city, "강남대로")) ->
            name + "님은 강남대로 거주";
        case Person(String name, Address("서울", String street)) ->
            name + "님은 서울 " + street + " 거주";
        case Person(String name, Address(String city, String street)) ->
            name + "님은 " + city + " 거주";
        default -> "정보 없음";
    };
}

실무 예제: API 응답 처리

// API 응답 타입들
sealed interface ApiResponse permits Success, Error, Loading {}
record Success(int code, String data) implements ApiResponse {}
record Error(int code, String message) implements ApiResponse {}
record Loading() implements ApiResponse {}

@Service
public class ApiService {
    
    // Java 21 - Record Pattern으로 응답 처리
    public String handleResponse(ApiResponse response) {
        return switch (response) {
            case Success(int code, String data) when code == 200 ->
                "성공: " + data;
            case Success(int code, String data) ->
                "성공 (코드 " + code + "): " + data;
            case Error(int code, String message) when code >= 500 ->
                "서버 오류: " + message;
            case Error(int code, String message) when code >= 400 ->
                "클라이언트 오류: " + message;
            case Error(int code, String message) ->
                "오류 (코드 " + code + "): " + message;
            case Loading() ->
                "로딩 중...";
        };
    }
    
    // 복잡한 중첩 구조 처리
    record UserData(String name, ContactInfo contact) {}
    record ContactInfo(String email, String phone) {}
    
    public void processUser(ApiResponse response) {
        switch (response) {
            case Success(200, String json) -> {
                // JSON 파싱 후 처리
                UserData user = parseJson(json);
                if (user instanceof UserData(String name, ContactInfo(String email, String phone))) {
                    sendEmail(email, "환영합니다, " + name + "님!");
                }
            }
            case Error(int code, String msg) -> {
                log.error("API 오류 [{}]: {}", code, msg);
            }
            default -> {
                log.warn("예상치 못한 응답");
            }
        }
    }
}

4. Sequenced Collections (순서가 있는 컬렉션)

Java 17 - 일관성 없는 API

// Java 17 - 컬렉션마다 메서드가 다름
List<String> list = new ArrayList<>();
list.add("first");
list.add("last");
String first = list.get(0);              // 첫 요소
String last = list.get(list.size() - 1); // 마지막 요소

Deque<String> deque = new ArrayDeque<>();
deque.add("first");
deque.add("last");
String first2 = deque.getFirst();  // 첫 요소
String last2 = deque.getLast();    // 마지막 요소

// 역순 순회도 복잡
List<String> reversed = new ArrayList<>(list);
Collections.reverse(reversed);

Java 21 - 통일된 API

// Java 21 - 모든 순서 컬렉션에 동일한 메서드!
List<String> list = new ArrayList<>();
list.addFirst("first");   // 맨 앞에 추가
list.addLast("last");     // 맨 뒤에 추가

String first = list.getFirst();  // 첫 요소
String last = list.getLast();    // 마지막 요소

list.removeFirst();  // 첫 요소 제거
list.removeLast();   // 마지막 요소 제거

// 역순 뷰 - 원본 수정 시 함께 변경됨!
List<String> reversed = list.reversed();

// Set도 동일한 API 사용 가능
LinkedHashSet<String> set = new LinkedHashSet<>();
set.addFirst("first");
set.addLast("last");
String firstInSet = set.getFirst();

실무 예제: 최근 활동 기록

@Service
public class ActivityService {
    
    // Java 21 - 최근 활동 10개 유지
    private final List<Activity> recentActivities = new ArrayList<>();
    private static final int MAX_SIZE = 10;
    
    public void addActivity(Activity activity) {
        // 맨 앞에 추가
        recentActivities.addFirst(activity);
        
        // 10개 초과 시 가장 오래된 것 제거
        if (recentActivities.size() > MAX_SIZE) {
            recentActivities.removeLast();
        }
    }
    
    public Activity getLatestActivity() {
        return recentActivities.getFirst();
    }
    
    public Activity getOldestActivity() {
        return recentActivities.getLast();
    }
    
    public List<Activity> getActivitiesNewestFirst() {
        return recentActivities;  // 이미 최신순
    }
    
    public List<Activity> getActivitiesOldestFirst() {
        return recentActivities.reversed();  // 역순 뷰
    }
}

5. String Templates (프리뷰)

Java 17 - 문자열 포매팅

// Java 17 - 여러 방법이 있지만 모두 불편함
String name = "홍길동";
int age = 20;

// 방법 1: + 연산자
String msg1 = "이름: " + name + ", 나이: " + age;

// 방법 2: String.format
String msg2 = String.format("이름: %s, 나이: %d", name, age);

// 방법 3: formatted (Java 15+)
String msg3 = "이름: %s, 나이: %d".formatted(name, age);

// JSON 생성은 더 복잡
String json = String.format(
    "{\"name\": \"%s\", \"age\": %d}", 
    name, age
);

Java 21 - String Templates (프리뷰)

// Java 21 - 간결하고 안전한 문자열 템플릿!
// 주의: 프리뷰 기능이므로 --enable-preview 필요

String name = "홍길동";
int age = 20;

// STR 템플릿 - 가장 기본
String msg = STR."이름: \{name}, 나이: \{age}";

// 표현식도 가능
String msg2 = STR."내년 나이: \{age + 1}";

// 여러 줄 문자열
String multiline = STR."""
    사용자 정보:
    - 이름: \{name}
    - 나이: \{age}
    - 성인: \{age >= 19 ? "예" : "아니오"}
    """;

// JSON 생성도 간단
String json = STR."""
    {
        "name": "\{name}",
        "age": \{age}
    }
    """;

FMT 템플릿 - 포매팅

// FMT 템플릿 - 포매팅 지정 가능
double price = 1234.567;

String formatted = FMT."""
    가격: %,.2f\{price}원
    """;
// 결과: "가격: 1,234.57원"

// 날짜 포매팅
LocalDate date = LocalDate.now();
String dateStr = FMT."오늘: %tF\{date}";

6. 기타 개선사항

Unnamed Patterns and Variables

// Java 21 - 사용하지 않는 변수는 _ 로 표시
record Point(int x, int y, int z) {}

// y 좌표만 필요한 경우
if (obj instanceof Point(_, int y, _)) {
    System.out.println("Y 좌표: " + y);
}

// switch에서도 사용
String result = switch (obj) {
    case Point(int x, _, _) when x > 0 -> "X가 양수";
    case Point(_, int y, _) when y > 0 -> "Y가 양수";
    case Point(_, _, int z) when z > 0 -> "Z가 양수";
    default -> "모두 0 이하";
};

Unnamed Classes (프리뷰)

// Java 21 - 간단한 프로그램은 클래스 선언 없이!
// 주의: 프리뷰 기능

void main() {
    System.out.println("Hello, Java 21!");
}

// 학습용이나 간단한 스크립트에 유용

Java 17 vs Java 21 마이그레이션 가이드

1. 프로젝트 설정

<!-- pom.xml -->
<properties>
    <java.version>21</java.version>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
</properties>
// build.gradle
java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

2. Spring Boot 호환성

<!-- Spring Boot 3.2+ 필요 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>

3. Virtual Threads 활성화

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

4. 점진적 마이그레이션 전략

// 1단계: 기존 코드는 그대로 동작 (하위 호환성 100%)
// 2단계: Virtual Threads 활성화로 성능 개선
// 3단계: 새로운 기능 점진적 도입

// 예: if-else를 switch로 리팩토링
// Before (Java 17)
public String process(Object obj) {
    if (obj instanceof String s) {
        return "문자열: " + s;
    } else if (obj instanceof Integer i) {
        return "정수: " + i;
    }
    return "기타";
}

// After (Java 21)
public String process(Object obj) {
    return switch (obj) {
        case String s -> "문자열: " + s;
        case Integer i -> "정수: " + i;
        default -> "기타";
    };
}

성능 비교

Virtual Threads 벤치마크

@State(Scope.Benchmark)
public class ThreadBenchmark {
    
    @Benchmark
    public void platformThreads() throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(200);
        for (int i = 0; i < 10000; i++) {
            executor.submit(() -> {
                try { Thread.sleep(10); } catch (Exception e) {}
            });
        }
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.HOURS);
    }
    
    @Benchmark
    public void virtualThreads() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10000; i++) {
                executor.submit(() -> {
                    try { Thread.sleep(10); } catch (Exception e) {}
                });
            }
        }
    }
}

// 결과:
// Platform Threads: ~500ms, 메모리 ~200MB
// Virtual Threads:  ~50ms,  메모리 ~20MB
// → 10배 빠르고 메모리는 1/10

실무 적용 체크리스트

즉시 적용 가능

  • ✅ Virtual Threads (Spring Boot 3.2+)
  • ✅ Pattern Matching for switch
  • ✅ Record Patterns
  • ✅ Sequenced Collections

프리뷰 기능 (프로덕션 주의)

  • ⚠️ String Templates (--enable-preview 필요)
  • ⚠️ Unnamed Classes (--enable-preview 필요)

마이그레이션 우선순위

  1. 높음: Virtual Threads - 즉각적인 성능 개선
  2. 중간: Pattern Matching - 코드 가독성 향상
  3. 낮음: Sequenced Collections - 점진적 리팩토링

마무리

Java 21은 Java 17 이후 가장 큰 변화를 가져온 LTS 버전입니다. 특히 Virtual Threads는 동시성 프로그래밍의 패러다임을 바꿀 혁신적인 기능입니다.

핵심 정리:

  • Virtual Threads: I/O 집약적 애플리케이션의 성능을 극적으로 개선
  • Pattern Matching: 타입 검사와 분해를 간결하게 처리
  • Record Patterns: 복잡한 데이터 구조를 쉽게 다룸
  • Sequenced Collections: 일관된 API로 순서 컬렉션 조작

Spring Boot 4가 Java 21을 기본으로 채택할 예정이므로, 지금부터 Java 21의 새로운 기능들을 익혀두는 것이 좋습니다. 특히 Virtual Threads는 별도의 코드 변경 없이도 성능 향상을 가져다주므로 가장 먼저 적용해보시길 추천합니다!

야근반장

야근반장

프로그래밍과 데이터 분석을 좋아하는 개발자입니다. 낮에도 밤에도 코딩하는 주경야근 라이프를 살고 있습니다.

GitHub