Engineering Note

[SW Engineering] 쇼핑몰 도메인 모델 설계를 통해 알아보는 JPA 엔티티 설계 의사결정 과정 본문

SW Engineering

[SW Engineering] 쇼핑몰 도메인 모델 설계를 통해 알아보는 JPA 엔티티 설계 의사결정 과정

Software Engineer Kim 2025. 9. 30. 02:06

이커머스 쇼핑몰 서비스 프로젝트를 하면서 도메인 모델을 설계한 경험을 공유합니다. 단순히 "이렇게 만들었다"가 아닌, 왜 이런 선택을 했는지에 초점을 맞춰 설명합니다.

 

전체 도메인 모델 개요

 

Member (회원)
   ↓ 1:1
Cart (장바구니) ← N:1 → CartItem (장바구니상품) → N:1 → Item (상품)
   
Member (회원)
   ↓ 1:N
Order (주문) ← N:1 → OrderItem (주문상품) → N:1 → Item (상품)

 

 

핵심 엔티티:

  • Member: 회원 정보
  • Item: 상품 정보
  • Cart / CartItem: 장바구니 (주문 전 임시 보관)
  • Order / OrderItem: 주문 (구매 확정)

 

1. Item 엔티티: 기본 설계 결정

설계 의도

상품은 쇼핑몰의 핵심 엔티티로, 독립적으로 존재하며 다른 엔티티들이 참조합니다.

 

@Entity
@Table(name="item")
public class Item extends BaseEntity {
    @Id
    @Column(name="item_id")
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;
    
    @Column(nullable = false, length = 50)
    private String itemNm;
    
    @Column(nullable = false)
    private int price;
    
    @Column(nullable = false)
    private int stockNumber;
    
    @Lob
    @Column(nullable = false)
    private String itemDetail;
    
    @Enumerated(EnumType.STRING)
    private ItemSellStatus itemSellStatus;
}

 

 

의사결정 1: 기본키 전략

선택: GenerationType.AUTO

@GeneratedValue(strategy=GenerationType.AUTO)

 

이유:

  • DB 변경 가능성 고려 (MySQL → PostgreSQL 등)
  • JPA가 데이터베이스에 맞는 전략 자동 선택
  • 개발 초기에는 유연성 우선

트레이드오프:

  • 성능 최적화가 필요하면 나중에 IDENTITY나 SEQUENCE로 변경 가능
  • 현재는 편의성 > 성능 최적화

 

의사결정 2: Enum 타입 저장 방식

선택: EnumType.STRING

@Enumerated(EnumType.STRING)
private ItemSellStatus itemSellStatus;

public enum ItemSellStatus {
    SELL, SOLD_OUT
}

 

이유:

  • EnumType.ORDINAL은 순서가 바뀌면 데이터 오염
  • 예: READY 상태를 중간에 추가하면 기존 데이터 의미 변경
  • 문자열로 저장하면 가독성도 좋고 안전

트레이드오프:

  • 저장 공간 약간 증가 (숫자 vs 문자열)
  • 하지만 안정성이 훨씬 중요

 

의사결정 3: 판매 상태 관리

왜 itemSellStatus 필드가 필요한가?

비즈니스 요구사항:

  • 재고가 없을 때 자동으로 SOLD_OUT 상태로 변경
  • 상품을 미리 등록하고 판매 시점에 SELL 상태로 변경
  • 품절 상품은 화면에 노출하지 않음

단순히 stockNumber == 0 체크로는 이런 요구사항을 만족할 수 없습니다.

 

2. Member-Cart 관계: 일대일 단방향

설계 의도

비즈니스 요구사항:

  • 회원당 장바구니 1개
  • 장바구니는 반드시 회원과 연결

관계 방향 결정

@Entity
@Table(name = "cart")
public class Cart extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @OneToOne
    @JoinColumn(name = "member_id")
    private Member member;
}

@Entity
@Table(name="member")
public class Member extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    
    // Cart 참조 없음
}

 

