Hacker News

Java sealed classes and exhaustive pattern matching

Java 봉인 클래스와 완전성 검사 패턴 매칭

Java의 sealed 클래스와 exhaustive pattern matching은 switch가 모든 하위 타입을 처리하는지 컴파일 시점에 검사합니다. 하지만 공개 API에 새 하위 타입을 추가하면 재컴파일한 소비자 코드가 깨질 수 있고, 재컴파일하지 않은 코드는 새 값 처리 시 MatchException을 던질 수 있습니다.

에디터 노트

공개 sealed API에서 새 하위 타입을 추가할 때 생기는 문제는 컴파일 타임 검사와 바이너리 호환성이 서로 다른 조건에 놓인다는 점입니다. 소비자를 함께 재컴파일하면 오류를 잡지만, 기존 소비자 바이너리를 그대로 두면 새 값이 들어오는 순간 MatchException이 날 수 있습니다. @smarks가 전한 JDK 27 PEM API의 비공개 하위 타입은 소비자가 모든 경우를 열거하지 못하게 해 라이브러리의 확장 여지를 남깁니다. 따라서 글의 내부 사용 권고는 설득력이 있지만 모든 공개 sealed API가 부적절하다는 뜻은 아닙니다. 공개 하위 타입을 닫아 둘지, 소비자의 exhaustive matching을 막을지 API 설계 단계에서 정해야 합니다.

AI 요약

Java 17의 sealed 클래스와 인터페이스는 허용할 하위 타입을 명시합니다. 레코드 두 개를 구현체로 둔 sealed 인터페이스를 switch로 처리하면, 모든 경우를 나열했을 때 default 없이도 컴파일됩니다. 컴파일러가 가능한 타입을 알기 때문입니다. 이 점에서 sealed 타입은 Haskell이나 ML의 대수적 데이터 타입과 비슷하게 쓸 수 있습니다.

하위 타입 추가가 만드는 호환성 문제

처리 코드가 하위 타입 하나를 빠뜨리고 default도 두지 않으면 컴파일 오류가 납니다. 반면 라이브러리가 공개 sealed 타입에 새 하위 타입을 추가하면, 기존 소비자 코드는 새 경우를 처리하지 않으므로 다시 컴파일할 때 오류가 납니다. 글쓴이는 SemVer를 따른다면 이런 변경을 major 버전으로 다뤄야 한다고 설명합니다.

소비자를 다시 컴파일하지 않으면 오류가 실행 시점으로 넘어갑니다. 글의 예에서는 기존 소비자 프로그램과 새 SealedType을 함께 실행하고 새 하위 타입 값을 switch에 넣자 MatchException이 발생합니다. 컴파일러의 완전성 검사는 소비자 코드와 타입을 함께 컴파일할 때만 새 경우를 잡아냅니다. 별도로 컴파일된 공개 API와 소비자 사이에서는 그 보장이 유지되지 않습니다. 글쓴이는 sealed 타입을 라이브러리 공개 API보다 한 컴포넌트 내부에서 쓰는 편이 낫다고 결론짓습니다. 내부 코드는 보통 함께 다시 컴파일하므로 완전성 검사의 이점을 살리기 쉽습니다.

PEM API의 설계와 토론

글쓴이는 PEM 인코딩 암호 객체를 다루는 API가 공개 sealed 인터페이스를 제공하고 소비자에게 패턴 매칭을 권하는 점을 사례로 듭니다. 댓글의 @smarks는 해당 JDK 26 API의 문제를 짚으며, JDK 27 버전에서는 비공개 permitted subtype을 추가했다고 설명합니다. 소비자가 공개된 하위 타입을 모두 열거해 exhaustive switch를 작성하지 못하게 해, API가 나중에 새 하위 타입을 추가할 때 예상치 못한 예외가 생기는 위험을 줄이는 방식입니다.

Hacker News 반응

  • @smarks — 글에서 다룬 것은 JDK 26의 PEM Encodings API입니다. JDK 27 버전에서는 해당 sealed 클래스에 비공개 permitted subtype을 넣었습니다. 이렇게 하면 애플리케이션이 허용된 하위 타입을 모두 열거하는 exhaustive switch를 작성할 수 없고, JDK가 나중에 새 하위 타입을 추가해도 예상치 못한 예외가 생길 위험을 줄입니다.
  • @misja111 — sealed 클래스의 하위 타입을 바꾸는 변경이라면 소비자와 제공자 API JAR 사이의 바이너리 호환성도 깨져야 한다고 생각합니다. 지금 방식에서는 새 제공자 JAR로 바꿨을 때 소비자에 따라 실행 중 오류가 날 수도, 안 날 수도 있습니다.
  • @nmadden — 인터페이스를 대상으로 프로그래밍하면 이런 문제는 생기지 않습니다. 패턴 매칭은 instanceof 검사와 하위 타입 변환을 문법으로 간결하게 쓴 것입니다. 모듈 경계 안에서는 괜찮지만 모듈 경계를 넘을 때는 인터페이스를 사용하는 편이 낫다고 봅니다.
  • @rf15 — switch 표현식에는 이론상 불가능한 경우라도 default나 fallback 분기를 두고 로그를 남기거나 기본값을 선택해야 합니다.
  • @masklinn — 완전성 검사를 기본으로 두는 것이 맞습니다. 대부분의 경우 exhaustive pattern matching이 필요하고, 라이브러리에서 소비자의 exhaustive matching을 막아야 할 때는 비공개 확장 변형을 쓸 수 있습니다. OCaml의 private extensible variants, Rust와 Swift의 non-exhaustive 표시와 비슷한 방식입니다.

원문: Neil Madden 블로그 / 번역·요약: Trawling