자바 언어·플랫폼 학습 노트 목차

모듈과 모던 자바 — 언어는 계속 진화한다

큰 코드를 어떻게 경계 지어 관리하고, 자바가 어떻게 더 안전하고 간결해졌는지. 모듈 시스템에서 시작해 record·sealed·패턴 매칭, 그리고 Java 21/25의 새 무기까지 — 왜 이런 기능이 생겼는지와 함께 본다.


1. 경계 긋기 — 모듈 시스템(JPMS)

public이면 어디서든 쓸 수 있다는 게 큰 시스템에선 약점이다. 내부 구현이 의도치 않게 외부에 새어 결합이 생긴다. 그래서 Java 9는 패키지 위에 모듈이라는 더 큰 경계를 뒀다.

// module-info.java
module com.shop.order {
    requires com.shop.common;     // 무엇에 의존하는가
    exports com.shop.order.api;   // 무엇을 공개하는가 (이것 외엔 전부 숨김)
    opens   com.shop.order.entity;// 리플렉션(JPA·Jackson)에만 연다
}

핵심은 강한 캡슐화exports하지 않은 패키지는 public이어도 모듈 밖에서 아예 안 보인다. JVM은 시작 시 requires를 따라 모듈 그래프를 해석해 경계를 강제한다.

실무에서는 — 꼭 모듈로 안 나눠도, jlink앱이 실제 쓰는 모듈만 묶어 경량 런타임을 만들면 컨테이너 이미지가 확 줄어든다. Java 25의 import module java.base;는 모듈이 공개한 패키지를 한 줄로 임포트하게 해 준다.


2. 데이터는 record로 — 보일러플레이트의 끝

값을 담는 클래스마다 생성자·접근자·equals·hashCode·toString을 손으로 쓰는 건 실수의 온상이었다(01의 hashCode 누락 버그처럼). record가 이걸 자동화한다.

record Range(int lo, int hi) {
    Range { if (lo > hi) throw new IllegalArgumentException(); }  // 컴팩트 생성자로 검증
}

컴파일러가 private final 필드 + 표준 생성자 + 접근자 + equals/hashCode/toString을 만들어 준다. 클래스파일에 Record 속성이 남아 리플렉션·패턴 매칭이 이를 활용한다.

실무에서는 — DTO, API 요청/응답, 다중 반환값, 맵 키 같은 값 객체는 record가 사실상 표준이다. 불변이라 스레드 안전·키 안전도 공짜로 따라온다.


3. 닫힌 계층 — sealed + 패턴 매칭

타입의 가능한 경우가 정해져 있다면(예: 도형은 원/사각형뿐), 그걸 컴파일러에게 알려 주는 게 sealed다.

sealed interface Shape permits Circle, Rectangle {}
record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}

permits로 하위 타입을 못 박으면(클래스파일 PermittedSubclasses 속성), switch모든 경우를 다뤘는지 검사해 준다 — 새 타입을 빠뜨리면 컴파일 오류가 나니, 누락 버그가 사라진다.

String describe(Shape s) {
    return switch (s) {
        case Circle(double r) when r > 10 -> "큰 원";        // 가드
        case Circle(double r)             -> "원 r=" + r;     // record 디컨스트럭션
        case Rectangle(double w, double h)-> "사각형 " + w*h;
    };  // sealed라 망라 → default 불필요
}

실무에서는 — sealed + record + switch 조합은 상태/이벤트/결과를 모델링하는 강력한 도구다. 예: sealed interface Result permits Ok, Err로 성공/실패를 타입으로 표현하면, 예외 대신 값으로 다루며 컴파일러가 두 경우를 다 처리했는지 보장한다.


4. 더 안전하고 간결해진 문법

String sql = """
        SELECT id FROM users
        WHERE active = true
        """;                         // text block: 여러 줄 문자열, 들여쓰기 자동 정리

var list = new ArrayList<String>();  // var: 우변으로 타입 추론(지역변수만)

