oguri's garage

(Java) Record - Lombok Builder vs Record 비교 본문

개발하다/Java

(Java) Record - Lombok Builder vs Record 비교

oguri 2025. 10. 5. 21:51

1. Record란 무엇인가?

1.1 핵심 개념

Java Record는 "불변 데이터 상자"와 같다.
Record 클래스는 일반 클래스보다 적은 의식(ceremony)으로 단순한 데이터 집합을 모델링하는 데 도움이 되는 특별한 종류의 클래스입니다.

Record의 본질:

  • 데이터 캐리어: 단순히 데이터를 운반하는 목적
  • 불변성: 한 번 생성되면 값 변경 불가
  • 자동 생성: getter, equals, hashCode, toString 자동 제공

1.2 공식 정의

Record 클래스는 고정된 값 집합(record components라고 함)에 대한 얕은 불변의 투명한 캐리어입니다.

 

 


2. 코드 비교: Lombok Builder vs Record

2.1 기존 Lombok Builder 방식

@Getter
@Builder
public class PdfDownloadData {
    private final ByteArrayResource resource;
    private final String originalFilename;
    private final String displayFilename;
    private final long contentLength;
}

// 사용법
PdfDownloadData data = PdfDownloadData.builder()
    .resource(resource)
    .originalFilename("test.pdf")
    .displayFilename("report.pdf")
    .contentLength(1024)
    .build();

 

2.2 Record 방식

public record PdfDownloadData(
    Resource resource, 
    String originalFilename, 
    String displayFilename, 
    long contentLength
) {
    // 검증 로직을 포함한 compact constructor
    public PdfDownloadData {
        Objects.requireNonNull(resource, "resource must not be null");
        Objects.requireNonNull(originalFilename, "originalFilename must not be null");
        Objects.requireNonNull(displayFilename, "displayFilename must not be null");
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }
    }
}

// 사용법 - 더 간단하고 직관적
PdfDownloadData data = new PdfDownloadData(resource, "test.pdf", "report.pdf", 1024);

 

 


3. Record의 자동 생성 메서드

3.1 자동으로 생성되는 것들

Record 클래스는 헤더에서 설명을 지정하면, 적절한 접근자, 생성자, equals, hashCode, toString 메서드가 자동으로 생성됩니다.

// 이 간단한 record 선언이...
public record Point(int x, int y) {}

// 다음과 동일한 클래스를 자동 생성합니다:
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }  // getter가 아닌 accessor
    public int y() { return y; }

    @Override
    public boolean equals(Object obj) { /* 자동 구현 */ }

    @Override
    public int hashCode() { /* 자동 구현 */ }

    @Override
    public String toString() { 
        return "Point[x=" + x + ", y=" + y + "]"; 
    }
}

 

 


4. Compact Constructor (압축 생성자)

4.1 기본 개념

Compact Constructor는 "입구에서 검문하는 경비원"과 같습니다. 데이터가 들어올 때 유효성을 검증하고, 필요한 전처리를 수행합니다.

 

4.2 일반 생성자 vs Compact Constructor

// ❌ 일반 생성자 - 중복이 많음
public record Rectangle(double length, double width) {
    public Rectangle(double length, double width) {
        if (length <= 0 || width <= 0) {
            throw new IllegalArgumentException("Invalid dimensions");
        }
        this.length = length;  // 중복된 할당
        this.width = width;    // 중복된 할당
    }
}

// ✅ Compact Constructor - 간결하고 명확
public record Rectangle(double length, double width) {
    public Rectangle {  // 매개변수 생략
        if (length <= 0 || width <= 0) {
            throw new IllegalArgumentException(
                String.format("Invalid dimensions: %f, %f", length, width)
            );
        }
        // this.length = length; 자동으로 처리됨
        // this.width = width;   자동으로 처리됨
    }
}

 

4.3 실제 프로젝트 적용 예시

public record PdfDownloadData(
    Resource resource, 
    String originalFilename, 
    String displayFilename, 
    long contentLength
) {
    public PdfDownloadData {
        // null 검증
        Objects.requireNonNull(resource, "resource must not be null");
        Objects.requireNonNull(originalFilename, "originalFilename must not be null");
        Objects.requireNonNull(displayFilename, "displayFilename must not be null");

        // 비즈니스 규칙 검증
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }

        // 파일명 정규화 (필요시)
        originalFilename = originalFilename.trim();
        displayFilename = displayFilename.trim();
    }
}

 

 


5. Record의 고급 활용

5.1 메서드 추가

public record PdfDownloadData(
    Resource resource, 
    String originalFilename, 
    String displayFilename, 
    long contentLength
) {
    // 검증 로직
    public PdfDownloadData {
        Objects.requireNonNull(resource, "resource must not be null");
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }
    }

    // 커스텀 메서드 추가
    public boolean isLargeFile() {
        return contentLength > 10 * 1024 * 1024; // 10MB 이상
    }

    public String getFormattedSize() {
        return String.format("%.2f MB", contentLength / (1024.0 * 1024.0));
    }

    // Static 팩토리 메서드
    public static PdfDownloadData create(Resource resource, String filename, long size) {
        return new PdfDownloadData(resource, filename, filename, size);
    }
}

 

