보일러플레이트를 없앤 Java의 세 가지 타입 — record, sealed, enum

Point 클래스를 만든다고 하자. x, y 필드, 생성자, getter, equals, hashCode, toString — Java에서 불변 데이터 객체 하나를 제대로 만들려면 50줄이 필요했다. Lombok @Value로 줄여봤지만, 그것은 서드파티 라이브러리에 대한 의존이었다.

Java 16에 record가 들어왔다. public record Point(double x, double y) {} — 한 줄. 컴파일러가 나머지를 다 만들어 준다. (JEP-395) 왜 Java는 30년이나 지나서야 이 한 줄을 가능하게 했을까? 이 글은 record, sealed, enum 세 가지 현대 타입이 기존 코드를 어떻게 바꾸는지 풀어간다.

record — 불변 데이터 객체의 한 줄 선언

record는 불변 데이터를 간결하게 표현하기 위한 Java 16+(정식) 타입이다. (JEP-395) (JLS §8.10)

// Java 25 — record 선언
public record Point(double x, double y) {}

이 한 줄이 컴파일러에 의해 다음으로 확장된다:

// Java 25 — 컴파일러가 생성하는 코드 (개념적)
public final class Point extends Record {
    private final double x;
    private final double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double x() { return x; }        // accessor (getX() 아님!)
    public double y() { return y; }

    @Override
    public boolean equals(Object o) { ... }    // 모든 필드 비교
    @Override
    public int hashCode() { ... }              // 모든 필드 기반
    @Override
    public String toString() { ... }           // "Point[x=1.0, y=2.0]"
}

자동 생성되는 것:

  • 모든 필드를 받는 canonical constructor (표준 생성자)
  • 각 필드의 accessorx(), y() (JavaBeans 스타일 getX()가 아님)
  • 모든 필드를 기반으로 한 equals, hashCode, toString

자동 생성되지 않는 것:

  • Comparable 구현 (필요하면 직접)
  • Serializable 구현 (필요하면 직접)
  • 유효성 검증 (직접 추가)

compact constructor — 검증 추가

record의 생성자에 검증 로직을 넣으려면 compact constructor 형식을 쓴다:

// Java 25 — compact constructor with validation
public record EmailAddress(String localPart, String domain) {
    public EmailAddress {   // 매개변수 목록 없음 (compact)
        if (localPart == null || domain == null) {
            throw new NullPointerException("null 불가");
        }
        if (localPart.isEmpty() || domain.isEmpty()) {
            throw new IllegalArgumentException("빈 문자열 불가");
        }
        // this.localPart = localPart; 는 컴파일러가 마지막에 자동 삽입
    }
}

compact constructor에서는 매개변수(이름만)를 명시하지 않는다 — 컴파일러가 자동으로 할당(this.x = x)한다. 개발자는 검증만 작성하면 된다.

record의 제약

  • final — 상속 불가. record는 암묵적으로 final.
  • 필드는 암묵적으로 private final — 불변.
  • 추가 인스턴스 필드 불가 — 헤더에 선언된 필드만 가짐.
  • extends 불가java.lang.Record를 암묵적으로 상속.

record에 추가 메서드를 정의할 수 있다 — 필드가 아니라 메서드는 자유롭게 추가 가능. 단, 이 메서드는 equals/hashCode 계산에 포함되지 않는다.

// Java 25 — record에 메서드 추가
public record Money(long amount, String currency) {
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화 불일치");
        }
        return new Money(amount + other.amount, currency);
    }

    // static factory
    public static Money usd(long amount) {
        return new Money(amount, "USD");
    }
}

sealed — 닫힌 상속 계층 (Java 17+, JEP-409)

sealed는 상속을 명시적으로 허용된 타입으로만 제한한다. (JEP-409) (JLS §8.1.2)

// Java 25
public sealed abstract class Shape
    permits Circle, Rectangle, Triangle {}

public final class Circle extends Shape { ... }
public final class Rectangle extends Shape { ... }
public final class Triangle extends Shape { ... }

// class Hexagon extends Shape { ... }  // 컴파일 에러: permits에 없음

