⌨️ 도메인 설계 및 구현 회고
우리 프로젝트는 우선 Java 17을 이용하여 개발하였다. 백엔드 팀원 전부 가 Java Record를 활용해보고 싶어 했기에, 그리고 11에서 17로의 마이그레이션 시의 장·단점을 알고 싶었기에 17을 선택했다.
또한, 기존 캠프 학습 간 배운 MapStruct 방식의 Mapper 클래스 구현이 아닌, from, to 방식을 활용했다.
1. DTO 그리고 Java record
먼저 가볍게 DTO에 대한 개념부터 잡고가는 것이 좋을 것 같다.
1.1. DTO 란?
DTO는 Data Transfer Object의 약자로, 계층 간(Controlelr, View, Business Layer) 데이터 교환을 위한 Java Bean를 의미한다. DTO는 로직을 가지지 않는 데이터 객체이고, getter, setter 메소드만 가진 클래스를 의미한다.
public class Product {
private String name;
private String price;
private String brand;
public Product() {
}
public Product(String name, String price, String brand) {
this.name = name;
this.price = price;
this.brand = brand;
}
public String getName() {
return name;
}
public String getPrice() {
return price;
}
public String getBrand() {
return brand;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(String price) {
this.price = price;
}
public void setBrand(String brand) {
this.brand = brand;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product)) return false;
Product product = (Product) o;
return Objects.equals(name, product.city) &&
Objects.equals(price, product.street) &&
Objects.equals(brabd, product.zipCode);
}
@Override
public int hashCode() {
return Objects.hash(name, price, brand);
}
}
우선 가장 레거시하게 구현해본 DTO다 보일러 플레이트 코드가 굉장히 많은 걸 알 수 있다.
(※ 보일러 플레이트 코드 : 최소한의 변경(인자, 혹은 결과 타입)으로 여러 곳에서 재사용되면 반복적으로 비슷한 형태를 가지고 있는 코드 → getter, setter, equals, hashCode, toString 등이 여기에 해당)
위와 같은 경우에 Java 진영에서는 lombok이나 IDE를 활용하여 해결을 시도하고 있다.
@Getter
@Setter
public class Product {
private String name;
private String price;
private String brand;
}
위와 같은 형태로 말이다. 코드가 굉장히 간결해지고 보기 좋지만, 근본적으로 Java가 가진 한계를 극복할 수는 없기에 해당 부분에서의 좋은 활용을 보여주는 Kotlin에 많이 밀리는 모습을 보여주고 있었다.
1.2. Java record 의 등장
위에서와 같이 DTO를 구현하는 데에 있어서, Java로 구현하기에 아쉬움이 많이 남은 점이 있다.
Java 진영에서 느낀 위기감은 Java의 한계를 극복해내기 위해 다양한 기능을 추가하고, 그중 하나가 record이다.
record의 목표는 아래와 같습니다.
- OOP에 맞게 데이터를 간결하게 표현하기 위한 방법을 제공
- 개발자가 동작을 확장하는 것보다 불변 데이터를 모델링하는데 집중하도록 함
- 데이터 지향 메서드를 자동으로 구현
- 단, java beans를 대체하기 위한 기술은 아님 + 어노테이션 지향적인 코드를 생성하기 위한 기능도 아님!
1-2-1. record의 구조
public record Product(String name, String price, String brand)
{}
record의 구조를 살펴보겠습니다. class 선언 시 들어가는 class 대신 record를 사용합니다. 레코드명(헤더), {바디}의 구조를 가지는데 헤더에 나열되는 필드를 컴포넌트라고 부릅니다. 위의 예제 코드로 보면 이름은 Product, private final 필드를 name, price, brand를 가진 record라고 볼 수 있습니다.
컴파일러는 헤더를 통해 내부 필드를 추론하는데, 이때 String 타입의 name, price, brand가 있다는 것을 인식하게 됩니다. 이후 코드에 명시적으로 접근자와 생성자, toString, equals, hashCode를 선언하지 않아도 이에 대한 구현을 자동으로 제공합니다.
단, getter를 사용할 때 getName(), getPrice()로 쓰는 게 아니라 필드명, 즉 컴포넌트의 이름만 사용하면 됨. 즉, name(), price()처럼 필드의 이름으로 사용합니다,
1-2-2. record 특징
- record는 불변 객체로 abstract로 선언할 수 없으며 암시적으로 final로 선언됩니다. 한 번 값이 정해지면 setter를 통해 값을 변경할 수 없으며 상속을 할 수 없습니다.
- record 내 각 필드(헤더에 나열한 컴포넌트)는 private final로 정의됩니다.
- 다른 클래스를 상속받을 수 없습니다만, 인터페이스로는 구현이 가능합니다. (extends : X, implements : O)
- 레코드 내부에 멤버 변수(인스턴스 필드)를 선언할 수 없습니다. 그러나 static 변수는 생성이 가능합니다. 이는 헤더에서 정의한 멤버만을 record에서 관리하기 위함입니다.
- 위의 4가지 주요 특징을 제외하고는 자바의 클래스 개발과 동일하게 사용할 수 있습니다.
- new 키워드를 통해 객체화 가능
- static 메서드, static 필드 선언 가능
- 중첩 클래스 사용 가능 및 제너릭 타입으로 지정 가능
1-2-3. body의 재정의
record의 body 부분은 자동으로 생성된 메소드 혹은 새로운 메서드를 추가로 작성할 수 있습니다. 쉽게 생각해 클래스 내부에 작성한다고 보시면 됩니다.
1-2-4. 콤팩트 생성자
콤팩트 생성자는 생성자 매개 변수를 받는 부분이 사라진 형태입니다. 개발자가 명시적으로 인스턴스 필드를 초기화 하지 않아도 컴팩트 생성자의 마지막 부분에 초기화 구문이 자동으로 삽입됩니다. 일반적으로 사용하는 표준 생성자와는 달리 컴팩트 생성자 내부에서는 인스턴스 필드에 접근할 수 없습니다.
이러한 이유로 컴팩트 생성자에는 컴포넌트로 들어온 값을 불변으로 만들거나 불변식이 만족하는지(유효성 체크, 예를 들어 null check) 등의 작업을 하기에 적합합니다.
Gavin Bierman이 말한 컴팩트 생성자의 선언 의도는 생성자 본문에 검증 및 정규화용 코드만 넣어야 한다는 것으로 나머지 초기화 코드는 컴파일러가 자동으로 수행해 개발자는 검증에만 집중할 수 있도록 한 것에 있습니다.
public record Gundam(String name, String price, String brand) {
public Product {
Objects.requireNonNull(name);
Objects.requireNonNull(price);
Objects.requireNonNull(brand);
}
}
이렇게 선언한 컴팩트 생성자는 일반 생성자 쓰듯이 똑같이 사용하면 됩니다.
Product fruit = new Product("apple", "3000", "농협");
혹은 of를 통한 팩토리 메서드 기반의 생성도 가능하다.
1.3. Java record의 한계
여기까지 보면 우리는 record를 활용하여 멋지게 개발을 할 수 있을 것 같다.
하지만 결국 entity로는 활용될 수 없기에 우리는 entity를 기존의 class 방식의 자바 파일로 구현해야 한다.
그렇다 결국 lombok을 활용하여 구현해야 한다는 소리다.
우선 record가 entity가 될 수 없는 이유를 알아보자
- hibernate와 같은 jpa는 프락시 생성을 위해 인수 생성자, non-final 필드, setter 및 non-final 클래스가 없는 엔티티에 의존합니다. 즉, 프락시를 생성하기 위해서 entity는 불변이면 안됩니다.(jpa의 프락시는 일대일 매핑 시 지연 로딩 제공 등 다양하게 쓰입니다.)
- 쿼리 결과를 매핑할 때 객체를 인스턴스화할 수 있도록 매개변수가 없는 생성자가 필요합니다. → record는 매개변수가 없는 생성자를 제공하지 않습니다.(record는 불변 객체이기 때문에 setter를 사용할 수 없습니다. 이로 인해 모든 필드의 값을 입력한 후에 생성할 수 있습니다)
- 접근자 메서드인 getter가 필수 명명 규칙을 따르지 않습니다. record의 getter는 필드명을 그대로 사용하고 있습니다.(name(), price() …) → 쿼리 결과 처리 후 수행할 getter, setter에 접근할 수 없습니다.
우리는 Java의 한계를 극복하기 위해 record를 활용하려 했으나, 결국 마지막 관문은 넘지 못한 것이다.
그렇다면 record는 어떻게 활용을 할 수 있을까?
답은 DTO 활용에 있는 것 같다.
기본적으로 Java class로 구현한 DTO는 @Setter 가 가능하기에 데이터의 정합성을 보장해주지 못한다. 물론 @Setter를 안 쓰면 그만이지 않은가? 란 의문을 품을 수 있지만, 근본적으로 가변성이냐, 불변성이냐 주는 장점이 있다.
이로 인해 우리는 DB에서 Entity를 가져와 DTO로 변환할 때 데이터의 정합성을 보장해 주는 record를 활용한 개발을 해보기로 한 것이다.
2. Mapstruct
부트캠프 수료 시에 배운 방법은 Mapstruct를 활용한 Mapper 클래스의 구현이다.
@Mapper(componentModel = "Spring")
public interface CoffeeMapper {
Coffee coffeePostDtoToCoffee(CoffeePostDto coffeePostDto);
Coffee coffeePatchDtoToCoffee(CoffeePatchDto coffeePatchDto);
CoffeeResponseDto coffeeToCoffeeResponseDto(Coffee coffee);
}
위와 같은 양식으로 활용을 하며 필요할 시 내부의 내용을 set()을 통해 변경한다. 따라서
DTO의 구현 또한 기존의 Java class를 활용한
public class CoffeePostDto {
@NotBlank
private String korName;
@NotBlank
@Pattern(regexp = "^([A-Za-z])(\\s?[A-Za-z])*$",
message = "커피명(영문)은 영문이어야 합니다(단어 사이 공백 한 칸 포함). 예) Cafe Latte")
private String engName;
@Range(min= 100, max= 50000)
private int price;
public String getKorName() {
return korName;
}
public void setKorName(String korName) {
this.korName = korName;
}
public String getEngName() {
return engName;
}
public void setEngName(String engName) {
this.engName = engName;
}
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
}
위와 같은 방식이 될 수밖에 없다. 비록 @Getter @Setter를 활용하여 코드를 간결하게 한다 해도 말이다.
결국 @Mapper 즉, Mapstruct를 활용하여 개발을 하기엔 record 자체는 서로 전제 조건부터가 맞지 않는다.
3. From To 방식
확실한 명명법을 찾지 못하여 우선 임의로는 From To 방식으로 부르고 있다.
DTO 통신에 있어 불필요한 쿼리문의 배제 등 다양한 여건을 고려해야 했기에, 우선 DTO의 flow부터 도식화해보았다.
도식화된 내용에 따라 직관성 있게 구현할 수 있다.
예를 들어 Request 가 Dto로 되려면 toDto, Dto가 Entity가 되려면 toEntity이다.
예시 코드는 프로젝트 간 작성한 코드로 대체하겠다.
public record ProductReviewRequest(
Long productId,
@NotBlank(message = "내용 입력은 필수입니다.") String content,
@NotBlank(message = "별점은 필수입니다.") Integer reviewStar
) {
public static ProductReviewRequest of(Long productId, String content, Integer reviewStar ) {
return new ProductReviewRequest(productId, content, reviewStar);
}
public ProductReviewDto toDto(ProfileDto profile) {
return ProductReviewDto.of(
productId,
profile.id(),
content,
reviewStar
);
}
}
public record ProductReviewDto(
Long id,
Long productId,
Long profileId,
Integer reviewStar,
Long like,
String content,
LocalDateTime createdAt,
LocalDateTime modifiedAt,
String createdBy,
String modifiedBy,
List<ProductReviewImageDto> productReviewImageDtos
)
{
public static ProductReviewDto of(Long id, Long productId,Long profileId, Integer reviewStar,Long like, String content, LocalDateTime createdAt, LocalDateTime modifiedAt, String createdBy, String modifiedBy,List<ProductReviewImageDto> productReviewImageDtos){
return new ProductReviewDto(id, productId, profileId,reviewStar, like, content, createdAt, modifiedAt, createdBy, modifiedBy,productReviewImageDtos);
}
public static ProductReviewDto of(Long productId,Long profileId, String content,Integer reviewStar){
return new ProductReviewDto(null, productId, profileId,reviewStar,null, content,null, null, null, null,null);
}
public static ProductReviewDto from(ProductReview entity, ProductReviewLike likeEntity) {
return new ProductReviewDto(
entity.getId(),
entity.getProduct().getId(),
entity.getProfile().getId(),
entity.getReviewStar(),
likeEntity.getLikeNum(),
entity.getContent(),
entity.getCreatedAt(),
entity.getModifiedAt(),
entity.getCreatedBy(),
entity.getModifiedBy(),
entity.getProductReviewImages().stream()
.map(ProductReviewImageDto::from)
.collect(Collectors.toList())
);
}
public static ProductReviewDto from(ProductReview entity) {
return new ProductReviewDto(
entity.getId(),
entity.getProduct().getId(),
entity.getProfile().getId(),
entity.getReviewStar(),
entity.getProductReviewLike().getLikeNum(),
entity.getContent(),
entity.getCreatedAt(),
entity.getModifiedAt(),
entity.getCreatedBy(),
entity.getModifiedBy(),
entity.getProductReviewImages().stream()
.map(ProductReviewImageDto::from)
.collect(Collectors.toList())
);
}
public ProductReview toEntity(Product product, Profile profile) {
return ProductReview.of(
content,
reviewStar,
product,
profile
);
}
}
위 두 코드에서 보면 알 수 있듯이 내가 원하는 Dto를 조립하여 Entity를 만들 수도 있고 내가 원하는 Entity를 뽑아 Dto로도 만들 수 있다.
어려워 보이지만, 내가 원하는 정보들을 직관적으로 구성할 수 있다는 장점이 있는 것 같다.
또한, Entity를 뽑아올 때 혹여라도 @Setter를 통해 손상시킬 수 있는 부분은 record로 방지해주고 있다.
각자의 방식의 장·단점이 있겠지만, 우리는 이번 프로젝트에서는 record가 가진 장점을 더욱 매력적으로 느껴 새로운 구조를 설계해야 했음에도 도전해 본 것 같다.
4. 피드백
- DTO, DAO, VO, Entity에 대한 개념 조금 더 공부해 보기
- JPA에서 record를 Entity로 변환할 수 없는 부분에 대해 더욱 이해하기
- Entity를 어느 계층까지 노출시켜도 될지 고민해 보기
- record의 한계를 고려하여 결국 다른 방향성은 없었나 생각해 보기