원문 캡처 · neilmadden.blog
원문 캡처 · neilmadden.blog

Java 17에 도입된 sealed 클래스는 인터페이스나 기본 클래스가 허용하는 하위 타입을 명시적으로 제한한다. 예를 들어 다음처럼 sealed 인터페이스와 레코드를 사용할 수 있다. 허용 타입 목록을 별도로 지정하지 않으면 내부 클래스가 허용된 하위 타입에 암묵적으로 추가된다.

public sealed interface SealedType {
 record TypeA() implements SealedType {}
 record TypeB() implements SealedType {}

 static SealedType of(String type) {
 return switch (type) {
 case “A” -> new TypeA();
 case “B” -> new TypeB();
 default -> throw new IllegalArgumentException();
 };
 }
}

대수적 데이터 타입을 사용하는 함수형 언어에 익숙하다면 Haskell이나 ML의 타입 선언과 비슷하다고 볼 수 있다.

data SealedType = TypeA | TypeB

이 타입을 간단한 Main 클래스에서 사용하면 다음과 같다.

void main(String[] args) {
 var val = SealedType.of(args[0]);
 System.out.println(switch (val) {
 case SealedType.TypeA() -> “A”;
 case SealedType.TypeB() -> “B”;
 });
}

여기서 주목할 점은 main 메서드의 switch 식에 default 절이 없어도 된다는 것이다. sealed 클래스와 열거형은 완전성 검사를 지원하므로 컴파일러가 가능한 경우를 파악해 모두 처리했는지 확인한다. 빠진 경우가 있으면 default 절이 없는 switch는 컴파일 오류가 난다.

모든 사용처에서 가능한 경우를 빠짐없이 다루게 할 수 있다는 장점이 있지만, sealed 클래스나 인터페이스에 하위 타입을 추가하면 이를 사용하는 코드가 깨질 수 있다. 예시의 SealedType에 TypeC를 추가하면 main 메서드가 그 경우를 처리하지 않아 컴파일되지 않는다. 따라서 라이브러리 같은 공개 API에서 sealed 타입을 제공한다면 하위 타입 추가는 호환성을 깨는 변경이며, SemVer를 따른다면 메이저 버전을 올려야 한다.

소비자 코드를 다시 컴파일하지 않고 sealed 타입만 변경하면 컴파일 시점에 오류를 잡을 수 없다. 예를 들어 SealedType.java만 다시 컴파일하고 Main은 그대로 둔 상태에서 새 경우를 실행하면 런타임 예외가 발생한다.

`% java Main C          Exception in thread "main" java.lang.MatchException	at Main.main(Main.java:3)`

이때 새 MatchException이 발생한다. Javadoc은 별도 컴파일에서 생길 수 있는 이 문제와 패턴에서 null을 다루는 일부 예외 사례를 언급한다. 따라서 sealed 클래스를 사용해 새 경우가 추가될 때 모든 소비자를 정적으로 갱신하도록 보장하려 해도, 전체 코드를 다시 컴파일하지 않으면 그렇게 되지 않는다.

이 사례에서 얻을 수 있는 결론은 sealed 타입이 컴포넌트 내부 구현에서 특히 유용하다는 것이다. 내부 코드에서는 보통 전체를 함께 다시 컴파일하므로 완전성 검사와 컴파일 시점의 안전성 향상을 얻을 수 있다. 반면 모듈 경계를 넘어 공개 API로 제공하면 코드가 깨질 수 있는 새로운 경로가 생기며, 그 문제가 런타임에야 드러날 수도 있다.

이러한 점은 PEM 인코딩 암호화 객체의 미리 보기 지원을 검토하는 과정에서 드러났다. 해당 기능은 sealed 인터페이스를 공개 API에 포함하고 클라이언트가 그 타입에 패턴 매칭하도록 권장하는데, 새 경우를 추가하려 할 때 클라이언트 코드가 깨질 가능성이 매우 높다는 것이 이 글의 지적이다.