var지역 변수에만 쓸 수 있다. 필드·매개변수·반환 타입에는 못 쓰고, 초기화식이 없으면(var x;) 추론할 근거가 없어 컴파일 오류다. var x = null;도 마찬가지다.

switch 문에서 switch 식으로 (Java 14 정식)

기존 switch는 문(statement) 이라 값을 만들지 못했다. 그래서 이렇게 썼다.

int code;                            // ✗ 먼저 선언하고
switch (status) {
    case ACTIVE:
    case PENDING:
        code = 200;
        break;                       // ← 빠뜨리면 조용히 아래로 흘러간다(폴스루)
    case CLOSED:
        code = 410;
        break;
    default:
        code = 500;
}

폴스루(fall-through) 가 이 문법의 고질병이었다. break를 하나 빠뜨리면 컴파일은 되고 런타임에 엉뚱한 값이 나온다. 게다가 code를 미리 선언해야 해서 final로 둘 수도 없다.

**switch 식**은 이 둘을 한 번에 없앤다.

int code = switch (status) {         // ✓ 값을 반환하는 식(expression)
    case ACTIVE, PENDING -> 200;     // 여러 레이블을 쉼표로
    case CLOSED          -> 410;
    default              -> 500;
};                                   // ← 식이므로 세미콜론이 붙는다

화살표(->) 형태는 해당 가지만 실행하고 끝난다. break가 없고 폴스루도 없다.

본문이 여러 줄이면 yield로 값을 낸다.

int code = switch (status) {
    case ACTIVE, PENDING -> 200;
    case CLOSED -> {
        log.warn("closed access: {}", status);
        yield 410;                   // ← 블록에서 값을 내보내는 키워드. return 이 아니다
    }
    default -> 500;
};

return을 쓰면 switch가 아니라 바깥 메서드가 반환되므로 컴파일 오류이거나 의도와 다른 동작이 된다. 값을 내보내는 건 yield다.

완전성(exhaustiveness)이 컴파일 타임에 강제된다

switch 은 모든 경우를 덮어야 한다. 이게 문(statement)과의 결정적 차이다.

enum Status { ACTIVE, PENDING, CLOSED }

int code = switch (status) {
    case ACTIVE  -> 200;
    case PENDING -> 202;
};                                   // ✗ 컴파일 오류 — CLOSED 를 안 다뤘다

enum의 모든 상수를 다루면 default 없이도 통과한다. 그리고 여기서 진짜 이득이 나온다 — 나중에 enum에 상수를 추가하면 그 switch가 컴파일 오류로 터진다. default -> ...를 넣어 두면 새 상수가 조용히 기본값으로 흘러가 버그가 되는데, default를 빼면 컴파일러가 빠진 곳을 전부 찾아 준다.

sealed 계층에 패턴 매칭을 쓸 때도 같은 원리다(3절 참고).

// permits 로 하위 타입이 닫혀 있으므로 default 없이도 완전성이 성립한다
String desc = switch (shape) {
    case Circle c    -> "반지름 " + c.r();
    case Rectangle r -> "가로 " + r.w();
    case Triangle t  -> "밑변 " + t.base();
};

자주 데는 곳

// ① null 은 여전히 NPE 다 (Java 21 의 case null 을 쓰지 않는 한)
String s = null;
switch (s) { case "a" -> ...; default -> ...; }   // 💥 NullPointerException

// ② 화살표와 콜론을 한 switch 안에 섞을 수 없다
switch (x) {
    case 1 -> foo();
    case 2: bar();                   // ✗ 컴파일 오류
}

// ③ switch '문' 으로 쓰면 완전성이 강제되지 않는다
switch (status) {                    // 값을 안 받으면 그냥 문 — 빠뜨려도 컴파일된다
    case ACTIVE -> handle();
}

①은 Java 21의 패턴 매칭 switch에서 case null ->을 명시적으로 쓸 수 있게 되면서 통제 가능해졌다. 다만 쓰지 않으면 여전히 NPE다.

