Java의 새로 출시된 LTS 버전 21, 변경점 모아보기!

드디어 가상 스레드가 도입된 Java의 새 LTS 버전이 출시되었다. 이번 포스팅에서는 이 Java 21의 주요 변경점들을 정리해보았다.

Java 21?

  • 2021년 출시된 Java 17 이후 2년만에 출시된 Java의 4번째 LTS(Long Term Support) 버전
  • 주요 변화는 다음과 같다.
    • Sequenced Collections
    • Virtual Thread
    • Record, Switch 개선

 

1. Sequenced Collections

기존 컬렉션은 순서를 다루기 불편함

기존 컬렉션 프레임워크는 컬렉션 내 원소들에 순서가 존재할 때 사용되는 문법이 제각각이고, 일부는 가독성도 좋지 않았다.

예를 들어, List 인터페이스의 첫 번째와 마지막 원소 접근 코드는 아래와 같다.

List<String> wordList = new ArrayList<>();
String firstWord = wordList.get(0); // 첫 번째 원소
String lastWord = wordList.get(wordList.size() - 1); // 마지막 원소

마지막 원소 접근의 경우 리스트의 길이에서 1을 뺀 값이 마지막 원소의 인덱스이기 때문에 이를 이용해 획득하고 있음을 알 수 있다.

알고리즘 문제를 많이 풀어 본 사람들에게는 익숙한 표현법이라 이상함을 느끼지 못할 수도 있겠지만,

메서드명과 파라미터 어디에도 마지막을 의미하는 표현이 없다.

이와 달리 Deque 인터페이스의 첫 번째와 마지막 원소 접근 코드는 아래와 같다.

Deque<String> wordList = new ArrayDeque<>();
wordList.getFirst();
wordList.getLast();

List와 달리 처음과 끝의 원소를 반환한다는 의미가 확실한 두 메서드를 지원하고 있다.

문제는, List 구현체를 사용하던 객체에 Deque 구현체를 할당하면서 발생한다.

  • 기존에 get을 통해 원소를 획득하던 부분을 모두 getFirst와 getLast로 바꿔주어야 한다.
  • 이는 확장성 면에서 좋지 않은 변화를 일으킨다고 볼 수 있다.

Sequenced Collections

위에서 설명한 바와 같이, 일부 인터페이스에서 가독성이 나쁜 문제도 있었지만 가장 큰 문제는 원소를 다루는 방식이 일관되지 않다는 점이다.

이러한 불편함을 해결하기 위해 Java 21에서 도입된 것이 바로 Sequenced Collections이다.

추가된 Sequenced Collections의 인터페이스 계층 구조는 아래와 같다.

출처: openjdk

추가된 인터페이스가 기존 List, Deque와 같은 인터페이스의 공통 조상임을 알 수 있다.

SequencedCollection 인터페이스는 아래와 같은 메서드를 제공한다.

interface SequencedCollection<E> extends Collection<E> {
    // 역순 조회
    SequencedCollection<E> reversed();

        // 기존 Deque와 동일한 첫/마지막 원소의 생성, 조회, 삭제
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}

따라서, 이제 아래와 같은 방식으로 List에서도 명시적으로 첫 번째와 마지막 원소에 접근 또는 추가/삭제가 가능하며, 추후 구현체를 다른 것으로 대체해도 메서드를 일일이 수정하지 않아도 되도록 개선되었다.

List<String> wordList = new ArrayList<>();

// 생성
wordList.addFirst(element);
wordList.addLast(element);

// 조회
wordList.getFirst();
wordList.getLast();

// 삭제
wordList.removeFirst();
wordList.removeLast();

집합과 맵에 특화된 Sequenced Collections

위의 계층도를 확인해보면, 집합과 맵 관련된 인터페이스 2가지가 함께 추가되었음을 알 수 있다.

SequencedSet은 단순히 역순 컬렉션 반환 메서드가 SequencedSet을 반환하도록 재정의하고 있다.

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
    SequencedSet<E> reversed();
}

SequencedMap은 단순 원소가 아닌, key 값에 대한 순서를 다루기 위해 존재하며, 아래와 같은 메서드들을 제공한다.

interface SequencedMap<K,V> extends Map<K,V> {
        // key 기준 역순 맵 반환
    SequencedMap<K,V> reversed();

