Java Record: 불변 객체 설계 & DTO 최적화

CH.dev·2025년 8월 8일
post-thumbnail

📄 요약

  • Java 16에서 정식 도입된 Record는 불변(immutable) 데이터의 집합을 표현하기 위한 특수한 형태의 클래스.
  • 생성자, 접근자(accessor), equals(), hashCode(), toString()같은 보일러플레이트 코드를 컴파일러가 자동으로 생성해 줌.
    • DTO나 VO 작성 시 코드의 양을 극적으로 줄이고, 가독성과 유지보수성을 크게 향상시킴.
  • 스프링에서는 별도 설정 없이 JSON 직렬화/역직렬화가 가능하여 API 개발에 바로 활용할 수 있음.

💡 주요 개념

Record란?

class 키워드 대신 record 키워드를 사용하여 '데이터 캐리어(data carrier)'를 간결하게 선언하는 방법임. 클래스명 옆에 소괄호로 정의된 헤더(header)를 통해 상태를 구성할 컴포넌트(component)를 선언함.

컴파일러는 이 헤더를 기반으로 다음 요소를 자동으로 생성함.

  • 모든 필드는 private final로 선언됨: 불변성을 보장하는 핵심.
  • 정식 생성자 (Canonical Constructor): 헤더에 선언된 모든 필드를 동일한 순서로 인자로 받는 public 생성자.
  • Public 접근자 (Accessor Method): 각 필드에 대한 접근자. get 접두사 없이 필드명과 동일한 이름(예: user.username()).
  • equals(): 모든 필드의 값이 같은지 비교.
  • hashCode(): 모든 필드의 hashCode를 조합하여 생성.
  • toString(): 클래스명과 각 필드 및 값을 UserRecord[username=testuser, email=... ] 형태로 출력.

모든 Record는 암묵적으로 java.lang.Record 클래스를 상속함.

예시 코드

// ✅ Record 버전 (Java 16+)
public record UserRecord(String username, String email) { } 
// 모든 필드는 private final로 자동 선언됨
// Canonical Constructor, equals, hashCode, toString, getter 자동 생성
// getter는 get 접두사 없이 username(), email() 형태로 제공됨

public class MainRecordExample {
    public static void main(String[] args) {
        UserRecord user = new UserRecord("testUser", "test@example.com"); // 자동 생성자 호출
        System.out.println(user.username()); // "testUser" (get 접두사 없음)
        System.out.println(user); // UserRecord[username=testUser, email=test@example.com]
    }
}

불변 객체(Immutable Object) 설계와 그 이점

Record의 모든 필드는 final이므로 얕은 불변성(Shallow Immutability)을 가짐.

  • Thread-Safety: 상태가 변하지 않아 멀티스레드 환경에서 동기화 없이 안전하게 공유 가능함.
  • 예측 가능성 및 안정성: 객체 생성 후 상태가 고정되어 동작을 예측하기 쉽고, 버그 발생 가능성을 줄임.
  • 데이터 무결성: DTO 등으로 전달 시 수신 측에서 데이터를 임의로 변경하는 것을 원천 차단함.

⚠️ 주의: 얕은 불변성 (Shallow Immutability)
Record의 필드가 List나 다른 객체와 같은 참조 타입일 경우, 그 참조 자체는 불변이지만 참조가 가리키는 객체 내부의 상태는 변경될 수 있음. 완전한 불변성을 원한다면 List.copyOf() 등을 사용해 방어적 복사를 해야 함.
public record Order(long id, List<String> items) {}
order.items().add("new item"); // 컴파일 오류는 없으나, 객체 상태가 변경됨.

DTO(Data Transfer Object) 최적화

DTO는 일반적으로 데이터와 접근자 메서드만 가지는 순수 데이터 객체임. 기존에는 Lombok 같은 라이브러리의 도움 없이는 많은 상용구 코드가 필요했으나, Record는 이를 언어 차원에서 해결함.

  • 코드량 감소: 수십 줄의 코드를 단 한 줄로 대체.
  • 의도 명확화: record 키워드 자체로 '이것은 데이터를 담는 객체'라는 의도를 명확하게 전달.

🧠 코드 예시

1. 컴팩트 생성자(Compact Constructor)와 유효성 검증

Record는 필드 초기화(this.x = x) 코드가 없는 컴팩트 생성자를 지원함. 주로 매개변수 유효성 검증이나 정규화에 사용됨.

import java.util.Objects;

