JAVA (14-1) - record 클래스
1. 개요
JAVA (14)에서 값이 같은 객체를 같다고 판단하려면 equals()와 hashCode()를 함께 재정의해야 한다고 정리했다. 그런데 필드 몇 개를 담아 옮기기만 하는 클래스에도 생성자, getter, equals(), hashCode(), toString()을 매번 직접 써야 한다면 코드가 금방 길어진다.
record는 이런 “데이터를 담는 클래스”를 한 줄로 선언하게 해 주는 문법이다. Java 14와 15에서 미리보기(preview) 기능으로 등장했고, Java 16에서 정식 기능이 되었다.
이 글에서는 공연 예매 서비스의 예약 조회 응답 ReservationDetailResponse를 예시로 record의 기본 문법을 정리하고, record 안에 record를 선언하는 중첩 record까지 살펴본다.
2. record 없이 데이터 클래스를 만들면
예약에 딸린 결제 정보를 결제 ID, 금액, 결제 시각 세 값으로 표현한다고 하자. 일반 클래스로 불변 객체를 만들면 다음과 같다.
public final class PaymentView {
private final Long paymentId;
private final long amount;
private final LocalDateTime paidAt;
public PaymentView(Long paymentId, long amount, LocalDateTime paidAt) {
this.paymentId = paymentId;
this.amount = amount;
this.paidAt = paidAt;
}
public Long getPaymentId() { return paymentId; }
public long getAmount() { return amount; }
public LocalDateTime getPaidAt() { return paidAt; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
전하고 싶은 정보는 “세 값을 담는다”뿐인데, 나머지는 모두 반복 코드(보일러플레이트)다. 필드를 하나 추가하면 생성자, getter, equals(), hashCode(), toString()을 모두 고쳐야 하고, 하나라도 빠뜨리면 버그가 된다.
3. record 선언과 컴파일러가 만들어 주는 것
같은 클래스를 record로 쓰면 한 줄이면 된다.
public record PaymentView(Long paymentId, long amount, LocalDateTime paidAt) { }
괄호 안의 Long paymentId, long amount, LocalDateTime paidAt을 레코드 컴포넌트(record component) 라고 부른다. 컴파일러는 이 컴포넌트 목록을 보고 다음 요소를 자동으로 만든다.
| 자동 생성 요소 | 내용 |
|---|---|
| 필드 | 컴포넌트마다 private final 필드 |
| 생성자 | 모든 컴포넌트를 선언 순서대로 받는 생성자 (canonical constructor) |
| 접근자 메서드 | 컴포넌트와 같은 이름의 public 메서드: paymentId(), amount(), paidAt() |
equals() / hashCode() |
모든 컴포넌트 값을 비교·계산에 사용 |
toString() |
PaymentView[paymentId=3, amount=150000, paidAt=...] 형태 |
접근자 이름에 get이 붙지 않는다는 점에 주의한다. payment.getAmount()가 아니라 payment.amount()로 읽는다.
equals()가 값 기준으로 구현되므로, 컴포넌트 값이 같으면 서로 다른 인스턴스라도 같다고 판단한다.
LocalDateTime paidAt = LocalDateTime.of(2026, 10, 1, 19, 3);
PaymentView a = new PaymentView(3L, 150_000, paidAt);
PaymentView b = new PaymentView(3L, 150_000, paidAt);
System.out.println(a == b); // false (다른 인스턴스)
System.out.println(a.equals(b)); // true (컴포넌트 값이 모두 같음)
System.out.println(a.amount()); // 150000
System.out.println(a); // PaymentView[paymentId=3, amount=150000, paidAt=2026-10-01T19:03]
hashCode()도 같은 기준으로 만들어진다. 컴포넌트 값이 같으면 서로 다른 인스턴스라도 같은 해시값을 돌려준다. HashMap은 hashCode()로 버킷을 찾은 뒤 equals()로 키를 비교하는데, record는 두 메서드의 기준이 같으므로 HashSet이나 HashMap의 키로 바로 써도 된다.
4. record에 할 수 있는 것과 없는 것
record도 클래스이므로 메서드를 추가할 수 있다. 다만 “값을 담는 불변 객체”라는 목적에 맞게 몇 가지 제약이 있다.
| 구분 | 가능 여부 | 설명 |
|---|---|---|
| 인스턴스 메서드 · static 메서드 추가 | 가능 | 계산 메서드나 정적 팩터리 메서드를 둘 수 있다 |
| static 필드 추가 | 가능 | 상수 등 |
| 인스턴스 필드 추가 | 불가 | 상태는 컴포넌트로만 표현한다 |
다른 클래스 상속(extends) |
불가 | 모든 record는 java.lang.Record를 이미 상속한다 |
인터페이스 구현(implements) |
가능 | Comparable 등 |
| 하위 클래스 만들기 | 불가 | record는 암묵적으로 final이다 |
| 필드 값 변경(setter) | 불가 | 필드가 final이다 |
5. 컴팩트 생성자로 값 검증하기
생성 시점에 값을 검증하고 싶다면 컴팩트 생성자(compact constructor) 를 쓴다. 괄호와 매개변수 목록을 생략하고, 검증 코드만 적는 생성자다. 아래 검증 코드는 설명을 위해 추가한 예시다.
public record PaymentView(Long paymentId, long amount, LocalDateTime paidAt) {
public PaymentView {
if (amount < 0) {
throw new IllegalArgumentException("결제 금액은 음수일 수 없다");
}
}
}
컴팩트 생성자 본문이 끝나면 컴파일러가 this.amount = amount; 같은 필드 대입을 자동으로 이어 붙인다. 그래서 검증에 통과한 값만 필드에 저장된다.
record의 불변성은 얕은 불변(shallow immutability) 이라는 점도 알아 둔다. 필드 자체는 다시 대입할 수 없지만, 필드가 List처럼 변경 가능한 객체를 가리키면 그 내용은 바뀔 수 있다. 이럴 때는 컴팩트 생성자에서 seatIds = List.copyOf(seatIds);처럼 변경할 수 없는 복사본으로 바꿔 둔다.
6. 정적 팩터리 메서드로 도메인 객체를 변환하기
실무에서 record는 API 응답처럼 다른 계층으로 데이터를 옮기는 객체(DTO)로 많이 쓴다. 이때 도메인 객체를 받아 record로 바꾸는 정적 팩터리 메서드 from()을 함께 두면 변환 로직이 한곳에 모인다.
public record PaymentView(Long paymentId, long amount, LocalDateTime paidAt) {
static PaymentView from(Payment payment) {
return new PaymentView(payment.getId(), payment.getAmount(), payment.getPaidAt());
}
}
Payment 엔티티는 JPA가 관리하는 도메인 객체이고, PaymentView는 응답으로 내보낼 값만 골라 담은 복사본이다. 응답 형식이 바뀌어도 엔티티를 건드리지 않고 record만 고치면 된다.
7. 중첩 record
예약 단건 조회 응답은 예약 정보에 결제와 티켓을 덧붙인 모양이다. JSON으로 보면 payment와 ticket이 안쪽 객체로 들어간다.
{
"reservationId": 1, "scheduleId": 10, "seatId": 100,
"status": "CONFIRMED", "expiresAt": "2026-10-01T19:05:00",
"payment": { "paymentId": 3, "amount": 150000, "paidAt": "2026-10-01T19:03:00" },
"ticket": { "ticketId": 5, "code": "3f2a9c1e-...", "issuedAt": "2026-10-01T19:03:00" }
}
이 구조를 그대로 코드로 옮긴 것이 다음 record다. PaymentView와 TicketView를 별도 파일로 두지 않고 ReservationDetailResponse 안에 선언했다.
public record ReservationDetailResponse(
Long reservationId,
Long scheduleId,
Long seatId,
ReservationStatus status,
LocalDateTime expiresAt,
PaymentView payment,
TicketView ticket) {
public static ReservationDetailResponse from(ReservationDetail detail) {
Reservation reservation = detail.reservation();
ScheduledSeat seat = reservation.getSeat();
return new ReservationDetailResponse(
reservation.getId(),
seat.scheduleId(),
seat.seatId(),
reservation.getStatus(),
reservation.getExpiresAt(),
detail.payment().map(PaymentView::from).orElse(null),
detail.ticket().map(TicketView::from).orElse(null));
}
public record PaymentView(Long paymentId, long amount, LocalDateTime paidAt) {
static PaymentView from(Payment payment) {
return new PaymentView(payment.getId(), payment.getAmount(), payment.getPaidAt());
}
}
public record TicketView(Long ticketId, String code, LocalDateTime issuedAt) {
static TicketView from(Ticket ticket) {
return new TicketView(ticket.getId(), ticket.getCode(), ticket.getIssuedAt());
}
}
}
7-1. 중첩 record는 항상 static이다
JAVA (12) 내부 클래스에서 본 것처럼, 일반 클래스 안에 static 없이 선언한 내부 클래스는 바깥 클래스의 인스턴스가 있어야 만들 수 있다. 반면 중첩 record는 static을 적지 않아도 암묵적으로 static이다.
그래서 PaymentView는 ReservationDetailResponse 인스턴스 없이 바로 만들 수 있고, 바깥 record의 인스턴스를 몰래 참조하지도 않는다. 바깥 record의 from()이 static 메서드인데도 PaymentView::from을 호출할 수 있는 이유다.
7-2. 왜 안쪽에 선언하는가
PaymentView와 TicketView는 이 응답의 일부로만 의미가 있다. 안쪽에 두면 얻는 이점은 다음과 같다.
| 이점 | 설명 |
|---|---|
| 구조가 한눈에 보인다 | 파일 하나만 읽으면 JSON 전체 모양을 알 수 있다 |
| 이름 충돌이 줄어든다 | 다른 응답에도 PaymentView가 필요하면 각자의 바깥 record 안에 따로 둘 수 있다 |
| 변환 로직이 모인다 | 바깥 from()이 안쪽 from()을 호출해 한 번에 조립한다 |
바깥에서 안쪽 record를 가리킬 때는 ReservationDetailResponse.PaymentView payment = response.payment();처럼 바깥 이름을 앞에 붙인다.
7-3. 없는 값은 null로 둔다
결제 전 예약에는 결제와 티켓이 없다. detail.payment()는 Optional<Payment>를 반환하고, map(PaymentView::from)은 값이 있을 때만 변환하며, orElse(null)은 값이 없을 때 null을 넣는다. Optional 사용법은 JAVA (24) - Optional에서 다뤘다.
필드를 응답에서 빼지 않고 "payment": null로 내보내면, 받는 쪽이 “아직 결제가 없다”와 “응답 형식이 다르다”를 헷갈리지 않는다. Spring Boot의 기본 JSON 라이브러리인 Jackson은 record의 접근자 메서드를 읽어 JSON 필드를 만들며, 별도 설정이 없으면 null 필드도 그대로 출력한다.
8. record를 쓰기 좋은 곳과 피할 곳
| 쓰기 좋은 곳 | 피할 곳 |
|---|---|
| API 요청·응답 DTO | JPA 엔티티 (@Entity) |
| 서비스 계층 간에 주고받는 조회 결과 | 생성 후 상태가 바뀌어야 하는 객체 |
| 좌석 위치처럼 값 자체가 의미인 값 객체 | 다른 클래스를 상속해야 하는 객체 |
JPA 엔티티는 기본 생성자로 빈 객체를 만든 뒤 필드를 채우고, 지연 로딩을 위해 프록시 하위 클래스를 만든다. record는 final이고 필드도 final이라 이 방식과 맞지 않는다. 대신 엔티티 안의 값 타입으로는 쓸 수 있는데, 이 내용은 @Embedded와 @Embeddable로 값 객체 매핑하기에서 정리했다.
9. 정리
record는 컴포넌트 목록만 선언하면 private final 필드, 생성자, 접근자, equals(), hashCode(), toString()을 컴파일러가 만들어 주는 불변 데이터 클래스다. 접근자 이름에 get이 붙지 않고, 다른 클래스를 상속하거나 인스턴스 필드를 추가할 수 없다. 값 검증은 컴팩트 생성자에서 처리한다.
중첩 record는 암묵적으로 static이라 바깥 인스턴스 없이 만들 수 있다. 응답 안에서만 의미 있는 PaymentView와 TicketView를 ReservationDetailResponse 안에 두면, JSON 구조와 변환 로직을 파일 하나에서 확인할 수 있다.