SY 개발일지

프로젝트를 진행하며 같이 스터디하는 분께서 코드 리뷰 중에 DTO에 Record를 적용해보면 어떻겠냐는 의견을 제시해주었습니다.

 

그래서 이번 포스팅에서는 Record에 대해 살펴보고, 프로젝트 DTO에 Record를 적용해보고자 합니다.

Record 란?

Record는 자바 14, 15 에서 preview로 추가된 이후 16버전 부터 정식 스펙이 되었습니다.

2024년 6월 12일 현재, 스프링부트2의 지원이 종료되며 스프링부트3을 이용해 프로젝트를 진행하고 있는데, 스프링부트3부터는 자바17버전 이상만 지원을 하다보니 이러한 Record에 대해 알아보면 더 좋을 것 같습니다. 🤔

 

등장 배경

일단 기본적인 DTO를 생각해봅시다.

DTO를 구현할 때에는 필드를 명시하고, 해당 필드와 관련있는 getter, setter, equals, hashCode, toString 함수 등과 같이 데이터를 처리하거나 특정 연산을 수행하기 위해 여러 메서드들을 각 DTO마다 반복적으로 오버라이드해서 구현해야 합니다.

class Point {
    private final int x;
    private final int y;

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

    int x() { return x; }
    int y() { return y; }

    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return other.x == x && other.y == y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() {
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}

 

실제로 필요한 값은 x와 y 뿐이지만, 이를 이용하기 위해서는 위와 같은 메소드들을 모두 구현해야 합니다. 즉, 이러한 코드들은 보일러 플레이트 코드가 불필요하게 큰 것을 알 수 있습니다. 

보일러 플레이트 코드
보일러 플레이트 코드는 컴퓨터 프로그래밍에서 사용되는 용어로 최소한의 변경으로 여러 고에서 재사용되며, 반복적으로 비슷한 형태를 띄는 코드를 의미합니다. 쉽게 말해 프로그래밍에서 반복되는 작업이나 패턴에 대한 일종의 표준화된 코드를 의미합니다.

 

비대해진 보일러 플레이트 코드는 자바가 갖는 단점입니다. 물론 @Lombok이나 IDE의 도움으로 코드를 간결하게 만들 수 있지만, 이는 근본적으로 자바가 가지고 있는 한계를 해결하지 못합니다.

 

이렇게 자바가 가지고 있는 한계를 극복하기 위해 자바를 업그레이드하면서 다양한 기능을 추가하게 되었는데, 그 기능 중 하나가 바로 record입니다.

 

Record의 목표

✅ 객체 지향의 사상에 맞게 데이터를 간결하게 표현하기 위한 방법 제공

개발자가 동작을 확장하는 것보다 불변 데이터를 모델링하는 데 집중하도록 함

데이터 지향 메소드를 자동으로 구현

오랫동안 유지되고 있는 자바의 사상과 호환되도록 함

 

목표가 아닌 것

java beans의 명명 규칙을 사용하는 변경가능한 클래스들의 문제점을 해결하기 위한 것은 아님

속성 혹은 어노테이션 지향적인 코드를 생성하기 위한 기능도 아님

 

Record의 특징

  • Record는 불변 객체로, abstract으로 선언할 수 없으며 암시적으로 final로 선언됩니다. 따라서 한번 값이 정해지면 setter를 통해 값을 변경할 수 없고, 상속도 불가능합니다.
  • Record내 각 필드는 private final로 정의됩니다.
  • 다른 클래스를 상속받을 수 없지만, 인터페이스로는 구현이 가능합니다.

 

내부적으로 다음과 같이 사용하면 됩니다.

record Point(int x, int y) {
    // Implicitly declared fields
    private final int x;
    private final int y;

    // Other implicit declarations elided ...

    // Implicitly declared canonical constructor
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

 

여기서 주의할 점은 this.x = x와 this.y = y처럼 값을 할당해주어야 한단 것입니다. Record는 private final과 동일하기 때문에 객체가 생성되는 시점에 값을 할당해주어야 합니다.

 

프로젝트에 적용해보기

지금까지의 내용은 개념적이었으니까 이제 프로젝트에 적용해보도록 하겠습니다.

현재 피드를 추가할 때 사용해주는 DTO는 다음과 같이 생겼습니다.

@Getter
public class AddFeedRequest {

  @NotNull(message = "피드 작성 가게는 필수 영역입니다.")
  @Min(value = 0, message = "가게 ID는 음수가 될 수 없습니다.")
  private Long storeId;

  @Size(max = 2000, message = "피드 내용은 최대 2000자까지 가능합니다.")
  @NotBlank(message = "피드 내용은 필수 영역입니다.")
  private String content;
}

 

이를 Record로 변환하면 다음과 같습니다.

 

public record AddFeedRequest(
    @NotNull(message = "피드 작성 가게는 필수 영역입니다.")
    @Min(value = 0, message = "가게 ID는 음수가 될 수 없습니다.")
    Long storeId,

    @Size(max = 2000, message = "피드 내용은 최대 2000자까지 가능합니다.")
    @NotBlank(message = "피드 내용은 필수 영역입니다.")
    String content) {

}

 

만일 내부에 함수가 있다면, 그 함수는 {} 내에 명시해주면 됩니다.

이렇게 하면 바로 필드값을 사용하는 것이기 때문에 기존에 getter를 이용해 가져왔다면, 이제 필드값을 이용해 가져오면 됩니다.

 

따라서,

@Override
@Transactional
public void addFeed(Long userId, AddFeedRequest req, List<MultipartFile> images) {
	...

  Store store = storeRepository.findById(req.getStoreId())
      .orElseThrow(() -> new RestApiException(UserErrorCode.INVALID_USER_ID,
          "사용자ID가 유효하지 않습니다. [사용자ID=" + req.getStoreId() + "]"));
	...
}

 

이런 코드를

@Override
@Transactional
public void addFeed(Long userId, AddFeedRequest req, List<MultipartFile> images) {
	...

  Store store = storeRepository.findById(req.getStoreId())
      .orElseThrow(() -> new RestApiException(UserErrorCode.INVALID_USER_ID,
          "사용자ID가 유효하지 않습니다. [사용자ID=" + req.storeId() + "]"));
	...
}

 

다음과 같이 변환 가능합니다.

 

물론 각자의 장단점이 있지만, DTO 특성상 변경될 일이 거의 없기 때문에 record를 사용하는 것도 좋은 방법인 것 같습니다. 

 

 

 

참고

 

profile

SY 개발일지

@SY 키키

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!