Java 8에 머물러 있다가 17이나 21로 올리려고 하면, 그 사이에 뭐가 바뀌었는지 감이 안 온다. 각 버전의 변경점을 "왜 추가되었는가"라는 관점으로 정리하면, 단순 나열이 아니라 언어의 진화 방향이 보인다.

Java 8 — 모던 Java의 시작

Java 8은 언어 차원에서 가장 큰 전환점이었어요. 여기서 추가된 기능들이 지금까지도 실무의 기본이 됩니다.

Lambda Expression

익명 클래스를 대체하는 간결한 함수 표현식입니다.

JAVA
// 익명 클래스 방식
Comparator<String> comp = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
};

// 람다
Comparator<String> comp = (a, b) -> a.length() - b.length();

람다는 함수형 인터페이스(추상 메서드가 딱 하나인 인터페이스)를 구현하는 문법 설탕이에요. @FunctionalInterface 어노테이션을 붙이면 컴파일러가 추상 메서드가 하나인지 검증해줍니다.

JAVA
@FunctionalInterface
public interface Converter<F, T> {
    T convert(F from);
}

Converter<String, Integer> converter = Integer::valueOf;
Integer result = converter.convert("123"); // 123

자주 쓰는 빌트인 함수형 인터페이스:

인터페이스시그니처용도
Function<T, R>T → R변환
Predicate<T>T → boolean조건 판별
Consumer<T>T → void소비
Supplier<T>() → T생성
UnaryOperator<T>T → T같은 타입 변환

Stream API

컬렉션을 선언적으로 처리하는 API입니다. 반복문 대신 파이프라인으로 데이터를 변환하고 필터링할 수 있게 됐어요.

JAVA
List<String> names = List.of("김철수", "이영희", "박지성", "김민수", "이하늘");

List<String> result = names.stream()
    .filter(name -> name.startsWith("김"))
    .map(String::toUpperCase)
    .sorted()
    .collect(Collectors.toList());
// [김민수, 김철수]

스트림은 지연 평가(lazy evaluation) 됩니다. 중간 연산(filter, map 등)은 최종 연산(collect, forEach 등)이 호출될 때까지 실행되지 않아요. 이게 의외로 중요한데, 불필요한 연산을 건너뛸 수 있어서 성능상 이점이 있습니다.

Optional

null 대신 값의 존재 여부를 명시적으로 표현하는 래퍼 클래스입니다.

JAVA
Optional<String> name = Optional.ofNullable(getUserName());

String result = name
    .filter(n -> n.length() > 2)
    .map(String::toUpperCase)
    .orElse("UNKNOWN");

인터페이스 default / static 메서드

인터페이스에 구현체를 가진 메서드를 넣을 수 있게 됐어요. 기존 인터페이스에 메서드를 추가하면서도 하위 호환성을 유지하려고 도입된 기능입니다.

JAVA
public interface Loggable {
    // default 메서드 — 구현체에서 오버라이드 가능
    default void log(String msg) {
        System.out.println("[LOG] " + msg);
    }

    // static 메서드 — 유틸리티성
    static Loggable create() {
        return new DefaultLogger();
    }
}

Collection 인터페이스에 stream(), forEach() 같은 메서드를 추가할 수 있었던 것도 default 메서드 덕분이에요.

java.time 패키지

Date, Calendar의 끔찍한 API를 대체하는 새로운 날짜/시간 API입니다.

JAVA
LocalDate today = LocalDate.now();
LocalDateTime dateTime = LocalDateTime.of(2026, 3, 15, 17, 30);
Duration duration = Duration.between(startTime, endTime);
Period period = Period.between(startDate, endDate);

// 불변이라 thread-safe
LocalDate tomorrow = today.plusDays(1); // today는 변하지 않음

Date가 mutable이라서 멀티스레드 환경에서 문제를 일으켰던 걸 생각하면, 불변 설계는 정말 중요한 개선이에요.

Java 9 — 모듈과 편의 기능

Module System (JPMS)

Java 9의 가장 큰 변화입니다. module-info.java로 패키지의 공개 범위를 모듈 단위로 제어할 수 있게 됐어요.

JAVA
// module-info.java
module com.myapp.core {
    requires java.sql;
    exports com.myapp.core.api;        // 이 패키지만 외부에 공개
    opens com.myapp.core.model to com.google.gson; // 리플렉션 허용
}