text block은 문자열 이어붙이기 실수를 없애고, var는 좌변 반복을 줄이며, switch 식은 폴스루와 누락을 컴파일 타임으로 끌어올린다 — 셋 다 실수를 런타임이 아니라 컴파일 시점에 잡자는 방향이다.


5. Java 21 / 25의 새 무기

기능 (JEP)한 줄 · 어디에
Virtual Threads (444, 21)블로킹 코드로 대규모 동시성 → I/O 많은 서버(동시성 페이지)
Sequenced Collections (431, 21)getFirst/getLast/reversed 통일
Scoped Values (506, 25)ThreadLocal의 불변·자동 해제 대체 → 요청 컨텍스트 전파
Lazy Constants (502→526, 25→26)지연 1회 초기화 상수 → 무거운 싱글톤·캐시
Module Import (511, 25)import module … 일괄 임포트
Compact Source + Instance main (512, 25)클래스 없이 void main() → 입문·스크립트
Compact Object Headers (519, 25)객체 헤더 12→8B → 힙 절약(JVM 페이지)

Lazy Constants — 이름이 바뀐 기능이라 주의한다

Java 25 에 Stable Values(JEP 502)로 나왔던 것이 Java 26 에서 Lazy Constants(JEP 526)로 이름과 API 가 함께 바뀌었다. 프리뷰 기능이라 이런 일이 생긴다.

// Java 26 (JEP 526) — 계산 함수를 팩토리에 넘기고, get() 으로 읽는다
private final LazyConstant<Config> cfg = LazyConstant.of(this::loadConfig);
Config cfg() { return cfg.get(); }        // 최초 1회만 loadConfig 가 실행된다

// 지연 컬렉션 — 원소를 실제로 꺼낼 때 계산된다
List<Double> roots = List.ofLazy(100, Math::sqrt);
Map<Locale, ResourceBundle> bundles = Map.ofLazy(locales, this::loadBundle);
Java 25 (JEP 502)Java 26 (JEP 526)
타입 이름StableValueLazyConstant
만들기StableValue.of() (빈 상태)LazyConstant.of(계산함수)
읽기orElseSet(계산함수)get()
저수준 메서드orElseSet·setOrThrow·trySet전부 제거
컬렉션별도 팩토리List.ofLazy·Map.ofLazy
null허용불허 — 분기를 없애 빠른 경로를 단순화

바뀐 방향이 분명하다 — "빈 상자를 만들고 나중에 채운다" 에서 "계산 방법을 미리 주고 알아서 채우게 한다" 로 옮겼다. 값이 없는 중간 상태가 사라지므로 잘못 쓸 여지가 준다.

무엇을 대체하는가 하면 **홀더 관용구와 이중 검사 잠금**이다. 스레드 안전한 지연 초기화를 직접 짜지 않아도 되고, final 이라 JIT 이 상수로 접을 수 있다는 이점까지 있다.

isInitialized()·orElse(T) 는 Java 27 에서 제거될 예정이다. 프리뷰 기능은 이렇게 계속 움직이므로, LTS 가 아닌 버전의 API 를 코드에 굳히지 않는다.

실무에서는 — 새 LTS(21·25)의 무게중심은 동시성(가상 스레드·구조적 동시성·Scoped Values)과 메모리/기동(Compact Headers·AOT)이다. 블로킹 I/O 위주 서버라면 가상 스레드 전환이 가장 체감이 크고, 컨테이너 밀도를 높이려면 Compact Object Headers가 조용히 힙을 아껴 준다.

5-bis. Java 26 — 백엔드에 바로 닿는 넷

26 은 non-LTS 지만 서버 코드에 직접 영향을 주는 것이 넷 있다.

HTTP/3 를 표준 클라이언트가 지원한다 (JEP 517)

HttpClient client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_3)          // 새로 생긴 값
        .build();

HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.example.com/orders"))
        .GET().build();
