Java Records: 언제 사용해야 하고 언제는 안 할까

1. 개요

Java 14 이전에는 불변 데이터만 담는 클래스를 만들려면 constructor, getter, equals(), hashCode(), toString()을 직접 작성해야 했습니다. 모두 반복되는 보일러플레이트 코드였죠.

Record는 이 문제를 정확히 해결합니다. 한 줄만 선언하면 Java가 모두 자동으로 생성해줍니다.

이 글에서는 record의 기본 문법, validation을 커스터마이징하는 방법, 일반 클래스 및 Lombok과의 비교, 그리고 record를 언제 사용해야 하고 언제는 피해야 하는지를 명확히 설명합니다. 읽고 나면 실제 Spring Boot 애플리케이션에서 record를 올바르게 적용하는 방법을 알게 될 겁니다.

2. Record란 무엇인가

Record는 불변 데이터(immutable data)를 모델링하기 위한 특수한 클래스입니다. record를 선언하는 것은 그것의 “컴포넌트”를 정의하는 것과 같습니다:

public record Point(int x, int y) {
}

이 한 줄만으로 compiler가 자동으로 생성합니다:

  • x, y를 받는 생성자
  • accessor 메서드 x()와 y() (get 접두사 없음)
  • 모든 component를 기반으로 한 equals(), hashCode()
  • Point[x=1, y=2] 형태의 toString()
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1

참고: Record는 자동으로 final입니다. record를 확장할 수 없고, 그 필드도 모두 final이므로 초기화 후 다시 할당할 수 없습니다.

3. Record의 구성 요소

3.1. 생성자와 validation

Record에는 자동으로 생성되는 정규 생성자(canonical constructor)가 있지만, validation을 추가하기 위해 재정의할 수 있습니다. 이를 compact constructor라고 부르며 매개변수를 다시 선언할 필요가 없습니다:

public record Order(String id, BigDecimal amount) {
    public Order {
        if (amount == null || amount.signum() < 0) {
            throw new IllegalArgumentException("amount는 0 이상이어야 합니다");
        }
    }
}

public Order { ... } 라인은 매개변수 목록이 없습니다. record의 매개변수를 공유합니다. Validation은 필드가 할당되기 전에 실행되므로 object 생성 시점에 잘못된 데이터를 막을 수 있습니다.

new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount는 0 이상이어야 합니다

3.2. 커스텀 메서드 추가

Record는 일반 클래스처럼 메서드, static 필드, static 메서드를 포함할 수 있습니다:

public record Money(String currency, BigDecimal value) {

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalStateException("통화 타입이 다릅니다");
        }
        return new Money(currency, value.add(other.value));
    }

    public static Money zero(String currency) {
        return new Money(currency, BigDecimal.ZERO);
    }
}

value가 final이므로 add() 메서드는 현재 object를 수정하지 않고 새로운 Money를 반환합니다. 이것이 바로 record가 추구하는 불변성의 정신입니다.

3.3. 인터페이스 구현

Record는 인터페이스를 구현할 수 있습니다(다른 클래스를 확장할 수는 없음):

public interface Shape {
    double area();
}

public record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }
}

4. Spring Boot에서 Record 사용: DTO와 request/response

Record의 가장 일반적인 사용 사례는 DTO입니다. REST API를 통해 주고받는 데이터입니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
        @NotBlank String name,
        @Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {

    @PostMapping
    public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
        Customer saved = new Customer(1L, request.name(), request.email());
        return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
    }
}

public record CustomerResponse(Long id, String name) {
}

Jackson(Spring Boot가 미리 설정한)은 canonical constructor를 통해 JSON을 record로 역직렬화하며 setter가 필요 없습니다. Bean Validation annotation(@NotBlank, @Email)을 record의 매개변수에 직접 붙이면 정상적으로 작동합니다. Spring이 생성자에서 이 annotation들을 인식하기 때문입니다.

POST /customers
{"name": "", "email": "a@b.com"}

HTTP 400 Bad Request

참고: Record를 JPA entity로 사용하면 문제가 생깁니다. JPA는 lazy-loading이 작동하려면 no-arg 생성자와 변경 가능한 필드가 필요합니다. Record는 이를 지원하지 않으므로 record를 @Entity로 사용하면 안 됩니다.

5. Record와 일반 클래스의 비교

기준 일반 클래스 Record
보일러플레이트 getter, equals, hashCode, toString 직접 작성 자동 생성
가변성 가능 불가능 (필드는 항상 final)
상속 다른 클래스 확장 가능 확장 불가능 (암시적 final)
적합한 용도 Entity, 상태가 변하는 클래스 DTO, value object, 불변 데이터

구체적인 예제를 통해 같은 value object FullName을 비교해봅시다:

// 기존 방식
public final class FullNameOld {
    private final String first;
    private final String last;

    public FullNameOld(String first, String last) {
        this.first = first;
        this.last = last;
    }

    public String getFirst() { return first; }
    public String getLast() { return last; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof FullNameOld other)) return false;
        return Objects.equals(first, other.first) && Objects.equals(last, other.last);
    }

    @Override
    public int hashCode() {
        return Objects.hash(first, last);
    }

    @Override
    public String toString() {
        return "FullNameOld[first=" + first + ", last=" + last + "]";
    }
}
// Record를 사용한 방식
public record FullName(String first, String last) {
}

이 두 클래스는 동작 면에서 동등하지만, record는 훨씬 더 간결하고 equals/hashCode를 잘못 작성할 가능성이 없습니다(직접 코딩할 때의 전형적인 실수).

6. Record와 Lombok @Value의 비교

많은 팀에서 record가 나오기 전에 Lombok @Value를 사용해서 보일러플레이트를 줄였습니다. 두 가지 접근 방식은 비슷한 문제를 해결하지만, 근본적으로 다릅니다:

