자바 함수형 마스터 — Java 21 가상 스레드·Record Patterns

2026-05-03확률과 통계 마스터 노트

자바 함수형 마스터 노트 시리즈 6편 (마지막). Project Loom의 결실 가상 스레드(Virtual Threads), 플랫폼 스레드와의 결정적 차이, 캐리어 스레드와 mount/unmount 동작, Record Patterns 정식, Switch 패턴 매칭 정식까지 — 자바 동시성의 패러다임 전환 + 시리즈 마무리.

이 글은 자바 함수형 마스터 노트 시리즈의 마지막 여섯 번째 편입니다. 5편(Modern Java)에서 Java 9~17의 신기능을 정리했다면, 이번엔 2023년 LTS Java 21의 핵심 변화 — Virtual Thread.

Project Loom 프로젝트가 7년간 다듬어 온 결실. 자바 동시성 처리의 패러다임 전환. 한 JVM에서 수백만 동시 처리 가능. 함께 정식이 된 Record Patterns·Switch 패턴 매칭까지 묶어 시리즈를 마무리합니다.

처음 가상 스레드가 어렵게 느껴지는 이유

처음 강의를 들을 때 가상 스레드 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, **"기존 스레드와 뭐가 다른지"**가 막연합니다. 같은 Thread 타입인데 왜 따로 부르나? 둘째, "OS 스레드·캐리어·플랫폼·가상" 4단어가 한 번에 쏟아져서. 누가 누구 위에 올라가는지 머리에 안 들어옵니다.

해결법은 한 가지예요. **"호텔 직원 비유"**로 묶는 것. 플랫폼 스레드 = 호텔 정직원(비싸고 한정), 가상 스레드 = 임시직(저렴하고 무제한), 캐리어 스레드 = 정직원이 임시직 작업을 운반. 이 그림이 잡히면 모든 동작이 따라옵니다.

왜 가상 스레드인가 — 기존 스레드의 한계

자바의 전통 스레드 = OS 스레드 1:1 매핑 (플랫폼 스레드).

스레드 1개 = OS 커널 스레드 1개
스레드 1개 = ~2MB 메모리 (스택)
스레드 1개 = OS 컨텍스트 스위칭 비용

문제 — 수만 동시 요청 처리에 한계:

1만 동시 요청 = 1만 스레드 = 20GB 메모리
컨텍스트 스위칭 폭발

해결책으로 등장한 게 비동기 프로그래밍 (CompletableFuture·Reactive). 그런데 코드가 어렵고 디버깅 지옥. "콜백 지옥".

여기서 정말 중요한 시험 함정 — 가상 스레드는 비동기 코드를 동기 스타일로 쓰게 함. 코드 쉬움 + 비동기 성능. 패러다임 전환의 핵심.

가상 스레드 — Project Loom의 결실

JVM이 관리하는 경량 스레드. OS 스레드와 분리.

플랫폼 스레드 (Platform Thread):
  OS 스레드와 1:1 — 무겁고 한정 (수천 개)

가상 스레드 (Virtual Thread):
  JVM 관리 — 가볍고 거의 무제한 (수백만 개)

메모리 비용 비교

종류 메모리 1GB로 만들 수 있는 수
플랫폼 스레드 ~2MB ~500개
가상 스레드 ~수 KB ~수십만 개

여기서 시험 함정이 하나 있어요. 가상 스레드는 OS 스레드 X. JVM 안에서 자체 관리. 그래서 무한히 만들 수 있음.

가상 스레드 만들기

1. 직접 생성

Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("Hello from virtual thread");
});
vt.join();

// 또는
Thread vt2 = Thread.startVirtualThread(() -> { ... });

2. ExecutorService

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10000; i++) {
        executor.submit(() -> {
            // 각 작업마다 새 가상 스레드
            Thread.sleep(1000);
            return "Done";
        });
    }
}

여기서 정말 중요한 시험 함정 — newVirtualThreadPerTaskExecutor는 풀 X. 각 작업마다 새 가상 스레드 생성. 가상 스레드는 풀링 비효율 — 만들고 버리는 게 더 쌈.

3. ThreadFactory

ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);

Carrier Thread — 가상 스레드의 운반자

가상 스레드가 실제 실행될 때 플랫폼 스레드를 빌려 씀. 그게 캐리어 스레드.

가상 스레드 (수백만 개)
   ↓ mount
