본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev31

instanceof 분기가 계속 늘어나던 코드를 sealed와 레코드 패턴으로 정리

  • #Engineering Note
  • #Java

문제 발생

결제 수단마다 다르게 처리하는 코드가 instanceof 사슬로 자라 있었습니다. 새 수단을 추가할 때 이 분기를 빠뜨려도 컴파일은 통과했고, 런타임에 default로 떨어져서야 알게 됐습니다.

if (payment instanceof Card) {
    Card c = (Card) payment;
    return c.number();
} else if (payment instanceof Transfer) {
    Transfer t = (Transfer) payment;
    return t.account();
}
return "unknown";     // 새 수단이 조용히 여기로 온다

원인 분석

문제는 컴파일러가 "결제 수단이 몇 가지인지" 모른다는 것입니다. Payment 인터페이스는 누구나 구현할 수 있으므로, 컴파일러 입장에서는 가능한 경우가 무한합니다. 그래서 모든 경우를 다뤘는지 검사해 줄 방법이 없고, 개발자가 default로 방어할 수밖에 없습니다.

sealed는 이 정보를 타입에 적습니다. 허용된 구현을 명시하면 컴파일러가 경우의 수를 알게 됩니다.

여기에 Java 21의 두 기능이 더해집니다.

  • switch의 패턴 매칭 — 타입으로 분기하면서 변수를 바로 바인딩합니다. sealed 타입이면 모든 경우를 다뤘는지 컴파일러가 검사하므로 default가 필요 없습니다.
  • 레코드 패턴 — 매칭과 동시에 레코드를 구성 요소로 분해합니다. 중첩된 레코드도 한 번에 풉니다.

해결 방안

  1. 구현 범위를 타입에 적습니다.
public sealed interface Payment permits Card, Transfer, Point {}

public record Card(String number, String owner) implements Payment {}
public record Transfer(String account, Bank bank) implements Payment {}
public record Point(int amount) implements Payment {}
  1. switch로 분기하고 동시에 분해합니다. 캐스팅도 임시 변수도 없습니다.
String describe(Payment payment) {
    return switch (payment) {
        case Card(String number, var owner) -> owner + " / " + mask(number);
        case Transfer(var account, Bank(var name, var code)) -> name + " " + account;
        case Point(int amount) -> amount + "P";
    };
}

default가 없다는 점이 핵심입니다. Payment에 네 번째 구현을 추가하는 순간 이 switch가 컴파일 에러가 되어, 빠뜨린 자리를 컴파일러가 알려 줍니다.

  1. 조건을 붙여야 하면 when을 씁니다.
case Point(int amount) when amount <= 0 -> "사용 불가";
case Point(int amount) -> amount + "P";
  1. null을 명시적으로 다룹니다. 패턴 switch는 case null을 쓸 수 있습니다. 적지 않으면 NullPointerException이 나므로, null이 올 수 있다면 분기를 하나 두는 편이 낫습니다.
  2. sealed는 같은 모듈/패키지 제약이 있습니다. permits에 적은 타입은 같은 모듈(또는 이름 없는 모듈이면 같은 패키지)에 있어야 합니다. 공개 API로 확장을 허용할 타입에는 맞지 않습니다 — 닫아도 되는 도메인 모델에 쓰는 도구입니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.