의사결정: 단방향 매핑

왜 Member에서 Cart를 참조하지 않았나?

비즈니스 로직 분석:

  • 장바구니 서비스: 회원 정보 필요 (Cart → Member 조회 필요)
  • 회원 서비스: 장바구니 정보 불필요 (Member → Cart 조회 불필요)

단방향 선택 이유:

  1. 불필요한 결합도 제거: Member는 Cart 존재를 몰라도 됨
  2. 순환참조 방지: JSON 직렬화나 toString() 문제 예방
  3. 단순성: 양방향은 양쪽 동기화 관리 부담

실제 사용 패턴:

// 필요한 패턴: 장바구니 조회 시 회원 정보 필요
Cart cart = cartRepository.findByMemberId(memberId);
Member member = cart.getMember(); // OK

// 불필요한 패턴: 회원 정보 조회 시 장바구니 불필요
Member member = memberRepository.findById(memberId);
// member.getCart() - 이런 로직이 없음

 

3. Cart-Item 관계: 다대다를 일대다로 풀기

설계 의도

비즈니스 요구사항:

  • 장바구니에 여러 상품 담기
  • 같은 상품이 여러 장바구니에 담김
  • 각 상품별 수량 관리 필요

다대다 해소: CartItem 중간 엔티티

@Entity
@Table(name = "cart_item")
public class CartItem {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "cart_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "cart_id")
    private Cart cart;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    private int count;  // 수량 정보
}

 

의사결정: 중간 엔티티의 필요성

왜 @ManyToMany를 사용하지 않았나?

// 사용하지 않은 방법
@Entity
public class Cart {
    @ManyToMany
    private List<Item> items;  // 수량을 어떻게 저장?
}

 

@ManyToMany의 한계:

  • 추가 정보 저장 불가: count 같은 필드 추가 불가능
  • 중간 테이블 제어 불가: JPA가 자동 생성한 테이블 커스터마이징 어려움
  • 확장성 부족: 나중에 할인율, 옵션 등 추가하기 어려움

CartItem 중간 엔티티의 장점:

  • 수량 관리 가능
  • 나중에 할인가, 선택 옵션 등 확장 가능
  • 장바구니 항목 단위로 삭제/수정 용이

 

의사결정: 관계 방향

선택: CartItem에서만 참조 (단방향)

// CartItem → Cart, Item 참조 O
// Cart, Item → CartItem 참조 X

 

이유:

// 필요한 쿼리 패턴
// 1. 장바구니 내 상품 조회
List<CartItem> items = cartItemRepository.findByCartId(cartId);

// 2. 특정 상품이 담긴 장바구니 조회 (거의 없음)
// List<CartItem> carts = cartItemRepository.findByItemId(itemId);

 

실제 비즈니스 로직에서 "특정 상품이 어떤 장바구니에 담겼는지" 조회는 거의 없습니다. 따라서 Item → CartItem 양방향 매핑은 불필요합니다.

4. Order-OrderItem 관계: 양방향이 필요한 경우

설계 의도

비즈니스 요구사항:

  • 주문에는 여러 상품 포함
  • 주문 총액 계산 필요
  • 주문 상세 조회 필요

 

@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
}

@Entity
@Table(name = "order_item")
public class OrderItem extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    private int orderPrice;  // 주문 당시 가격
    private int count;
}

 

의사결정: 양방향 매핑

왜 Order에서 OrderItem을 참조하는가?

비즈니스 로직 분석:

// 주문 총액 계산
Order order = orderRepository.findById(orderId);
int totalPrice = order.getOrderItems().stream()
    .mapToInt(item -> item.getOrderPrice() * item.getCount())
    .sum();

// 주문 상세 화면
Order order = orderRepository.findById(orderId);
List<OrderItem> items = order.getOrderItems();  // 필요한 패턴

 

양방향이 필요한 이유:

  • 주문 도메인에서 주문 항목 접근이 자주 발생
  • 주문 취소 시 모든 주문 항목 상태 변경 필요
  • 객체 그래프 탐색이 비즈니스 로직을 명확하게 표현

