코드 한 줄의 기록

Java Record와 데이터 캐리어 모델: 간결하고 불변한 데이터 구조 구축하기 본문

JAVA

Java Record와 데이터 캐리어 모델: 간결하고 불변한 데이터 구조 구축하기

CodeByJin 2026. 1. 18. 19:29
반응형

자바를 다루다 보면 데이터를 담기 위한 클래스를 만들일이 많습니다. 사용자 정보, 상품 정보, API 응답 데이터처럼 특정 형태의 데이터를 한곳에 모아야 할 때요. 과거에는 이런 클래스를 만들 때마다 생성자, getter, toString(), equals(), hashCode() 메서드를 수동으로 작성해야 했습니다. 같은 형태의 코드를 반복해서 쓰는 것이 개발자들 입장에서 얼마나 번거로웠는지는 누구나 알 것 같습니다.

 

자바 14에서 나온 Record는 이런 반복적인 작업을 완전히 바꿔놨습니다. 자바 16에서 정식 기능으로 포함된 이후, 이제는 데이터를 담는 클래스를 훨씬 더 간단하게 만들 수 있습니다.

데이터 캐리어 모델이란

사실 Record를 이해하려면 먼저 데이터 캐리어(Data Carrier)가 무엇인지 알아야 합니다. 복잡한 개념은 아닙니다. 데이터 캐리어는 단순히 데이터를 한 곳에서 다른 곳으로 '운반'하는 역할만 하는 객체를 의미합니다. 메서드가 없고, 비즈니스 로직도 없고, 단지 정보를 담고 있을 뿐입니다.

 

예를 들어보겠습니다. API 요청을 받았을 때 전달된 사용자 정보를 데이터베이스에 저장하려고 합니다. 요청 데이터를 받는 단계, 검증하는 단계, 저장하는 단계 사이에 데이터를 건네받습니다. 이 과정에서 데이터를 담아두기만 하면 되는 객체가 필요한데, 이것이 바로 데이터 캐리어입니다.

 

일반 자바 클래스로 이걸 표현하려면 어떻게 될까요?

public class UserRequest {
    private String name;
    private String email;
    private int age;

    public UserRequest(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return "UserRequest{" +
                "name='" + name + '\'' +
                ", email='" + email + '\'' +
                ", age=" + age +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserRequest that = (UserRequest) o;
        return age == that.age &&
                Objects.equals(name, that.name) &&
                Objects.equals(email, that.email);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, email, age);
    }
}

 

한 클래스에 정보를 담기만 하려는데 이렇게 많은 코드가 필요합니다. 특히 필드가 많아지거나 여러 클래스를 만들어야 할 때 이 반복이 계속됩니다.

 

Record를 사용하면 어떻게 되는지 보세요.

public record UserRequest(String name, String email, int age) {}

겨우 한 줄입니다. 생성자, 모든 getter, toString(), equals(), hashCode()가 자동으로 생성됩니다. 사용 방식도 동일합니다:

UserRequest user = new UserRequest("홍길동", "hong@example.com", 30);
System.out.println(user.name());      // 게터 호출
System.out.println(user.email());
System.out.println(user.age());
System.out.println(user);             // toString() 자동 호출

 

getter 메서드의 이름이 특이하다는 걸 눈여겨봐야 합니다. Record에서는 전통적인 getName()이 아니라 name()처럼 필드명 그대로를 사용합니다. 이게 Record만의 관례입니다.

Java Record 데이터 구조 다이어그램

Record의 핵심 특징들

불변성

Record의 가장 중요한 특징은 불변성(Immutable)입니다. Record로 만든 모든 필드는 자동으로 final이 되므로 객체가 생성된 이후에는 값을 바꿀 수 없습니다. 이건 단순한 제약이 아니라 장점입니다. 데이터가 변하지 않는다는 보장이 있으면, 여러 스레드가 동시에 접근해도 안전합니다. 또한 HashMap이나 HashSet의 키로 안전하게 사용할 수 있습니다. 왜냐하면 hashCode()의 반환값이 절대 바뀌지 않기 때문입니다.

 

상상해봅시다. 가변 객체가 Map의 키로 사용되고, 누군가 그 객체의 상태를 변경하면 어떻게 될까요? 그 객체의 hashCode가 바뀌게 되고, Map은 더 이상 그 키를 찾을 수 없게 됩니다. 버그가 발생합니다. Record는 이런 상황을 원천적으로 차단합니다.

 