client.send(req, BodyHandlers.ofString());

지금까지 자바에서 HTTP/3 를 쓰려면 외부 라이브러리가 필요했다. 이제 표준 클라이언트가 QUIC 위에서 말한다.

무엇이 좋아지나 — HTTP/2 는 TCP 하나에 스트림을 여럿 태우므로, 패킷 하나가 유실되면 그 연결의 모든 스트림이 함께 멈춘다(전송 계층 HOL 블로킹). QUIC 은 스트림마다 독립적으로 재전송하므로 한 스트림의 손실이 다른 스트림을 막지 않는다. 손실률이 있는 망(모바일)에서 외부 API 를 병렬 호출할 때 꼬리 지연이 눈에 띄게 준다.

다만 UDP 기반이라 방화벽·프록시가 막는 환경이 아직 있다. 폴백 경로를 유지해야 한다.

AOT 캐시를 어떤 GC 와도 쓸 수 있다 (JEP 516)

25 까지의 AOT 캐시는 사용하는 GC 에 제약이 있었다. 26 부터는 ZGC 를 포함한 어떤 GC 와도 함께 쓸 수 있다. "지연 시간 때문에 ZGC 를 쓰는데 기동도 줄이고 싶다" 가 그동안 안 됐던 조합인데 풀렸다.

final 이 정말 final 이 된다 (JEP 500)

Field f = Config.class.getDeclaredField("MAX_RETRY");
f.setAccessible(true);
f.setInt(null, 5);        // Java 26: 경고가 뜬다. 이후 릴리스에서 막힌다

리플렉션으로 final 필드를 바꾸는 것에 경고가 붙기 시작했다. 일부 테스트 프레임워크와 직렬화 라이브러리가 이 수법을 써 왔으므로, 오래된 의존성이 있다면 로그에서 경고를 확인해야 한다.

방향은 분명하다 — final 이 진짜 불변임을 보장해야 JIT 이 상수로 접고 재배열할 수 있고, 앞서 본 final 필드의 스레드 안전 보장도 실제로 성립한다.

G1 이 빨라졌다 (JEP 522)

애플리케이션 스레드와 GC 스레드 사이의 동기화를 줄여, 객체 참조를 많이 고치는 워크로드에서 최대 15% 처리량이 올랐다. 기본 GC 를 쓰는 대부분의 서버가 코드 변경 없이 받는 이득이다.

나머지 여섯구조적 동시성 6차 프리뷰(525), Lazy Constants 2차 프리뷰(526), 원시 타입 패턴 매칭 4차 프리뷰(530), PEM 인코딩 2차 프리뷰(524), Vector API 11차 인큐베이터(529), Applet API 제거(504).

프리뷰가 이렇게 오래 도는 것이 정상이다. 구조적 동시성은 6차, Vector API 는 11차다 — 자바가 API 를 굳히기 전에 실사용 피드백을 얼마나 오래 받는지 보여 준다.


6. 기동이 느린 이유와 AOT 캐시 (Project Leyden)

JVM이 느리게 시작하는 이유는 매번 같은 일을 처음부터 다시 하기 때문이다. 클래스 파일을 읽고 → 파싱하고 → 검증·로드·링크하고 → 인터프리터로 돌면서 프로파일을 모으고 → 그제서야 JIT이 뜨거운 메서드를 컴파일한다. 서버 한 대가 오래 사는 시절엔 상관없었지만, 컨테이너가 수시로 뜨고 죽는 환경에선 이 준비 과정이 그대로 비용이다.

**Project Leyden**이 이걸 "미리 해 두고 파일로 저장"하는 방식으로 푼다 — AOT 캐시다.