sealed의 세 가지 하위 타입 선언 옵션:

키워드 의미
final 더 이상 상속 불가
sealed 또 다른 sealed 계층 (permits 명시)
non-sealed 열림 — 이 트리 아래는 누구나 상속 가능
// Java 25 — sealed 계층의 혼합
sealed abstract class Event permits LoginEvent, LogoutEvent, SystemEvent {}
final class LoginEvent extends Event { ... }
final class LogoutEvent extends Event { ... }
non-sealed class SystemEvent extends Event { }   // SystemEvent는 열림
class DiskFullEvent extends SystemEvent { }       // OK: SystemEvent가 non-sealed

sealed의 가치는 switch expression의 exhaustiveness 검사와 결합될 때 발휘된다. 모든 하위 타입을 알고 있으므로, default 없이도 컴파일러가 완전성을 검증한다. 새 하위 타입을 추가하면 관련 switch가 컴파일 에러로 알려준다.

enum — 타입 안전 열거 (Java 5+)

enum은 미리 정의된 상수 인스턴스의 집합이다. (JLS §8.9) C/C++의 단순한 정수 매크로가 아니라, 각 상수가 싱글톤 객체다.

// Java 25 — 기본 enum
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
Day today = Day.WEDNESDAY;
System.out.println(today.name());        // WEDNESDAY
System.out.println(today.ordinal());     // 2 (선언 순서, 0부터)
System.out.println(Day.valueOf("FRIDAY"));  // Day.FRIDAY
System.out.println(Arrays.toString(Day.values()));  // 모든 상수

필드와 메서드를 가진 enum

// Java 25 — enum with fields and methods
public enum HttpStatus {
    OK(200, "성공"),
    NOT_FOUND(404, "찾을 수 없음"),
    INTERNAL_ERROR(500, "서버 오류");

    private final int code;
    private final String message;

    // 생성자 (private이 유일 — 외부에서 인스턴스화 불가)
    HttpStatus(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }

    public boolean isError() {
        return code >= 400;
    }
}
// Java 25
HttpStatus status = HttpStatus.NOT_FOUND;
System.out.println(status.getCode());      // 404
System.out.println(status.isError());      // true

enum에 추상 메서드 — 상수별 구현

// Java 25 — 상수마다 다른 동작
public enum Operation {
    PLUS {
        @Override public double apply(double a, double b) { return a + b; }
    },
    MINUS {
        @Override public double apply(double a, double b) { return a - b; }
    },
    TIMES {
        @Override public double apply(double a, double b) { return a * b; }
    },
    DIVIDE {
        @Override public double apply(double a, double b) { return a / b; }
    };

    public abstract double apply(double a, double b);
}
// Java 25
double result = Operation.PLUS.apply(3, 4);   // 7.0

enum의 상수별 메서드 구현은 각 상수가 익명 하위 클래스의 인스턴스가 되는 것으로 구현된다. Operation.PLUS.getClass()Operation$1 같은 이름을 반환한다. (JLS §8.9.1)

enum과 switch

// Java 25 — enum switch expression
String type = switch (today) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "평일";
    case SATURDAY, SUNDAY -> "주말";
    // default 불필요 — enum의 모든 값을 처리했으므로
};

enum은 컴파일 타임에 모든 상수를 알 수 있으므로, switch expression에서 default 없이도 exhaustiveness가 보장된다.

단, enum 상수가 나중에 추가되면 컴파일 에러가 아니라 런타임 에러(IncompatibleClassChangeError 또는 default로 빠짐)가 발생할 수 있다. enum 정의와 switch가 같은 컴파일 단위에 있으면 컴파일 에러로 잡히지만, 다른 모듈이면 런타임에 발견된다.

EnumSet과 EnumMap — enum에 최적화된 컬렉션

enum 상수를 모을 때 HashSet<Day> 대신 EnumSet<Day>를 쓰면 성능이 극적으로 좋아진다. (EnumSet API)

// Java 25 — EnumSet (비트 벡터 기반, 극히 빠름)
import java.util.EnumSet;

