Domain Model과 JPA Entity를 분리한 이유
실전 결제 시스템 구현기
- 01임시 주문 → 실주문 2단계 결제 모델 설계하기
- 02Domain Model과 JPA Entity를 분리한 이유← 현재
- 03Toss Payments 연동: 3계층 구조와 에러 처리 전략
- 04PaymentEvent 이벤트 소싱과 PaymentDetail 다형성
- 05주문/결제 이중 상태 머신과 트랜잭션 분리 전략
이전 편에서 임시 주문과 확정 주문을 분리했다. 이번에는 그 과정에서 내린 또 하나의 설계 결정을 이야기한다. "OrderEntity에 비즈니스 로직을 넣을 것인가, 말 것인가."
Entity에 로직을 넣는 게 당연하다고 생각했다
클린 아키텍처 책에도, DDD 책에도 "도메인 객체에 비즈니스 로직을 넣어라"라고 한다. 처음에는 당연히 OrderEntity에 결제 완료, 상태 전이, 금액 검증 로직을 넣었다.
// 처음 코드 — Entity에 비즈니스 로직 혼재
@Entity
@Table(name = "orders")
public class OrderEntity extends SoftDeleteBaseAuditing {
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@BatchSize(size = 100)
private List<OrderProductEntity> products;
private BigDecimal totalPaymentAmount;
private Long paymentId;
public void completePayment(final Long paymentId) {
if (!this.status.isPayable()) {
throw new CommerceException(CommerceErrorType.INVALID_ORDER_STATUS);
}
this.status = OrderStatus.PAID;
this.paymentId = paymentId;
}
public void validatePayable(final BigDecimal paymentAmount) {
if (!this.status.isPayable()) {
throw new CommerceException(CommerceErrorType.INVALID_ORDER_STATUS);
}
if (this.totalPaymentAmount.compareTo(paymentAmount) != 0) {
throw new CommerceException(CommerceErrorType.TOTAL_AMOUNT_NOT_MATCH);
}
}
}나쁘지 않아 보였다. 실제로 동작도 잘 했다. 문제가 드러난 건 테스트를 작성할 때였다.
테스트에서 터진 문제들
completePayment()를 단위 테스트하려고 했다. PENDING 상태에서 호출하면 PAID로 바뀌는지, PAID 상태에서 호출하면 예외가 던져지는지 확인하는 간단한 테스트.
그런데 OrderEntity를 생성하려면 JPA가 요구하는 것들이 있었다.
// 이 테스트를 쓰고 싶었다
@Test
void completePayment_결제_가능_상태에서_성공() {
// given — 여기서 막힌다
final OrderEntity order = OrderEntity.builder()
.status(OrderStatus.PENDING)
.totalPaymentAmount(BigDecimal.valueOf(50000))
.build();
// ❌ @NoArgsConstructor(access = PROTECTED)라 빌더를 써도
// JPA 프록시, @Id 자동생성 등의 제약이 따라온다
// when
order.completePayment(1L);
// then
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
}JPA Entity를 "순수하게" 테스트하는 게 생각보다 까다로웠다.
@Id가@GeneratedValue로 설정되어 있어서,persist없이는 id가 null이다@OneToMany관계 때문에 products를 초기화하지 않으면 NullPointerException@BatchSize,@Where,@EntityGraph같은 어노테이션이 실제 DB 없이는 의미가 없다- Hibernate 프록시 때문에
equals()동작이 일반 객체와 다르다
결국 @DataJpaTest로 통합 테스트를 쓰게 됐는데, 비즈니스 로직 하나 검증하려고 DB를 올리는 게 맞나? 라는 의문이 들었다.
validatePayable()을 테스트할 때, OrderEntity를 생성하고 totalPaymentAmount를 설정하려면 결국 @DataJpaTest + EntityManager.persist()를 거쳐야 했다. 테스트 하나에 2초. 비즈니스 로직 테스트 20개면 40초. CI가 점점 느려졌다.
분리를 결심한 순간
결정적인 계기는 불변 객체를 만들 수 없다는 점이었다.
CLAUDE.md에도 적어뒀듯이 우리 팀은 도메인 객체의 불변성을 중요하게 생각한다. toBuilder()로 새 객체를 반환하는 패턴을 쓰고 싶었다. 하지만 JPA Entity는 태생적으로 mutable이다. Hibernate가 Dirty Checking으로 변경을 감지하려면 setter 또는 필드 직접 수정이 필요하다.
// 이렇게 쓰고 싶었다 — 상태 변경 시 새 객체 반환
public Order completePayment(final Long paymentId) {
if (!this.status.isPayable()) {
throw new CommerceException(CommerceErrorType.INVALID_ORDER_STATUS);
}
return this.toBuilder()
.status(OrderStatus.PAID)
.paymentId(paymentId)
.build();
}
// 하지만 JPA Entity에서는 이게 안 된다
// Hibernate가 변경을 추적하려면 같은 인스턴스의 필드를 수정해야 한다그래서 결론을 내렸다. 비즈니스 로직은 순수 도메인 모델에, 영속성은 JPA Entity에. 둘을 물리적으로 분리하자.
분리된 구조
Order Context — Domain vs Infrastructure
Order — 도메인 모델
JPA 어노테이션이 하나도 없다. @Builder(toBuilder = true)로 불변 객체의 상태 변경을 표현한다.
@Getter
@Builder(access = AccessLevel.PRIVATE, toBuilder = true)
public class Order {
private final Long id;
private final String orderNumber;
private final OrderStatus status;
private final OrderType type;
private final BigDecimal totalAmount;
private final BigDecimal totalPaymentAmount;
private final Long paymentId;
private final String officeId;
private final List<OrderProduct> products;
private final boolean enable;
public Order completePayment(final Long paymentId) {
if (!this.status.isPayable()) {
throw new CommerceException(CommerceErrorType.INVALID_ORDER_STATUS);
}
return this.toBuilder()
.status(OrderStatus.PAID)
.paymentId(paymentId)
.build();
}
public Order failPayment(final Long paymentId) {
return this.toBuilder()
.status(OrderStatus.FAILED)
.paymentId(paymentId)
.build();
}
public void validatePayable(final BigDecimal paymentAmount) {
if (!this.status.isPayable()) {
throw new CommerceException(CommerceErrorType.INVALID_ORDER_STATUS);
}
if (this.totalPaymentAmount.compareTo(paymentAmount) != 0) {
throw new CommerceException(CommerceErrorType.TOTAL_AMOUNT_NOT_MATCH);
}
}
}테스트가 깨끗해졌다
분리 후 테스트가 이렇게 바뀌었다. JPA 컨텍스트 없이, 밀리초 단위로 실행된다.
class OrderTest {
@Test
void completePayment_결제_가능_상태에서_PAID로_변경된다() {
// given — 순수 객체 생성, DB 불필요
final Order order = Order.builder()
.status(OrderStatus.PENDING)
.totalPaymentAmount(BigDecimal.valueOf(50000))
.build();
// when
final Order paid = order.completePayment(1L);
// then
assertThat(paid.getStatus()).isEqualTo(OrderStatus.PAID);
assertThat(paid.getPaymentId()).isEqualTo(1L);
// 원본은 불변 — PENDING 그대로
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
}
@Test
void completePayment_이미_결제된_주문은_예외를_던진다() {
// given
final Order order = Order.builder()
.status(OrderStatus.PAID)
.build();
// when & then
assertThatThrownBy(() -> order.completePayment(2L))
.isInstanceOf(CommerceException.class);
}
@Test
void validatePayable_금액이_다르면_예외를_던진다() {
// given
final Order order = Order.builder()
.status(OrderStatus.PENDING)
.totalPaymentAmount(BigDecimal.valueOf(50000))
.build();
// when & then
assertThatThrownBy(() -> order.validatePayable(BigDecimal.valueOf(30000)))
.isInstanceOf(CommerceException.class);
}
}Entity에 로직이 있을 때 @DataJpaTest 기반 테스트 20개 = 약 40초. 분리 후 순수 단위 테스트 20개 = 약 0.3초. 130배 빨라졌다. CI 파이프라인에서 체감이 확 됐다.
Mapper의 비용 — 이걸 감수할 가치가 있나?
분리하면 Mapper가 생긴다. 솔직히 이 코드는 재미없는 보일러플레이트다.
@Component
public class OrderMapper {
public Order toDomain(final OrderEntity entity) {
return Order.builder()
.id(entity.getId())
.orderNumber(entity.getOrderNumber())
.status(entity.getStatus())
.type(entity.getType())
.totalAmount(entity.getTotalAmount())
.totalPaymentAmount(entity.getTotalPaymentAmount())
.paymentId(entity.getPaymentId())
.officeId(entity.getOfficeId())
.products(toProductDomains(entity.getProducts()))
.enable(entity.isEnable())
.build();
}
public OrderEntity toEntity(final Order order) {
return OrderEntity.builder()
.orderNumber(order.getOrderNumber())
.status(order.getStatus())
// ... 나머지 필드 매핑
.build();
}
}"이 매핑 코드를 쓸 가치가 있는가?"라고 팀에서 논의했다. 결론은 도메인에 따라 다르다였다.
| 상황 | 판단 |
|---|---|
| 비즈니스 로직이 복잡하고 상태 전이가 많다 (결제) | 분리할 가치 있음 |
| 단순 CRUD (검색 마스터 데이터) | Entity에 로직 포함이 더 간결 |
| 도메인 테스트가 핵심인 영역 | 분리할 가치 있음 |
| 필드가 3-4개인 간단한 엔티티 | 과설계 |
결제 도메인은 상태 전이 규칙이 복잡하고, 금액 검증 로직이 많고, 이 로직들이 정확히 동작하는지 빠르게 검증해야 했다. Mapper 30줄을 감수할 만한 가치가 있었다.
반면 Searching 모듈의 SkillEntity, MajorEntity 같은 마스터 데이터는 분리하지 않았다. 로직이라고 할 게 name 중복 체크 정도밖에 없었으니까.
Repository 구현체
Repository 인터페이스는 Order(도메인 모델)를 반환한다. 구현체에서 Mapper로 변환한다.
// Port — 도메인 계층에 정의
public interface OrderRepository {
Order save(Order order);
Order getByOrderNumber(String orderNumber);
Order getByOrderNumberWithLock(String orderNumber);
}
// Adapter — 인프라 계층에서 구현
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public Order save(final Order order) {
final OrderEntity entity = mapper.toEntity(order);
final OrderEntity saved = jpaRepository.save(entity);
return mapper.toDomain(saved);
}
@Override
public Order getByOrderNumberWithLock(final String orderNumber) {
return jpaRepository.findByOrderNumberWithLock(orderNumber)
.map(mapper::toDomain)
.orElseThrow(() -> new CommerceException(CommerceErrorType.NOT_FOUND));
}
}Service 계층에서는 OrderRepository만 의존한다. JPA의 존재를 모른다. 나중에 JPA를 다른 것으로 교체해도 Service 코드는 한 줄도 안 바뀐다. 실제로 교체할 일이 있을까? 아마 없을 거다. 하지만 테스트에서 Mock Repository를 쓸 수 있다는 건 확실한 이득이었다.
다음 편에서는 Toss Payments를 연동하면서 3계층으로 나눈 이유와 에러 처리에서 삽질한 이야기를 한다.