본문 바로가기

프리뷰 실험을 이어 갈지 끝낼지 정하기

JDK 25의 프리뷰 기능이 다음 릴리스에서 실제로 어떻게 바뀌었는지 살펴보고, 기능 이력 기록과 정식 기능 기준선, 어댑터를 이용해 프리뷰 실험을 승격하거나 정리하는 기준을 세웁니다.

프리뷰 기능은 채택을 약속하지 않습니다. 다음 릴리스에서 정식 기능이 될 수도 있고, 바뀐 모습으로 다시 프리뷰될 수도 있고, 사라질 수도 있습니다. 실제로 이런 변화는 자주 일어납니다. 구조화된 동시성은 JDK 25의 다섯 번째 프리뷰(JEP 505)에서 StructuredTaskScope를 생성자 대신 정적 메서드 open()으로 열고, 완료 정책을 Joiner로 고르는 방식으로 API가 크게 바뀌었습니다. JDK 25에서 처음 프리뷰된 Stable Values(JEP 502)는 JDK 26에서 Lazy Constants(JEP 526)라는 이름으로 두 번째 프리뷰가 되면서, 중심 타입이 LazyConstant로 바뀌고 orElseSet, trySet, setOrThrow 같은 메서드가 빠졌습니다. 계산 결과로 null을 허용하지 않게 된 것도 이때의 변화입니다.

그래서 JDK 25의 StableValue로 쓴 실험 코드는 JDK 26에서 그대로 쓸 수 없습니다. 이런 일을 겪고 나면, 프리뷰 실험에서 오래 남겨야 할 것은 소스 코드보다 "무엇을 알고 싶었고, 어떤 조건에서 무엇을 확인했는가"라는 사실을 깨닫게 됩니다. 이 글에서는 프리뷰 기능의 상태를 잘못 판단하는 흔한 실수부터 고치고, 기능의 이력을 기록하는 방법, 정식 기능으로 만든 기준선과 비교하는 방법, 새 JDK가 나왔을 때 실험을 승격하거나 정리하는 기준을 차례로 살펴봅니다. 버전에 관한 내용은 JDK 25를 기준으로 하고, JDK 26에서 실제로 바뀐 내용을 함께 적었습니다.

클래스가 있다고 정식 기능은 아니다

어떤 기능을 써도 되는지 런타임에 확인하고 싶을 때 가장 먼저 떠오르는 방법은 리플렉션입니다. 아래 프로그램은 Class.forName으로 java.lang.StableValue를 찾아보고, 클래스가 있으면 정식 기능이라고 판단합니다. 이 판단이 왜 틀렸는지가 이 절의 주제입니다.

lab/PreviewPresenceIsNotStabilityBug.java
public final class PreviewPresenceIsNotStabilityBug {
    public static void main(String[] args) {
        boolean present;
        try {
            Class.forName("java.lang.StableValue");
            present = true;
        } catch (ClassNotFoundException exception) {
            present = false;
        }
        System.out.println("class-present=" + present);
        System.out.println("wrong-stable=" + present);
    }
}

이 프로그램은 StableValue를 코드에서 직접 쓰지 않고 이름 문자열로만 찾으므로, 프리뷰 플래그 없이 컴파일하고 실행할 수 있습니다. JDK 25에서 실행하면 두 줄 모두 true가 나옵니다. 프리뷰 API도 JDK 안에 들어 있는 보통의 클래스이고, 프리뷰를 켰는지 따지는 일은 javac가 소스에서 그 API를 쓸 때 하기 때문입니다. 하지만 StableValue는 정식 API가 아니라 프리뷰 API이므로 wrong-stable=true는 틀린 판단입니다. 같은 프로그램을 JDK 21에서 실행하면 그 클래스가 아직 없어서 다음과 같이 나옵니다.

class-present=false
wrong-stable=false

