Java 21의 Sealed 클래스와 Switch 패턴 매칭
1. 개요
고정된 변형만 있는 데이터 타입을 다룰 때가 있습니다. 예를 들어 결제 수단이 카드, 계좌이체, 또는 전자지갑만 가능하다면, 보통 if (x instanceof ...) 체인으로 처리합니다. 이 방식은 코드가 길고 번거롭습니다. 더 큰 문제는 경우를 빠뜨리기 쉬우면서 아무도 알아채지 못한다는 점입니다.
Java 21은 이 문제를 세 가지 기능으로 해결합니다: sealed 클래스(Java 17부터 정식), switch에서의 패턴 매칭(JEP 441), record 패턴(JEP 440). 이 글을 읽고 나면, 고정된 타입 집합을 모델링하고, 컴파일러가 모든 경우를 자동으로 검사하는 switch를 작성하며, 흔한 실수를 피하는 방법을 알게 될 것입니다.
2. 기존 접근 방식의 문제점
각 결제 수단별로 수수료를 계산해야 한다고 가정합시다. Java 21 이전에는 보통 이런 식으로 코드를 작성했습니다:
public long fee(Object payment) {
if (payment instanceof CardPayment c) {
return c.amount() * 2 / 100;
} else if (payment instanceof BankTransfer b) {
return 5_500;
}
throw new IllegalArgumentException("지원하지 않음: " + payment);
}
이 코드는 세 가지 문제가 있습니다:
EWallet을 빠뜨렸는데 코드는 여전히 컴파일됩니다. 오류는 런타임에만 드러납니다.- 끝의
throw문은 필수인데, 업무 로직상 이것이 발생해서는 안 됩니다. - 새로운 결제 수단을 추가할 때, 이런
if체인이 있는 모든 곳을 찾아 수정해야 합니다.
3. Sealed 클래스
3.1. Sealed 인터페이스 선언
Sealed 클래스나 sealed 인터페이스는 정확히 어떤 클래스가 이를 상속할 수 있는지 명시합니다:
public sealed interface Payment permits CardPayment, BankTransfer, EWallet {
long amount();
}
public record CardPayment(String cardNumber, long amount) implements Payment {}
public record BankTransfer(String bankCode, long amount) implements Payment {}
public record EWallet(String provider, long amount) implements Payment {}
sealed 키워드와 permits 절은 컴파일러에게 Payment가 정확히 세 가지 하위 타입만 가질 수 있음을 알립니다. 다른 클래스, 예를 들어 class Crypto implements Payment가 이를 구현하려 하면 컴파일 오류가 발생합니다.
만약 서브클래스들이 부모 클래스와 같은 파일에 있다면, permits를 생략할 수 있습니다. 컴파일러가 자동으로 목록을 유추합니다.
3.2. 서브클래스 규칙
Sealed 타입의 직접 서브클래스는 다음 세 가지 한정자 중 하나를 반드시 선언해야 합니다:
| 한정자 | 의미 |
|---|---|
final |
더 이상 상속될 수 없음 |
sealed |
permits로 제한을 계속함 |
non-sealed |
누구나 상속 가능 |
Record는 기본적으로 final이므로, 위의 예제는 추가로 작성할 필요가 없습니다. 일반 클래스의 경우, 명시해야 합니다:
public sealed abstract class Notification permits EmailNotification, SmsNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
// SmsNotification이 non-sealed이므로 유효
public class ZaloNotification extends SmsNotification {}
여기서 EmailNotification은 완전히 봉쇄됩니다. 반대로 SmsNotification은 누구나 상속할 수 있습니다. 결과적으로 Notification에 대해 switch할 때, SmsNotification 브랜치는 ZaloNotification도 포함합니다.
3.3. 패키지 및 모듈 제약
permits 목록의 클래스들은 반드시 부모 클래스와 같은 모듈에 있어야 합니다. 프로젝트가 모듈을 사용하지 않으면(unnamed module), 같은 패키지에 있어야 합니다. 이는 의도된 설계입니다: 부모 클래스의 작성자가 전체 계층 구조를 제어합니다.
4. Switch에서의 패턴 매칭
4.1. 타입 패턴
Java 21부터 case 레이블은 타입 패턴과 변수를 가질 수 있습니다:
public class FeeCalculator {
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
}
각 case는 타입을 확인하면서 동시에 이미 형변환된 변수를 할당합니다(c, b, w). 변수는 해당 브랜치 안에서만 유효합니다. 코드에는 default가 없고 throw도 없습니다.
4.2. Exhaustiveness: 컴파일러가 모든 경우 검사
Payment가 sealed이므로, 컴파일러는 정확히 세 가지 서브타입을 알고 있습니다. case EWallet w -> 0; 줄을 삭제하면 코드가 컴파일되지 않습니다:
FeeCalculator.java:5: error: the switch expression does not cover all possible input values
return switch (payment) {
^
이것이 가장 큰 장점입니다. QrPayment를 permits에 추가하면, 코드베이스의 모든 Payment에 대한 switch가 업데이트될 때까지 오류를 낼 것입니다. 컴파일러가 할 일 목록 역할을 합니다.
참고: 클래스가 별도로 컴파일되고 런타임에
switch가 모르는 서브타입이 나타나면, JVM은MatchException을 발생시킵니다. 컴파일러가 자동으로 이 브랜치를 삽입합니다.
4.3. when을 이용한 가드
패턴에 조건을 추가하려면 when을 사용합니다:
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() * 1 / 100;
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
천만 원 이상의 카드 거래는 1% 수수료, 그 이하는 2%입니다. 가드가 있는 브랜치는 CardPayment 전체를 포함하지 않습니다. 따라서 두 번째 case CardPayment c 브랜치는 여전히 필수입니다.
몇 가지 거래로 테스트해봅시다:
public class Main {
public static void main(String[] args) {
var calculator = new FeeCalculator();
List<Payment> payments = List.of(
new CardPayment("4111-1111", 500_000),
new CardPayment("4111-2222", 20_000_000),
new BankTransfer("VCB", 1_000_000),
new EWallet("momo", 200_000));
payments.forEach(p -> System.out.println(p + " -> 수수료 " + calculator.fee(p)));
}
}
출력:
CardPayment[cardNumber=4111-1111, amount=500000] -> 수수료 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> 수수료 200000
BankTransfer[bankCode=VCB, amount=1000000] -> 수수료 5500
EWallet[provider=momo, amount=200000] -> 수수료 0
4.4. case 순서와 dominance
switch는 위에서 아래로 패턴을 매칭합니다. 더 일반적인 브랜치가 더 구체적인 브랜치보다 위에 있으면, 아래 브랜치는 절대 실행되지 않습니다. 컴파일러가 이를 감지합니다:
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // 오류
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
error: this case label is dominated by a preceding case label
기억하기 쉬운 규칙: 구체적인 브랜치(가드 있거나 더 구체적인 타입)를 위에, 일반적인 브랜치를 아래에 배치합니다.
4.5. null 처리
기본적으로 switch가 null을 만나면 NullPointerException을 던집니다. 이전과 같은 동작입니다. null을 명시적으로 처리하려면 case null을 추가합니다:
public String label(Payment payment) {
return switch (payment) {
case null -> "결제 수단을 선택하지 않음";
case CardPayment c -> "카드";
case BankTransfer b -> "계좌이체";
case EWallet w -> "지갑 " + w.provider();
};
}
switch에 default 브랜치가 있을 때 case null, default ->로 병합할 수도 있습니다.
5. Record 패턴
Record 패턴을 사용하면 case 안에서 record의 컴포넌트를 바로 분해할 수 있습니다. 따라서 접근자를 호출할 필요가 없습니다:
public String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number, long amount) ->
"카드 ****" + number.substring(number.length() - 4) + ": " + amount;
case BankTransfer(var bank, var amount) -> "계좌이체 " + bank + ": " + amount;
case EWallet(var provider, var amount) -> "지갑 " + provider + ": " + amount;
};
}
CardPayment(String number, long amount)는 payment가 CardPayment일 때 매칭되고 동시에 number와 amount를 할당합니다. var를 사용하여 컴파일러가 타입을 추론하게 할 수 있습니다. new CardPayment("4111-1111", 500_000)의 경우 결과는 카드 ****1111: 500000입니다.
Record 패턴의 진정한 힘은 중첩 가능하다는 점입니다:
public sealed interface Transaction permits Charge, Refund {}
public record Charge(Payment payment) implements Transaction {}
public record Refund(Payment original, String reason) implements Transaction {}
public String audit(Transaction tx) {
return switch (tx) {
case Charge(CardPayment(var number, var amount)) when amount >= 10_000_000 ->
"검토 필요: 카드 " + number;
case Charge(Payment p) -> "수금 " + p.amount();
case Refund(var original, var reason) -> "환불 " + original.amount() + " (" + reason + ")";
};
}
첫 번째 브랜치는 큰 금액의 CardPayment를 포함한 Charge를 한 줄에 매칭합니다. 컴파일러는 여전히 중첩 패턴을 검사합니다: Charge(Payment p)는 모든 Charge를 포함하고, Refund(...)는 모든 Refund를 포함합니다.
참고: 이름 없는 패턴
_, 예를 들어case Refund(_, var reason), Java 21에서는 미리보기이며--enable-preview가 필요합니다. 이 기능은 Java 22부터 정식입니다. 프로젝트가 Java 21에 고정되어 있다면var를 사용하여 보통 변수명을 지으세요.
6. 테스트
Java 21과 JUnit 5, AssertJ를 설정해야 합니다. Spring Boot 3.x를 사용하면, spring-boot-starter-test에 둘 다 포함되어 있습니다. Maven 프로젝트의 경우:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
4.3절의 FeeCalculator와 5절의 describe에 대한 테스트:
class FeeCalculatorTest {
private final FeeCalculator calculator = new FeeCalculator();
@Test
void cardPaymentBelowThreshold_charges2Percent() {
assertThat(calculator.fee(new CardPayment("4111-1111", 500_000))).isEqualTo(10_000);
}
@Test
void cardPaymentAboveThreshold_charges1Percent() {
assertThat(calculator.fee(new CardPayment("4111-2222", 20_000_000))).isEqualTo(200_000);
}
@Test
void bankTransferAndWallet_haveFixedFee() {
assertThat(calculator.fee(new BankTransfer("VCB", 1_000_000))).isEqualTo(5_500);
assertThat(calculator.fee(new EWallet("momo", 200_000))).isZero();
}
@Test
void describe_masksCardNumber() {
assertThat(calculator.describe(new CardPayment("4111-1111", 500_000)))
.isEqualTo("카드 ****1111: 500000")
.doesNotContain("4111-");
}
}
테스트는 가드의 경계(천만 원 바로 아래와 위)에 집중해야 합니다. 이것이 case 순서를 잘못하기 쉬운 부분입니다. Exhaustiveness는 테스트할 필요가 없습니다: 컴파일러가 이미 처리합니다.
7. 흔한 실수 및 사용하지 말아야 할 때
Sealed 타입의 switch에 default 추가. 코드는 여전히 컴파일되지만, 주요 장점을 잃게 됩니다. 새로운 서브타입을 추가하면, 조용히 default로 떨어질 뿐 컴파일 오류를 발생시키지 않습니다. Sealed 타입의 경우, 모든 브랜치를 나열하고 default는 빼세요.
when 가드가 있는 모든 case가 타입을 포함한다고 착각. 모든 case CardPayment에 가드가 있으면, 컴파일러가 경우 부족을 보고합니다. 항상 가드 없는 브랜치, 또는 더 일반적인 브랜치가 끝에 필요합니다.
모든 곳에서 switch로 다형성 대체. 동작이 진짜로 객체 자신에 속하는 경우, 부모 클래스의 추상 메서드가 여전히 더 좋은 선택입니다. 패턴 매칭은 로직이 계층 구조 바깥에 있을 때 적합합니다. 예를 들어 수수료 계산, 렌더링, DTO 변환 등. 그리고 record에 모든 것을 밀어넣고 싶지 않을 때입니다.
참고: Sealed 클래스를 사용하지 말아야 할 때:
- 라이브러리나 플러그인 확장점을 작성할 때 사용자가 자신의 서브타입을 추가해야 하는 경우.
- JPA entity로서 지연 로딩이 필요한 경우. Hibernate는 프록시를 만들기 위해 entity를 상속하는데, sealed나 final이 이를 방해합니다.
- 서브타입이 여러 모듈에 흩어져 있는 경우. Sealed는 같은 모듈에, 또는 모듈을 사용하지 않으면 같은 패키지에 있어야 합니다.
JSON 직렬화. Jackson은 interface가 sealed라는 이유만으로 Payment를 정확한 서브타입으로 역직렬화하는 방법을 자동으로 알지 못합니다. 여전히 타입 정보를 설정해야 합니다. 예를 들어 @JsonTypeInfo와 @JsonSubTypes의 조합입니다.
8. 결론
Sealed 클래스를 사용하면 어떤 타입이 정확히 고정된 변형 집합만 가질 수 있음을 명확히 선언할 수 있습니다. Switch의 패턴 매칭과 record 패턴은 이 집합을 간결한 코드로 변환하면서 동시에 컴파일러가 모든 경우를 검사하게 합니다. when을 사용하여 조건을 추가하거나, case null로 null을 명시적으로 처리하거나, 중첩 패턴으로 데이터를 깊이 있게 탐색할 수 있습니다.
세 가지 핵심 원칙을 기억하세요: sealed 타입의 switch에서 default를 빼기, 구체적인 브랜치를 일반적인 브랜치보다 위에 배치하기, 그리고 sealed를 정말 계층 구조를 닫고 싶을 때만 사용하기.