본문으로 건너뛰기
Advertisement

7.7 Sealed 클래스 (Java 17+)

Sealed 클래스(봉인된 클래스) 는 Java 17에서 정식 도입된 기능으로, 어떤 클래스가 해당 클래스를 상속(extends) 또는 구현(implements) 할 수 있는지를 명시적으로 제한합니다. 이를 통해 상속 계층을 완전히 통제하고, 컴파일러가 모든 경우를 검사할 수 있게 됩니다.

1. 왜 Sealed 클래스가 필요한가?

기존에는 final 클래스로 상속을 완전히 금지하거나, 아무 제한 없이 모든 클래스가 상속할 수 있었습니다. 그 사이 어딘가 — "특정 클래스들만 상속 가능" 이라는 제어가 불가능했습니다.

기존 선택지:
- final class Shape → 아무도 상속 못 함 (너무 제한적)
- class Shape → 누구나 상속 가능 (너무 개방적)
- sealed class Shape → 지정한 클래스만 상속 가능 (딱 적당!) ← Java 17

2. 기본 문법

// sealed 클래스: permits로 허용할 자식 클래스를 명시
public sealed class Shape permits Circle, Rectangle, Triangle {
abstract double area();
}

// 자식 클래스에는 3가지 옵션이 있음
final class Circle extends Shape { // final: 더 이상 확장 불가
private final double radius;
Circle(double r) { this.radius = r; }
@Override double area() { return Math.PI * radius * radius; }
}

non-sealed class Rectangle extends Shape { // non-sealed: 다시 자유롭게 확장 가능
protected double width, height;
Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override double area() { return width * height; }
}

sealed class Triangle extends Shape // sealed: 다시 봉인 (추가로 permits 필요)
permits RightTriangle {
protected double base, height;
Triangle(double b, double h) { this.base = b; this.height = h; }
@Override double area() { return 0.5 * base * height; }
}

final class RightTriangle extends Triangle { // Triangle의 유일한 자식
RightTriangle(double b, double h) { super(b, h); }
}

3. 허용되는 자식 클래스 수식어

수식어의미
final이 클래스도 봉인. 더 이상 상속 불가
sealed이 클래스도 봉인하되, permits로 자신의 자식 클래스 지정
non-sealed봉인 해제. 누구든 상속 가능 (일부러 개방할 때)

4. Sealed 인터페이스

클래스뿐 아니라 인터페이스에도 sealed를 적용할 수 있습니다.

// 도형 계층의 완전한 예제
public sealed interface Shape
permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

recordfinal이므로 sealed 인터페이스의 구현체로 바로 사용할 수 있습니다.

5. 패턴 매칭과의 시너지 (Java 21)

Sealed 클래스의 진정한 가치는 switch 패턴 매칭과 결합할 때 나타납니다. 컴파일러가 "모든 경우가 처리됐는가"를 컴파일 타임에 검증합니다!

public sealed interface Expr permits Num, Add, Mul {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}

public class Calculator {
// sealed + switch = 완전성 검사 (Exhaustiveness Check)
static int eval(Expr expr) {
return switch (expr) {
case Num(int v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
// default 불필요! sealed이므로 컴파일러가 모든 경우를 알고 있음
// 만약 Expr 구현체 추가 시 여기서 컴파일 에러 → 실수 방지!
};
}

public static void main(String[] args) {
// (2 + 3) * 4
Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
System.out.println(eval(expr)); // 20
}
}

6. 실전 활용: 결과 타입 (Result Type)

함수의 성공/실패를 타입으로 표현하는 패턴입니다 (Rust의 Result<T, E>와 유사).

public sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message, Exception cause) implements Result<T> {}

static <T> Result<T> ok(T value) { return new Ok<>(value); }
static <T> Result<T> err(String msg) { return new Err<>(msg, null); }
static <T> Result<T> err(String msg, Exception e) { return new Err<>(msg, e); }
}

public class UserService {
Result<String> findUser(int id) {
if (id <= 0) return Result.err("ID는 양수여야 합니다.");
if (id == 999) return Result.err("사용자를 찾을 수 없습니다.");
return Result.ok("홍길동"); // 성공
}

void run() {
switch (findUser(1)) {
case Result.Ok<String>(var name) -> System.out.println("찾음: " + name);
case Result.Err<String>(var msg, var e)-> System.out.println("실패: " + msg);
}
}
}

고수 팁

Sealed 클래스 활용 시나리오:

  1. 대수적 데이터 타입(ADT): 상태나 명령을 타입으로 표현 (e.g., sealed interface Command permits Save, Delete, Update)
  2. API 설계: 라이브러리의 공개 API에서 구현 가능한 타입을 제한할 때
  3. 도메인 모델: 비즈니스 규칙으로 존재 가능한 상태를 코드로 표현

주의: Sealed 클래스와 그 permits 대상 클래스는 반드시 같은 패키지(또는 같은 모듈) 안에 있어야 합니다.

Advertisement