2014년 3월, Java 8의 등장은 자바 개발 생태계에 거대한 파장을 일으켰습니다. 람다 표현식(Lambda Expressions), 스트림 API(Stream API), 그리고 Optional의 도입은 단순히 새로운 문법의 추가를 넘어, 자바 개발자들이 데이터를 다루고 비동기 코드를 작성하는 방식의 근본적인 패러다임 전환을 의미했습니다. 함수형 프로그래밍 스타일을 자바에 성공적으로 이식한 Java 8은 지난 10여 년간 수많은 프로젝트의 기반이 되며 '현대 자바'의 시작을 알리는 기념비적인 버전으로 자리 잡았습니다.
그러나 많은 개발자들의 인식은 안타깝게도 Java 8에 머물러 있는 경우가 많습니다. "아직도 현업에서는 Java 8을 많이 쓴다"는 현실적인 이유도 있지만, 그사이 자바가 얼마나 역동적으로 발전해왔는지에 대한 정보 부족도 큰 원인 중 하나입니다. Java 8이 견고한 반석이었다면, 그 이후의 자바는 그 반석 위에 눈부신 속도로 첨단 건축물을 쌓아 올리는 과정에 있습니다. 6개월 단위의 빠른 릴리스 주기를 도입한 이래, 자바는 매 버전마다 개발자의 생산성을 극대화하고, 최신 하드웨어의 성능을 최대한 활용하며, 클라우드 네이ティブ 환경에 완벽하게 대응하기 위한 혁신적인 기능들을 쏟아내고 있습니다.
이 글은 Java 8이라는 익숙한 항구를 떠나, Java 11, 17, 그리고 최신 LTS 버전인 Java 21에 이르기까지, 자바라는 거대한 배가 어떤 새로운 항해 기술을 장착하며 미래로 나아가고 있는지 상세히 탐험하는 여정이 될 것입니다. 단순히 새로운 기능을 나열하는 것을 넘어, 각 기능이 등장한 배경과 그것이 해결하고자 했던 문제, 그리고 실제 코드에 어떻게 적용되어 우리의 개발 경험을 바꾸어 놓는지 깊이 있게 파헤쳐 봅니다. 지역 변수 타입 추론(var)부터 불변 데이터 객체를 위한 레코드(Record), 복잡한 제어 흐름을 우아하게 만드는 패턴 매칭(Pattern Matching), 그리고 동시성 프로그래밍의 역사를 새로 쓸 가상 스레드(Virtual Threads)에 이르기까지, Java 8 이후의 세계는 상상 이상으로 풍요롭고 강력합니다.
1. 변화의 서막: 새로운 릴리스 모델과 Java 9의 모듈 시스템
Java 8 이후 가장 먼저 주목해야 할 변화는 기술적인 내용 이전에 자바의 '발전 철학' 그 자체의 변화입니다. 과거 수년에 한 번씩 거대한 기능 묶음을 발표하던 '기차 모델(Train Model)'에서 벗어나, 2017년 Java 9를 기점으로 6개월마다 새로운 버전을 출시하는 '기능 릴리스(Feature Release)' 모델로 전환했습니다. 이는 자바가 최신 기술 트렌드에 훨씬 더 민첩하게 대응할 수 있게 되었음을 의미합니다.
이와 함께 2~3년 주기로 장기 지원(Long-Term Support, LTS) 버전을 지정하여 안정성이 중요한 기업 환경을 배려하는 전략도 병행하고 있습니다. Java 8, 11, 17, 21이 바로 이 LTS 버전에 해당합니다. 이 새로운 정책은 개발자들에게 선택지를 제공합니다. 최신 기능을 빠르게 도입하고 싶다면 매 6개월마다 버전을 올리면 되고, 검증된 안정성을 선호한다면 LTS 버전 간에 마이그레이션을 계획할 수 있습니다.
Java 9: 거대한 JDK를 분해하다, 프로젝트 직소(Jigsaw)
Java 9의 가장 핵심적인 변화는 단연 '자바 플랫폼 모듈 시스템(Java Platform Module System, JPMS)', 일명 프로젝트 직소입니다. 이는 거대하고 단일화된 JDK를 논리적인 모듈 단위로 분해하고, 애플리케이션 역시 필요한 모듈만 조합하여 구성할 수 있도록 만든 혁신적인 시스템입니다.
등장 배경: 클래스패스 지옥(Classpath Hell)과 거대한 JRE
기존의 자바 애플리케이션은 클래스패스(Classpath)에 의존하여 필요한 라이브러리(JAR 파일)를 찾았습니다. 이 방식은 여러 문제를 야기했습니다.
- 캡슐화의 부재: 라이브러리 내부에서만 사용되어야 할 클래스들이 외부에 그대로 노출되어, 개발자가 의도치 않게 내부 구현에 의존하는 코드를 작성할 위험이 있었습니다.
- 의존성 충돌: 두 개의 다른 라이브러리가 서로 다른 버전의 동일한 라이브러리에 의존할 경우, 어떤 버전이 로드될지 예측하기 어려워 런타임 에러의 원인이 되었습니다. (예: '다이아몬드 의존성' 문제)
- 거대한 런타임: 아주 작은 기능을 사용하는 애플리케이션조차도 전체 JRE(Java Runtime Environment)를 필요로 했습니다. 이는 특히 경량화가 중요한 마이크로서비스나 IoT 환경에 큰 부담이었습니다.
프로젝트 직소는 이러한 문제들을 해결하기 위해 `module-info.java`라는 명시적인 모듈 기술자(descriptor)를 도입했습니다.
// com.example.mymodule 모듈 정의
module com.example.mymodule {
// 이 모듈이 외부에 노출(export)할 패키지를 선언
exports com.example.mymodule.api;
// 이 모듈이 의존하는 다른 모듈을 선언
requires java.net.http;
requires com.google.gson;
}
위 코드에서 `exports` 키워드는 `com.example.mymodule.api` 패키지의 public 타입들만 다른 모듈에서 접근할 수 있도록 허용합니다. 이는 강력한 캡슐화를 보장합니다. `requires` 키워드는 이 모듈이 `java.net.http` 모듈과 `com.google.gson` 모듈에 의존하고 있음을 명시적으로 선언합니다. JVM은 시작 시점에 이 의존성 관계를 확인하여 문제가 있을 경우 애플리케이션을 시작하지 않고 빠르게 실패(Fail-fast)시켜 런타임의 불안정성을 줄여줍니다.
모듈 시스템의 가장 큰 장점 중 하나는 `jlink`라는 도구를 통해 '맞춤형 런타임 이미지'를 생성할 수 있다는 것입니다. 애플리케이션에 필요한 모듈과 그 의존 모듈들만 포함하는 최소한의 JRE를 만들 수 있어, 배포 이미지의 크기를 획기적으로 줄일 수 있습니다. 이는 도커(Docker)와 같은 컨테이너 환경에서 이미지 크기를 줄여 배포 속도를 높이고 리소스 사용량을 절감하는 데 매우 효과적입니다.
Java 9의 소소하지만 유용한 개선점들
- JShell: 자바 코드를 즉석에서 테스트하고 실행해볼 수 있는 REPL(Read-Eval-Print Loop) 도구입니다. 간단한 API 테스트나 문법 학습에 매우 유용합니다.
- 컬렉션 팩토리 메소드(`of()`): `List.of()`, `Set.of()`, `Map.of()` 와 같은 메소드를 통해 불변(immutable) 컬렉션을 간편하게 생성할 수 있게 되었습니다.
// 이전 방식 List<String> list = new ArrayList<>(); list.add("Java"); list.add("Python"); list.add("Go"); list = Collections.unmodifiableList(list); // Java 9 이후 List<String> immutableList = List.of("Java", "Python", "Go"); - 인터페이스의 private 메소드: 인터페이스에 `default` 메소드뿐만 아니라 `private` 메소드도 선언할 수 있게 되어, 여러 `default` 메소드 간의 코드 중복을 줄이고 로직을 캡슐화하는 것이 가능해졌습니다.
2. 개발 생산성의 비약적 향상: Java 10, 11이 가져온 선물
Java 10과 11은 개발자들이 일상적으로 코드를 작성하는 방식에 직접적인 영향을 미치는, 체감 효과가 매우 큰 기능들을 선보였습니다. 특히 Java 10의 `var` 키워드는 자바 문법에 대한 오랜 논쟁에 종지부를 찍는 중요한 변화였습니다.
Java 10: `var`의 등장과 타입 추론의 시대
지역 변수 타입 추론(Local-Variable Type Inference), 즉 `var` 키워드의 도입은 자바 코드의 장황함(verbosity)을 크게 줄여주었습니다. 컴파일러가 대입되는 값을 보고 변수의 타입을 스스로 추론할 수 있게 해주는 기능입니다.
Before (Java 9 이전)
Map<String, List<User>> userMap = new HashMap<String, List<User>>();
URL url = new URL("https://www.example.com");
BufferedReader reader = new BufferedReader(new FileReader("data.txt"));
After (Java 10 이후)
var userMap = new HashMap<String, List<User>>();
var url = new URL("https://www.example.com");
var reader = new BufferedReader(new FileReader("data.txt"));
위 예시처럼, `var`를 사용하면 변수 선언부의 중복되는 타입 정보를 생략하여 코드가 훨씬 간결하고 가독성이 높아집니다. 몇 가지 중요한 점을 짚고 넘어가야 합니다.
- 정적 타이핑(Statically Typed)은 그대로: `var`는 파이썬이나 자바스크립트의 동적 타이핑(dynamic typing)과는 다릅니다. 컴파일 시점에 타입이 결정되며, 한번 결정된 타입은 변경할 수 없습니다. 즉, `var s = "hello"; s = 10;` 과 같은 코드는 컴파일 에러가 발생합니다. `var`는 단지 개발자가 타입 이름을 쓰는 수고를 덜어주는 '문법적 설탕(Syntactic Sugar)'일 뿐입니다.
- 사용 제한: `var`는 지역 변수에만 사용할 수 있습니다. 클래스의 필드, 메소드의 파라미터나 반환 타입으로는 사용할 수 없습니다.
- 가독성과의 트레이드오프: `var`는 코드를 간결하게 만들지만, 남용할 경우 오히려 가독성을 해칠 수 있습니다. 예를 들어, `var result = someComplexMethod();` 와 같이 메소드 이름만으로는 반환 타입을 유추하기 어려운 경우에는 명시적으로 타입을 적어주는 것이 더 좋습니다. 좋은 변수 이름을 사용하는 것이 그 어느 때보다 중요해졌습니다.
Java 11 (LTS): 실용성을 더한 표준 API 강화
두 번째 LTS 버전인 Java 11은 언어 자체의 큰 변화보다는 표준 라이브러리를 강화하고 개발 편의성을 높이는 데 집중했습니다. 실무에서 바로 유용하게 쓰일 수 있는 기능들이 대거 추가되었습니다.
- `String` 클래스의 새로운 메소드들:
- `isBlank()`: 문자열이 비어있거나 공백(whitespace)으로만 이루어져 있는지 확인합니다. 기존의 `isEmpty()`는 공백을 문자로 취급했지만 `isBlank()`는 그렇지 않아 사용자 입력 값 검증 등에 매우 유용합니다.
- `lines()`: 문자열을 라인 단위로 나누어 스트림(`Stream<String>`)으로 반환합니다.
- `strip()`, `stripLeading()`, `stripTrailing()`: 문자열의 앞/뒤/양쪽 공백을 제거합니다. 기존의 `trim()`이 유니코드의 다양한 공백 문자를 제대로 처리하지 못했던 문제를 해결한 개선된 버전입니다.
- `repeat(n)`: 문자열을 n번 반복하여 새로운 문자열을 생성합니다.
String multilineString = "Java\nPython\nGo"; multilineString.lines() .filter(line -> !line.isBlank()) .map(line -> "Language: " + line) .forEach(System.out::println); System.out.println("-".repeat(10)); // "----------" 출력 - `Files` 클래스의 편의 메소드:
- `Files.writeString(path, content)`: 문자열을 파일에 쓰는 작업을 한 줄로 처리할 수 있습니다.
- `Files.readString(path)`: 파일의 내용을 문자열로 읽어오는 작업을 한 줄로 처리할 수 있습니다.
이제 간단한 파일 입출력을 위해 `BufferedReader`나 `BufferedWriter`를 장황하게 설정할 필요가 없어졌습니다.
- 람다 파라미터에 `var` 사용: Java 10의 `var`를 람다 표현식의 파라미터에도 사용할 수 있게 되었습니다. 이는 파라미터에 어노테이션을 추가해야 할 때 특히 유용합니다.
// 이전에는 타입을 모두 명시해야만 어노테이션 사용 가능 BiConsumer<String, Integer> consumer = (@Nonnull String k, @Nonnull Integer v) -> { ... }; // Java 11부터는 var로 간소화 가능 BiConsumer<String, Integer> consumer = (var k, var v) -> { ... }; - 단일 소스 파일 실행: `java` 런처가 컴파일 과정 없이 `.java` 소스 파일을 직접 실행할 수 있게 되었습니다. 간단한 스크립트를 작성하거나 자바를 처음 배우는 사람들에게 매우 편리한 기능입니다.
// HelloWorld.java 파일 public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, Java 11!"); } }터미널에서 `javac HelloWorld.java` 후 `java HelloWorld`를 실행할 필요 없이, 바로 `java HelloWorld.java` 명령만으로 실행이 가능합니다.
- HTTP 클라이언트 표준화: Java 9에서 인큐베이터 모듈로 처음 소개되었던 새로운 HTTP 클라이언트 API가 `java.net.http` 패키지로 표준화되었습니다. 기존의 `HttpURLConnection`에 비해 사용이 간편하고, HTTP/2와 웹소켓을 지원하며, 비동기 처리를 위한 `CompletableFuture`를 자연스럽게 통합하여 현대적인 웹 통신에 훨씬 적합합니다.
3. 표현력의 극대화: 프로젝트 앰버(Amber)가 이끄는 언어의 진화
Java 12부터는 '프로젝트 앰버'의 결과물들이 본격적으로 자바 언어에 도입되기 시작합니다. 프로젝트 앰버는 "자바 언어의 생산성과 표현력을 높이기 위한 작은 규모의 기능 개선"을 목표로 하는 장기 프로젝트입니다. 텍스트 블록, 스위치 표현식, 레코드, 패턴 매칭, 봉인 클래스 등이 모두 이 프로젝트의 산물이며, 이들은 서로 유기적으로 결합하여 자바 코드를 이전과는 비교할 수 없을 정도로 간결하고 안전하며 표현력이 풍부하게 만들어줍니다.
Java 12 & 14: 스위치 표현식(Switch Expressions)
기존의 `switch` 문(statement)은 장황하고, `break`를 실수로 빠뜨리면 발생하는 오류(fall-through)에 취약하며, 값을 반환할 수 없는 등 여러 단점이 있었습니다. 스위치 표현식은 이러한 문제들을 해결합니다.
Before (기존 switch 문)
DayOfWeek day = DayOfWeek.TUESDAY;
int numLetters;
switch (day) {
case MONDAY:
case FRIDAY:
case SUNDAY:
numLetters = 6;
break;
case TUESDAY:
numLetters = 7;
break;
case THURSDAY:
case SATURDAY:
numLetters = 8;
break;
case WEDNESDAY:
numLetters = 9;
break;
default:
throw new IllegalStateException("Invalid day: " + day);
}
After (스위치 표현식)
DayOfWeek day = DayOfWeek.TUESDAY;
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
case THURSDAY, SATURDAY -> 8;
case WEDNESDAY -> 9;
default -> throw new IllegalStateException("Invalid day: " + day);
};
달라진 점은 다음과 같습니다.
- 값을 반환: `switch` 자체가 하나의 표현식(expression)이 되어 결과를 변수에 직접 할당할 수 있습니다.
- `break` 불필요: 화살표(`->`) 라벨을 사용하면 해당 라인의 표현식만 실행되고 자동으로 `switch`를 빠져나가므로 `break`가 필요 없습니다. Fall-through 버그가 원천적으로 차단됩니다.
- 다중 라벨: 쉼표(`,`)를 사용하여 여러 `case`를 한 줄에 묶을 수 있어 코드가 훨씬 간결해집니다.
- 철저한 검사(Exhaustiveness): 컴파일러는 `enum`과 같은 타입을 `switch`할 때 모든 가능한 `case`가 처리되었는지 확인합니다. 만약 `default` 절이 없고 처리되지 않은 `case`가 있다면 컴파일 에러를 발생시켜 코드의 안정성을 높여줍니다.
Java 13 & 15: 텍스트 블록(Text Blocks)
JSON, XML, SQL 쿼리 등 여러 줄에 걸친 문자열을 자바 코드에 포함시키는 것은 매우 번거로운 작업이었습니다. 이스케이프 문자(`\n`, `\"`)와 문자열 연결(`+`) 연산자로 인해 가독성이 크게 떨어졌습니다.
Before
String json = "{\n" +
" \"name\": \"John Doe\",\n" +
" \"age\": 30,\n" +
" \"email\": \"john.doe@example.com\"\n" +
"}";
텍스트 블록은 큰따옴표 세 개(`"""`)를 사용하여 이 문제를 우아하게 해결합니다.
After (텍스트 블록)
String json = """
{
"name": "John Doe",
"age": 30,
"email": "john.doe@example.com"
}
""";
텍스트 블록 안에서는 이스케이프 문자를 사용할 필요 없이 보이는 그대로 문자열이 만들어집니다. 들여쓰기도 자동으로 관리되어 코드의 가독성을 극적으로 향상시킵니다. 이는 테스트 코드에서 예상 결과를 작성하거나, 데이터베이스 쿼리를 코드에 내장할 때 특히 강력한 위력을 발휘합니다.
Java 14 & 16: 레코드(Records)
레코드는 자바 역사상 가장 중요한 변화 중 하나로 꼽힙니다. 이는 '데이터 전달'이라는 단일 목적을 가진 불변(immutable) 객체를 간결하게 정의할 수 있는 새로운 타입입니다. DTO(Data Transfer Object)나 값 객체(Value Object)를 만들 때 발생하는 상용구 코드(boilerplate code)를 획기적으로 줄여줍니다.
Before (일반 클래스)
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 getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[" +
"x=" + x +
", y=" + y +
']';
}
}
단순히 x, y 좌표를 저장하기 위해 수십 줄의 코드가 필요합니다. `Lombok`과 같은 라이브러리로 이 문제를 해결해왔지만, 이제는 언어 자체에서 지원합니다.
After (레코드)
public record Point(int x, int y) {}
이 한 줄의 코드는 위의 장황한 클래스와 완벽하게 동일한 기능을 합니다. 컴파일러는 다음을 자동으로 생성해줍니다.
- `private final` 필드 (`x`, `y`)
- 모든 필드를 인자로 받는 public 생성자
- 각 필드에 대한 접근자 메소드 (
getX()가 아닌x(),y()) - `equals()`, `hashCode()`, `toString()` 메소드의 적절한 구현
레코드는 기본적으로 불변이므로 데이터의 일관성을 유지하고 스레드로부터 안전한 코드를 작성하는 데 큰 도움이 됩니다. 레코드는 데이터를 모델링하는 방식을 근본적으로 바꾸어, 개발자가 비즈니스 로직에 더 집중할 수 있도록 해줍니다.
Java 14 & 21: 패턴 매칭(Pattern Matching)
패턴 매칭은 객체의 타입을 확인하고, 해당 타입으로 형변환하며, 그 값을 변수에 바인딩하는 과정을 하나의 연산으로 결합한 기능입니다. 이는 코드를 더 안전하고 읽기 쉽게 만들어 줍니다.
1. `instanceof`를 위한 패턴 매칭 (Java 16 정식 도입)
Before
Object obj = "Hello Java";
if (obj instanceof String) {
String s = (String) obj;
if (s.length() > 5) {
System.out.println(s.toUpperCase());
}
}
타입 확인 후 별도의 형변환과 변수 선언이 필요했습니다. 이는 번거롭고 실수하기 쉬운 과정입니다.
After
Object obj = "Hello Java";
if (obj instanceof String s && s.length() > 5) {
System.out.println(s.toUpperCase());
}
`instanceof` 연산자에서 바로 변수 `s`를 선언하고, `if` 블록 안에서 즉시 사용할 수 있습니다. 심지어 `&&` 연산자를 통해 같은 줄에서 `s`의 메소드를 호출하는 것도 가능합니다. 코드가 훨씬 간결하고 논리적 흐름이 명확해집니다.
2. `switch`를 위한 패턴 매칭 (Java 21 정식 도입)
패턴 매칭은 `switch`와 결합될 때 그 진정한 힘을 발휘합니다.
Before (if-else if 체인)
static String format(Object obj) {
String formatted = "unknown";
if (obj instanceof Integer) {
formatted = String.format("int %d", (Integer) obj);
} else if (obj instanceof Long) {
formatted = String.format("long %d", (Long) obj);
} else if (obj instanceof Double) {
formatted = String.format("double %f", (Double) obj);
} else if (obj instanceof String) {
formatted = String.format("String %s", (String) obj);
}
return formatted;
}
After (`switch` 패턴 매칭)
static String format(Object obj) {
return switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> "unknown";
};
}
코드가 선언적으로 바뀌어 "obj가 Integer라면 i로 바인딩하고 이 로직을 실행하라"는 의도가 명확하게 드러납니다. 여기에 `when` 절(guarded patterns)을 추가하면 더 복잡한 조건도 처리할 수 있습니다.
static void process(Object obj) {
switch (obj) {
case String s when s.length() > 5 -> System.out.println("Long string: " + s);
case String s -> System.out.println("Short string: " + s);
case Integer i -> System.out.println("It's a number: " + i);
default -> { /* do nothing */ }
}
}
Java 17 (LTS): 봉인 클래스(Sealed Classes)
봉인 클래스와 인터페이스는 상속이나 구현을 특정 클래스들로만 제한하는 기능입니다. 이는 객체 모델링에 더 많은 제어권을 부여하고, 패턴 매칭과 결합하여 강력한 시너지를 냅니다.
어떤 도형(Shape)을 표현하는 객체 모델을 만든다고 가정해 봅시다. 도형의 종류는 원(Circle), 사각형(Rectangle), 정사각형(Square)으로 한정하고 싶습니다.
// Shape 인터페이스는 Circle, Rectangle, Square 클래스만 구현할 수 있도록 봉인(seal)합니다.
public sealed interface Shape permits Circle, Rectangle, Square {
double area();
}
// 허가된(permitted) 클래스들은 final, sealed, non-sealed 중 하나여야 합니다.
public final record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public non-sealed class Rectangle implements Shape { // non-sealed: 누구나 상속 가능
public final double length, width;
// ... 생성자, area() 구현
}
public final class Square extends Rectangle { // Rectangle이 non-sealed이므로 상속 가능
// ... 생성자
}
`sealed` 키워드를 통해 `Shape`의 하위 타입이 무엇인지 컴파일러에게 알려줄 수 있습니다. 이것이 왜 중요할까요? 바로 `switch` 패턴 매칭에서 `default` 절 없이도 모든 케이스를 검사할 수 있게 되기 때문입니다.
public static double getArea(Shape shape) {
// 컴파일러는 Shape의 하위 타입이 Circle, Rectangle, Square 뿐임을 알고 있습니다.
// 따라서 이 switch문은 모든 경우를 다루므로 default가 필요 없으며,
// 만약 개발자가 case 하나를 빠뜨리면 컴파일 에러를 발생시킵니다.
return switch (shape) {
case Circle c -> c.area();
case Square s -> s.area(); // Square를 Rectangle보다 먼저 체크해야 함
case Rectangle r -> r.area();
};
}
이처럼 레코드, 패턴 매칭, 봉인 클래스는 함께 사용될 때 데이터 모델링과 로직 처리를 매우 안전하고, 간결하며, 표현력있게 만들어주는 환상의 조합입니다. 이것이 바로 프로젝트 앰버가 추구하는 '현대적인 자바'의 모습입니다.
4. 동시성 프로그래밍의 혁명: 프로젝트 룸(Loom)과 가상 스레드
자바의 진화는 언어 문법에만 국한되지 않습니다. JVM 레벨에서의 혁신이야말로 자바가 엔터프라이즈 환경의 왕좌를 지키는 핵심 동력입니다. 그중에서도 프로젝트 룸(Loom)이 가져온 '가상 스레드(Virtual Threads)'는 자바의 동시성 프로그래밍 모델을 근본적으로 바꾸어 놓을 혁명적인 기능입니다.
전통적인 스레드 모델의 한계
자바는 초창기부터 스레드를 언어 수준에서 지원해왔습니다. 그러나 자바의 `java.lang.Thread`는 운영체제(OS)의 네이티브 스레드와 1:1로 매핑되는 '플랫폼 스레드(Platform Thread)'입니다. OS 스레드는 생성 비용이 비싸고, 개수가 제한적(수천 개 수준)이며, 컨텍스트 스위칭에 많은 오버헤드를 유발합니다.
네트워크 I/O가 많은 현대적인 웹 애플리케이션에서는 '요청 당 스레드(Thread-per-request)' 모델을 흔히 사용합니다. 클라이언트 요청이 들어올 때마다 스레드를 하나 할당하고, 데이터베이스 조회나 외부 API 호출과 같은 블로킹(blocking) I/O 작업이 끝날 때까지 해당 스레드는 아무 일도 하지 않고 대기합니다. 이 모델은 코드를 작성하고 디버깅하기에는 직관적이지만, 수만, 수십만 동시 사용자를 처리해야 하는 고성능 시스템에서는 스레드 풀 고갈이라는 심각한 병목 현상을 야기합니다.
이 문제를 해결하기 위해 Netty, RxJava, Project Reactor와 같은 라이브러리들은 비동기(Asynchronous) 및 논블로킹(Non-blocking) I/O에 기반한 '반응형 프로그래밍(Reactive Programming)' 패러다임을 도입했습니다. 이는 적은 수의 스레드로 높은 처리량을 달성할 수 있지만, 콜백 지옥(Callback Hell), 복잡한 스택 트레이스, 가파른 학습 곡선이라는 새로운 문제들을 가져왔습니다.
가상 스레드: 가볍고, 빠르고, 풍부하게
가상 스레드(Java 21 정식 도입)는 이러한 딜레마를 해결하기 위한 해답입니다. 가상 스레드는 OS 스레드에 직접 매핑되지 않고, JVM에 의해 관리되는 매우 가벼운(lightweight) 사용자 수준의 스레드입니다. JVM은 소수의 플랫폼 스레드(캐리어 스레드라고 함) 풀 위에서 수백만 개의 가상 스레드를 실행할 수 있습니다.
가상 스레드의 핵심 아이디어는 다음과 같습니다: 가상 스레드에서 실행 중인 코드가 블로킹 I/O 작업을 만나면, 해당 가상 스레드는 잠시 '일시 중단(suspend)'되고, 그 스레드를 실행하던 플랫폼 스레드는 다른 가상 스레드를 실행하는 데 사용됩니다. I/O 작업이 완료되면, 중단되었던 가상 스레드는 다시 플랫폼 스레드에 할당되어 작업을 이어갑니다. 이 모든 과정은 JVM 내부에서 투명하게 처리되므로, 개발자 입장에서는 마치 블로킹 코드가 논블로킹처럼 동작하는 효과를 누릴 수 있습니다.
가상 스레드를 생성하는 방법은 매우 간단합니다.
// 방법 1: Thread.startVirtualThread()
Thread.startVirtualThread(() -> {
System.out.println("Hello from a virtual thread!");
});
// 방법 2: 새로운 ExecutorService 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
System.out.println("Task complete");
});
}
} // try-with-resources 구문이 끝나면 자동으로 shutdown
위 코드에서 10만 개의 작업을 제출하더라도, 플랫폼 스레드 기반의 `newCachedThreadPool`을 사용했다면 즉시 `OutOfMemoryError`가 발생했을 것입니다. 하지만 `newVirtualThreadPerTaskExecutor`는 10만 개의 가상 스레드를 가볍게 생성하고, JVM은 적은 수의 플랫폼 스레드를 효율적으로 사용하여 모든 작업을 처리합니다.
가상 스레드의 등장은 "높은 처리량을 위해서는 복잡한 비동기 코드를 써야 한다"는 고정관념을 깨뜨립니다. 개발자들은 가장 직관적이고 이해하기 쉬운 '요청 당 스레드' 스타일의 동기식 코드를 그대로 유지하면서도, 비동기/반응형 시스템에 필적하는 높은 처리량과 확장성을 얻을 수 있게 되었습니다. 이는 Spring, Jakarta EE와 같은 주요 프레임워크가 동시성을 처리하는 방식에 거대한 변화를 가져올 것이며, 자바 생태계 전체의 생산성을 한 단계 끌어올릴 것입니다.
5. JVM의 끊임없는 진화와 미래를 향한 프로젝트들
자바의 혁신은 언어와 동시성 모델에 그치지 않습니다. 자바 가상 머신(JVM)은 그 자체로 끊임없이 발전하는 기술의 집약체입니다. 특히 가비지 컬렉션(GC) 기술의 발전은 자바가 응답 시간이 중요한 애플리케이션에서도 뛰어난 성능을 발휘할 수 있게 하는 핵심 요소입니다.
저지연 GC의 등장: ZGC와 Shenandoah
과거 자바의 GC는 'Stop-the-World'라는 긴 멈춤 시간 때문에 비판을 받곤 했습니다. Java 11부터 기본 GC로 채택된 G1GC가 이 문제를 상당 부분 해결했지만, 수백 기가바이트(GB)에 달하는 거대한 힙 메모리를 사용하는 시스템에서는 여전히 밀리초(ms) 단위의 응답 시간을 보장하기 어려웠습니다.
이를 해결하기 위해 두 개의 혁신적인 저지연(Low-Latency) GC가 등장했습니다.
- ZGC (Z Garbage Collector): Oracle에서 개발한 GC로, 테라바이트(TB) 단위의 힙에서도 멈춤 시간을 1밀리초 미만으로 유지하는 것을 목표로 합니다. GC 작업의 대부분을 애플리케이션 스레드와 동시에(concurrently) 수행하여 멈춤 시간을 극단적으로 줄입니다. (Java 15 정식 도입)
- Shenandoah: Red Hat에서 개발한 GC로, ZGC와 유사하게 동시 작업을 통해 멈춤 시간을 최소화하는 데 초점을 맞춥니다. (Java 12 정식 도입)
이러한 GC들의 등장은 금융 거래 시스템, 실시간 데이터 분석, 대규모 전자상거래 플랫폼 등 아주 짧은 지연 시간(latency)이 비즈니스에 치명적인 영향을 미치는 분야에서도 자바가 최고의 선택지가 될 수 있음을 의미합니다.
미래를 준비하는 거대 프로젝트들
프로젝트 앰버(Amber)와 룸(Loom) 외에도 자바의 미래를 만들어가고 있는 중요한 프로젝트들이 있습니다.
- 프로젝트 파나마(Panama): JVM과 네이티브 코드(C/C++) 간의 상호작용을 개선하는 것을 목표로 합니다. 기존의 JNI(Java Native Interface)는 복잡하고, 느리며, 오류에 취약했습니다. 프로젝트 파나마의 Foreign Function & Memory API (Java 22 정식 도입)는 자바 코드에서 네이티브 라이브러리를 훨씬 더 안전하고 효율적으로 호출할 수 있는 현대적인 방법을 제공합니다. 이는 머신러닝, 과학 계산 등 고성능 컴퓨팅 라이브러리와의 통합을 용이하게 할 것입니다.
- 프로젝트 발할라(Valhalla): 자바의 메모리 모델을 근본적으로 개선하려는 매우 야심 찬 프로젝트입니다. '값 타입(Value Types)' 또는 '원시 클래스(Primitive Classes)'라는 새로운 개념을 도입하여, 객체의 메모리 오버헤드 없이 클래스의 장점을 누릴 수 있게 하는 것을 목표로 합니다. 이것이 실현되면, 자바는 고성능 계산 집약적인 작업에서 C++와 대등하거나 더 나은 성능을 낼 수 있는 잠재력을 갖게 됩니다. 아직 진행 중인 장기 프로젝트이지만, 자바의 성능을 한 차원 더 높은 곳으로 이끌어 줄 것입니다.
결론: 끊임없이 진화하는 플랫폼, 자바의 현재와 미래
Java 8은 위대한 버전이었지만, 그것은 결코 자바의 종착역이 아니었습니다. 오히려 새로운 시대를 여는 출발점이었습니다. 6개월의 빠른 릴리스 주기 도입 이후, 자바는 그 어느 때보다 활발하게 변화하고 있으며, 그 변화의 방향은 명확합니다.
- 개발자 생산성 극대화: `var`, 레코드, 텍스트 블록, 패턴 매칭 등 프로젝트 앰버의 결과물들은 상용구 코드를 제거하고, 코드의 의도를 명확하게 드러내어 개발자가 더 빠르고 즐겁게 코딩할 수 있도록 돕습니다.
- 현대적인 하드웨어 및 아키텍처 대응: 가상 스레드는 멀티코어 프로세서와 클라우드 네이티브 환경의 잠재력을 최대한 끌어내고, 저지연 GC는 대용량 메모리를 효율적으로 관리하며, 프로젝트 파나마와 발할라는 CPU와 메모리를 더 직접적이고 효율적으로 제어할 수 있는 길을 열어주고 있습니다.
- 안정성과 표현력의 조화: 봉인 클래스와 개선된 `switch` 표현식 등은 더 강력한 컴파일 타임 검사를 통해 런타임 오류를 줄여주며, 코드를 더욱 선언적이고 이해하기 쉽게 만들어줍니다.
오늘날의 자바 개발자에게는 두 가지 선택지가 있습니다. Java 8이라는 익숙하고 편안한 과거에 머무르거나, 혹은 끊임없이 발전하는 자바의 새로운 기능들을 적극적으로 학습하고 받아들여 자신의 기술적 역량을 한 단계 끌어올리는 것입니다. Spring Boot 3.x, Quarkus, Micronaut과 같은 최신 프레임워크들은 이미 Java 17 이상을 기반으로 이러한 새로운 기능들을 적극 활용하고 있습니다.
자바는 더 이상 느리고 장황한 언어가 아닙니다. 지난 10년간의 눈부신 발전을 통해 자바는 가장 현대적이고, 생산적이며, 고성능을 자랑하는 언어 중 하나로 다시 태어났습니다. Java 8 이후의 세계를 탐험하는 것은 선택이 아닌 필수이며, 그 여정 속에서 우리는 더 나은 개발자, 더 뛰어난 아키텍트가 될 수 있는 무한한 가능성을 발견하게 될 것입니다.