현대적 자바: Records와 Sealed Classes로 데이터 모델링의 격을 높이다

최근 자바 생태계는 '장황한 언어'라는 오명을 벗기 위해 대대적인 변화를 거듭하고 있습니다. 특히 데이터 중심의 프로그래밍이 강조되면서, 불필요한 보일러플레이트(반복적인 코드)를 줄이고 데이터의 구조를 명확히 정의하려는 노력이 계속되고 있죠. 그 중심에 서 있는 것이 바로 Records와 Sealed Classes입니다.
이 두 기능은 단순히 타이핑을 줄여주는 도구가 아닙니다. 코드의 의도를 명확히 하고, 컴파일러가 우리의 로직을 검증할 수 있게 돕는 강력한 무기입니다.
1. Deep Dive: 왜 Records와 Sealed Classes인가?
과거의 자바에서 데이터 운반 객체(DTO) 하나를 만들려면 Getter, Setter, equals, hashCode, toString까지 수십 줄의 코드가 필요했습니다. 이는 가독성을 떨어뜨리고 버그가 숨어들 틈을 만들었죠.
Records: 불변 데이터의 표준화
Records는 "데이터를 보관하기 위한 클래스"라는 본질에 집중합니다. 클래스 선언 시 필드만 정의하면 컴파일러가 모든 관례적인 메서드를 자동으로 생성해 줍니다. 핵심은 **불변성(Immutability)**입니다. 한 번 생성된 데이터는 변하지 않는다는 신뢰를 코드 전체에 부여합니다.
Sealed Classes: 도메인의 경계를 긋다
Sealed Classes는 상속 계층을 엄격하게 제한합니다. "이 클래스는 A, B, C 외에는 상속받을 수 없다"라고 못 박는 것이죠. 이는 현실 세계의 비즈니스 규칙을 코드에 그대로 이식할 수 있게 해줍니다.
비유로 이해하기
- Records는 '밀봉된 영수증'과 같습니다. 품목과 가격이 적혀 있고, 한 번 발행되면 내용을 수정할 수 없으며 그 자체로 완전한 정보를 담고 있죠.
- Sealed Classes는 '결제 수단'이라는 상자입니다. 이 상자 안에는 오직 '카드', '현금', '포인트'라는 세 가지 카드만 들어올 수 있도록 입구를 제한해둔 것과 같습니다. 다른 엉뚱한 것이 들어올 여지를 아예 차단하는 것이죠.
2. Hands-on: 실전 이커머스 주문 시스템 구현
실제 이커머스 환경에서 주문의 상태와 결제 수단을 모델링하는 예제를 통해 이 기술들을 어떻게 활용하는지 살펴보겠습니다.
데이터 구조 설계
// 결제 수단을 Sealed Interface로 정의하여 종류를 제한
public sealed interface PaymentMethod permits Card, Cash, Point {}
// 각 결제 수단은 Record로 구현하여 불변 데이터로 관리
public record Card(String cardNumber, String bankName) implements PaymentMethod {}
public record Cash(long amount) implements PaymentMethod {}
public record Point(String userId, long pointBalance) implements PaymentMethod {}
// 주문 정보를 담는 Record
public record Order(
String orderId,
long totalPrice,
PaymentMethod paymentMethod // Sealed Type 사용으로 안전성 확보
) {}
비즈니스 로직 적용 (Pattern Matching)
public class OrderProcessor {
public void processOrder(Order order) {
// Sealed Class와 Pattern Matching을 결합하면 switch 문에서 모든 케이스를 강제할 수 있음
String result = switch (order.paymentMethod()) {
case Card c -> String.format("카드 결제 진행: %s (%s)", c.bankName(), c.cardNumber());
case Cash m -> "현금 결제 접수: " + m.amount() + "원";
case Point p -> "포인트 차감 결제: 사용자ID " + p.userId();
// default 문이 필요 없음! 모든 PaymentMethod 구현체를 다뤘기 때문
};
System.out.println("주문 처리 상태: " + result);
}
}
💡 트러블슈팅 팁
- 컴파일 에러 발생 시: 만약 PaymentMethod에 Coupon이라는 구현체를 새로 추가했는데 switch 문에서 처리하지 않았다면, 자바 컴파일러는 즉시 에러를 발생시킵니다. 이는 런타임에 발생할 수 있는 '처리되지 않은 케이스' 오류를 사전에 방지해 주는 강력한 안전장치입니다.
- Record의 확장성: Record는 상태를 상속받을 수 없습니다. 만약 공통 필드가 너무 많아 상속 구조가 반드시 필요하다면 일반 클래스를 고려해야 하지만, 대부분의 데이터 전달 객체는 Record만으로도 충분합니다.
3. Trade-offs: 고려해야 할 사항
모든 기술에는 비용이 따릅니다. 무조건적인 도입보다는 상황에 맞는 판단이 필요합니다.
- Records의 한계: 모든 필드가 final입니다. 즉, 객체 생성 후 상태를 변경해야 하는 JPA 엔티티(Entity) 등에는 적합하지 않을 수 있습니다. 주로 DTO, VO, 메시지 객체에 최적화되어 있습니다.
- Sealed Classes의 폐쇄성: 라이브러리 제작자라면 사용자가 자유롭게 확장해야 하는 인터페이스에는 Sealed를 사용해서는 안 됩니다. 하지만 도메인 로직처럼 비즈니스 규칙이 명확한 경우에는 최고의 선택입니다.
4. 요약 및 제언
Records와 Sealed Classes는 자바를 더욱 안전하고 간결한 언어로 만들어 주었습니다.
- Records를 통해 데이터의 투명성과 불변성을 확보하세요.
- Sealed Classes를 통해 비즈니스 도메인의 유한한 상태를 엄격하게 관리하세요.
- 이 둘을 결합하면 **대수적 데이터 타입(Algebraic Data Types)**의 이점을 자바에서도 누릴 수 있습니다.
여러분의 프로젝트에서 가장 먼저 Record로 전환할 수 있는 데이터 객체는 무엇인가요? 반복적인 코드를 제거하는 것만으로도 비즈니스 로직의 핵심이 훨씬 선명하게 보이기 시작할 것입니다.
현대적 자바의 문법을 활용해 더 견고한 아키텍처를 설계해 보시길 바랍니다.