간결성

기존 방식처럼 수십 줄 쓸 필요 없이 한두 줄로 클래스를 정의할 수 있다는 것 자체가 개발 생산성을 높입니다. 코드를 읽을 때도 한눈에 어떤 데이터를 담는지 파악할 수 있습니다. Record 키워드를 보는 순간 "이건 단순히 데이터를 담는 클래스구나"라는 의도가 명확히 드러납니다.

 

실무에서는 이 효과가 생각보다 큽니다. 한 프로젝트에서 API 엔드포인트가 100개라면, 각각에 대한 요청·응답 클래스를 만들어야 합니다. 기존 방식이라면 15,000줄 가량의 보일러플레이트 코드가 나올 텐데, Record로는 300줄 정도로 줄일 수 있다는 사례도 있습니다.

 

언어 차원의 지원

Record는 단순 라이브러리나 어노테이션이 아닙니다. Java 언어 자체에서 지원하는 기능입니다. 따라서 추가 의존성이 필요 없고, 별도의 설정도 필요 없습니다. 자바 14 이상이면 바로 사용할 수 있습니다. Lombok처럼 특별한 컴파일 과정도 필요 없습니다. 일반적인 자바 클래스와 동일하게 컴파일됩니다.

 

Record의 내부 동작

Record를 선언하면 자바 컴파일러가 무엇을 자동으로 생성해주는지 정확히 이해하면, 언제 어떻게 사용할지 결정하기 쉬워집니다.

 

Record를 선언할 때

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

 

컴파일러는 다음을 자동으로 만듭니다.

  • private final 필드들 (x, y)
  • 모든 필드를 받는 생성자
  • 각 필드에 대한 public 메서드 (x(), y())
  • equals() 메서드 (모든 필드 비교)
  • hashCode() 메서드
  • toString() 메서드 (Point[x=1, y=2] 같은 형태)

이 모든 것이 자동이므로, 개발자는 필드만 선언하면 됩니다.

 

검증 로직 추가하기

만약 객체를 만들 때 값을 검증하고 싶다면 어떻게 할까요? Record도 생성자를 커스터마이징할 수 있습니다. 다만 방식이 조금 특별합니다.

 

일반 생성자처럼 작성할 수도 있지만, 더 간결한 방법은 Compact 생성자를 사용하는 것입니다.

public record Person(String name, int age) {
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("나이는 0 이상이어야 합니다");
        }
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다");
        }
    }
}

 

Compact 생성자 안에서 검증 로직을 넣으면, 각 필드에 값이 할당되기 전에 검증이 실행됩니다. 이 방식은 생성자 매개변수를 명시적으로 쓸 필요가 없어서 깔끔합니다.

 

사용할 때는 일반 생성자처럼

Person person = new Person("홍길동", 30);      // OK
Person invalid = new Person("", -5);           // IllegalArgumentException 발생

 

Record가 할 수 없는 것들

Record를 이해하려면 제약사항도 중요합니다. Record는 데이터 캐리어를 만들기 위한 특화된 도구이므로, 다른 목적에는 사용할 수 없습니다.

 

상속이 불가능합니다. Record는 다른 클래스를 상속받을 수 없고, Record를 상속받는 다른 클래스도 만들 수 없습니다. 하지만 인터페이스 구현은 가능합니다. 예를 들어 특정 인터페이스를 구현하는 여러 Record를 만들고, 패턴 매칭과 함께 사용할 수 있습니다.

 

헤더에 정의된 필드 외에 추가 인스턴스 필드를 선언할 수 없습니다. 정적 필드(static)는 가능하지만, 데이터 캐리어의 본질을 벗어나는 추가 상태는 허용하지 않는다는 뜻입니다. 대신 메서드나 정적 메서드는 추가할 수 있습니다.

 

모든 필드가 final이므로 setter 메서드도 없습니다. 만약 값을 변경해야 한다면 새로운 객체를 만들어야 합니다. 이를 "방어적 복사(Defensive Copy)" 또는 "복사 생성자" 패턴이라고도 합니다.

Person person = new Person("홍길동", 30);
// 나이를 31로 변경하려면? → 새 객체를 만들어야 함
Person olderPerson = new Person(person.name(), 31);

