Java 21 출시
Java 21이 2023년 9월 19일에 정식으로 출시되었습니다. Java 17 이후 새로운 LTS(Long-Term Support) 버전으로, Java 21은 다양한 개선 사항과 기능이 추가되었습니다. 이번 포스팅에서는 JDK 17 이후부터 JDK 21까지 통합된 JEP(JDK Enhancement Proposal) 중 개발자들의 코드 작성에 직접적인 영향을 미치는 주요 기능들을 살펴보겠습니다.
잠깐, JEP란?
JEP는 "JDK Enhancement Proposal"의 약자로, Java 개발자들이 Java 플랫폼의 새로운 기능, 개선 사항 또는 변경 사항을 제안하기 위한 공식적인 절차입니다. JEP는 Java 언어, 라이브러리, 도구 또는 JDK 전반에 걸친 개선을 다루며, 새로운 기능을 도입하거나 기존 기능을 최적화하는 데 중요한 역할을 합니다. 이를 통해 Java 커뮤니티는 체계적으로 혁신을 추진할 수 있으며, 제안된 내용은 광범위한 토론과 검토 과정을 거쳐 채택됩니다.
1. JEP 440: 레코드 패턴(Record Patterns)
JDK 16에서 instanceof 연산자에 대한 패턴 매칭이 가능해졌었는데요.
if(obj instanceof Name name) {
name.first(); name.last();
}
JDK 21부터는 레코드 패턴이 추가됩니다.
레코드 패턴은 자바의 패턴 매칭 기능을 확장하여 레코드 타입의 값을 더 쉽게 분해하고 처리할 수 있게 해줍니다.
- 객체 분해: 레코드 객체의 구성 요소를 한 번에 추출할 수 있음
- 중첩 패턴: 복잡한 데이터 구조를 단일 패턴으로 매칭할 수 있음
- var 키워드 지원: 타입 추론을 통해 더 간결한 코드 작성 가능
public class RecordPatternExample {
record Point(int x, int y) {}
record Rectangle(Point upperLeft, Point lowerRight) {}
public static void printRectangleCoordinates(Object obj) {
if (obj instanceof Rectangle(Point(var x1, var y1), Point(var x2, var y2))) {
System.out.printf("Upper left: (%d, %d), Lower right: (%d, %d)%n", x1, y1, x2, y2);
}
}
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(new Point(0, 5), new Point(10, 0));
printRectangleCoordinates(rectangle);
// switch 문과 함께 사용
Object shape = new Point(3, 4);
String description = switch (shape) {
case Point(var x, var y) when x == y -> "Square point at " + x;
case Point(var x, var y) -> "Point at (" + x + ", " + y + ")";
default -> "Unknown shape";
};
System.out.println(description);
}
}
이 예제는 레코드 패턴을 사용하여 복잡한 객체 구조를 쉽게 분해하고 처리하는 방법을 보여줍니다. instanceof와 switch 문에서 모두 레코드 패턴을 사용할 수 있으며, 이를 통해 데이터 중심의 프로그래밍을 더욱 간결하고 명확하게 할 수 있습니다.
몇 가지 주의해야 할 사항이 있는데요.
- 패턴 매칭 실패 시 MatchException이 발생할 수 있으므로 적절한 예외 처리가 필요
- 중첩된 패턴을 과도하게 사용하면 코드 가독성이 떨어질 수 있으므로 적절한 수준에서 사용해야 함
2. JEP 441: 패턴 매칭 for switch (Pattern Matching for switch)
JDK 17에서 switch 문을 통한 패턴 매칭을 프리뷰 기능으로 제공했었는데요.
JDK 21에서는 when 키워드를 활용한 가드 조건 등과 함께 해당 기능이 정식으로 포함됩니다. switch 문의 표현력이 크게 향상되어, 데이터의 구조와 타입에 따라 더 세밀하고 유연한 분기 처리가 가능해집니다. 이 기능을 통해 복잡한 if-else 구조를 간소화할 수 있으며, 다양한 타입의 값에 대해 손쉽게 분기 처리를 할 수 있습니다.
- 다양한 타입 지원: 기본 타입뿐만 아니라 참조 타입에 대해서도 switch 문을 사용할 수 있음
- null 처리: null 값에 대한 명시적인 처리가 가능
- 가드 조건: when 키워드를 사용하여 추가적인 조건을 지정할 수 있음
- 타입 패턴과 레코드 패턴의 결합: 복잡한 객체 구조에 대한 패턴 매칭이 가능
public class PatternMatchingForSwitchExample {
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
public static double calculateArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
case null -> 0;
};
}
public static void describeShape(Object obj) {
switch (obj) {
case Circle c when c.radius() > 5 ->
System.out.println("Large circle");
case Circle c ->
System.out.println("Small circle");
case Rectangle(var width, var height) when width == height ->
System.out.println("Square");
case Rectangle r ->
System.out.println("Rectangle: " + r.width() + "x" + r.height());
case Triangle t ->
System.out.println("Triangle with base " + t.base());
case String s ->
System.out.println("String: " + s);
case null ->
System.out.println("Null object");
default ->
System.out.println("Unknown shape");
}
}
public static void main(String[] args) {
Shape circle = new Circle(10);
Shape rectangle = new Rectangle(4, 5);
Shape square = new Rectangle(3, 3);
Shape triangle = new Triangle(5, 8);
System.out.println("Circle area: " + calculateArea(circle));
System.out.println("Rectangle area: " + calculateArea(rectangle));
System.out.println("Triangle area: " + calculateArea(triangle));
describeShape(circle);
describeShape(rectangle);
describeShape(square);
describeShape(triangle);
describeShape("Not a shape");
describeShape(null);
}
}
이 예제는 패턴 매칭 for switch의 다양한 기능을 보여줍니다. 타입 패턴, 레코드 패턴, null 처리, 가드 조건 등을 사용하여 복잡한 객체 구조와 조건을 처리하는 방법을 보여줍니다.
switch 패턴 매칭을 사용하기전에 고려해야할 사항이 있습니다.
1. 패턴 지배 (Pattern Dominance)
패턴 매칭 switch 문에서는 case 순서가 중요합니다. 더 구체적인 패턴이 먼저 와야 하며, 이를 패턴 지배라고 합니다.
예제의 describeShape 메소드에서:
case Circle c when c.radius() > 5 ->
System.out.println("Large circle");
case Circle c ->
System.out.println("Small circle");
해당 부분의 순서를 바꾸면 "Large circle" case는 절대 실행되지 않습니다. 컴파일러는 이런 상황을 감지하고 에러를 발생시킵니다.
2. 완전성 (Exhaustiveness)
패턴 매칭 switch는 모든 가능한 경우를 처리해야 합니다. 이를 완전성(exhaustiveness)이라고 합니다.
sealed interface Shape permits Circle, Rectangle, Triangle {}
public static double calculateArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
case null -> 0;
};
}
예제의 Shape 인터페이스는 sealed로 선언되어 있습니다. 이 경우, calculateArea 메소드에서는 Shape의 모든 하위 타입을 처리해야 합니다. 여기서 Circle, Rectangle, Triangle을 모두 처리하지 않으면 컴파일 에러가 발생합니다.
새로운 타입이 추가될 때 컴파일러가 누락된 케이스를 알려준다는 장점이 있을 뿐만 아니라 모든 케이스를 명시적으로 처리하게 되어 코드의 의도가 명확해진다는 장점이 있습니다.
default case를 사용해 처리되지 않은 모든 타입을 한번에 처리할 수 있는데, 새로운 케이스 추가 시 컴파일러 경고를 받지 못할 수 있다는 단점이 있으므로 신중하게 사용해야 합니다.
이러한 메커니즘들은 컴파일 시점에서 타입 안정성과 패턴 매칭의 완전성을 검사함으로써 런타임 오류를 줄이고 코드의 견고성을 높입니다. 결과적으로 개발자의 의도치 않은 실수를 미리 방지하는 안전장치 역할을 한다고 볼 수 있겠네요.
3. JEP 431: 순차 컬렉션(Sequenced Collections)
Java의 컬렉션 프레임워크는 오랫동안 요소의 순서가 정의된 컬렉션 타입을 나타내는 단일한 인터페이스가 없었습니다. List와 Deque는 순서를 정의하지만, 이들의 공통 상위 타입인 Collection은 순서를 정의하지 않습니다. 또한, Set은 순서를 정의하지 않지만, LinkedHashSet과 같은 일부 구현체는 순서를 가집니다. 이로 인해 API 설계 시 순서가 있는 컬렉션을 표현하기 어려웠고, 일관된 작업 방식이 부족했습니다.
JDK 21에서 새롭게 추가된 순차 컬렉션(SequencedCollection) 은 Java 컬렉션 프레임워크에 순서가 있는 컬렉션에 대한 일관된 API를 제공합니다. 이를 통해 첫 번째 요소, 마지막 요소에 대한 접근과 역순 처리 등이 모든 순차 컬렉션에서 일관되게 가능해집니다.

