Java 17의 Sealed Class and Interface
Overview
JDK 15, 16에서 preview feature로 소개되었던 sealed 클래스가 JDK 17에서 릴리즈 되었습니다. (JEP 409)
'seal'는 '봉인하다' 라는 의미를 가지며, sealed 클래스는 상속될 하위 클래스들을 봉인할 수 있습니다.
다시 말하면, sealed 클래스 및 인터페이스는 허용된 클래스와 인터페이스에 의해서만 확장되거나 구현될 수 있도록 제한합니다.
Motivation
클래스 계층 구조는 상속을 통한 코드 재사용의 목적도 있지만, 또 다른 목적으로 도메인 모델링을 위해 사용하기도 합니다.
예를 들어, 결제 수단으로 카드와 현금만 지원하는 도메인이 있다고 가정해봅시다.
Java에서는 이렇게 고정된 수의 인스턴스만 있는 상황을 모델링 하기 위해 enum 클래스를 지원하고 있습니다.
enum PayType { CARD, CASH }
만약 enum 클래스가 아닌 클래스 계층을 통해 모델링하고 싶을 때도 있을 것입니다.
이런 경우 우리는 다음과 같이 코드를 작성할 수 있습니다.
interface PayType { ... }
class Card implements PayType { ... }
class Cash implements PayType { ... }
이러한 계층 구조는 치명적인 단점이 있습니다. 바로 결제 수단으로 카드와 현금만 지원한다는 중요한 도메인 지식을 반영하지 않는다는 것 입니다. 누군가 결제 수단으로 PayType을 상속하는 Point 클래스를 추가해도 코드는 아무 문제 없이 작동할 것 입니다.
이때 sealed 클래스나 인터페이스를 활용하여 위의 문제를 해결할 수 있습니다.
Sealed Class and Interface
Sealed는 상속을 제한하는 기능으로, 명시적으로 선언된 하위 클래스들만이 상속을 할 수 있습니다.
먼저, Sealed 클래스와 인터페이스는 sealed 키워드를 통해 선언합니다. 그런 다음 extends 와 implements 뒤에 permits 을 통해 이를 확장할 하위클래스들을 명시합니다.
sealed interface PayType permits Card, Cash { ... }
final class Card implements PayType { ... }
final class Cash implements PayType { ... }
만약 하위 클래스들의 크기와 수가 작을 때는 sealed 클래스와 같은 소스 파일 안에 선언하는 것이 편할 수도 있습니다. 이때는 Java 컴파일러가 하위 클래스를 추론할 수 있기 때문에 permits 절을 생략할 수 있습니다.
제약 조건
Sealed 클래스 및 인터페이스를 확장한 하위 클래스에는 3가지 제약 조건이 있습니다.
- 허용된 모든 하위 클래스는 sealed 클래스와 동일한 모듈 또는 동일한 패키지에 속해야 한다.
- 허용된 모든 하위 클래스는 sealed 클래스를 명시적으로 확장해야 한다. (implements, extends)
- 허용된 모든 하위 클래스는 final, sealed, non-sealed 중 하나를 꼭 정의해야 한다.
- final: 클래스 계층 더 이상 확장 불가
- sealed: 허용된 하위 클래스로만 확장 가능
- non-sealed: 확장을 위해 봉인이 열린 상태로 어떠한 클래스든 확장 가능
sealed interface PayType permits Card, Cash { ... }
non-sealed class Card implements PayType { ... }
class NPay extends Card { ... } // Card는 non-sealed여서 확장이 가능하다.
final class Cash implements PayType { ... }
사용법 정리
- sealed 키워드를 통해 sealed 클래스 또는 인터페이스로 선언
- permits 키워드를 통해 하위 클래스 명시 (sealed 클래스와 같은 소스 파일 안에 선언된 경우 제외)
- 허용된 모든 하위 클래스에 위의 3가지 제약 조건 적용
다른 기능과의 호환성
Record
Record는 암시적인 final 클래스입니다. 따라서 허용된 하위 클래스를 record로 작성할 수 있습니다.
public sealed interface PayType permits Card, Cash { ... }
record Card() implements PayType { ... }
record Cash() implements PayType { ... }
Reflection API
Reflection API에서도 Sealed 클래스를 지원하고 있습니다.
- Class<?>[] getPermittedSubclasses(): 허용된 모든 하위 클래스의 Class 배열을 반환합니다.
- boolean isSealed(): 지정된 클래스가 sealed 클래스 또는 인터페이스인 경우 true를 반환합니다.
@Test
void reflectionTest() {
Card card = new Card();
Assertions.assertThat(card.getClass().isSealed()).isEqualTo(false);
Assertions.assertThat(card.getClass().getInterfaces()[0].isSealed()).isEqualTo(true);
Assertions.assertThat(card.getClass().getInterfaces()[0].getPermittedSubclasses())
.contains(Card.class, Cash.class);
}
궁금한 점
아래 답변은 데브코스 팀 RBF 중에 나온 대답입니다 :)
Q1. Sealed Class와 Interface를 통해 하위 클래스를 제한해서 얻는 이점이 뭘까?
A1) 무분별한 상속을 제한할 수 있습니다. 개발자가 고려하지 않은 구현체로 인해 발생하는 문제들을 방지할 수 있습니다.
A2) 구현하지 않은 클래스에 대해서는 컴파일 오류가 발생하기 때문에 구현을 강제할 수 있고, 이로 인해 개발자의 실수를 예방할 수 있습니다.
A3) 상속이 허용된 하위 클래스들의 명세로, 모르는 사람이 코드를 볼 때 일일이 상속된 하위 클래스들을 찾지 않아도 됩니다.
Q2. 어떤 경우에 사용할 수 있을까?
A1) API 응답을 커스텀할 때 사용할 수 있습니다. 응답은 성공과 실패가 있고 성공/실패에 따라 다르게 동작할 것 입니다. 이런 경우 응답 클래스를 sealed class로 만들고, 성공 응답과 실패 응답을 하위 클래스로 지정하여 구현을 강제할 수 있습니다.
A2) DTO를 inner static class로 관리하는 경우가 있습니다. 이때 sealed를 적용하면 DTO 관리가 더 간결해지지 않을까 생각합니다. sealed로 선언된 DTO 클래스만을 보고 하위의 어떤 DTO 클래스들이 있는지 한 눈에 파악할 수 있습니다.
Conclusion
아직까지는 sealed class를 통해 크게 이점을 얻을만한 상황이 오지 않아서 사용해본 적은 없습니다.
하지만 명확하게 클래스 계층 구조를 명시해야 하거나, 상속을 특정 클래스들로 제한해야할 때가 생긴다면 sealed class 사용을 고려해볼 것 같습니다.
Reference