Java Record 불변 데이터 변환 다이어그램

실무에서의 활용

DTO와 데이터 전송

Spring Boot REST API에서 가장 흔한 사용 사례는 DTO입니다.

public record CreateUserRequest(String name, String email, int age) {}
public record UserResponse(Long id, String name, String email, int age, LocalDateTime createdAt) {}

@RestController
@RequestMapping("/api/users")
public class UserController {
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
        User user = userService.create(request);
        return ResponseEntity.ok(new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getAge(),
            user.getCreatedAt()
        ));
    }
}

 

Spring 5.1부터 Record는 JSON 직렬화/역직렬화를 완벽하게 지원합니다. @RequestBody나 @ResponseBody 어노테이션과 함께 자동으로 작동합니다.

 

설정 관리

Spring Boot의 설정 프로퍼티를 관리할 때도 Record가 유용합니다.

@ConfigurationProperties(prefix = "app")
public record AppConfig(
    String name,
    String version,
    Security security,
    Database database
) {
    public record Security(String jwtSecret, long jwtExpiration) {}
    public record Database(int maxConnections, int timeout) {}
}

 

YAML 설정과 자동으로 바인딩되며, 중첩된 Record도 깔끔하게 표현할 수 있습니다.

 

값 객체와 도메인 모델

도메인 주도 설계(DDD)에서 말하는 값 객체(Value Object)를 만들 때 Record가 적합합니다. 값 객체는 그 자체로 의미 있는 정보를 담으면서도 불변이어야 합니다.

public record Email(String value) {
    public Email {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("유효한 이메일이 아닙니다");
        }
    }
}

public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("금액은 0 이상이어야 합니다");
        }
    }
}

 

값 객체는 equals()와 hashCode()가 중요한데, Record가 자동으로 만들어주므로 이 용도에 완벽합니다.

 

패턴 매칭과의 결합

자바 21부터는 Record와 패턴 매칭이 결합되어 더욱 강력해집니다. Record Patterns를 사용하면 객체에서 필요한 데이터만 추출할 수 있습니다.

record Point(int x, int y) {}

// 기존 방식
if (obj instanceof Point) {
    Point p = (Point) obj;
    int x = p.x();
    int y = p.y();
    System.out.println(x + y);
}

// 패턴 매칭 사용 (Java 21+)
if (obj instanceof Point(int x, int y)) {
    System.out.println(x + y);
}

 

switch 문에서도 사용할 수 있습니다.

sealed interface Shape {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

double area = switch (shape) {
    case Circle(double r) -> Math.PI * r * r;
    case Rectangle(double w, double h) -> w * h;
};

 

이렇게 되면 타입 확인과 데이터 추출이 동시에 이루어지며, 코드가 훨씬 간결해집니다.

 

주의할 점

Record를 선택할 때 가장 중요한 판단 기준은 "이 클래스가 데이터만 담으려고 하는가?"입니다. 비즈니스 로직이 들어가야 한다면 일반 클래스를 사용해야 합니다. Record는 데이터 캐리어이지, 일반적인 비즈니스 객체를 만드는 도구가 아닙니다.

 

또한 상태가 변해야 하는 경우도 Record를 피해야 합니다. JPA Entity처럼 데이터베이스에서 조회한 후 속성을 변경해야 하는 경우는 일반 클래스를 써야 합니다. Record의 불변성은 어떤 상황에서는 강점이지만, 변경이 필요한 상황에서는 제약이 됩니다.

 

실제 프로젝트에서는 이 둘을 구분해서 사용합니다. API 요청·응답, 설정값, 값 객체 같은 순수 데이터 담기 용도에는 Record를 쓰고, 엔티티나 비즈니스 로직을 담는 도메인 객체는 일반 클래스로 만듭니다. 이렇게 명확히 구분하면 코드의 의도가 한눈에 드러나고, 유지보수도 쉬워집니다.

 

 

Java 람다·스트림으로 선언적 데이터 처리하기 - 함수형 코딩 가이드

코드를 짤 때마다 같은 패턴이 반복된다는 생각이 들었다. 데이터를 가져오고, 필터링하고, 변환하고, 결과를 모은다. for 루프로 하나씩 지정해야 했던 그 과정들이 말이다. Java 8에서 람다 표현

byteandbit.tistory.com

 

반응형