JEP (Java)캐시에 담는 것
483 (24)로드·링크까지 끝난 클래스 — 읽기·파싱·검증을 건너뛴다
514 (25)캐시 생성을 한 단계로 축약(원래는 학습 실행 + 생성 2단계)
515 (25)메서드 호출 프로파일 — 기동 직후 바로 뜨거운 메서드를 JIT 컴파일
516 (26)어떤 GC 와도 캐시를 쓸 수 있게 — ZGC 와 함께 쓰는 길이 열렸다
# 25 기준: 한 번 "학습 실행"을 시키면 캐시가 만들어지고, 이후 기동에 재사용된다
java -XX:AOTCacheOutput=app.aot -jar app.jar     # 학습 + 캐시 생성
java -XX:AOTCache=app.aot -jar app.jar           # 이후 기동

측정된 기동 시간 단축은 최대 42% 수준이다.

실무에서는 — GraalVM 네이티브 이미지와 무엇이 다른가. 네이티브 이미지는 AOT로 기계어까지 만들어 밀리초 단위로 뜨지만, 닫힌 세계 가정(런타임 리플렉션·동적 프록시·클래스 로딩을 미리 다 알아야 함) 때문에 설정 파일과 빌드 시간이라는 값을 치른다. AOT 캐시는 평범한 JVM 그대로, 애플리케이션 코드를 한 줄도 안 고치고 기동만 줄인다. "네이티브 이미지는 부담스럽고 기동은 줄이고 싶다" 는 대부분의 서버가 여기 해당한다. 둘은 대체재가 아니라 비용이 다른 두 선택지다.


7. LTS 지형 — 지금 무엇을 쓰나

버전위치
21 LTS (2023-09)현재 가장 널리 쓰이는 주류. 가상 스레드가 여기서 정식화됐다
25 LTS (2025-09)신규 프로젝트 기본값. Scoped Values 정식·Compact Headers 프로덕션·AOT 캐시 완성
26 (2026-03)non-LTS. 구조적 동시성 6차 프리뷰, HTTP/3 클라이언트, AOT 객체 캐싱, G1 동기화 축소, Applet API 제거
17 LTS (2021-09)여전히 많지만 감소 중

실무에서는 — non-LTS(22·23·24·26…)는 다음 LTS를 미리 보는 창이다. 26에서 6차 프리뷰인 구조적 동시성이 27 LTS에서 정식화되는 식이다. 프로덕션은 LTS를 타되, non-LTS 릴리스 노트는 읽어 두면 다음 LTS 이전이 훨씬 싸진다.

(출처: JEP 483 · JEP 515 · JEP 516 · JEP 517 · JEP 500 · JEP 522 · JEP 526 · Project Leyden · JDK 26)


한눈에 정리

  • 모듈(JPMS): 패키지 위의 경계. exports 안 하면 public이어도 숨김. jlink로 경량 런타임.
  • record: 값 객체의 보일러플레이트 자동화 → DTO·키·다중반환. 불변.
  • sealed + 패턴 매칭: 닫힌 계층 → switch 망라 검사. 상태/결과 모델링(Result 타입).
  • 간결 문법: text block·var·switch 식 — 실수 줄이고 읽기 쉽게.
  • 21/25: 동시성(가상스레드·ScopedValues)·메모리(Lazy Constants·Compact Headers)가 핵심.
  • Lazy Constants: 25의 StableValue가 26에서 LazyConstant이름·API가 바뀌었다. of(계산함수) + get(). 프리뷰 API를 코드에 굳히지 않는다.
  • AOT 캐시(Leyden, 24~26): 로드·링크된 클래스 + 메서드 프로파일을 파일로 저장 → 기동 최대 42% 단축. 26부터 ZGC 포함 어떤 GC와도 쓸 수 있다.
  • Java 26에서 서버에 닿는 넷: HTTP/3 표준 클라이언트(517) · AOT×모든 GC(516) · final 변경에 경고(500) · G1 처리량 최대 15%(522).
  • 버전 지형: 21 LTS 주류 · 25 LTS 신규 기본값 · 26은 non-LTS(다음 LTS 예고편).
GC·JIT는 무대 뒤에서 — 내부와 실무 튜닝실무 노하우·실제 사고 사례