Sealed Classes와 Pattern Matching 심화 — 대수적 데이터 타입을 자바에서 표현하기
함수형 언어에서 자연스러운 "이 값은 A 또는 B 또는 C 중 하나"라는 표현을, 자바에서도 컴파일러가 보장하게 할 수 있을까요?
Kotlin의 sealed class, Rust의 enum, Haskell의 대수적 데이터 타입처럼 "가능한 경우를 컴파일 타임에 확정"하는 것이 자바에서도 가능해졌습니다. Sealed Classes(Java 17)와 Pattern Matching for switch(Java 21)의 조합으로요.
Sealed Classes 기본
선언
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 final class Triangle implements Shape {
private final double a, b, c;
// 생성자, getter 등
}
sealed와 permits로 허용되는 하위 타입을 명시적으로 제한 합니다. permits에 없는 클래스는 이 인터페이스를 구현할 수 없습니다.
하위 타입의 modifier 규칙
sealed interface Shape permits Circle, Rectangle, Polygon {}
record Circle(double radius) implements Shape {} // record는 암묵적 final
final class Rectangle implements Shape { /* ... */ } // final: 상속 불가
sealed class Polygon implements Shape permits Triangle, Quadrilateral {}
// sealed: 제한된 상속
non-sealed class OpenShape implements Shape { /* ... */ } // non-sealed: 자유 상속
final: 더 이상 상속할 수 없음sealed: 다시 제한된 상속 계층 정의non-sealed: 봉인 해제, 누구나 상속 가능 (탈출구)
permits 생략
같은 파일에 하위 타입이 모두 정의되어 있으면 permits를 생략할 수 있습니다.
// Shape.java 안에 모두 정의
sealed interface Shape {
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
}
Pattern Matching for switch
기본 타입 패턴
// Java 21 정식
public double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.area();
};
// default 불필요 — sealed 타입의 모든 경우를 커버했으므로
}
컴파일러가 철저한 분기(exhaustiveness) 를 검증합니다. 새로운 하위 타입을 permits에 추가하면, 이 switch를 사용하는 모든 곳에서 컴파일 에러가 발생하여 누락을 방지합니다.
Record Pattern — 구조 분해
public String describe(Shape shape) {
return switch (shape) {
case Circle(var radius) ->
"반지름 " + radius + "인 원";
case Rectangle(var w, var h) ->
w + " x " + h + " 직사각형";
case Triangle t ->
"삼각형";
};
}
Circle(var radius)는 Record의 컴포넌트를 직접 분해하여 변수에 바인딩합니다. instanceof로 타입 체크하고 캐스팅하고 getter를 호출하는 코드가 한 줄로 줄어듭니다.
중첩 Record Pattern
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 int evaluate(Expr expr) {
return switch (expr) {
case Num(var v) -> v;
case Add(var l, var r) -> evaluate(l) + evaluate(r);
case Mul(var l, var r) -> evaluate(l) * evaluate(r);
};
}
// 중첩 패턴으로 특정 구조 매칭
public int optimize(Expr expr) {
return switch (expr) {
case Mul(Num(var a), Num(var b)) -> a * b; // 상수끼리의 곱셈 → 즉시 계산
case Add(Num(int a), Num(int b)) -> a + b; // 상수끼리의 덧셈 → 즉시 계산
default -> evaluate(expr); // 그 외는 일반 평가
};
}
Guarded Pattern (when 절)
public String classify(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "큰 원";
case Circle c when c.radius() > 10 -> "중간 원";
case Circle c -> "작은 원";
case Rectangle r when r.width() == r.height() -> "정사각형";
case Rectangle r -> "직사각형";
case Triangle t -> "삼각형";
};
}
when 절로 타입 매칭 후 추가 조건을 검사합니다. 순서가 중요합니다 — 더 구체적인 조건을 먼저 배치해야 합니다.
null 처리
public String describe(Shape shape) {
return switch (shape) {
case null -> "도형 없음";
case Circle c -> "원";
case Rectangle r -> "직사각형";
case Triangle t -> "삼각형";
};
}
Java 21부터 switch에서 case null을 직접 처리할 수 있습니다.
대수적 데이터 타입 (ADT)
합 타입 (Sum Type) — sealed interface
"A ** 또는** B ** 또는** C"를 표현합니다.
sealed interface Result<T> {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
}
곱 타입 (Product Type) — record
"A ** 와** B ** 와** C"를 표현합니다.
record User(String name, int age, String email) {}
// User = String × int × String
합 타입 + 곱 타입 = ADT
sealed interface JsonValue {
record JsonNull() implements JsonValue {}
record JsonBool(boolean value) implements JsonValue {}
record JsonNumber(double value) implements JsonValue {}
record JsonString(String value) implements JsonValue {}
record JsonArray(List<JsonValue> elements) implements JsonValue {}
record JsonObject(Map<String, JsonValue> members) implements JsonValue {}
}
public String toJson(JsonValue value) {
return switch (value) {
case JsonNull() -> "null";
case JsonBool(var b) -> String.valueOf(b);
case JsonNumber(var n) -> String.valueOf(n);
case JsonString(var s) -> "\"" + escape(s) + "\"";
case JsonArray(var elements) ->
elements.stream()
.map(this::toJson)
.collect(Collectors.joining(", ", "[", "]"));
case JsonObject(var members) ->
members.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\": " + toJson(e.getValue()))
.collect(Collectors.joining(", ", "{", "}"));
};
}
실무 활용 패턴
상태 머신
sealed interface OrderState {
record Created(LocalDateTime at) implements OrderState {}
record Paid(LocalDateTime at, String paymentId) implements OrderState {}
record Shipped(LocalDateTime at, String trackingNo) implements OrderState {}
record Delivered(LocalDateTime at) implements OrderState {}
record Cancelled(LocalDateTime at, String reason) implements OrderState {}
}
public OrderState transition(OrderState current, OrderEvent event) {
return switch (current) {
case Created c -> switch (event) {
case PaymentReceived p -> new Paid(now(), p.paymentId());
case CancelRequested r -> new Cancelled(now(), r.reason());
default -> throw new IllegalStateException("잘못된 이벤트");
};
case Paid p -> switch (event) {
case ShipmentStarted s -> new Shipped(now(), s.trackingNo());
case CancelRequested r -> new Cancelled(now(), r.reason());
default -> throw new IllegalStateException("잘못된 이벤트");
};
case Shipped s -> switch (event) {
case DeliveryConfirmed d -> new Delivered(now());
default -> throw new IllegalStateException("잘못된 이벤트");
};
case Delivered d -> throw new IllegalStateException("최종 상태");
case Cancelled c -> throw new IllegalStateException("최종 상태");
};
}
에러 핸들링
sealed interface AppError {
record NotFound(String resource, String id) implements AppError {}
record Unauthorized(String reason) implements AppError {}
record ValidationFailed(List<String> errors) implements AppError {}
record ServerError(Throwable cause) implements AppError {}
}
public ResponseEntity<?> toResponse(AppError error) {
return switch (error) {
case NotFound(var res, var id) ->
ResponseEntity.status(404).body(res + " " + id + " 없음");
case Unauthorized(var reason) ->
ResponseEntity.status(401).body(reason);
case ValidationFailed(var errors) ->
ResponseEntity.badRequest().body(Map.of("errors", errors));
case ServerError(var cause) ->
ResponseEntity.status(500).body("서버 오류");
};
}
instanceof 패턴 매칭
switch 외에도 instanceof에서 패턴 매칭을 사용할 수 있습니다.
// Java 16+
if (obj instanceof String s && s.length() > 5) {
System.out.println(s.toUpperCase());
}
// Record Pattern (Java 21+)
if (shape instanceof Circle(var radius) && radius > 0) {
System.out.println("유효한 원, 반지름: " + radius);
}
기존 코드와의 비교
// Before: instanceof 체인 (실수하기 쉬움)
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.width() * r.height();
}
// Triangle을 까먹어도 컴파일러가 모름!
// After: sealed + switch (컴파일러가 보장)
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.area();
// Triangle을 빼면 컴파일 에러!
};
주의할 점
permits에 지정된 클래스는 같은 패키지/모듈이어야 한다
permits에 다른 패키지의 클래스를 넣으면 컴파일 에러가 발생한다. 모듈 시스템을 쓰지 않는 프로젝트에서는 sealed 계층을 하나의 패키지에 모아두는 것이 안전하다.
non-sealed 하위 타입이 있으면 exhaustiveness가 깨진다
sealed 클래스의 하위 타입 중 하나라도 non-sealed이면, 그 타입을 상속한 임의의 클래스가 생길 수 있다. 이 경우 switch에서 default가 필요해지며, 컴파일 타임 안전성의 핵심 이점이 사라진다. non-sealed은 정말 필요한 경우에만 사용해야 한다.
Java 21 이전에는 switch 패턴 매칭이 preview다
Pattern Matching for switch는 Java 21에서 정식 도입되었다. Java 17에서는 sealed 클래스는 쓸 수 있지만, switch에서의 패턴 매칭은 preview 기능이라 --enable-preview 플래그가 필요하다.
정리
| 개념 | 역할 | 핵심 특성 |
|---|---|---|
sealed | 하위 타입을 permits로 제한 | 컴파일러가 모든 케이스를 알 수 있음 |
final / sealed / non-sealed | 하위 타입의 상속 전략 | 계층 종료 / 제한 상속 / 개방 |
| Pattern Matching for switch | 타입별 분기 처리 | exhaustiveness 체크, default 불필요 |
| Record Pattern | 객체 구조 분해 | case Circle(var radius)로 컴포넌트 추출 |
Guarded Pattern (when) | 추가 조건 분기 | 더 구체적인 조건을 먼저 배치해야 함 |
| sealed + record | ADT (합 타입 + 곱 타입) | 상태 머신, 에러 핸들링, 커맨드 패턴에 효과적 |