캐리어 스레드 (CPU 코어 수만큼, 보통 ForkJoinPool)
   ↓ 실행
OS 스레드

Mount / Unmount

Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("Step 1");
    Thread.sleep(1000);   // ← 여기서 unmount!
    System.out.println("Step 2");  // ← 다시 mount
});

블로킹 작업(I/O·sleep) 시 가상 스레드가 캐리어에서 떨어져 나옴 (unmount). 캐리어는 다른 가상 스레드를 받음. 작업 완료 후 가상 스레드가 다시 캐리어에 mount.

여기서 정말 중요한 시험 함정 — 가상 스레드의 핵심 = I/O 대기 시간 활용. 1만 가상 스레드가 모두 I/O 대기여도, 실제 캐리어 스레드는 거의 노는 상태. CPU 효율 극대화.

호텔 비유로 정리

  • 플랫폼 스레드 = 호텔 정직원. 월급 비싸고 채용 한정 (수백 명).
  • 가상 스레드 = 임시 알바. 저렴, 일이 있을 때만 부름 (수백만 명).
  • 캐리어 스레드 = 정직원 중 알바 일감 처리 담당. 알바가 손님 응대하다 손님이 화장실 가면(unmount) 정직원은 다른 알바 일감 받음.

이 그림이 머리에 잡히면 모든 동작 자연스럽습니다.

가상 스레드의 동기 코드 미학

비동기 vs 가상 스레드 비교:

// 비동기 (CompletableFuture) — 콜백 지옥
CompletableFuture<String> a = httpClient.fetch("url1");
a.thenCompose(r -> httpClient.fetch("url2"))
 .thenAccept(System.out::println);

// 가상 스레드 — 동기 코드 그대로
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        String r1 = httpClient.fetch("url1");  // 블로킹
        String r2 = httpClient.fetch("url2");  // 블로킹
        System.out.println(r2);
    });
}

블로킹 같지만, 가상 스레드는 unmount되며 캐리어를 다른 작업에 양보. 비동기 효율 + 동기 코드 단순함.

가상 스레드 적합·부적합

적합

  • HTTP 서버 (수만 동시 연결)
  • 데이터베이스 풀 (대기 많음)
  • 외부 API 호출 (네트워크 대기)
  • 마이크로서비스 통신

부적합

  • CPU 집약 작업 (계산 위주) — 캐리어 스레드만 점유, 효과 X
  • synchronized 블록 — pinning (캐리어에 고정, unmount X)
  • JNI 콜 — pinning
  • ThreadLocal 다량 사용 — 가상 스레드는 ThreadLocal 비효율

여기서 시험 함정이 하나 있어요. synchronized → ReentrantLock 권장. synchronized 안에선 unmount 안 됨 (pinning). ReentrantLock은 OK. 가상 스레드 사용 시 락 코드 점검 필수.

Record Patterns (Java 21 정식)

Record를 패턴으로 분해.

record Point(int x, int y) {}

// Java 16 — instanceof 패턴 (필드 직접 접근)
if (obj instanceof Point p) {
    System.out.println(p.x() + ", " + p.y());
}

// Java 21 — Record Patterns로 분해
if (obj instanceof Point(int x, int y)) {
    System.out.println(x + ", " + y);
}

중첩 Record

record Pair(Point left, Point right) {}

if (obj instanceof Pair(Point(int x1, int y1), Point(int x2, int y2))) {
    // 한 번에 분해
    System.out.println(x1 + y2);
}

여기서 정말 중요한 시험 함정 — Record Patterns + Switch 패턴 = 강력한 도메인 모델링. 함수형 언어의 ADT(Algebraic Data Type) 매칭과 비슷.

Switch 패턴 매칭 (Java 21 정식)

Type Pattern

Object obj = ...;

String desc = switch (obj) {
    case Integer i -> "Int: " + i;
    case String s  -> "Str: " + s;
    case null      -> "Null";
    default        -> "Unknown";
};

null 케이스 명시 가능 (Java 21). 이전엔 별도 if 분기 필요.

Guarded Pattern

String describe(Object obj) {
    return switch (obj) {
        case Integer i when i > 0 -> "Positive int";
        case Integer i when i < 0 -> "Negative int";
        case Integer i -> "Zero";
        case String s when s.isEmpty() -> "Empty string";
        case String s -> "Non-empty string";
        default -> "Other";
    };
}