5.2 인터페이스 구현

public record PdfDownloadData(
    Resource resource, 
    String originalFilename, 
    String displayFilename, 
    long contentLength
) implements Downloadable, Serializable {

    @Override
    public void download(HttpServletResponse response) {
        response.setHeader("Content-Disposition", 
            "attachment; filename=\"" + displayFilename + "\"");
        // 다운로드 로직
    }
}

 

5.3 제네릭 Record

public record ApiResponse<T>(
    T data,
    String message,
    int statusCode
) {
    public ApiResponse {
        Objects.requireNonNull(data, "data must not be null");
        if (statusCode < 100 || statusCode >= 600) {
            throw new IllegalArgumentException("Invalid HTTP status code");
        }
    }

    public boolean isSuccess() {
        return statusCode >= 200 && statusCode < 300;
    }
}

// 사용 예시
ApiResponse<PdfDownloadData> response = new ApiResponse<>(
    pdfData, "Success", 200
);

 

 


6. Record vs Lombok Builder 비교

측면 Record Lombok Builder
코드 길이 매우 짧음 중간 길이
컴파일 타임 빠름 (언어 차원 지원) 느림 (애노테이션 프로세싱)
IDE 지원 완벽 플러그인 필요
불변성 강제됨 선택적
생성자 검증 Compact Constructor @Builder.Default, 커스텀 빌더
가독성 매우 높음 높음
빌더 패턴 미지원 완벽 지원
null 안전성 수동 검증 필요 @NonNull 지원

 

 


7. 언제 Record를 사용할까?

7.1 Record 사용 권장 상황

// 1. 단순 데이터 전송 객체 (DTO)
public record UserDto(Long id, String name, String email) {}

// 2. API 응답 객체
public record ApiError(String code, String message, LocalDateTime timestamp) {}

// 3. 설정 데이터
public record DatabaseConfig(String url, String username, int maxConnections) {}

// 4. 이벤트 데이터
public record FileDownloadEvent(String filename, String userId, LocalDateTime timestamp) {}

 

7.2 Record 사용 지양 상황

// 1. 가변 데이터가 필요한 경우
public class UserSession {
    private String sessionId;
    private LocalDateTime lastAccess; // 계속 업데이트됨
    // Record로는 불가능
}

// 2. 복잡한 빌더 패턴이 필요한 경우
QueryBuilder.select("name", "email")
    .from("users")
    .where("age > 18")
    .orderBy("name")
    .build(); // Record로는 복잡함

// 3. 상속이 필요한 경우
public class Employee extends Person { } // Record는 final 클래스

 

 


8. 실무 활용 패턴

8.1 검증과 함께 사용

public record CreateUserRequest(
    @NotBlank String username,
    @Email String email,
    @Size(min = 8) String password
) {
    public CreateUserRequest {
        // 추가 비즈니스 검증
        if (username.toLowerCase().contains("admin")) {
            throw new IllegalArgumentException("Username cannot contain 'admin'");
        }
    }
}

 

8.2 팩토리 메서드 패턴

public record PdfDownloadData(
    Resource resource, 
    String originalFilename, 
    String displayFilename, 
    long contentLength
) {
    // 다양한 생성 방법 제공
    public static PdfDownloadData fromFile(File file) throws IOException {
        byte[] content = Files.readAllBytes(file.toPath());
        ByteArrayResource resource = new ByteArrayResource(content);
        return new PdfDownloadData(
            resource, 
            file.getName(), 
            file.getName(), 
            content.length
        );
    }

    public static PdfDownloadData fromS3(S3Object s3Object, String displayName) throws IOException {
        byte[] content = s3Object.getObjectContent().readAllBytes();
        ByteArrayResource resource = new ByteArrayResource(content);
        return new PdfDownloadData(
            resource,
            s3Object.getKey(),
            displayName,
            content.length
        );
    }
}

 

8.3 중첩 Record 활용

public record ApiResponse<T>(
    T data,
    Meta meta,
    Error error
) {
    public record Meta(
        String requestId,
        LocalDateTime timestamp,
        String version
    ) {}

    public record Error(
        String code,
        String message,
        String detail
    ) {}

    // 성공 응답 팩토리
    public static <T> ApiResponse<T> success(T data, String requestId) {
        return new ApiResponse<>(
            data,
            new Meta(requestId, LocalDateTime.now(), "1.0"),
            null
        );
    }

    // 실패 응답 팩토리
    public static <T> ApiResponse<T> error(String code, String message) {
        return new ApiResponse<>(
            null,
            new Meta(UUID.randomUUID().toString(), LocalDateTime.now(), "1.0"),
            new Error(code, message, null)
        );
    }
}

 

 