결국 리플렉션이 알려 주는 것은 "지금 이 런타임에 그 이름의 클래스가 있는가"뿐입니다. JDK 21에서는 기능이 아직 없어서 false, JDK 25에서는 프리뷰로 있어서 true이고, JDK 26에서는 같은 기능의 중심 타입 이름이 LazyConstant로 바뀌었습니다. 어느 답도 그 기능이 프리뷰인지, 인큐베이터 모듈인지, 정식 기능인지는 말해 주지 않습니다.

기능의 상태를 판단하는 기준은 그 릴리스의 JEP와 명세입니다. JDK의 API 문서(Javadoc)도 프리뷰 API에 표시를 해 둡니다. 예를 들어 JDK 25의 StableValue 문서는 맨 위에 "StableValue is a preview API of the Java platform"이라고 적고, 프리뷰 기능을 켠 프로그램에서만 쓸 수 있다고 안내합니다. 소스에서 직접 쓰는 경우라면 javac가 --enable-preview 없이는 컴파일을 거부하므로 실수할 일이 적고, 리플렉션처럼 컴파일러의 검사를 거치지 않는 경로에서 이런 착각이 생깁니다.

기능마다 이력을 기록하기

프리뷰 기능을 추적할 때는 기능 이름만 적어 두면 부족합니다. 같은 이름의 기능이라도 릴리스마다 JEP 번호가 새로 붙고, 프리뷰 차수가 올라가면서 API가 바뀌기 때문입니다. 이 장에서 다룬 기능들이 JDK 25까지 어떤 길을 걸어왔고 JDK 26에서 어떻게 됐는지 정리하면 다음과 같습니다.

기능JDK 25에서의 상태JDK 25 이전JDK 26
Scoped ValuesJEP 506, 정식 기능인큐베이터 JEP 429(JDK 20), 프리뷰 JEP 446(21), 464(22), 481(23), 487(24)정식 기능
Structured ConcurrencyJEP 505, 다섯 번째 프리뷰인큐베이터 JEP 428(JDK 19), 437(20), 프리뷰 JEP 453(21), 462(22), 480(23), 499(24)JEP 525, 여섯 번째 프리뷰
기본형 패턴(Primitive Types in Patterns, instanceof, and switch)JEP 507, 세 번째 프리뷰프리뷰 JEP 455(JDK 23), 488(24)JEP 530, 네 번째 프리뷰
Stable ValuesJEP 502, 첫 번째 프리뷰없음JEP 526, Lazy Constants라는 이름으로 두 번째 프리뷰

Scoped Values는 인큐베이터 한 번과 프리뷰 네 번을 거쳐 JDK 25에서 정식 기능이 되었습니다. 구조화된 동시성은 JDK 26에서도 여전히 프리뷰입니다. 이처럼 프리뷰 기간은 기능마다 다르고 미리 알 수 없으므로, 실험을 시작할 때부터 "언제 다시 확인할지"를 정해 두는 것이 좋습니다.

아래 프로그램은 이런 기록을 코드로 구조화한 예입니다. 기능마다 이름, JEP 번호, JDK 릴리스, 상태, 프리뷰 차수, 다시 검토할 날짜를 레코드로 묶었습니다.

src/PreviewFeatureRegistry.java
import java.time.LocalDate;
import java.util.List;

public final class PreviewFeatureRegistry {
    private enum Status {
        PREVIEW,
        INCUBATOR,
        EXPERIMENTAL,
        STABLE
    }

    private record Feature(
            String name, int jep, int jdk, Status status, int iteration, LocalDate reviewDate) {}

    public static void main(String[] args) {
        List<Feature> features = List.of(
                new Feature("Stable Values", 502, 25, Status.PREVIEW, 1, LocalDate.of(2026, 3, 1)),
                new Feature("Structured Concurrency", 505, 25, Status.PREVIEW, 5, LocalDate.of(2026, 3, 1)),
                new Feature("Primitive Patterns", 507, 25, Status.PREVIEW, 3, LocalDate.of(2026, 3, 1)),
                new Feature("Scoped Values", 506, 25, Status.STABLE, 0, LocalDate.of(2027, 1, 1)));
        features.forEach(feature -> System.out.println(
                feature.name() + " JEP=" + feature.jep() + " status=" + feature.status()));
    }
}
Stable Values JEP=502 status=PREVIEW
Structured Concurrency JEP=505 status=PREVIEW
Primitive Patterns JEP=507 status=PREVIEW
Scoped Values JEP=506 status=STABLE

