빌더 패턴을 사용하면 객체를 생성할 때 많은 이점을 얻을 수 있다. (링크 참조)
하지만 빌더 패턴을 사용하면 많은 코드를 작성해야 하는 단점이 존재하나 Lombok의 @Builder 어노테이션 을 사용하면 이러한 단점을 보완할 수 있다.(성능 저하라는 단점도 있으나 미미한 편)
@Builder + @AllArgsConstructor
Builder를 사용하면 모든 필드를 매개변수로 받는 생성자를 필요로 한다.
** @Builder 어노테이션 내부 설명
* If a member is annotated, it must be either a constructor or a method. If a class is annotated,
* then a package-private constructor is generated with all fields as arguments
* (as if {@code @AllArgsConstructor(access = AccessLevel.PACKAGE)} is present
* on the class), and it is as if this constructor has been annotated with {@code @Builder} instead.
* Note that this constructor is only generated if you haven't written any constructors and also haven't
* added any explicit {@code @XArgsConstructor} annotations. In those cases, lombok will assume an all-args
* constructor is present and generate code that uses it; this means you'd get a compiler error if this
* constructor is not present.
생성자가 없는 경우엔 @Builder 어노테이션에서 자동 생성해주지만, 다른 생성자가 있는 경우엔 @AllArgsConstructor로 생성자를 추가하여 사용할 수 있다.
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor // @Entity는 빈 생성자(기본 생성자)가 필요
@AllArgsConstructor // @NoArgsConstructor가 있기 때문에 명시적으로 @AllArgsConstructor를 추가
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Builder.Default
@Column(name = "status", nullable = false)
private String status = ORDER_CREATE_STATUS;
// ...필드 및 메서드 생략
// 주문 정보 검증
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}
이 Entity 클래스를 생성하고 데이터베이스에 저장하기 위해 다음과 같이 사용할 수 있다.
주문 정보를 생성하고 검증 후 저장하는 간단한 서비스 코드이다.
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private final Long userId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.build();
}
}
// service.java
public class OrderService {
// ...
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
// 1. 주문 생성
OrderInfo createdOrderInfo = orderCreateRequest.toEntity(
userService.getById(orderCreateRequest.getUserId()),
productService.getById(orderProduct.getProductId())
);
// 2. 주문 상품 정보 검증
createdOrderInfo.validateProductInfo(
orderCreateRequest.getAmount(),
orderProduct.getQuantity()
);
// 3. 주문 정보 저장
OrderInfo createdOrder = orderInfoRepository.save(createdOrderInfo);
return new OrderCreateResponse(createdOrder);
}
// ...
}
서비스 로직을 살펴보면 크게 세 가지 단계로 나눌 수 있다.
주문 생성
주문 상품 정보 검증
주문 정보 저장
위 코드에서는 2번 단계를 성실하게 수행하여 결과적으로 데이터베이스에 검증이 완료 된 주문 정보만 저장할 수 있게 되었다.
하지만 2번 단계를 생략하여 저장하게 되면 올바르지 않은 주문 정보가 데이터베이스에 저장될 수 있다.(다른 메서드에서 생성하거나 기존 코드가 수정되는 등)
때문에 2번 단계를 서비스 코드에서 수행하는 것이 아닌 Entity 클래스에서 생성할 때 검증하는 것이 더 안전하다고 볼 수 있다.
생성 시 검증 로직 추가
@Builder 어노테이션을 사용하면서 생성 시 검증 로직을 추가하는 방법으로 두 가지를 생각해볼 수 있다.
build() 메서드를 재작성
생성자에서 검증 로직 수행
build() 메서드 재작성
@Builder 어노테이션을 사용하면 build() 메서드를 호출하여 인스턴스를 생성하게 되는데, 이 메서드를 재작성할 수 있다.
// entity.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor
@AllArgsConstructor
public class OrderInfo extends BaseTime {
// @Builder.Default가 정상적으로 동작하지 않은 것을 제외하고 위의 코드와 동일
public static class OrderInfoBuilder {
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
// build() 메서드 재작성
public OrderInfo build(BigDecimal totalAmount, Integer quantity) {
this.validateProductInfo(totalAmount, quantity);
return new OrderInfo(
this.id,
this.user,
this.product,
this.orderId,
this.paymentKey,
this.orderName,
this.method,
this.quantity,
this.totalAmount,
this.status,
this.requestedAt,
this.approvedAt,
this.lastTransactionKey
);
}
}
}
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private static final String ORDER_CREATE_STATUS = "READY";
private final Long userId;
private final String orderId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.orderId(this.orderId)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.status(ORDER_CREATE_STATUS) // @Build.Default 동작하지 않아 직접 추가
.build(amount, this.orderProduct.getQuantity()); // build() 호출 시 필요한 매개변수 추가
}
}
build() 메서드를 호출 할 때 필요한 매개변수를 추가하여 검증 로직을 수행할 수 있게 되었지만, 많은 단점이 생겼다.
@Builder.Default가 정상적으로 동작하지 않아 기본 값을 직접 할당해야 함
해당 클래스의 생성자를 직접 호출하는 경우 검증 로직이 수행되지 않음
코드의 양이 많아지고 굉장히 복잡해짐
2번의 문제 같은 경우엔 @AllArgsConstructor(access = PROTECTED)를 사용하여 외부에서 생성자를 호출하지 못하도록 제한할 수 있지만,
1, 3번의 문제는 여전히 존재하고, 2번의 문제도 여전히 내부에서 생성자를 호출하는 경우에는 검증 로직이 수행되지 않는다.
이 방식을 사용하면 불필요한 매개변수까지 포함되는 것을 방지하는 것 뿐만 아니라, 어떤 필드가 생성 시 전달 받고 기본 값으로 할당되는지도 명확하게 알 수 있다.
결론
@Builder 어노테이션을 사용하는 경우 롬복을 사용하고 있기 때문에 무의식적으로 @AllArgsConstructor를 사용할 수 있다.
@AllArgsConstructor를 사용하면 모든 필드를 매개변수로 받는 생성자가 생성 될 뿐만 아니라 생성 시 검증 로직을 수행할 수 없게 된다.
때문에 생성자에 @Builder 어노테이션을 사용하여 꼭 필요한 파라미터만 받을 수 있도록 하고, 생성 시 검증 로직을 수행할 수 있도록 하는 것이 좋다.