순차 컬렉션과 관련된 3가지 인터페이스가 추가되었습니다.
- SequencedCollection<E>
- SequencedSet<E>
- SequencedMap<K,V>
List 인터페이스는 SequencedCollection을 직접 상속받습니다. 이는 모든 List 구현체가 본질적으로 요소들의 순서를 유지하기 때문입니다. SequencedCollection이 List의 상위 인터페이스가 됨으로써, 순서가 있는 다른 컬렉션 타입들과 List 사이에 공통의 상위 타입이 생겼습니다. 이로 인해 순서가 있는 컬렉션들에 대한 일관된 작업 방식을 제공할 수 있게 되었습니다.
Set의 경우, 기본적으로 요소의 순서를 보장하지 않기 때문에 SequencedCollection을 직접 상속받지 않습니다. 그러나 SortedSet이나 LinkedHashSet과 같이 순서를 유지하는 특정 Set 구현체들을 위해 SequencedSet 인터페이스가 도입되었습니다. SequencedSet은 Set과 SequencedCollection 두 인터페이스를 모두 상속받아, 순서가 있는 Set의 특성을 나타냅니다.
Map 인터페이스 역시 기본적으로 순서를 보장하지 않습니다. 따라서 SequencedMap이 Map의 하위 인터페이스로 새롭게 정의되어, LinkedHashMap과 같이 순서를 유지하는 Map 구현체들을 위한 인터페이스를 제공합니다. SortedMap은 이제 SequencedMap을 상속받아, 정렬된 상태를 유지하면서도 순차적 접근 기능을 제공합니다.
Queue 인터페이스는 특정 순서(FIFO)로 요소를 처리하지만, 임의의 요소에 접근하는 기능을 제공하지 않기 때문에 SequencedCollection을 상속받지 않습니다. 반면 Deque(Double Ended Queue)는 양쪽 끝에서 요소를 추가하고 제거할 수 있어 SequencedCollection의 개념과 잘 맞기 때문에 이를 구현합니다.
구현체 측면에서 LinkedHashSet은 SequencedSet의 구현체로, LinkedHashMap은 SequencedMap의 구현체로 나타납니다. 이는 두 클래스 모두 요소나 키의 삽입 순서를 유지하는 특성 때문입니다.
Sequenced Collections의 핵심 인터페이스들은 다음과 같이 정의됩니다.
interface SequencedCollection<E> extends Collection<E> {
// 새로운 메소드
SequencedCollection<E> reversed();
// Deque에서 승격된 메소드들
void addFirst(E);
void addLast(E);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
}
interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
SequencedSet<E> reversed(); // 공변 반환 타입
}
interface SequencedMap<K,V> extends Map<K,V> {
// 새로운 메소드
SequencedMap<K,V> reversed();
SequencedSet<K> sequencedKeySet();
SequencedCollection<V> sequencedValues();
SequencedSet<Entry<K,V>> sequencedEntrySet();
V putFirst(K, V);
V putLast(K, V);
// NavigableMap에서 승격된 메소드들
Entry<K, V> firstEntry();
Entry<K, V> lastEntry();
Entry<K, V> pollFirstEntry();
Entry<K, V> pollLastEntry();
}
기존에는 아래 표와 같이 첫 번째 요소와 마지막 요소의 가져오기가 각 컬렉션마다의 고유한 방식으로 정의되었고, 일부는 명확하지 않거나 아예 누락되어 있었습니다.
첫번째 요소 | 마지막 요소 | |
List | list.get(0) | list.get(list.size() - 1) |
Deque | deque.getFirst() | deque.getLast() |
SortedSet | sortedSet.first() | sortedSet.last() |
LinkedHashSet | linkedHashSet.iterator().next() | // missing |
이를 Sequenced Collections API를 통해 일관된 방식으로 접근할 수 있게 되었습니다. 새로운 인터페이스들은 getFirst()와 getLast() 메서드를 제공하여 모든 순차 컬렉션에서 동일한 방식으로 첫 번째와 마지막 요소에 접근할 수 있게 합니다.
더불어, reversed() 메서드가 눈에 띄는데, 이는 컬렉션의 요소들을 역순으로 볼 수 있는 뷰를 제공하는 중요한 기능입니다. 이 메서드는 원본 컬렉션의 순서를 실제로 변경하지 않고, 대신 역순으로 요소들을 탐색할 수 있는 새로운 뷰를 반환합니다.
컬렉션 프레임워크의 기존 역순 처리 방식은 컬렉션 타입에 따라 일관성이 부족하고, 일부 컬렉션에서는 구현이 복잡하거나 내장 메서드가 없어 개발자가 직접 로직을 구현해야 했으며, 때로는 전체 컬렉션을 복사하여 재정렬하는 비효율적인 방법을 사용해야 했기 때문에 성능 문제와 코드 가독성 저하를 초래했습니다.
reversed() 메서드의 핵심 원리는 '뷰(view)' 개념에 기반합니다. 이는 원본 컬렉션에 대한 '윈도우' 또는 '렌즈'와 같은 역할을 하며, 데이터를 다르게 보여주지만 실제로 데이터를 복사하거나 재배열하지는 않습니다. 이로 인해 메모리 사용이 효율적이며, 원본 컬렉션의 변경 사항이 역순 뷰에 즉시 반영됩니다.
예를 들어, LinkedHashSet에 대해 reversed()를 호출하면, 요소들을 마지막부터 첫 번째까지 역순으로 순회할 수 있는 뷰가 반환됩니다. 이 뷰를 통해 반복자를 사용하거나 스트림을 생성할 때, 요소들이 역순으로 처리됩니다. 실제 데이터의 순서를 변경하지 않기 때문에, 대규모 컬렉션에서도 효율적으로 동작합니다.
간단한 사용 예시를 확인해 봅시다.
SequencedCollection<String> sequencedList = new ArrayList<>(List.of("A", "B", "C"));
System.out.println(sequencedList.getFirst()); // 출력: A
System.out.println(sequencedList.getLast()); // 출력: C
sequencedList.addFirst("Z");
sequencedList.addLast("D");
System.out.println(sequencedList); // 출력: [Z, A, B, C, D]
// reversed() 메서드 사용
for (String s : sequencedList.reversed()) {
System.out.print(s + " "); // 출력: D C B A Z
}
System.out.println();
// SequencedSet 사용 예시
SequencedSet<Integer> sequencedSet = new LinkedHashSet<>(Set.of(1, 2, 3, 4, 5));
System.out.println(sequencedSet.getFirst()); // 출력: 1
System.out.println(sequencedSet.getLast()); // 출력: 5
// SequencedMap 사용 예시
SequencedMap<String, Integer> sequencedMap = new LinkedHashMap<>();
sequencedMap.put("One", 1);
sequencedMap.put("Two", 2);
sequencedMap.putFirst("Zero", 0);
sequencedMap.putLast("Three", 3);
System.out.println(sequencedMap.firstEntry()); // 출력: Zero=0
System.out.println(sequencedMap.lastEntry()); // 출력: Three=3
// 역순 맵 순회
for (var entry : sequencedMap.reversed().entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
사용 시 주의해야 할 점이 있습니다.
1. SortedSet과 SortedMap의 제한사항
SortedSet과 SortedMap은 요소들이 특정 순서(주로 자연 순서나 지정된 Comparator에 의한 순서)로 정렬되어 있는 컬렉션입니다. 이러한 컬렉션에서는 요소의 순서가 그 요소의 값에 의해 결정됩니다.
예를 들어:
SortedSet<Integer> sortedSet = new TreeSet<>();
sortedSet.add(3);
sortedSet.add(1);
sortedSet.add(2);
System.out.println(sortedSet); // 출력: [1, 2, 3]
sortedSet.addFirst(5); // UnsupportedOperationException 발생
여기서 요소들은 자동으로 오름차순으로 정렬됩니다. 따라서 addFirst()나 addLast() 같은 메서드는 이러한 컬렉션의 본질적인 특성과 충돌합니다. 요소를 추가할 때 그 위치는 요소의 값에 의해 결정되어야 하기 때문입니다. 그래서 이러한 메서드들은 이들 컬렉션에서 지원되지 않고, 호출 시 예외를 발생시킵니다.
실제로 SortedSet의 내부를 확인하면 다음과 같이 UnsupportedOperationException을 throw 하는 것을 확인할 수 있습니다.

IDE에서도 메서드 사용시 별다른 경고를 띄워주지 않고 빌드도 성공하므로, 런타임에서 예외가 발생할 가능성이 있으므로 꼭 확인해야 합니다.
2. LinkedHashSet의 특별한 동작
LinkedHashSet은 요소의 삽입 순서를 유지하는 Set입니다. 일반적으로 Set은 중복을 허용하지 않지만, LinkedHashSet의 addFirst()와 addLast() 메서드는 이미 존재하는 요소에 대해 특별한 동작을 합니다.
LinkedHashSet<String> set = new LinkedHashSet<>(Arrays.asList("A", "B", "C"));
System.out.println(set); // 출력: [A, B, C]
set.addFirst("B"); // B는 이미 존재하지만, 이 호출은 B를 맨 앞으로 이동시킵니다.
System.out.println(set); // 출력: [B, A, C]
set.addLast("A"); // A는 이미 존재하지만, 이 호출은 A를 맨 뒤로 이동시킵니다.
System.out.println(set); // 출력: [B, C, A]
set.add("B"); // 이 호출에서는 B가 원래 자리에 위치합니다
System.out.println(set); // 출력: [B, C, A]
이 동작은 요소를 추가하는 것이 아니라 기존 요소의 위치를 변경하는 것입니다. 이는 Set의 일반적인 동작과는 다르므로, 사용 시 주의가 필요합니다.
이러한 특별한 동작들은 각 컬렉션의 특성을 고려하여 설계되었지만, 개발자가 예상하지 못한 결과를 초래할 수 있으므로 주의 깊게 사용해야 합니다.
4. JEP 444: 가상 스레드(Virtual Threads)
JDK 19에서 가상 스레드를 프리뷰 기능으로 제공했었는데요, JDK 21에서는 드디어 해당 기능이 정식으로 포함됩니다!
Java 개발자들은 거의 30년 동안 서버 애플리케이션의 동시성 처리를 위해 스레드를 사용해 왔습니다. 하지만 전통적인 스레드 모델은 현대 애플리케이션의 요구사항을 충족시키는 데 한계를 보였습니다.
1. OS 스레드와 1:1 매핑으로 인한 높은 리소스 소비
전통적인 Java 스레드 모델에서는 각 Java 스레드가 하나의 OS 스레드와 1:1로 매핑됩니다. 이는 다음과 같은 문제를 야기합니다.
- 메모리 소비: 각 OS 스레드는 기본적으로 1MB 정도의 스택 메모리를 필요로 합니다. 따라서 1,000개의 스레드를 생성하면 약 1GB의 메모리가 스레드 스택으로만 사용됩니다.
- 컨텍스트 스위칭 오버헤드: OS 스레드 간의 컨텍스트 스위칭은 비용이 높은 작업입니다. 스레드 수가 증가할수록 컨텍스트 스위칭의 빈도도 증가하여 전체적인 시스템 성능이 저하될 수 있습니다.
- 생성 및 소멸 비용: OS 스레드의 생성과 소멸은 상대적으로 비용이 높은 작업입니다. 이로 인해 스레드의 동적인 생성과 소멸이 제한되며, 대부분의 경우 스레드 풀을 사용하게 됩니다.
2. 제한적인 동시 요청 처리 능력
OS 스레드의 수가 제한적이기 때문에, 동시에 처리할 수 있는 요청의 수도 제한됩니다.
- 스레드 수 제한: 대부분의 운영 체제는 프로세스당 생성할 수 있는 스레드의 수를 제한합니다. 예를 들어, 리눅스의 경우 기본적으로 프로세스당 약 32,000개의 스레드로 제한됩니다.
- 리소스 경쟁: 스레드 수가 증가하면 CPU와 메모리 등의 시스템 리소스에 대한 경쟁이 심해집니다. 이는 전체적인 시스템 성능 저하로 이어질 수 있습니다.
- I/O 블로킹: 전통적인 스레드 모델에서 I/O 작업은 해당 스레드를 블로킹합니다. 따라서 I/O 바운드 작업이 많은 애플리케이션의 경우, 많은 스레드가 대부분의 시간을 대기 상태로 보내게 되어 리소스 낭비가 발생합니다.
3. 스레드 풀을 사용해도 여전히 존재하는 확장성 한계
스레드 풀은 위의 문제들을 완화하기 위해 널리 사용되는 기술이지만, 여전히 한계가 있습니다.
잠깐, 스레드 풀(Thread Pool) 이란?
스레드 풀은 미리 생성된 다수의 재사용 가능한 스레드를 관리하는 메커니즘으로, 작업 요청이 들어오면 풀에서 가용한 스레드에 할당하여 처리합니다. 이는 스레드의 생성과 소멸에 따른 오버헤드를 줄이고 자원 사용을 효율적으로 관리하여 시스템의 안정성을 높이는 것이 주요 목적입니다. Java에서는 ExecutorService 인터페이스를 통해 쉽게 구현할 수 있으며, 고정 크기 풀이나 캐시 풀 등 다양한 형태로 사용할 수 있습니다.
- 고정된 병렬성: 대부분의 스레드 풀은 고정된 수의 스레드를 사용합니다. 이는 동시 처리할 수 있는 작업의 수를 제한합니다.
- 스레드 블로킹: 풀의 모든 스레드가 I/O나 다른 블로킹 작업으로 인해 대기 상태에 있다면, 새로운 작업은 처리되지 못하고 대기하게 됩니다.
- 리소스 낭비: 작업량이 적을 때도 스레드 풀의 모든 스레드가 계속 존재하므로 리소스가 낭비될 수 있습니다.
- 튜닝의 어려움: 최적의 풀 크기를 결정하는 것은 어려운 작업입니다. 너무 작으면 성능이 저하되고, 너무 크면 리소스 낭비가 발생합니다.
이러한 문제들로 인해, 전통적인 스레드 모델은 높은 동시성이 요구되는 현대적인 서버 애플리케이션의 요구사항을 충족시키는 데 한계가 있었습니다. 이러한 한계를 극복하고자 Java 19에서부터 가상 스레드가 도입되었습니다. 가상 스레드는 JEP 425에서 처음 제안되어 preview 기능으로 제공되었고, JEP 436을 거쳐 JEP 444에서 최종 확정되었습니다.
가상 스레드는 Java 런타임에 의해 관리되는 경량 스레드입니다.
기존의 정통 스레드와 비교하여 가상 스레드의 특징과 구조를 자세히 살펴보겠습니다.
항목 | 정통 스레드 모델 | 가상 스레드 모델 |
OS 스레드와의 관계 | 1:1 매핑 (Java 스레드 ↔ OS 스레드) | 1:M매핑 (많은 가상 스레드가 적은 OS 스레드를 공유) |
메모리 사용 | 고정된 스택 크기(약 1MB), 스레드 수 제한 | 메모리 효율적 사용, 수백만 개의 가상 스레드 생성 가능 |
블로킹 작업 처리 | OS 스레드 전체가 블록됨 | 가상 스레드만 블록되고, OS 스레드는 다른 작업 처리 |
스케쥴링 | OS의 스케줄러에 의해 관리 | Java 런타임 스케줄러(ForkJoinPool)로 관리 |
생성 및 관리 | 생성 비용이 높아 스레드 풀로 관리 | 생성 비용이 낮아 필요 시 생성 및 폐기 가능, 스레드 풀 필요 없음 |
프로그래밍 모델 | 비동기 프로그래밍 모델 사용 | 동기적 프로그래밍 모델로 높은 동시성 구현 가능 |
리소스 사용 효율성 | OS 스레드 수 제한으로 리소스 활용에 제약 | 많은 수의 동시 작업 처리로 하드웨어 리소스 효율적 사용 |
정통 스레드 vs 가상 스레드의 구조 비교

정통 스레드 모델에서는 JVM의 각 스레드가 OS의 스레드와 1:1로 직접 매핑됩니다. 이는 Java 애플리케이션의 각 스레드가 OS 레벨의 스레드를 직접 사용함을 의미합니다. 이 구조에서는 동시 요청 수가 증가하면 OS 스레드 수도 비례하여 증가하게 되며, 이는 OS 스레드 수의 제한으로 인해 확장성에 한계를 갖게 됩니다.

반면 가상 스레드 모델에서는 많은 수의 가상 스레드가 적은 수의 OS 스레드(Platform Thread)를 공유하는 M:N 매핑 구조를 보여줍니다. JVM 내부에 ForkJoin Pool을 통한 스케줄링 메커니즘이 추가되어, 가상 스레드들을 효율적으로 관리합니다. 이 구조에서 가상 스레드는 필요할 때만 OS 스레드를 사용하며, 블로킹 작업 시 OS 스레드를 해제하여 다른 가상 스레드가 사용할 수 있게 합니다.
이러한 구조적 차이로 인해 가상 스레드 모델은 정통 스레드 모델에 비해 훨씬 더 많은 수의 동시 작업을 효율적으로 처리할 수 있으며, OS 스레드 자원을 더욱 효율적으로 사용할 수 있습니다. 이는 특히 I/O 바운드 작업이 많은 애플리케이션에서 큰 이점을 제공하며, 전체적인 시스템의 확장성과 성능을 크게 향상시킬 수 있습니다.
OS가 큰 가상 주소 공간을 제한된 양의 물리적 RAM에 매핑하여 풍부한 메모리의 환상을 제공하는 것처럼, Java 런타임은 많은 수의 가상 스레드를 적은 수의 OS 스레드에 매핑하여 풍부한 스레드에 대한 일종의 환상을 제공합니다.
가상 스레드는 Java의 기존 Thread API를 확장하여 사용하므로, 개발자들이 쉽게 적응하고 기존 코드를 최소한의 변경으로 가상 스레드로 마이그레이션할 수 있습니다.
단일 가상 스레드 생성 및 실행
Thread.startVirtualThread(() -> {
System.out.println("Virtual thread is running");
});
가상 스레드 ExecutorService 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// 작업 수행
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
가상 스레드를 사용할 때 주의해야 할 고려사항들이 있습니다.
1. 스레드 로컬 변수 사용 최소화
가상 스레드는 매우 가볍고 수가 많기 때문에, 각 가상 스레드에 ThreadLocal 변수를 사용하면 메모리 사용량이 급격히 증가할 수 있습니다. 대안으로 Scoped Value를 고려할 수 있습니다.
2. 핀닝(Pinning) 현상
가상 스레드 사용 시 주의해야 할 중요한 점은 '핀닝(Pinning)' 현상입니다. 핀닝은 가상 스레드가 특정 OS 스레드에 고정되어 다른 가상 스레드로 전환되지 못하는 상황을 말합니다. 이는 주로 다음과 같은 경우에 발생합니다.
synchronized void potentialPinningMethod() {
// 1. 동기화 블록 내 블로킹 작업
networkService.sendRequest();
// 2. 네이티브 메소드 호출
nativeMethod();
// 3. 긴 실행 시간을 가진 동기화된 코드
for (int i = 0; i < 1000000; i++) {
// 복잡한 연산
}
}
가상 스레드는 내용이 방대하고 아직 충분히 학습하지 못한 부분이 많아, 추후 다른 포스팅에서 더 자세히 다루도록 하겠습니다.
마무리
Java 21의 출시는 Java 언어와 플랫폼의 지속적인 혁신과 현대화를 잘 보여주고 있는 것 같습니다. 레코드 패턴과 패턴 매칭은 데이터 중심 프로그래밍을 더욱 간결하고 표현력 있게 만들어주고, 순차 컬렉션은 컬렉션 프레임워크의 일관성과 사용 편의성을 크게 개선한 것 같아요.
특히 가상 스레드의 도입은 동시성 프로그래밍의 패러다임을 변화시키는 혁신적인 기능으로 보이는데요. 이러한 변화들은 Java가 개발자의 생산성 향상, 성능 최적화, 현대적 프로그래밍 패러다임 수용 등 다양한 방향으로 발전해 나가고 있음을 보여주는 것 같습니다.