// Lombok
@Value
public class ProductDto {
    String sku;
    BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}

주요 차이점:

  • Record는 Java의 기본 기능이므로 추가 dependency가 필요 없고 annotation processor도 필요 없습니다.
  • Record는 sku() 형태의 accessor를 생성하고(get 접두사 없음), Lombok은 getSku()를 생성합니다.
  • Record는 상속을 지원하지 않으며 Lombok처럼 builder pattern을 통해 선택적 필드를 쉽게 추가하지 못합니다(@Builder는 선택적 필드가 많은 object에 더 인기 있습니다).

참고: 프로젝트에서 Lombok을 광범위하게 사용하고 있으며 필드가 많은 DTO에 @Builder가 필요하다면 해당 경우에는 Lombok을 계속 사용해도 됩니다. Record는 DTO가 단순하고 필드가 적으며 builder가 필요 없을 때 더 적합합니다.

7. Record를 사용해야 하는 경우

다음과 같은 데이터에는 record를 사용하세요:

  • 본질적으로 불변인 데이터: 좌표, 금액 범위, 시간 범위, 계산 결과
  • 레이어 간 전달되는 DTO: API의 request/response, 쿼리에서 반환되는 데이터, 큐를 통해 전송되는 메시지
  • domain model의 value object: Money, EmailAddress, Coordinates — identity가 아닌 값으로 비교하는 것들
  • 처리 로직에서의 임시 결과: method에서 반환할 여러 값을 모으되 복잡한 별도 클래스를 만들 필요가 없을 때
public record ValidationResult(boolean valid, List<String> errors) {
    public static ValidationResult ok() {
        return new ValidationResult(true, List.of());
    }
}

8. Record를 사용하면 안 되는 경우

8.1. JPA entity

4장에서 언급했듯이 JPA는 entity가 mutable이어야 하고 no-arg 생성자가 있어야 하며 lazy loading을 위한 proxy를 지원해야 합니다. Record는 이를 만족하지 못합니다.

// 이렇게 하면 안 됩니다
@Entity
public record Customer(@Id Long id, String name) {
}

Hibernate는 record에 대해 proxy를 생성하려고 할 때 오류가 발생하거나 예상치 못한 동작이 일어날 것입니다. entity에는 일반 클래스(또는 Lombok @Entity + @Getter/@Setter)를 사용하세요.

8.2. 지속적으로 상태가 변하는 object

만약 object가 생명 주기 동안 상태가 자주 변한다면(예: ShoppingCart에서 item을 계속 추가/삭제), 이를 record로 만들면 변경할 때마다 새로운 object를 만들어야 하므로 비효율적이고 부자연스럽습니다.

// 부자연스럽습니다: item을 추가할 때마다 새로운 cart를 만듭니다
public record ShoppingCart(List<Item> items) {
    public ShoppingCart addItem(Item item) {
        List<Item> newItems = new ArrayList<>(items);
        newItems.add(item);
        return new ShoppingCart(newItems);
    }
}

이 경우 List<Item>를 가지고 addItem() 메서드에서 직접 수정하는 일반적인 mutable 클래스가 더 자연스럽고 효율적입니다.

8.3. 상속이 필요한 경우

Record는 암시적으로 final이고 다른 클래스를 확장할 수 없습니다(인터페이스만 구현 가능). domain model에 클래스 계층 구조가 필요하다면(Employee → Manager, Developer), record는 적합하지 않습니다. 일반 클래스를 사용하거나 sealed interface/class와 각 리프 분기에 record를 결합하세요.

public sealed interface Shape permits Circle, Square {}

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}

이것이 계층 구조에서 record를 사용하는 합리적인 방법입니다. record는 불변 리프 분기를 만들고 sealed interface는 하위 유형의 집합을 관리합니다.

8.4. 복잡한 직렬화가 필요한 경우

Record는 Serializable을 구현할 수 있지만, 복잡한 custom serialization(versioning, 장기 역호환성)이 필요하다면 더 상세한 제어가 가능한 일반 클래스가 더 안전합니다.

9. Record 테스트

equals()/hashCode()가 component를 기반으로 자동 생성되므로 AssertJ를 사용한 테스트는 매우 간단합니다:

class MoneyTest {

    @Test
    void shouldBeEqualWhenSameValues() {
        Money a = new Money("VND", new BigDecimal("1000"));
        Money b = new Money("VND", new BigDecimal("1000"));

        assertThat(a).isEqualTo(b);
        assertThat(a.hashCode()).isEqualTo(b.hashCode());
    }

    @Test
    void shouldThrowWhenAddingDifferentCurrency() {
        Money vnd = new Money("VND", BigDecimal.TEN);
        Money usd = new Money("USD", BigDecimal.ONE);

        assertThatThrownBy(() -> vnd.add(usd))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("통화 타입이 다릅니다");
    }
}

assertThat(a).isEqualTo(b)는 자동 생성된 equals() 동작을 검증합니다. component 값이 같은 두 record는 메모리의 서로 다른 instance라도 같아야 합니다.

10. 결론

Record는 Java가 제공하는 불변 데이터를 위한 최고의 도구입니다. 코드가 적고, 오류가 적으며, 의도가 명확합니다. DTO, value object, 임시 반환 결과에 사용하세요. JPA entity, 지속적으로 상태가 변하는 object, 또는 상속을 통한 복잡한 클래스 계층이 필요한 경우에는 사용하지 마세요. 올바른 작업에 맞는 올바른 도구를 선택하세요. Record는 일반 클래스를 완전히 대체하는 것이 아니라 특정 문제 영역에 대한 더 간결한 선택지를 추가합니다.

javarecordsjava-21spring-bootbest-practices