일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 윤성우의 열혈 자료구조
- insertion sort
- 이스케이프 문자
- C programming
- R
- s
- 이것이 자바다
- C 언어 코딩 도장
- list 컬렉션
- Selection Sorting
- Algorithm
- Graph
- stream
- JSON
- coding test
- 혼자 공부하는 C언어
- Serialization
- buffer
- Stack
- 메모리구조
- 윤성우 열혈자료구조
- 알기쉬운 알고리즘
- datastructure
- Today
- Total
Engineering Note
[SW Engineering] 쇼핑몰 도메인 모델 설계를 통해 알아보는 JPA 엔티티 설계 의사결정 과정 본문
[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 조회 불필요)
단방향 선택 이유:
- 불필요한 결합도 제거: Member는 Cart 존재를 몰라도 됨
- 순환참조 방지: JSON 직렬화나 toString() 문제 예방
- 단순성: 양방향은 양쪽 동기화 관리 부담
실제 사용 패턴:
// 필요한 패턴: 장바구니 조회 시 회원 정보 필요
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 업데이트 필요
- 복잡도 증가
의사결정: 계산 방식 선택
현재 선택 이유:
- 조기 최적화 회피: 성능 문제가 실제 발생하지 않음
- 데이터 정합성 우선: 금액 불일치는 치명적
- 단순성: 유지보수 부담 최소화
향후 계획:
- 실제 성능 측정 후 결정
- 필요시 쿼리 최적화 (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 엔티티 설계는 단순히 테이블 구조를 옮기는 것이 아닙니다. 비즈니스 요구사항, 성능, 유지보수성을 모두 고려한 의사결정의 연속입니다.
핵심 질문:
- 이 관계가 정말 필요한가?
- 양방향 매핑의 복잡도를 감수할 가치가 있는가?
- 지금 최적화가 필요한가, 나중에 해도 되는가?
이런 질문들에 답하며 설계한 경험이 더 나은 도메인 모델을 만드는 밑거름이 되었습니다.
'SW Engineering' 카테고리의 다른 글
[SW Engineering] Stream forEach에서 map으로: 함수형 프로그래밍과 N+1 문제 해결기 (0) | 2025.10.05 |
---|---|
[SW Engineering] 게시판 댓글 좋아요 기능 DB 설계 - UNIQUE 제약조건 활용하기 (0) | 2025.10.04 |
[SW Engineering] GitHub Actions + Docker를 활용한 Spring Boot 자동 배포 파이프라인 구축 과정 (0) | 2025.09.28 |
[SW Engineering] 구체적인 사례로 보는 JavaScript Stack Trace 읽는 법 (0) | 2025.09.27 |
[SW Engineering] AJAX 응답 타입 불일치로 인한 TypeError 해결기 (0) | 2025.09.27 |