Status의 네 값은 JDK가 아직 확정하지 않은 기능을 내놓는 방식에 맞춘 것입니다. 프리뷰(PREVIEW)는 언어와 API 기능, 인큐베이터(INCUBATOR)는 jdk.incubator로 시작하는 별도 모듈로 내놓는 API, 실험(EXPERIMENTAL)은 JVM 옵션으로 켜는 실험적 JVM 기능입니다. iteration은 프리뷰 차수이고, 정식 기능에는 0을 넣었습니다. 검토 날짜는 다음 릴리스 직전을 예로 넣은 값이며, 실제로 검토를 마쳤다는 뜻은 아닙니다.

이 프로그램은 기록을 출력할 뿐 빌드 플래그를 정하거나 JEP 상태를 확인하지는 않습니다. 실제 기록에는 컴파일과 실행 옵션, 담당자, 확인한 JEP와 문서의 링크도 함께 남겨야 합니다. 그리고 기록만 믿지 말고, 새 JDK가 나오면 openjdk.org의 JEP 상태와 실제 설치한 JDK에서의 컴파일 결과를 함께 확인합니다.

정식 기능으로 만든 기준선과 비교하기

Stable Values 같은 프리뷰 API를 평가할 때는 먼저 정식 기능만으로 같은 일을 하는 방법, 즉 기준선(baseline)을 정해 둡니다. 지연 초기화라면 volatile 필드와 synchronized를 쓰는 이중 검사, 클래스 초기화를 이용하는 홀더 관용구, 처음부터 값을 넣어 두는 final 필드 같은 방법이 이미 있습니다. 기준선이 요구 사항을 충분히 채운다면 프리뷰를 도입해서 얻는 이익은 작습니다. 아래는 이중 검사로 만든 기준선입니다.

src/SynchronizedLazyCellBaseline.java
import java.util.Objects;
import java.util.function.Supplier;

public final class SynchronizedLazyCellBaseline {
    private static final class LazyCell<T> {
        private volatile T value;

        T get(Supplier<? extends T> initializer) {
            T observed = value;
            if (observed != null) {
                return observed;
            }
            synchronized (this) {
                if (value == null) {
                    value = Objects.requireNonNull(initializer.get());
                }
                return value;
            }
        }
    }

    public static void main(String[] args) {
        LazyCell<String> cell = new LazyCell<>();
        System.out.println(cell.get(() -> "ready"));
        System.out.println(cell.get(() -> "must-not-replace"));
    }
}
ready
ready

첫 호출은 value가 null이므로 잠금을 잡고 공급자를 실행해 ready를 저장합니다. 두 번째 호출은 volatile 필드에서 이미 저장된 값을 읽어 바로 돌려주므로, 두 번째 공급자의 must-not-replace는 쓰이지 않습니다. 잠금 안에서 value를 한 번 더 확인하는 이유는, 두 스레드가 동시에 첫 검사를 통과했을 때 늦게 잠금을 잡은 스레드가 값을 다시 계산하지 않게 하려는 것입니다.

다음 프로그램은 같은 일을 프리뷰 구현으로 합니다. 이번에는 StableValue를 바로 쓰지 않고 작은 LazyCell 인터페이스 뒤에 숨겼습니다. 앞의 기준선 클래스가 이 인터페이스를 구현하는 것은 아니며, 두 프로그램은 서로 독립적입니다. 이 프로그램은 프리뷰 플래그로 컴파일하고 실행해야 하며, 출력은 기준선과 같은 ready 두 줄입니다.

