레코드(Record) — 불변 데이터 객체를 간결하게 만드는 법
DTO 클래스를 만들 때마다 필드, 생성자, getter, equals, hashCode, toString을 반복해서 쓰고 있다면? Lombok 없이도 해결할 수 있습니다.
이게 뭔가요?
Record 는 Java 16에서 도입된 불변 데이터 전용 클래스입니다. 필드를 선언하면 생성자, getter, equals(), hashCode(), toString()이 자동 생성됩니다.
왜 필요한가요?
데이터를 담기만 하는 클래스에 보일러플레이트가 너무 많습니다:
// 기존: 30줄 이상
public class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
// equals, hashCode, toString...
}
// Record: 1줄
public record Point(int x, int y) { }
자동 생성되는 것들
public record User(Long id, String name, String email) { }
이 한 줄로 다음이 모두 생성됩니다:
private final필드 3개- 모든 필드를 받는 정규 생성자(Canonical Constructor)
- 접근자 메서드:
id(),name(),email()(get 접두사 없음) - 모든 필드를 비교하는
equals()와hashCode() - 모든 필드를 출력하는
toString()
User user = new User(1L, "홍길동", "hong@email.com");
user.name(); // "홍길동" (getter가 아닌 필드명과 같은 메서드)
user.toString(); // "User[id=1, name=홍길동, email=hong@email.com]"
컴팩트 생성자 — 유효성 검증
public record Email(String address) {
// 컴팩트 생성자: 파라미터와 할당을 자동 처리
public Email {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("잘못된 이메일: " + address);
}
address = address.toLowerCase(); // 정규화
// this.address = address; ← 자동으로 추가됨
}
}
커스텀 생성자
public record Range(int min, int max) {
// 정규 생성자
public Range {
if (min > max) throw new IllegalArgumentException("min > max");
}
// 추가 생성자 — 정규 생성자를 호출해야 함
public Range(int value) {
this(value, value); // 정규 생성자 위임
}
}
메서드 추가
Record에도 메서드를 추가할 수 있습니다.
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화 불일치");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public boolean isPositive() {
return amount.compareTo(BigDecimal.ZERO) > 0;
}
}
인터페이스 구현
public record Product(String name, int price)
implements Comparable<Product> {
@Override
public int compareTo(Product other) {
return Integer.compare(this.price, other.price);
}
}
Record와 Sealed Class
패턴 매칭과 함께 대수적 데이터 타입을 표현할 수 있습니다.
public sealed interface Shape permits Circle, Rectangle, Triangle {
}
public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }
// 패턴 매칭 (Java 21+)
public static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
Record의 제약
- 상속 불가: Record는 암묵적으로
java.lang.Record를 상속하므로 다른 클래스를 상속할 수 없습니다. - 필드 추가 불가: 레코드 헤더에 선언한 필드 외에 인스턴스 필드를 추가할 수 없습니다. (static 필드는 가능)
- 필드 변경 불가: 모든 필드가
final이므로 setter가 없습니다.
DTO로 활용
// 요청 DTO
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Min(0) int age
) { }
// 응답 DTO
public record UserResponse(Long id, String name, String email) {
// 엔티티 → DTO 변환 팩토리 메서드
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail());
}
}
Jackson은 Record를 기본 지원합니다. @RequestBody나 @ResponseBody에 바로 사용할 수 있습니다.
Record vs Lombok @Value
| 항목 | Record | Lombok @Value |
|---|---|---|
| 언어 표준 | O (Java 16+) | X (라이브러리) |
| IDE 지원 | 완벽 | 플러그인 필요 |
| 상속 | 불가 | 가능 |
| 빌더 | 없음 | @Builder 지원 |
| 접근자 | name() | getName() |
자주 헷갈리는 포인트
- 접근자 이름: Record의 접근자는
getName()이 아니라name()입니다. JavaBeans 규칙과 다릅니다. - JPA 엔티티로 사용 불가: JPA 엔티티는 기본 생성자와 setter가 필요합니다. Record는 불변이므로 엔티티가 아닌 DTO/Projection에 사용하세요.
- 직렬화: Record는
Serializable을 구현할 수 있지만, 역직렬화 시 정규 생성자를 사용하므로 유효성 검증이 자동으로 적용됩니다.
정리
| 항목 | 설명 |
|---|---|
| 도입 | Java 16 (정식) |
| 자동 생성 | 생성자, 접근자, equals, hashCode, toString |
| 유효성 검증 | 컴팩트 생성자에서 처리 |
| 제약 | 상속 불가, 인스턴스 필드 추가 불가, 불변 |
| 적합한 용도 | DTO, 값 객체, 설정 데이터, 패턴 매칭 |