애플리케이션 개발에서 직접 모듈을 정의해서 쓰는 경우는 아직 많지 않아요. 하지만 JDK 자체가 모듈화되면서 jlink로 필요한 모듈만 포함한 경량 런타임을 만들 수 있게 되었다는 점이 핵심입니다.

컬렉션 팩토리 메서드

불변 컬렉션을 한 줄로 만들 수 있습니다.

JAVA
List<String> list = List.of("a", "b", "c");       // 불변 리스트
Set<String> set = Set.of("x", "y", "z");           // 불변 셋
Map<String, Integer> map = Map.of("a", 1, "b", 2); // 불변 맵

list.add("d"); // UnsupportedOperationException!

Arrays.asList()도 있었지만 이건 크기가 고정일 뿐 요소 변경은 가능했어요. List.of()는 완전 불변입니다.

JShell

Java에도 드디어 REPL이 생겼어요. 코드 조각을 바로 실행해볼 수 있어서 빠른 검증에 쓸 만합니다.

PLAINTEXT
jshell> var list = List.of(1, 2, 3)
list ==> [1, 2, 3]

jshell> list.stream().mapToInt(i -> i).sum()
$2 ==> 6

Java 11 — 두 번째 LTS

Java 11은 8 다음 LTS입니다. Oracle JDK가 유료화되면서 많은 조직이 OpenJDK 기반 배포판(Adoptium, Amazon Corretto 등)으로 갈아탔던 시점이기도 해요.

var (로컬 변수 타입 추론)

정확히는 Java 10에서 도입됐는데, 11이 LTS라서 보통 여기서 다뤄요.

JAVA
// 타입을 명시할 필요 없이, 컴파일러가 추론
var list = new ArrayList<String>();  // ArrayList<String>으로 추론
var stream = list.stream();          // Stream<String>으로 추론
var map = Map.of("a", 1);           // Map<String, Integer>로 추론

주의할 점은, var는 로컬 변수에서만 쓸 수 있어요. 필드, 메서드 파라미터, 리턴 타입에는 쓸 수 없습니다. 그리고 var를 남용하면 가독성이 떨어져요. 타입이 명확하지 않은 상황에서는 타입을 직접 쓰는 게 낫습니다.

JAVA
// 좋은 예 — 오른쪽에서 타입이 보임
var reader = new BufferedReader(new FileReader("file.txt"));
var numbers = List.of(1, 2, 3);

// 나쁜 예 — 뭘 반환하는지 모름
var result = service.process(data);

String 메서드 추가

JAVA
"  hello  ".strip();     // "hello" (유니코드 공백도 처리, trim()과 차이)
"  ".isBlank();          // true
"hello\nworld".lines();  // Stream<String>: ["hello", "world"]
"ha".repeat(3);          // "hahaha"

strip() vs trim() 차이는 알아두면 좋아요. trim()은 ASCII 공백(U+0020 이하)만 제거하고, strip()은 유니코드 공백(\u2000 같은 것들)까지 처리합니다.

HTTP Client

java.net.http.HttpClient가 정식 도입됐어요. HttpURLConnection의 끔찍한 API를 드디어 대체할 수 있게 됐습니다.

JAVA
HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .header("Content-Type", "application/json")
    .GET()
    .build();

// 동기 호출
HttpResponse<String> response = client.send(request,
    HttpResponse.BodyHandlers.ofString());

// 비동기 호출
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
    .thenApply(HttpResponse::body)
    .thenAccept(System.out::println);

HTTP/2도 기본 지원합니다.

Java 14 — Records와 패턴 매칭

Records

불변 데이터 클래스를 선언하는 간결한 방법입니다. equals(), hashCode(), toString(), getter가 자동 생성돼요.

JAVA
// 이 한 줄이
public record Point(int x, int y) {}

// 아래와 동등하다 (대략)
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }  // getX()가 아니라 x()
    public int y() { return y; }

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

커스텀 검증도 가능해요:

JAVA
public record Age(int value) {
    public Age {
        if (value < 0 || value > 150) {
            throw new IllegalArgumentException("유효하지 않은 나이: " + value);
        }
    }
}

Record는 DTO나 값 객체(Value Object)에 딱 맞아요. 다만 상속은 불가능하고, 필드는 무조건 final입니다. Lombok의 @Value를 쓰던 곳을 대체할 수 있어요.

Pattern Matching for instanceof

instanceof 체크 후에 캐스팅하는 보일러플레이트를 줄여줍니다.

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