의사결정: orderPrice 필드 추가

왜 Item의 price를 참조하지 않는가?

// OrderItem에 orderPrice 별도 저장
private int orderPrice;  // 주문 당시 가격

 

 

이유:

  • 할인 적용: 회원 등급별 할인, 쿠폰, 프로모션
  • 가격 변동: 상품 가격은 시간에 따라 변함
  • 정확한 주문 이력: 주문 당시 실제 결제 금액 보존

비즈니스 시나리오:

1. 2024-01-01: 상품 가격 10,000원
2. 사용자 주문 (20% 할인 적용) → orderPrice: 8,000원
3. 2024-06-01: 상품 가격 12,000원으로 인상
4. 사용자가 과거 주문 내역 조회
   → item.price(12,000원) 아닌 orderPrice(8,000원) 표시

 

 

5. 성능 vs 정합성: 총액 캐싱 문제

설계 고민

문제 상황: 주문 총액을 매번 계산할 것인가, 미리 저장할 것인가?

옵션 1: 계산 방식 (선택)

// Order 엔티티
public int getTotalPrice() {
    int totalPrice = 0;
    for(OrderItem orderItem : orderItems) {
        totalPrice += orderItem.getTotalPrice();
    }
    return totalPrice;
}

// OrderItem 엔티티
public int getTotalPrice() {
    return orderPrice * count;
}

 

장점:

  • 데이터 정합성 보장
  • OrderItem 변경 시 자동 반영
  • 유지보수 간단

단점:

  • 매번 계산 비용

옵션 2: 캐싱 방식

@Entity
public class Order {
    @Column(name = "total_price")
    private int totalPrice;  // 미리 계산해서 저장
}

 

장점:

  • 조회 성능 최적화
  • 단순 컬럼 읽기

단점:

  • 데이터 동기화 이슈
  • OrderItem 변경 시 totalPrice 업데이트 필요
  • 복잡도 증가

의사결정: 계산 방식 선택

현재 선택 이유:

  1. 조기 최적화 회피: 성능 문제가 실제 발생하지 않음
  2. 데이터 정합성 우선: 금액 불일치는 치명적
  3. 단순성: 유지보수 부담 최소화

향후 계획:

  • 실제 성능 측정 후 결정
  • 필요시 쿼리 최적화 (fetch join, batch size)
  • 그래도 느리면 캐싱 고려
-- 이 쿼리가 실제로 느린지 측정 후 결정
SELECT o.order_id, SUM(oi.order_price * oi.count) as total
FROM orders o
JOIN order_item oi ON o.order_id = oi.order_id
GROUP BY o.order_id

 

배운 점과 설계 원칙

1. 비즈니스 요구사항이 설계를 결정한다

  • 단방향 vs 양방향: 실제 사용 패턴 분석
  • 필드 추가: orderPrice처럼 비즈니스 규칙 반영

2. 조기 최적화 대신 데이터 정합성 우선 고려

  • 성능 문제가 없으면 단순하게
  • 측정 가능한 문제가 생기면 그때 최적화
  • 금액 관련 필드는 특히 신중하게
  • 성능 < 정확성

3. 확장성을 고려하되 과도하지 않게

  • CartItem 중간 엔티티: 확장 가능
  • 하지만 당장 필요 없는 필드까지 추가하진 않음

결론

JPA 엔티티 설계는 단순히 테이블 구조를 옮기는 것이 아닙니다. 비즈니스 요구사항, 성능, 유지보수성을 모두 고려한 의사결정의 연속입니다.

핵심 질문:

  • 이 관계가 정말 필요한가?
  • 양방향 매핑의 복잡도를 감수할 가치가 있는가?
  • 지금 최적화가 필요한가, 나중에 해도 되는가?

이런 질문들에 답하며 설계한 경험이 더 나은 도메인 모델을 만드는 밑거름이 되었습니다.

Comments