데이터를 전달하기 위한 DTO(Data Transfer Object) 클래스를 만들 때, 우리는 항상 getter, setter, equals, hashCode, toString을 기계적으로 만들어야 했습니다. (Lombok 라이브러리가 필수였던 이유).
- 핵심 개념: Record는 "불변(Immutable) 데이터 객체"를 아주 쉽게 생성할 수 있게 해주는 새로운 클래스 타입입니다.
- 핵심 장점: 레코드로 만들면 필수 메서드가 자동으로 생성됩니다. 간결성 & 불변성이라는 장점을 가져갈 수 있어요.
- 근데 이렇게 불변 데이터 객체라고만 말할 수 있을까? 놉!
비교하기
이름과 나이 데이터를 담아서 전달하는 간단한 DTO(Data Transfer Object) 클래스를 만들 때를 비교해보겠습니다.
❌ Before (Java 17 이전): 고작 데이터 2개를 담는 클래스인데, 생성자부터 getter, equals, hashCode, toString까지 수십 줄의 보일러플레이트(상투적인) 코드가 필요했습니다.
public class UserDto {
private final String name;
private final int age;
public UserDto(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* 생략 */ }
@Override
public int hashCode() { /* 생략 */ }
@Override
public String toString() { /* 생략 */ }
}
✅ After (Java 17 이후 - Record 도입): 마법처럼 단 한 줄로 끝! 불변성 보장과 모든 필수 메서드 생성을 컴파일러가 알아서 처리해줍니다.
public record UserDto(String name, int age) {
}
공식문서 짚어보기
openjdk에 따르면, 자바는 의례적인 절차가 너무 많고 특히 Data Carrier를 생성할 때 그 특징이 두드러집니다. 그래서 이러한 불만을 해결하기 위해서 record를 만들었습니다. record의 목표는 단순히 코드를 줄이는 것은 아닙니다. “데이터를 데이터 그 자체로 모델링”하는 것이 주된 목적입니다. 올바른 의미를 부여하면, 코드는 자연스럽게 줄어든다는 철학을 담고 있습니다.
그래서 record는 불변인가?
Record는 얕은 불변 (Swallowly Immutable)데이터를 담기 위한 제한적 형태의 클래스입니다.
- 상자는 바꿀 수 없지만, 상자 안에 담긴 내용물 (객체)이 가변적 (List, Map, Set이나 사용자 정의 객체)이라면 그 내용물까지는 지켜주지 못한다는 의미입니다.
- record의 모든 컴포넌트는 암묵적으로 final이다. 즉, 한 번 할당된 참조값(주소)는 절대로 바꿀 수 없다는 뜻!
- 예를 들어 record 안에 가변 객체 List가 들어있다고 하면, record는 이 리스트가 가리키는 주소값이 바뀌는 것은 막아주지만, 그 주소지에 있는 실제 리스트의 내부 데이터가 수정되는 것은 막아주지 못합니다.
- 왜 깊은 불변이 아닌가? → record의 성능과 유연성 때문이라고 생각합니다. 내부 객체까지 강제로 불변으로 만들려면, 컴파일러가 모든 참조를 추적하면서 복사본을 만들어야 하는데 비효율적입니다. 개발자에게 이 문제를 던집니다.
- 개발자는 진정한 불변 객체로 만들고 싶다면… 가변 객체를 불변 객체로 변환해서 저장하는 방법을 선택할 수 있습니다.
제약 사항
- 제약 사항은 Data Carrier로서의 역할을 명확히 하기 위함입니다.
- 상속 불가 (extends)
- 상태 외 인스턴스 필드 불가 (정적 static 필드는 가능)
- 변경 불가: 암묵적으로 final 클래스입니다. abstract가 될 수 없고 모든 컴포넌트 역시 암묵적으로 final입니다.
- 제약 사항 외에는 일반 클래스처럼 인터페이스 구현(implements), 제네릭 사용, 정적 메서드 선언, 커스텀 메스 작성 등 모두 가능
튜플 대신 Record를 사용한 이유
- (openjdk는)이름이 없는 튜플을 사용할 수도 있었지만 다음의 이유로 record를 선택했습니다.
- 이름의 중요성: 자바는 명확한 이름을 중요하게 생각합니다. String, String 튜플보다 firstName, lastName을 가진 클래스가 훨씬 명확하고 안전합니다.
- 튜플은 생성자를 통한 데이터 유효성 검증 기능이 없습니다.
- Record는 데이터와 관련된 행위를 한 곳에 묶어둘 수 있지만, 순수 데이터인 튜플은 불가능합니다.
공식 문서에서 추천하는 다른 기능과의 시너지
- 저도 생각하고 써본적은 없는데 추천하는 시너지라고 하니까 좀 보기는 해야겠습니다…
Sealed Types → 데이터의 종류를 제한한다.
- 인터페이스는 누구나 구현할 수 있지만, sealed는 내 자식은 얘네뿐이다!라고 제한해줍니다.
- 예를 들어, 지불방식은 카드와 계좌이체 두 종류밖에 없다고 정해줄 수 있습니다.
Pattern Matching → 데이터를 꺼내는 형식을 자동화한다.
- 기존에는 타입을 확인하고, 강제로 변환하고, 메서드를 호출하는 3단계 과정이 필요했습니다.
- public void process(PaymentMethod method) { if (method instanceof Card) { // 1. 너 카드 맞니? (타입 확인) Card card = (Card) method; // 2. 그럼 카드로 변신해! (형변환) String num = card.cardNumber(); // 3. 카드 번호 좀 꺼내줘 (데이터 추출) System.out.println("카드 결제: " + num); } else if (method instanceof Transfer) { Transfer transfer = (Transfer) method; System.out.println("계좌 이체: " + transfer.accountNo()); } }
- record는 구조가 투명하기 때문에, 컴파일러가 상자를 열어서 내용물을 바로 변수에 담아줍니다.
// 딱 '카드'와 '계좌이체' 두 종류만 있다고 선언 (Sealed)
public sealed interface PaymentMethod permits Card, Transfer {}
public record Card(String cardNumber, String bank) implements PaymentMethod {}
public record Transfer(String accountNo) implements PaymentMethod {}
// 패턴매칭
public void process(PaymentMethod method) {
var result = switch (method) {
// 'Card'라는 상자를 열어서 cardNumber와 bank를 바로 변수로 꺼냄 (패턴 매칭)
case Card(var num, var bank) -> "카드번호 " + num + "로 결제";
// 'Transfer' 상자를 열어서 accountNo를 바로 꺼냄
case Transfer(var acc) -> "계좌 " + acc + "에서 이체";
};
}
만약에 switch문에서 하나라도 빼먹은 게 있으면 컴파일 시점에 에러를 잡아줘서 default를 따로 쓸 필요가 없어요.
또 만약에 여기서 gift card라는 결제 수단이 생겨서 permit에 추가한다면?
결제수단이 포함된 모든 switch문에 gift card를 추가하지 않은 곳은 컴파일 에러가 납니다.
그래서 결국 sealed가 우리의 비즈니스 로직을 나 대신 감시해주고, default…runtime exception을 날릴 필요가 없어져요.
그리고 누가봐도 결제수단의 의도를 잘 파악할 수 있음!