EnumSet<Day> weekdays = EnumSet.of(
    Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY, Day.THURSDAY, Day.FRIDAY
);
EnumSet<Day> weekend = EnumSet.complementOf(weekdays);   // 나머지 상수

System.out.println(weekend);   // [SATURDAY, SUNDAY]

EnumSet은 내부적으로 비트 벡터(bit vector)로 구현된다 — enum 상수의 ordinal()을 비트 위치로 사용한다. 집합 연산(합집합, 교집합, 차집합)이 비트 연산으로 수행되어 HashSet보다 수십 배 빠르다.

// Java 25 — EnumMap (배열 기반, enum 키 전용)
import java.util.EnumMap;

EnumMap<Day, String> schedule = new EnumMap<>(Day.class);
schedule.put(Day.MONDAY, "스터디");
schedule.put(Day.FRIDAY, "배포");

EnumMap은 키의 ordinal()을 배열 인덱스로 사용한다 — 해시 계산이나 충돌 해결이 필요 없다. HashMap<Day, String>보다 빠르고 메모리도 적게 쓴다.

enum을 키나 요소로 사용하는 컬렉션에서는 항상 EnumSet/EnumMap을 우선한다. 성능 차이가 유의미하며, API도 더 간결하다(EnumSet.range(), EnumSet.complementOf() 등).

record + sealed + switch — 현대 Java의 타입 설계

세 가지를 조합하면 ADT(Algebraic Data Type)에 가까운 설계가 가능하다:

// Java 25 — ADT 스타일 타입 설계
sealed interface Result<T> permits Result.Success, Result.Failure {}

record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}

// 패턴 매칭으로 안전하게 분기
static String describe(Result<?> result) {
    return switch (result) {
        case Success<?> s -> "성공: " + s.value();
        case Failure<?> f -> "실패: " + f.error();
        // default 불필요 — sealed로 모든 하위 타입을 처리했음이 보장됨
    };
}

이 패턴이 Scala, Rust, Haskell의 ADT와 본질적으로 같다 — Java가 함수형 언어의 타입 설계를 수용한 결과다.

요약 — 이 글의 결론

  • record는 불변 데이터 객체의 보일러플레이트를 제거한다. 생성자, accessor, equals, hashCode, toString을 자동 생성. compact constructor로 검증 추가 가능. 단, final이고 추가 필드를 가질 수 없다.
  • sealed는 상속 계층을 닫는다. permits로 허용된 타입만 하위 타입 가능. switch expression의 exhaustiveness 검사와 결합하여 "새 타입 추가 → 컴파일 에러" 패턴을 만든다.
  • enum은 타입 안전 상수다. 단순 정수가 아니라 싱글톤 객체. 필드, 메서드, 상수별 구현을 가질 수 있다. equals/hashCode가 자동으로 올바르다(참조 동일성 = 값 동일성).
  • record + sealed + switch pattern matching = ADT. 현대 Java에서 함수형 언어 스타일의 타입 안전한 설계가 가능하다. 이것이 이후 장(람다, Stream, 동시성)의 타입적 기반이다.

생각해 볼 문제

  1. record의 accessor가 getX()가 아니라 x()인 이유는 무엇인가? JavaBeans 규약을 따르지 않으면 어떤 도구가 호환되지 않는가?
  2. record Money(long amount, String currency)에서 equals는 어떤 필드를 기반으로 동작하는가? amount만으로 equals를 정의하고 싶다면 어떻게 해야 하는가?
  3. enum 상수에 ==를 쓰는 것이 안전한 이유를 설명하라. equals()와의 차이는?
  4. sealed interface를 쓰지 않고 일반 interface에서 switch expression을 쓸 때 default가 필요한 이유는 무엇인가?
  5. record에 mutable 상태를 추가하는 것이 가능한가? (예: AtomicInteger 필드) 이것이 record의 "불변" 약속과 충돌하는가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 10. collections  (0) 2026.07.12
Java - 09. exception  (0) 2026.07.12
Java - 07. interface and abstract  (0) 2026.07.12
Java - 06. inheritance and polymorphism  (0) 2026.07.12
Java - 05. class and object  (0) 2026.07.12