// Java 14+
if (obj instanceof String s) {
    System.out.println(s.length()); // 바로 s 사용
}

부정 조건에서도 스코프가 흘러갑니다:

JAVA
if (!(obj instanceof String s)) {
    return;
}
// 여기서 s 사용 가능 (flow scoping)
System.out.println(s.toUpperCase());

Switch Expression

switch가 문(statement)에서 식(expression)으로 쓸 수 있게 됐어요. 값을 반환할 수 있고, break 대신 화살표를 씁니다.

JAVA
// 기존 switch — fall-through 위험
switch (day) {
    case MONDAY:
    case TUESDAY:
        type = "평일 시작";
        break;
    case FRIDAY:
        type = "불금";
        break;
    default:
        type = "기타";
}

// Switch Expression
String type = switch (day) {
    case MONDAY, TUESDAY -> "평일 시작";
    case FRIDAY -> "불금";
    default -> "기타";
};

여러 줄이 필요하면 yield로 값을 반환해요:

JAVA
int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY -> 7;
    default -> {
        String s = day.toString();
        yield s.length();
    }
};

Java 17 — 세 번째 LTS

많은 기업이 Java 8이나 11에서 17로 올리는 추세입니다. Spring Boot 3.x가 Java 17을 최소 요구 버전으로 잡으면서 전환이 가속화됐어요.

Sealed Classes

클래스의 상속 계층을 제한하는 기능이에요. 어떤 클래스가 자신을 상속할 수 있는지 명시적으로 지정합니다.

JAVA
public sealed class Shape
    permits Circle, Rectangle, Triangle {
}

public final class Circle extends Shape {
    private final double radius;
    // ...
}

public final class Rectangle extends Shape {
    private final double width, height;
    // ...
}

public non-sealed class Triangle extends Shape {
    // non-sealed로 선언하면 Triangle은 자유롭게 상속 가능
}

sealed의 진가는 패턴 매칭과 결합할 때 나옵니다. 컴파일러가 모든 하위 타입을 알고 있으니까, switch에서 default 없이도 exhaustiveness 체크가 가능해요 (Java 21에서 완성).

텍스트 블록

여러 줄 문자열을 편하게 쓸 수 있어요. JSON, SQL, HTML 같은 걸 Java 코드 안에 넣을 때 유용합니다.

JAVA
// 기존
String json = "{\n" +
    "  \"name\": \"홍길동\",\n" +
    "  \"age\": 30\n" +
    "}";

// 텍스트 블록 (실제로는 Java 15에서 정식)
String json = """
    {
      "name": "홍길동",
      "age": 30
    }
    """;