preview/StableValueLazyCellExperiment.java
import java.util.function.Supplier;

public final class StableValueLazyCellExperiment {
    private interface LazyCell<T> {
        T get(Supplier<? extends T> initializer);
    }

    private static final class PreviewCell<T> implements LazyCell<T> {
        private final StableValue<T> value = StableValue.of();

        @Override
        public T get(Supplier<? extends T> initializer) {
            return value.orElseSet(initializer);
        }
    }

    public static void main(String[] args) {
        LazyCell<String> cell = new PreviewCell<>();
        System.out.println(cell.get(() -> "ready"));
        System.out.println(cell.get(() -> "must-not-replace"));
    }
}

인터페이스 뒤에 숨기는 이유는 프리뷰 API가 바뀌거나 사라질 때 고칠 곳을 한 클래스로 줄이기 위해서입니다. 공개 API가 StableValue를 직접 받거나 돌려준다면, JDK 26에서 타입 이름이 바뀌었을 때 그 API를 쓰는 모든 코드를 고쳐야 합니다. 어댑터 뒤에 숨겼다면 PreviewCell만 고치거나 기준선 구현으로 바꾸면 됩니다.

두 main은 정상 문자열을 차례로 두 번 받는 경우만 보여 줍니다. 그런데 두 구현은 정상 경로가 아닌 곳에서 다르게 동작합니다. 아래 그림은 코드와 JDK 25 API 문서를 비교해 정리한 것이고, 실행해서 잰 결과는 아닙니다.

두 지연 초기화 예제의 정책 차이

원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.

Java 25 · 지연 초기화의 null·실패·재귀 정책

두 지연 초기화 예제의 정책 차이

정상적으로 계산한 첫 값은 두 예제 모두 재사용합니다. null 저장과 같은 셀의 재귀 초기화 정책은 다르며, 공급자 예외 뒤에는 다시 초기화할 수 있습니다.

정식 기능 기준선

정상 문자열 반환
첫 값 저장 후 재사용
null 반환
예외 · 미설정 유지
공급자 예외
예외 전파 · 나중에 재시도 가능
같은 셀 재귀 초기화
명시적인 재귀 방어 없음

StableValue 실험

정상 문자열 반환
첫 값 저장 후 재사용
null 반환
null을 설정된 값으로 저장
공급자 예외
예외 전파 · 나중에 재시도 가능
같은 셀 재귀 초기화
IllegalStateException

본문 main은 ready 문자열의 정상 재사용을 보여줍니다. null·예외·재귀 행은 코드와 Java 25 API 계약을 비교한 것으로, 성능이나 실행 횟수의 측정 결과가 아닙니다.

가장 큰 차이는 null을 다루는 방식입니다. 기준선은 null을 "아직 값이 없음"이라는 표시로 쓰므로 null을 값으로 저장할 수 없고, 공급자가 null을 돌려주면 requireNonNull이 NullPointerException을 던집니다. 이때 셀은 비어 있는 채로 남아 다음 호출이 공급자를 다시 부릅니다. 반면 JDK 25의 StableValue는 null도 정해진 값으로 저장합니다. 공급자가 예외를 던지는 경우에는 두 구현 모두 예외를 호출자에게 전하고 값을 비워 두므로, 나중에 다시 시도할 수 있습니다.

재귀 초기화도 다릅니다. 공급자가 실행 도중 같은 셀의 get을 다시 부르면, StableValue는 IllegalStateException을 던집니다. 기준선에는 이런 방어가 없습니다. synchronized는 같은 스레드가 다시 들어오는 것을 허용하므로 안쪽 호출이 먼저 값을 저장하고, 바깥 호출이 그 값을 자기 결과로 덮어씁니다. 실제로 바깥 공급자가 "outer:" + cell.get(() -> "inner")를 돌려주게 하면, 안쪽 호출은 inner를 받는데 셀에는 최종적으로 outer:inner가 남습니다. 한 셀에서 두 가지 값이 나온 것입니다. 정상 문자열 예제만 비교하면 이런 차이를 모두 놓치므로, 구현을 바꾸기 전에 null, 예외, 재귀, 동시 호출을 각각 시험해 봐야 합니다.