// 컴팩트 생성자로 email 필드의 null 여부를 검증하는 Record
public record UserRecord(String username, String email) {
    // 이것이 컴팩트 생성자
    public UserRecord {
        Objects.requireNonNull(username);
        Objects.requireNonNull(email);
        if (username.isBlank()) {
            throw new IllegalArgumentException("사용자 이름은 비어 있을 수 없습니다.");
        }
    }
}

// 사용 예시
// UserRecord user = new UserRecord(null, "test@test.com"); // NullPointerException 발생
// UserRecord user = new UserRecord(" ", "test@test.com"); // IllegalArgumentException 발생

2. 인터페이스 구현 및 정적 메서드 추가

Record도 일반 클래스처럼 인터페이스를 구현하거나, 정적(static) 필드/메서드를 가질 수 있음. (단, 다른 클래스 상속은 불가)

public interface JsonSerializable {
    String toJson(); // 간단한 JSON 변환 메서드를 가진 인터페이스
}

public record UserRecord(String username, String email) implements JsonSerializable {

    // 정적 팩토리 메서드
    public static UserRecord createGuest() {
        return new UserRecord("guest", "guest@example.com");
    }

    // 인터페이스 메서드 구현
    @Override
    public String toJson() {
        return """
               {
                 "username": "%s",
                 "email": "%s"
               }
               """.formatted(username, email);
    }
}

// 사용 예시
UserRecord guest = UserRecord.createGuest();
System.out.println(guest.toJson());

3. 스프링 적용 심화

Spring Boot 2.5+, Jackson 2.12+ 환경에서 Record를 DTO로 완벽하게 지원함.

// /src/main/java/com/example/demo/dto/UserDto.java
// DTO는 별도의 패키지에서 top-level record로 관리하는 것이 일반적
public record UserDto(String username, String email, int age) {}

// /src/main/java/com/example/demo/controller/UserController.java
import com.example.demo.dto.UserDto;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    // Jackson 라이브러리가 HTTP Message Body(JSON)를 UserDto Record로 역직렬화(@RequestBody)
    @PostMapping
    public UserDto createUser(@RequestBody UserDto user) {
        System.out.println("생성된 사용자: " + user);
        // ... 비즈니스 로직 처리 (예: service.join(user)) ...
        
        // 처리 결과를 다시 Record 객체에 담아 반환
        // Jackson이 Record를 JSON으로 직렬화(@ResponseBody)하여 응답
        return user;
    }

    @GetMapping("/{username}")
    public UserDto getUser(@PathVariable String username) {
        // ... 사용자 조회 로직 ...
        return new UserDto(username, username + "@example.com", 30);
    }
}

🔍 더 깊이 찾아보기

  1. Lombok과의 비교: Record는 Lombok의 @Value와 가장 유사함. 하지만 언어 내장 기능이므로 별도 의존성이나 플러그인 설정이 필요 없음.
구분record (Java 16+)@Value (Lombok)@Data (Lombok)
불변성기본 (모든 필드 private final)기본 (모든 필드 private final)선택 (변경 가능, setter 생성)
접근자field() (get 접두사 없음)getField()getField(), setField()
빌더지원 안 함 (직접 구현 필요)@Builder 추가 가능@Builder 추가 가능
의존성없음 (언어 내장)Lombok 라이브러리 필요Lombok 라이브러리 필요
상속java.lang.Record 암묵적 상속, 다른 클래스 상속 불가상속 가능상속 가능
  1. Pattern Matching for instanceof (Java 16+): Record와 함께 사용하면 타입 체크와 동시에 컴포넌트를 변수로 추출하여 코드를 더욱 간결하게 만듦.

    // 이전 방식
    if (obj instanceof UserRecord) {
        UserRecord user = (UserRecord) obj;
        System.out.println("사용자 이름: " + user.username());
    }
    
    // Pattern Matching 적용
    if (obj instanceof UserRecord(String username, String email)) {
        System.out.println("사용자 이름: " + username); // 바로 변수 사용 가능
    }
  2. Sealed Classes (Java 17+): 특정 클래스나 인터페이스를 상속/구현할 수 있는 타입을 명시적으로 제한하는 기능. Record와 함께 사용하면, 특정 인터페이스를 구현하는 타입이 오직 지정된 Record들 뿐임을 보장하여 더욱 정교하고 안정적인 도메인 모델 설계가 가능함.

profile
더 이상 미룰 수 없다 나의 공부 나의 성장

0개의 댓글