        // key 기준 순차적 key, value, entry에 대한 collection 반환
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();

        // 첫 번째, 마지막 entry의 추가, 조회, 삭제
    V putFirst(K, V);
    V putLast(K, V);
    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    Entry<K, V> pollFirstEntry();
    Entry<K, V> pollLastEntry();
}

주의할 점 - reversed()는 새로운 객체를 반환하는 것이 아님

oracle 공식문서에서도 다음과 같이 명시하고 있다.

(번역) reversed() 메서드는 원본 컬렉션의 역순 view를 제공하는 것으로, 원본 컬렉션의 수정은 이 view에서도 확인할 수 있다.

즉, 해당 메서드의 호출 시점에 새 역순 컬렉션 객체를 생성해 반환하는 것이 아니라, 단순히 역순 시점만 가진 동일 객체의 view를 반환하는 것이다.

따라서, 원본 객체의 수정이 reversed()를 통해 획득한 객체에도 반영됨을 주의해야 한다.

 

2. Virtual Thread

기존의 Java 스레드

하나의 스레드는 하나의 OS 스레드와 1:1 관계를 가지며, 동일하게 동작한다.

  • 고비용이며, 개수가 극히 제한되어 있으며, I/O 작업을 수행하면 blocking

스레드는 고비용이기 때문에, 필요할 때마다 생성해서 사용하는 대신 스레드 풀에 일정 개수 스레드를 할당하고 필요할 때 꺼내 쓰고 다 쓰면 다시 반환한다.

현대의 대부분의 자바의 사용처인 서버 애플리케이션은 여러 I/O 작업을 동반하며, 이에 따라 성능 저하가 발생한다.

  • 스레드가 I/O 대기 등으로 인해 사용 불가능하게 되고, 스레드 풀 내 모든 스레드가 사용 불가능하면 뒤의 요청들은 대기 큐에서 대기하게 된다.
  • 이 문제를 해결하자고 스레드 풀 내 스레드 수를 늘리자니, OS 스레드는 물리적으로 한계가 있다.

지금까지는 이러한 문제를 해결하기 위해 비동기 I/O (reactor)가 도입되는 시도도 있었으나, 너무 코드가 어렵고 코드 실행 흐름을 예측하기 어려워지는 문제로 잘 사용되지는 않았다.

Java 21에 도입된 가상 스레드

기존 스레드는 플랫폼 스레드(Platform Thread)라는 명칭으로, 여전히 Thread Pool 내에 존재한다.

가상 스레드는 기존 스레드와 다대다 관계를 갖는 user-mode 스레드이다.

가상 스레드는 다음과 같은 특징을 가진다.

  • 기존 스레드보다 가벼움 - 생성 비용, 스위칭 비용이 저렴
  • 기존 스레드보다 효율적 - I/O에 의해 OS스레드가 놀지 않도록 동작

기존 스레드보다 가벼움

  • Context Switching 비용이 더 적다.
  • 메모리를 덜 사용한다.
  • I/O blocking에 의한 시간 낭비도 더 적다.

기존 스레드보다 효율적

  • 하나의 가상 스레드가 여러 플랫폼 스레드에 의해 사용되기도 하며, 하나의 플랫폼 스레드는 여러 가상 스레드를 사용한다. (N:M)
  • 플랫폼 스레드가 사용 중인 가상 스레드가 I/O에 의해 응답 대기 상태에 접어들면, 다른 가상 스레드를 실행하여 플랫폼 스레드가 놀지 않도록 한다.
  • 따라서, 동기로 동작하도록 짜인 코드도 실제로는 비동기처럼 동작하도록 할 수 있다.

가상 스레드를 사용할 때 주의할 점

1. Pinned Thread가 발생하지 않도록 주의

  • 가상 스레드가 캐리어에 고정되면 해당 가상 스레드가 blocking 되었을 때 이를 실행 중인 플랫폼 스레드도 함께 blocking 된다. 즉, 기존 스레드의 동작과 동일해진다는 뜻으로, 별다른 성능 개선을 누릴 수 없게 된다는 의미이다.
  • Pinned Thread는 두 가지 상황에서 발생한다.
    1. synchronized 사용
    2. 네이티브 메서드나 외부 함수를 사용
  • 여기서Pinned 란, 가상 스레드가 캐리어에 고정되는 현상을 말한다.
    (캐리어란, 가상 스레드의 실행 주체로 프로세서와 동일한 것이라 볼 수 있다.)