더 깊이: 어댑터의 모양도 프리뷰 API를 닮으면 안 된다

어댑터로 숨겼다고 해서 JDK 26으로의 이전이 저절로 쉬워지지는 않습니다. 위 LazyCell의 get(Supplier)는 orElseSet의 모양을 그대로 본뜬 것인데, JDK 26의 LazyConstant는 LazyConstant.of(supplier)처럼 만들 때 공급자를 한 번 받고 get()으로 값을 읽는 방식만 제공합니다. 호출할 때마다 공급자를 넘기는 인터페이스는 이 방식과 맞지 않아서, PreviewCell 하나가 아니라 LazyCell을 쓰는 모든 코드를 바꿔야 할 수 있습니다.

처음부터 "공급자는 만들 때 한 번 정하고, 값은 get()으로 읽는다"처럼 우리 코드가 실제로 필요로 하는 모양으로 인터페이스를 설계했다면 JDK 26에서도 구현 클래스만 바꾸면 됐을 것입니다. 어댑터는 프리뷰 API의 메서드 목록이 아니라 우리 도메인의 필요를 기준으로 설계해야 고칠 곳이 정말로 한 곳에 모입니다.

새 JDK가 나오면 같은 소스를 다시 컴파일하기

새 JDK가 나오면 먼저 이전 실험 소스를 한 글자도 고치지 않고 새 JDK로 컴파일해 봅니다. 컴파일이 실패한다면 그 오류 메시지가 그 기능이 어떻게 바뀌었는지 알려 주는 첫 단서이므로 기록해 둡니다. 그다음에 새 릴리스의 JEP를 읽고 거기에 맞춰 실험 코드를 고칩니다. 프리뷰 클래스 파일과 실행 규칙에서 본 것처럼 프리뷰 클래스 파일은 다른 릴리스에서 실행되지 않으므로, 기존 바이너리로 실험을 이어 가는 길은 처음부터 없습니다.

이때 하나의 소스를 조건부 리플렉션으로 여러 JDK에 억지로 맞추려 하지 않습니다. 앞에서 봤듯이 리플렉션은 기능의 상태를 알려 주지 못하고, 릴리스마다 다른 API를 리플렉션으로 감싸면 코드는 복잡해지는데 어느 버전에서 무엇이 바뀌었는지는 오히려 감춰집니다. 릴리스마다 소스를 따로 두고, 아래처럼 릴리스와 소스 리비전, 명령, 결과를 짝지어 기록하는 편이 낫습니다.

src/PreviewReevaluationMatrix.java
import java.util.List;

public final class PreviewReevaluationMatrix {
    private record Run(
            int jdk,
            String feature,
            String sourceRevision,
            String compileCommand,
            String observed) {}

    public static void main(String[] args) {
        List<Run> runs = List.of(
                new Run(25, "structured-concurrency", "jdk25-open-api",
                        "javac --enable-preview --release 25", "compiled"),
                new Run(25, "stable-values", "jep502-v1",
                        "javac --enable-preview --release 25", "compiled"),
                new Run(25, "primitive-patterns", "jep507-v3",
                        "javac --enable-preview --release 25", "compiled"));
        runs.forEach(System.out::println);
    }
}
Run[jdk=25, feature=structured-concurrency, sourceRevision=jdk25-open-api, compileCommand=javac --enable-preview --release 25, observed=compiled]
Run[jdk=25, feature=stable-values, sourceRevision=jep502-v1, compileCommand=javac --enable-preview --release 25, observed=compiled]
Run[jdk=25, feature=primitive-patterns, sourceRevision=jep507-v3, compileCommand=javac --enable-preview --release 25, observed=compiled]