9. 마이그레이션 가이드: Lombok → Record

9.1 단계별 변환 과정

Step 1: 기본 변환
// Before - Lombok
@Getter
@Builder
public class PdfDownloadData {
    private final ByteArrayResource resource;
    private final String originalFilename;
    private final String displayFilename;
    private final long contentLength;
}

// After - Record (기본)
public record PdfDownloadData(
    ByteArrayResource resource,
    String originalFilename,
    String displayFilename,
    long contentLength
) {}
Step 2: 검증 로직 추가
// Before - Lombok with validation
@Getter
@Builder
public class PdfDownloadData {
    @NonNull private final ByteArrayResource resource;
    @NonNull private final String originalFilename;
    @NonNull private final String displayFilename;
    private final long contentLength;

    // 별도 검증 메서드 필요
    public void validate() {
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }
    }
}

// After - Record with compact constructor
public record PdfDownloadData(
    Resource resource,
    String originalFilename,
    String displayFilename,
    long contentLength
) {
    public PdfDownloadData {
        Objects.requireNonNull(resource, "resource must not be null");
        Objects.requireNonNull(originalFilename, "originalFilename must not be null");
        Objects.requireNonNull(displayFilename, "displayFilename must not be null");
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }
    }
}
Step 3: 메서드 추가 (필요시)
public record PdfDownloadData(
    Resource resource,
    String originalFilename,
    String displayFilename,
    long contentLength
) {
    // 검증 로직
    public PdfDownloadData {
        Objects.requireNonNull(resource, "resource must not be null");
        Objects.requireNonNull(originalFilename, "originalFilename must not be null");
        Objects.requireNonNull(displayFilename, "displayFilename must not be null");
        if (contentLength < 0) {
            throw new CyturException("TI40001");
        }
    }

    // 빌더 패턴이 필요한 경우 팩토리 메서드로 대체
    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private Resource resource;
        private String originalFilename;
        private String displayFilename;
        private long contentLength;

        public Builder resource(Resource resource) {
            this.resource = resource;
            return this;
        }

        public Builder originalFilename(String originalFilename) {
            this.originalFilename = originalFilename;
            return this;
        }

        public Builder displayFilename(String displayFilename) {
            this.displayFilename = displayFilename;
            return this;
        }

        public Builder contentLength(long contentLength) {
            this.contentLength = contentLength;
            return this;
        }

        public PdfDownloadData build() {
            return new PdfDownloadData(resource, originalFilename, displayFilename, contentLength);
        }
    }
}

 

 


10. 고급 활용 패턴

10.1 조건부 검증

public record UserRegistrationData(
    String username,
    String email,
    String password,
    boolean isAdmin
) {
    public UserRegistrationData {
        Objects.requireNonNull(username, "Username is required");
        Objects.requireNonNull(email, "Email is required");

        // 조건부 검증
        if (isAdmin && !email.endsWith("@company.com")) {
            throw new IllegalArgumentException("Admin users must use company email");
        }

        // 데이터 정규화
        username = username.toLowerCase().trim();
        email = email.toLowerCase().trim();
    }
}

 

10.2 계산된 필드

public record OrderItem(
    String productName,
    int quantity,
    double unitPrice
) {
    // 계산된 값을 메서드로 제공
    public double totalPrice() {
        return quantity * unitPrice;
    }

    public double totalPriceWithTax(double taxRate) {
        return totalPrice() * (1 + taxRate);
    }
}

 

10.3 패턴 매칭과 함께 사용 (Java 17+)

public sealed interface Result<T> permits Success, Error {
    record Success<T>(T data) implements Result<T> {}
    record Error<T>(String message, String code) implements Result<T> {}
}

// 패턴 매칭 사용
public String handleResult(Result<String> result) {
    return switch (result) {
        case Result.Success<String>(var data) -> "Success: " + data;
        case Result.Error<String>(var message, var code) -> "Error " + code + ": " + message;
    };
}

 

 


11. 학습 포인트

11.1 Record 사용시 주의사항

  1. 불변성: 한 번 생성되면 값 변경 불가
  2. final 클래스: 상속 불가능
  3. 인스턴스 필드: 추가 인스턴스 변수 선언 불가
  4. accessor 네이밍: getX() 대신 x() 사용

 

11.2 성능 고려사항

// 메모리 효율적인 Record 사용
public record LightweightData(int id, String name) {} // 작고 효율적

// 큰 객체는 주의
public record HeavyData(
    byte[] largeArray,  // 주의: 큰 배열
    List<String> items  // 주의: 큰 컬렉션
) {
    public HeavyData {
        // 방어적 복사가 필요할 수 있음
        largeArray = largeArray.clone();
        items = List.copyOf(items);
    }
}