1. Sealed 클래스 문법: 어떻게 생겼는가
Java에서의 전통적인 OOP 문제부터 시작해 봅시다: 열린 상속 계층. 일반적인 Java에서는 클래스가 final로 선언되지 않는 한 누구나 해당 클래스를 상속할 수 있습니다. 편리하지만, 때로는 예기치 않은 상황을 초래합니다 — 예컨대 어떤 클래스들이 여러분의 클래스를 상속하게 될지 미리 알 수 없고, 따라서 switch나 if-else에서 모든 경우를 처리했다고 보장하기 어렵습니다.
그 결과, 타입에 따라 객체를 처리할 때(예: switch에서 pattern matching을 사용할 때) “혹시 몰라서” default 분기를 넣어두곤 합니다. 누군가 어딘가에서 새로운 하위 클래스를 만들었을 수도 있으니까요.
Sealed 클래스는 이 문제를 해결합니다. 허용되는 하위 클래스의 목록을 명시적으로 제한할 수 있게 해 주기 때문입니다. 이렇게 하면 계층이 닫히고 통제 가능해지며, 코드가 더 예측 가능하고 안전해집니다.
기본 문법
Sealed 클래스는 Java 17에 도입되었습니다. sealed 한정자로 선언하고, 허용되는 하위 클래스 목록은 permits 키워드로 지정합니다:
public sealed class Shape permits Circle, Rectangle, Square {
// 모든 도형에 대한 공통 동작
}
여기서는 Shape 클래스를 선언했고, Circle, Rectangle, Square만이 그 직계 하위 클래스가 될 수 있습니다. 그 외 누구도 Shape를 확장할 수 없습니다 — 컴파일러가 허용하지 않습니다.
중요: permits에 지정된 모든 클래스는 같은 파일에서 선언되거나 컴파일러가 볼 수 있어야 합니다(보통 같은 패키지). 참고로, 모든 하위 클래스가 sealed 클래스와 같은 파일에 선언되어 있다면 permits를 생략할 수도 있습니다 — 컴파일러가 알아서 인식합니다.
예:
// 모두 하나의 파일에 있음 - permits는 꼭 필요하지 않음
public sealed class Shape {
}
final class Circle extends Shape {}
final class Rectangle extends Shape {}
하위 타입 요구사항
각 하위 타입은 자신의 상태를 명시해야 합니다:
- final이어야 함(추가 상속 금지),
- 혹은 sealed이어야 함(그리고 계속 상속을 제한),
- 혹은 non-sealed이어야 함(상속 허용, 제한 해제).
예:
public sealed class Shape permits Circle, Rectangle, Square {}
public final class Circle extends Shape {}
public sealed class Rectangle extends Shape permits FilledRectangle, EmptyRectangle {}
public non-sealed class Square extends Shape {}
- Circle — 최종형이라 더 이상 상속할 수 없습니다.
- Rectangle — 자체가 sealed이며 두 하위 클래스에만 허용합니다.
- Square — non-sealed이므로 누구나 확장할 수 있습니다.
최소 예제
public sealed class Animal permits Dog, Cat {}
public final class Dog extends Animal {}
public final class Cat extends Animal {}
public class Wolf extends Animal {} 같은 새 클래스를 선언해 보세요 — 컴파일 오류가 발생합니다:
class Wolf is not allowed to extend sealed class Animal
2. Sealed 클래스의 활용: 언제, 왜 사용하는가
Pattern matching과 switch
Sealed 클래스는 switch의 pattern matching과 특히 잘 맞습니다. 컴파일러가 가능한 모든 하위 타입을 알고 있으면, 각 경우를 모두 처리했는지 확인할 수 있고, default 분기조차 요구하지 않을 수 있습니다.
public sealed interface Result permits Success, Error {}
public final class Success implements Result {
public final String data;
public Success(String data) { this.data = data; }
}
public final class Error implements Result {
public final String message;
public Error(String message) { this.message = message; }
}
public class Main {
public static void main(String[] args) {
Result result = new Success("야호!");
switch (result) {
case Success s -> System.out.println("성공: " + s.data);
case Error e -> System.out.println("오류: " + e.message);
}
}
}
컴파일러는 Result에 다른 경우가 없다는 것을 알기 때문에 default를 요구하지 않습니다. 만약 어떤 경우를 빠뜨리면, 컴파일러가 곧바로 알려줍니다.
참고: Java 21부터는 switch에서 모든 경우를 처리하지 않으면 컴파일 오류가 발생합니다. 그 이전 버전(17–20)에서는 default가 필요할 수 있지만, IDE가 여전히 누락된 경우를 경고합니다.
보안과 제어
Sealed 클래스는 설계자가 계층을 완전히 통제할 수 있게 해 줍니다. 경우의 수가 고정되어야 하는 도메인 모델(예: 주문 상태: New, Paid, Cancelled)에 특히 중요합니다.
유지보수와 진화의 단순화
하위 타입의 모든 경우를 알고 있으면, 새 기능을 추가하고 코드를 유지보수하며 리팩터링하기가 쉬워집니다. IDE도 모든 경우를 “인지”하고 자동완성이나 분석으로 도움을 줄 수 있습니다.
3. Sealed 클래스 활용 실전 예제
예제 1: 기하 도형
public sealed interface Shape permits Circle, Rectangle, Square {}
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) { this.radius = radius; }
}
public final class Rectangle implements Shape {
public final double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
}
public final class Square implements Shape {
public final double side;
public Square(double side) { this.side = side; }
}
이제 pattern matching이 있는 switch를 안전하게 사용할 수 있습니다:
Shape shape = new Circle(5);
switch (shape) {
case Circle c -> System.out.println("반지름 " + c.radius + "인 원");
case Rectangle r -> System.out.println("직사각형 " + r.width + "x" + r.height);
case Square s -> System.out.println("한 변이 " + s.side + "인 정사각형");
}
예제 2: 금융 트랜잭션
public sealed interface Transaction permits Deposit, Withdraw, Transfer {}
public final class Deposit implements Transaction {
public final double amount;
public Deposit(double amount) { this.amount = amount; }
}
public final class Withdraw implements Transaction {
public final double amount;
public Withdraw(double amount) { this.amount = amount; }
}
public final class Transfer implements Transaction {
public final double amount;
public final String toAccount;
public Transfer(double amount, String toAccount) {
this.amount = amount;
this.toAccount = toAccount;
}
}
이제 핸들러에서 어떤 트랜잭션 타입도 빠뜨리지 않았다는 확신을 가질 수 있습니다:
Transaction tx = new Transfer(100, "ACC123");
switch (tx) {
case Deposit d -> System.out.println("입금: " + d.amount);
case Withdraw w -> System.out.println("출금: " + w.amount);
case Transfer t -> System.out.println("이체: " + t.amount + " 계정 " + t.toAccount + "로");
}
예제 3: non-sealed가 섞인 제한된 계층
public sealed class Notification permits EmailNotification, SmsNotification, PushNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
public final class PushNotification extends Notification {}
// 이제 누구나 SmsNotification을 상속할 수 있습니다
public class ViberNotification extends SmsNotification {}
4. Sealed 클래스의 특징, 제한 사항, 주의점
한정자 요구사항
- Sealed 클래스는 permits로 모든 하위 타입을 명시해야 합니다.
- 모든 하위 타입은 final이거나 sealed이거나 non-sealed이어야 합니다.
- 하위 타입은 같은 파일에서 선언되거나 컴파일러가 볼 수 있어야 합니다.
추상 sealed 클래스
Sealed 클래스는 추상 클래스일 수도, interface일 수도, 일반 클래스일 수도 있습니다. 예:
public sealed abstract class Expr permits Const, Add, Mul {}
다른 한정자와의 호환성
- Sealed 클래스를 final이나 non-sealed로 선언할 수는 없습니다.
- interface도 sealed가 될 수 있습니다(매우 유용!).
record 클래스와의 사용
record 클래스는 sealed 클래스의 하위 타입이 될 수 있으며, final로 선언되어 있어야 합니다(기본적으로 record는 항상 final).
public sealed interface Expr permits Const, Add, Mul {}
public record Const(int value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Mul(Expr left, Expr right) implements Expr {}
5. 유용한 팁
Sealed 클래스와 pattern matching: 어떤 관계인가
Sealed 클래스의 핵심은 “완전한(exhaustive) pattern matching”입니다. 이름은 거창하지만 동작은 간단합니다. 컴파일러가 가능한 모든 경우를 알고 있으므로 switch에서 default 없이도 안심하고 작성할 수 있습니다:
Expr expr = ...;
switch (expr) {
case Const c -> ...
case Add a -> ...
case Mul m -> ...
}
어떤 경우를 빠뜨리면 컴파일러가 프로젝트 빌드를 허용하지 않습니다 — 매우 편리하고 안전합니다.
실전 적용 사례
Sealed 클래스가 정말 유용한 곳은? 선택지가 고정된 모든 상황입니다:
- 연산 결과: Success, Error(위 예시처럼).
- 주문 상태: New, Paid, Cancelled.
- 파서용 추상 구문 트리(AST).
- API 응답: Ok, NotFound, Error.
- 시스템 이벤트: UserLoggedIn, UserLoggedOut, UserRegistered.
6. Sealed 클래스로 작업할 때 자주 발생하는 실수
오류 1: permits에 모든 하위 타입을 나열하지 않음.
필요한 하위 타입을 모두 permits로 열거하지 않으면 컴파일러가 즉시 오류를 보고합니다. 예를 들어 permits Circle, Rectangle까지만 쓰고 Square를 빠뜨렸는데 실제로 해당 클래스가 존재한다면 오류가 발생합니다.
오류 2: 하위 타입이 final, sealed 또는 non-sealed가 아님.
하위 타입이 필요한 한정자로 선언되지 않으면 컴파일러가 “Class must be either final, sealed or non-sealed” 오류를 냅니다.
오류 3: 하위 타입이 다른 파일에 선언되어 컴파일러가 볼 수 없음.
permits에 열거된 모든 클래스는 컴파일러가 접근 가능해야 합니다 — 같은 파일이거나 적어도 같은 패키지에 있어야 합니다.
오류 4: switch에서 모든 경우를 처리하지 않음.
Sealed 클래스를 switch의 pattern matching과 함께 사용하면서 어떤 경우를 빠뜨리면, 컴파일러가 빌드를 허용하지 않습니다. 이는 모든 경우를 놓치지 않도록 돕는 긍정적인 제약입니다.
오류 5: permits에 없는 타입으로 sealed 클래스를 상속하려 함.
permits에 지정되지 않은 하위 클래스를 만들려고 하면 “is not allowed to extend sealed class” 오류가 발생합니다.
오류 6: 오래된 JDK 버전에서 sealed 클래스를 사용하려 함.
Sealed 클래스는 Java 17부터 도입되었습니다. 더 오래된 버전에서 사용하려 하면 컴파일 오류가 발생하거나 프로젝트를 아예 빌드할 수 없습니다.
GO TO FULL VERSION