when 절로 가드 조건. 강력한 분기.

Sealed + Switch = exhaustive

5편에서 다룬 sealed와 결합:

sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double r) implements Shape {}
record Square(double s) implements Shape {}
record Triangle(double a, double b, double c) implements Shape {}

double area(Shape shape) {
    return switch (shape) {
        case Circle(double r) -> Math.PI * r * r;
        case Square(double s) -> s * s;
        case Triangle(double a, double b, double c) -> {
            double p = (a + b + c) / 2;
            yield Math.sqrt(p * (p-a) * (p-b) * (p-c));
        }
        // default 없음 — sealed로 컴파일러가 모든 케이스 안다
    };
}

여기서 시험 함정이 하나 있어요. Sealed 인터페이스에 새 자식 추가 시 Switch가 컴파일 에러. 안전망 역할. 누락 케이스 컴파일 시점 발견.

Structured Concurrency (Preview, Java 21)

여러 가상 스레드를 하나의 단위로 관리.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user = scope.fork(() -> fetchUser(id));
    Subtask<String> orders = scope.fork(() -> fetchOrders(id));

    scope.join();           // 모두 끝까지 대기
    scope.throwIfFailed();  // 하나라도 실패 시 예외

    String result = user.get() + orders.get();
}

장점:

  • 작업 흐름 명확 (트리 구조)
  • 한 작업 실패 시 나머지 자동 취소
  • 디버깅·로깅 단순화

시리즈 마무리 — 6편 종합

1편부터 6편까지의 흐름:

주제 한 줄
1 기초 JVM·OOP·Pass-by-Value·컬렉션 — 함수형 토대
2 람다 익명 클래스 변환·메서드 참조·effectively final
3 함수형 인터페이스 12종을 4 패턴으로 묶어 정리
4 Stream API 파이프라인·중간/최종/Collectors/Optional/병렬
5 Modern Java var·Record·Sealed·텍스트 블록·Switch 표현식
6 Java 21 Virtual Thread·Record Patterns·Switch 패턴

자바 8(2014) 람다·Stream에서 시작된 함수형 변환은 Java 21(2023)에 이르러 함수형 + 데이터 + 동시성 세 축이 모두 자리 잡았습니다.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 플랫폼 스레드 = OS 스레드 1:1, 무거움 (~2MB)
  • 가상 스레드 = JVM 관리, 가벼움 (~수 KB), 거의 무제한
  • 가상 스레드 = OS 스레드 X
  • Carrier Thread = 가상 스레드를 운반하는 플랫폼 스레드 (ForkJoinPool)
  • Mount / Unmount = I/O 대기 시 캐리어 양보
  • 가상 스레드 핵심 = I/O 대기 시간 활용
  • 생성 — Thread.ofVirtual().start() / Executors.newVirtualThreadPerTaskExecutor
  • 풀링 X — 만들고 버리는 게 효율적
  • 적합 — HTTP·DB·외부 API·마이크로서비스
  • 부적합 — CPU 집약 / synchronized (pinning) / JNI / ThreadLocal 다량
  • synchronized → ReentrantLock 권장
  • Record Patterns (Java 21 정식) — obj instanceof Point(int x, int y)
  • 중첩 Record 한 번에 분해
  • Switch 패턴 매칭 (Java 21 정식)
  • Type Pattern — case Integer i ->
  • null 케이스 명시 가능 (Java 21+)
  • Guarded Patterncase X x when condition
  • Sealed + Switch = exhaustive (default 불필요, 누락 시 컴파일 에러)
  • Structured Concurrency (Preview) — 여러 가상 스레드 단위 관리
  • StructuredTaskScope — fork·join·throwIfFailed
  • 한 작업 실패 시 나머지 자동 취소
  • 자바 8 람다(2014) → Java 21 가상 스레드(2023) = 9년 함수형 + 동시성 진화

시리즈 다른 편 (시리즈 마지막)

공식 문서: JEP 444 — Virtual Threads / JEP 440 — Record Patterns / JEP 441 — Pattern Matching for switch 에서 더 깊이.

자바 함수형 마스터 시리즈는 여기서 마무리. 1편부터 6편까지의 흐름이 머리에 남으면 자바 8 이후 9년의 진화를 한 번에 통찰할 수 있는 토대가 됩니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!