2. ThreadLocal 사용 시 주의

  • 가상 스레드의 개수는 매우 많으며, 이 가상 스레드마다 ThreadLocal을 사용하게되면 그 크기가 아무리 작더라도 차지하는 메모리의 양을 무시할 수 없게 되기 때문에 ThreadLocal 변수를 사용하는 것에 주의해야 한다.

3. Pooling이 필요 없음

  • 가상 스레드의 생성 비용은 매우 저렴하다. 따라서, 필요 시마다 생성하는 것이 별도의 스레드 풀을 사용하는 것보다 간편하다.

4. CPU를 주로 사용하는 작업에서는 성능 개선을 기대할 수 없음

  • 가상 스레드가 개선한 것 역시 이 부분으로, I/O가 발생해도 스레드가 wait 상태가 되는 대신 다른 동작을 실행할 수 있도록 한다.
  • 따라서, 코드 자체를 더 빠르게 실행하는 것은 아니며, 오히려 CPU를 사용하는 동작에서는 Thread Switching 동작이 더 자주 발생하므로 약간 성능이 저하된다고 볼 수 있다.
  • 기존 스레드의 문제는 I/O가 발생할 때 blocking이 발생한다는 점이었다.

 

3. Record 패턴 추가

사전 지식 - Java 14에서 추가된 record

record란, 불변 데이터의 저장에 특화된 특수 클래스이다.

record RecordName(Type field...) {
    // method...
}

사전 지식 - Java 16에서 추가된 패턴 변수

패턴 변수란, 아래와 같이 instanceof 연산자를 사용할 때 바로 형변환된 객체를 얻을 수 있도록 하는 문법이다.

// 패턴 변수가 도입되기 이전
if (obj instanceof String) {
    String s = (String) obj;
}

// 패턴 변수가 도입된 후
if (obj instanceof String s) {

}

Java 21에 도입된 record 패턴

이제 레코드의 각 구성 요소를 패턴 변수로 할당할 수 있게 되었다. JavaScript의 구조 분해 할당과 유사하게 표현할 수 있게 된 것이다.

record Point(int x, int y) {}

if (point instanceof Point(int x, int y) {
    System.out.println("x=" + x + ", y=" + y);
}

레코드가 중첩된 경우 패턴도 중첩해서 할당할 수 있다.

record ColorPoint(int x, int y, Color color) {}

record Color(String name, String hexCode) {}

// 중첩 할당도 가능
if (colorPoint instanceof ColorPoint(int x, int y, Color(String name, String hexCode)) {
    System.out.println("x=" + x + ", y=" + y);
    System.out.println("color name=" + name + ", color code=" + hexCode);
}

Generic 레코드인 경우 타입 변수에 따라 할당하는 것도 가능하다.

record MyPair<T1, T2>(T1 first, T2 second) {}

if (pair instanceof MyPair(String first, Integer second)) {
    System.out.println("Pair<String, Integer>");
} else if (pair instanceof MyPair(Integer first, String second)) {
    System.out.println("Pair<Integer, String>");
} // ...

 

4. Switch 개선

null인 case의 처리 가능

기존에는 switch에 전달되는 값이 null인 경우 NullPointerException 발생했다.

Java 21에서는 case null이 추가되어 null인 경우의 처리를 추가할 수 있게 되었다.

case 문에서 타입 패턴 매칭 가능

전달된 인자가 어떤 타입이냐에 따른 분기 처리가 가능해진다.

// Java 21 이전
if (obj instanceof Integer i) {

} else if (obj instanceof String s) {

} else if ...

// Java 21의 Switch 타입 패턴 매칭 사용
switch (obj) {
    case null -> ...
    case Integer i -> ...
    case String s -> ...
    case Point(int x, int y) -> ... // 레코드 패턴
    default -> ...
}

when 절을 통해 패턴에 조건을 추가할 수 있음

case-when 문을 통해 동일 타입 패턴에 매칭되어도 특정 조건에 따라 다른 처리가 이루어질 수 있도록 할 수 있다. 이를 통해 더 유연하게 타입별/조건별 처리가 가능하다.

static void printYesOrNo(String input) {
    switch (input) {
        case null -> { }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("YESSSSS~~~");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("NOOOOO!!!");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

 

출처