레코드의 toString이 필드 이름과 값을 모두 보여 주므로, 이 출력이 그대로 한 줄짜리 기록이 됩니다. 다만 observed의 compiled는 예로 직접 써 넣은 문자열입니다. 이 프로그램은 컴파일러를 실행하지 않으므로, 실제 기록에서는 컴파일과 실행을 돌린 결과(종료 코드, 경고 개수, 첫 오류 메시지)를 이 자리에 채워야 합니다. JDK 26으로 다시 평가한다면 jdk=26인 행을 새로 추가하고, JDK 25 행은 지우지 않고 남겨 두어 무엇이 바뀌었는지 비교할 수 있게 합니다.

승격할지, 이어 갈지, 정리할지

새 릴리스의 상태를 확인했다면 실험마다 다음 중 하나를 고릅니다.

  • 승격: 기능이 정식 기능이 되었다면 프리뷰 플래그를 빼고, 정식 명세에 맞춰 소스를 고친 뒤 안정 소스로 옮겨 컴파일하고 테스트합니다.
  • 이어 가기: 다음 프리뷰에서 API가 바뀌었다면 이전 릴리스의 실험 소스는 보존하고, 새 릴리스용 소스를 따로 만들면서 무엇이 왜 바뀌었는지 기록합니다.
  • 정리: 기대한 이점이 확인되지 않았거나 기능이 철회되었다면 어댑터 구현과 프리뷰 CI 작업을 삭제합니다.

어느 쪽을 고르든 운영 코드가 실험에 기대지 않아서 삭제가 작은 변경으로 끝나는지 확인해야 합니다. 그리고 실험 결과를 문서로 남길 때는 "JDK 25 프리뷰 기준"처럼 버전을 밝히고, 그 API를 앞으로도 계속 쓸 수 있는 권장 API처럼 적지 않습니다.

실험을 정리하는 것은 실패가 아닙니다. "정식 기능으로 만든 기준선으로도 충분하다"는 결론도 실험이 얻어 낸 중요한 결과입니다. 오히려 위험한 것은 프리뷰 코드가 조금씩 늘어나서, 이미 들인 노력이 아까워 정리하지 못하게 되는 상황입니다. 그래서 실험을 시작할 때 기한과 함께, 정리할 때 들일 수 있는 비용의 한도를 미리 정해 둡니다.

프리뷰 기능이 다음 JDK에서 정식 기능이 되면 기존 클래스 파일을 그대로 써도 되나요?

아닙니다. 프리뷰 클래스 파일은 그것을 만든 릴리스에 묶여 있어서, 기능이 정식이 된 새 JDK에서도 실행되지 않습니다. 새 JDK의 정식 명세에 맞춰 소스를 다시 컴파일해야 하고, 그렇게 만든 클래스 파일은 마이너 버전이 0인 평범한 클래스 파일이 됩니다. 이때 플래그만 빼고 끝내지 말고, 정식 기능이 되면서 API의 시그니처나 의미가 마지막 프리뷰와 달라진 곳이 없는지도 확인합니다. 그다음 실행 확인과 테스트를 다시 수행합니다.

정리

  • 리플렉션으로 클래스를 찾을 수 있다고 해서 정식 기능은 아닙니다. 기능의 상태는 그 릴리스의 JEP와 API 문서로 판단합니다.
  • 프리뷰 기능은 릴리스마다 JEP 번호와 차수가 바뀌므로, 기능 이름이 아니라 JEP 번호, 릴리스, 상태, 차수, 검토 날짜를 함께 기록합니다.
  • 프리뷰 API는 정식 기능 기준선과 정상 경로뿐 아니라 null, 예외, 재귀, 동시 호출에서 비교하고, 우리 코드에 맞춘 어댑터 뒤에 숨깁니다.
  • 새 JDK가 나오면 이전 소스를 그대로 다시 컴파일해 결과를 기록한 뒤, 승격하거나 이어 가거나 정리합니다.