들여쓰기는 닫는 """의 위치를 기준으로 자동 정리돼요. \s(공백 유지), \(줄바꿈 제거) 같은 이스케이프도 지원합니다.

Java 21 — 네 번째 LTS

Java 21은 LTS이면서 동시성 모델에 혁신적인 변화를 가져왔어요.

Virtual Threads (가상 스레드)

전통적인 Java 스레드는 OS 스레드와 1:1로 매핑됩니다. OS 스레드는 생성 비용이 크고, 보통 수천 개 이상 만들면 시스템이 버거워해요. 가상 스레드는 JVM이 관리하는 경량 스레드로, 수십만 개를 만들어도 문제없습니다.

JAVA
// 가상 스레드 직접 생성
Thread vt = Thread.ofVirtual().name("worker").start(() -> {
    System.out.println(Thread.currentThread());
});

// Executor로 사용 — 작업마다 가상 스레드 생성
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        })
    );
}

가상 스레드의 핵심 메커니즘은 continuation 입니다. 가상 스레드가 I/O 등에서 블로킹되면, JVM이 해당 가상 스레드를 캐리어 스레드(실제 OS 스레드)에서 분리(unmount)하고, 캐리어 스레드는 다른 가상 스레드를 실행해요. I/O가 끝나면 다시 캐리어 스레드에 올라탑니다(mount).

그래서 블로킹 코드를 그대로 쓰면서도 리액티브 프로그래밍에 버금가는 처리량을 낼 수 있어요. 리액티브 스타일의 콜백 지옥 없이 말이죠.

주의 — Pinning 문제:

JAVA
// synchronized 안에서 블로킹하면 캐리어 스레드가 고정(pinning)됨
synchronized (lock) {
    Thread.sleep(1000); // 캐리어 스레드가 여기 묶여버림
}

// 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    Thread.sleep(1000); // 캐리어 스레드에서 unmount 가능
} finally {
    lock.unlock();
}

Structured Concurrency (Preview)

여러 비동기 작업을 구조적으로 관리합니다. 하나가 실패하면 나머지를 자동 취소할 수 있어요.

JAVA
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> user = scope.fork(() -> fetchUser());
    Subtask<String> order = scope.fork(() -> fetchOrder());

    scope.join();           // 모든 작업 완료 대기
    scope.throwIfFailed();  // 하나라도 실패하면 예외

    // 모두 성공한 경우에만 여기 도달
    String result = user.get() + " / " + order.get();
}

CompletableFuture로 비슷한 걸 할 수 있긴 한데, Structured Concurrency는 try-with-resources로 생명주기가 관리되니까 리소스 누수 걱정이 없어요. 작업의 부모-자식 관계가 코드 구조에 그대로 드러난다는 것도 장점입니다.

Pattern Matching 심화 — switch와 Record 패턴

Java 21에서 패턴 매칭이 한 단계 더 발전했어요. switch와 결합해서 강력한 분기 처리가 가능합니다.

JAVA
// sealed class + switch 패턴 매칭
String description = switch (shape) {
    case Circle c    -> "반지름 " + c.radius() + "인 원";
    case Rectangle r -> "가로 " + r.width() + ", 세로 " + r.height();
    case Triangle t  -> "삼각형";
    // sealed class라 default 필요 없음 — 컴파일러가 모든 케이스 보장
};

// Record 패턴 — 구조 분해
record Point(int x, int y) {}
record Line(Point start, Point end) {}

if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
    double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}

// switch에서 guard 조건
String classify(Object obj) {
    return switch (obj) {
        case Integer i when i > 0  -> "양수";
        case Integer i when i == 0 -> "영";
        case Integer i             -> "음수";
        case String s              -> "문자열: " + s;
        default                    -> "기타";
    };
}

이 정도 되면 Java도 함수형 언어의 패턴 매칭에 꽤 근접해졌다고 볼 수 있어요.

Stream API 심화

Stream을 제대로 이해하려면 단순 사용법보다는 동작 원리나 주의점을 알아야 합니다.

핵심 연산 정리

JAVA
List<Order> orders = getOrders();

// map — 변환
List<String> names = orders.stream()
    .map(Order::getCustomerName)
    .collect(Collectors.toList());

// filter — 조건 필터링
List<Order> expensive = orders.stream()
    .filter(o -> o.getAmount() > 10000)
    .collect(Collectors.toList());

// reduce — 누적 연산
int totalAmount = orders.stream()
    .map(Order::getAmount)
    .reduce(0, Integer::sum);

// collect — 다양한 수집 전략
Map<String, List<Order>> byCustomer = orders.stream()
    .collect(Collectors.groupingBy(Order::getCustomerName));

// flatMap — 중첩 컬렉션 평탄화
List<Item> allItems = orders.stream()
    .flatMap(order -> order.getItems().stream())
    .collect(Collectors.toList());

flatMap이 왜 필요한가

map을 쓰면 Stream<Stream<Item>>이 돼버려요. flatMap은 중첩된 스트림을 하나로 펼쳐줍니다.

JAVA
// map → Stream<List<Item>> (쓸모없는 구조)
orders.stream()
    .map(Order::getItems) // Stream<List<Item>>

// flatMap → Stream<Item> (원하는 구조)
orders.stream()
    .flatMap(order -> order.getItems().stream()) // Stream<Item>

이건 CompletableFuturethenApply vs thenCompose와 같은 패턴이에요. Optionalmap vs flatMap도 마찬가지입니다.

병렬 스트림 주의점

JAVA
// 병렬 스트림 사용
long count = list.parallelStream()
    .filter(s -> s.length() > 5)
    .count();

쉬워 보이지만 함부로 쓰면 안 돼요. 몇 가지 이유가 있습니다:

  1. ** 공유 상태 변경 금지 **: 병렬 스트림에서 외부 변수를 수정하면 race condition이 발생합니다.
JAVA
// 절대 하지 마라
List<String> results = new ArrayList<>();
stream.parallel().forEach(s -> results.add(s)); // race condition!

// 이렇게 해야 한다
List<String> results = stream.parallel().collect(Collectors.toList());
  1. **ForkJoinPool.commonPool()을 공유 **: parallelStream()은 기본적으로 공통 풀을 씁니다. 한 곳에서 무거운 병렬 스트림을 돌리면 다른 곳의 병렬 스트림까지 영향받아요.

  2. ** 데이터가 충분히 많을 때만 이득 **: 스레드 분배와 병합에 오버헤드가 있어서, 데이터가 적으면 오히려 순차 스트림보다 느려요. 일반적으로 수만 건 이상일 때 고려하라는 가이드라인이 있는데, 실제로는 벤치마크를 돌려봐야 합니다.

  3. ** 순서가 보장 안 됨 **: forEach는 순서가 뒤섞일 수 있어요. 순서가 필요하면 forEachOrdered를 쓰되, 이러면 병렬의 의미가 줄어듭니다.

Optional 올바른 사용법

Optional은 잘 쓰면 코드가 깔끔해지지만, 잘못 쓰면 오히려 코드를 더 복잡하게 만들어요.

orElse vs orElseGet

이 둘의 차이는 성능에 직접적인 영향을 미칩니다.

JAVA
// orElse — 값이 있어도 항상 기본값 생성을 실행
String result = optional.orElse(createDefault()); // createDefault() 항상 호출됨

// orElseGet — 값이 없을 때만 Supplier 실행
String result = optional.orElseGet(() -> createDefault()); // 필요할 때만 호출

createDefault()가 DB 조회나 API 호출처럼 비용이 큰 작업이라면, orElse를 쓰면 값이 있는데도 불필요한 호출이 일어나요. 대부분의 경우 orElseGet이 안전합니다.

안티 패턴

JAVA
// 안티 패턴 1: Optional을 조건 분기용으로만 쓰기
if (optional.isPresent()) {
    return optional.get();
}
// 이건 null 체크랑 다를 바 없다. 이렇게 쓰자:
return optional.orElse(defaultValue);

// 안티 패턴 2: Optional을 메서드 파라미터로 받기
public void process(Optional<String> name) { } // 하지 마라
public void process(String name) { }           // 그냥 null 체크하거나 오버로딩

// 안티 패턴 3: Optional을 필드로 쓰기
class User {
    private Optional<String> nickname; // 하지 마라 — Serializable 아님
    private String nickname;           // null 허용 필드는 그냥 null로
}

// 안티 패턴 4: Optional.of()에 null 넣기
Optional.of(null);          // NullPointerException!
Optional.ofNullable(null);  // Optional.empty() — 이걸 써야 한다

Optional은 ** 메서드 반환 타입 **에 쓰라고 만든 거예요. "이 메서드는 결과가 없을 수 있다"는 걸 호출자에게 명확히 알려주는 게 목적입니다.

주의할 점

var 남용은 가독성을 해친다

var는 오른쪽에서 타입이 명확할 때만 써야 해요. var result = service.process(data) 같은 코드는 반환 타입을 IDE 없이 알 수 없습니다. 메서드 시그니처가 바뀌면 의도치 않게 타입이 달라질 수도 있어요.

parallelStream은 공유 풀을 사용한다

parallelStream()ForkJoinPool.commonPool()을 기본으로 사용합니다. 한 곳에서 무거운 병렬 스트림을 돌리면 ** 같은 JVM 내 다른 병렬 스트림까지 영향 **을 받아요. 데이터가 수만 건 미만이라면 순차 스트림이 오히려 빠른 경우가 많습니다.

가상 스레드의 pinning 문제

synchronized 블록 안에서 블로킹 연산을 하면 캐리어 스레드가 고정(pinning)되어 가상 스레드의 장점이 사라집니다. 기존 라이브러리 중 synchronized를 쓰는 것이 많으므로, 가상 스레드 도입 전에 pinning 이슈를 반드시 점검해야 해요. 해결법은 ReentrantLock으로 전환하는 것입니다.

Optional을 필드나 파라미터로 쓰면 안 된다

Optional은 ** 메서드 반환 타입** 전용으로 설계되었습니다. 필드로 쓰면 Serializable이 아니라서 직렬화 문제가 생기고, 파라미터로 받으면 호출 쪽 코드가 불필요하게 복잡해져요.

LTS 전략

Java는 6개월마다 새 버전이 나오지만, 모든 버전을 추적할 필요는 없어요. LTS(Long-Term Support)만 따라가면 됩니다.

버전유형출시무료 지원 기간
8LTS2014.03벤더마다 다름
11LTS2018.09~2024 (벤더 의존)
17LTS2021.09~2026
21LTS2023.09~2028

비LTS 버전(9, 10, 1216, 1820)은 6개월 후 지원 종료예요. 프로덕션에서 비LTS를 쓸 이유는 거의 없습니다. Spring Boot 3.x부터 Java 17 이상이 필수라는 점도 버전 선택의 중요한 기준이 돼요.

람다 vs 익명 클래스 — 캡처링 차이

겉보기에는 람다가 익명 클래스의 축약형 같지만, 내부 동작이 달라요.

JAVA
// 익명 클래스 — 컴파일 시 별도의 .class 파일 생성
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println(this); // this = 익명 클래스 인스턴스
    }
};

// 람다 — invokedynamic 명령어로 처리, 별도 클래스 파일 없음
Runnable r2 = () -> {
    System.out.println(this); // this = 감싸는 클래스(enclosing class)의 인스턴스
};

핵심 차이:

구분익명 클래스람다
this익명 클래스 자신바깥 클래스
클래스 파일별도 생성 (Outer$1.class)없음 (invokedynamic)
변수 캡처effectively final만effectively final만 (동일)
성능클래스 로딩 오버헤드상대적으로 가벼움
상태(필드)가질 수 있음가질 수 없음

람다가 invokedynamic으로 구현된다는 건 꽤 깊은 질문이에요. JVM이 런타임에 람다 구현 전략을 결정할 수 있도록 유연하게 설계된 것인데, 현재 구현은 LambdaMetafactory를 통해 내부 클래스를 동적으로 생성합니다.

가상 스레드가 기존 스레드 모델을 대체하나?

결론부터 말하면, ** 완전히 대체하진 않습니다.**

가상 스레드가 빛나는 곳은 I/O 바운드 작업 입니다. DB 쿼리, HTTP 호출, 파일 읽기처럼 대기 시간이 긴 작업에서 수만 개의 동시 요청을 처리할 때 플랫폼 스레드보다 훨씬 효율적이에요.

하지만 CPU 바운드 작업 에서는 가상 스레드가 이점이 없어요. 결국 캐리어 스레드(OS 스레드) 위에서 실행되니까, CPU를 점유하는 작업은 캐리어 스레드 수만큼만 동시에 돌아갑니다. 이 경우에는 여전히 ForkJoinPool이나 고정 크기 ThreadPoolExecutor가 적합해요.

또 하나, synchronized 블록 안에서 블로킹 연산을 하면 pinning 문제가 발생해서 가상 스레드의 장점이 사라져요. 기존 라이브러리 중에 synchronized를 쓰는 게 많으니까, 전면 전환 전에 pinning 이슈를 꼼꼼히 점검해야 합니다.

정리하면:

  • I/O 바운드 → 가상 스레드 적극 활용
  • CPU 바운드 → 플랫폼 스레드 풀 유지
  • 리액티브 스타일(WebFlux) → 가상 스레드로 대체 검토 가능 (블로킹 코드로 돌아가면서 같은 처리량)
  • synchronized를 많이 쓰는 레거시 → ReentrantLock 전환 후 적용

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

버전핵심 변경해결한 문제
Java 8람다, 스트림, Optional, java.time함수형 프로그래밍 지원, 컬렉션 선언적 처리, null 안전성
Java 9모듈 시스템, 컬렉션 팩토리패키지 캡슐화, 불변 컬렉션 간편 생성
Java 11var, HttpClient, String 메서드타입 추론, 표준 HTTP 클라이언트
Java 14Record, Pattern Matching, Switch ExpressionDTO 보일러플레이트, instanceof 캐스팅, fall-through
Java 17Sealed Classes, 텍스트 블록상속 계층 통제, 여러 줄 문자열 가독성
Java 21가상 스레드, Structured Concurrency, Record PatternI/O 바운드 확장성, 동시성 구조화, 객체 분해

8의 람다부터 21의 가상 스레드까지, 자바는 ** 보일러플레이트 제거 → 타입 안전성 강화 → 동시성 모델 혁신 **이라는 방향으로 진화하고 있습니다.

22 이후가 궁금하다면

Java 22부터 25까지의 Unnamed Variables, Primitive Pattern Matching, Stream Gatherers, Markdown JavaDoc 등은 별도 글에서 다룹니다.

Java 22-25 신규 기능 — Unnamed Variables부터 Primitive Patterns까지

댓글 로딩 중...