연습 문제

  1. 실험의 상태를 받아 결정을 돌려주는 decide 함수를 작성하세요. 정식 기능이 되었으면(STABLE) 승격(PROMOTE), 다음 릴리스에서 다시 프리뷰되었으면(REPREVIEW) 이전(MIGRATE), 철회되었으면(WITHDRAWN) 삭제(DELETE), 상태를 모르면(UNKNOWN) 보류(HOLD)입니다. 네 상태의 결과를 먼저 적어 보고, UNKNOWN일 때 운영 코드는 무엇을 계속 써야 하는지도 생각해 보세요.
  2. PreviewPresenceIsNotStabilityBug를 JDK 21과 JDK 25에서 실행한 결과가 다른 이유를 설명하세요. 두 결과 가운데 기능의 상태를 올바르게 알려 주는 것이 있나요?
  3. 본문의 LazyCell 인터페이스(get(Supplier))를 JDK 26의 LazyConstant로 구현하기 어려운 이유는 무엇인가요? 어떤 모양의 인터페이스였다면 구현 클래스만 바꿔서 옮길 수 있었을까요?
해설 보기

1번은 두 enum과 switch 식으로 깔끔하게 쓸 수 있습니다. switch 식은 enum의 모든 상수를 다루지 않으면 컴파일되지 않으므로, 나중에 상태가 추가되었을 때 결정을 빠뜨리는 실수를 컴파일러가 잡아 줍니다.

exercise/PreviewDecisionSolution.java
public final class PreviewDecisionSolution {
    private enum Status {
        STABLE,
        REPREVIEW,
        WITHDRAWN,
        UNKNOWN
    }

    private enum Decision {
        PROMOTE,
        MIGRATE,
        DELETE,
        HOLD
    }

    static Decision decide(Status status) {
        return switch (status) {
            case STABLE -> Decision.PROMOTE;
            case REPREVIEW -> Decision.MIGRATE;
            case WITHDRAWN -> Decision.DELETE;
            case UNKNOWN -> Decision.HOLD;
        };
    }

    public static void main(String[] args) {
        System.out.println(decide(Status.STABLE));
        System.out.println(decide(Status.REPREVIEW));
        System.out.println(decide(Status.WITHDRAWN));
        System.out.println(decide(Status.UNKNOWN));
    }
}
PROMOTE
MIGRATE
DELETE
HOLD

HOLD는 아무것도 바꾸지 않는다는 뜻이므로, 운영 코드는 계속 정식 기능 기준선을 씁니다. 다만 보류가 길어지지 않도록 누가 언제까지 JEP 상태를 확인할지를 함께 정해야 합니다. 이 함수는 상태 값만 보고 결정할 뿐 실제 동작 시험이나 API 호환성 검사를 하지는 않으므로, 실제 결정 기록에는 확인한 내용과 담당자, 기한을 함께 적습니다.

2번은 각 런타임에 그 이름의 클래스가 있는지만 다르기 때문입니다. JDK 21에는 java.lang.StableValue가 없어서 false, JDK 25에는 프리뷰 API로 들어 있어서 true가 나옵니다. 그러나 false는 "기능이 없다"는 뜻이고 true는 "클래스가 있다"는 뜻일 뿐이라, 어느 쪽도 프리뷰인지 정식인지를 알려 주지 않습니다. JDK 25에서 true를 정식 기능으로 읽으면 틀린 판단이 됩니다.

3번에서 LazyConstant는 공급자를 만들 때 한 번 받고 get()으로 값을 읽는 방식만 제공합니다. 호출할 때마다 공급자를 넘기는 get(Supplier)는 이 방식에 그대로 대응하지 않으므로, 인터페이스를 쓰는 쪽 코드까지 바꿔야 할 수 있습니다. 처음부터 생성할 때 공급자를 받고(LazyCell.of(supplier) 같은 팩터리) 값은 get()으로 읽는 인터페이스였다면, JDK 25에서는 StableValue로, JDK 26에서는 LazyConstant로, 정식 기능만 쓸 때는 기준선으로 구현 클래스만 바꿔 끼울 수 있었을 것입니다.