외주를 받아 쇼핑몰 서비스를 개발하던 와중에 상품 도메인의 상품 옵션 도메인의 잘못된 설계로 이후, 장바구니, 구매 테이블에 문제가 생겼다.
그러나 당시에는 문제가 많다는 것을 뒤늦게 알게되었고, 재설계를 하기에는 마감 일자가 빠듯해 리팩토링하지 못했다.
내 데이터 베이스 설계에 어떤 문제점이 있는지와, 해당 문제로 인한 파급효과를 정리하고, 데이터 베이스 설계시 주의할 점을 정리해 앞으로는 해당 문제를 겪지 않도록 하고자 한다.
-해당 이미지는 네이버 쇼핑에서 들고온 사진으로 제가 실제로 개발한 서비스는 이닙니다._
당시 나는 기본 옵션과 선택옵션의 비즈니스 로직에 차이가 있다는 것을 간과했고, 테이블 수가 줄어들면 엔티티나 관리해야하는 양이 줄어들 것이라고 생각했다.
따라서 기본 옵션과 추가 옵션을 같은 상품 옵션의 테이블로 넣고, 성격의 분리를 enum
값을 통해서 관리했다.
실제로는 여러 필드가 있지만 단축해서 가져왔다.!
public enum OptionKind {
OPTION,
ADD_ON
}
OptionKind
로 필터링하는 단계가 필요하다. public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
// 필터링 후 옵션 정보를 가져오는 메서드
public List<ProductOption> getOptions() {
return options.stream()
.filter(o -> o.getKind() == OptionKind.OPTION)
.toList();
}
public List<ProductOption> getAddOns() {
return options.stream()
.filter(o -> o.getKind() == OptionKind.ADD_ON)
.toList();
}
}
public class ProductOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 옵션명(예: “화이트 / M”) */
@Column(nullable = false, length = 100)
private String name;
@Enumerated(EnumType.STRING)
private OptionKind kind;
/* 연관관계 */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
}
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String orderId; // 외부 노출용 주문번호
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
public class OrderProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private Long productId; // 원본 Product PK 가격과 정보들을 스냅샷으로 저장
}
public class OrderProductItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_product_id", nullable = false)
private OrderProduct orderProduct;
// 옵션 스냅샷
private Long optionId; // ProductOption PK
@Enumerated(EnumType.STRING)
private OptionKind optionKind; // BASIC / ADDON (기존 로직 그대로)
private String optionNameSnap; // 옵션명 스냅샷
private Integer quantity; // 수량
}
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY) // 유저당 1개
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
}
public class CartItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = LAZY) @JoinColumn(name="cart_id")
private Cart cart;
private Long productId;
private Long optionId; //기본 옵션
private Integer optionQuantity;
@OneToMany(mappedBy = "cartItem", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<CartItemAddOn> addOns = new ArrayList<>(); // 추가 옵션 리스트
}
public class CartItemAddOn {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "cart_item_id", nullable = false)
private CartItem cartItem;
/** 상품_옵션 테이블 PK */
private Long addOnOptionId;
private Integer quantity;
}
상품 클래스의 getter 메서드만 살펴보아도 If가 여러번 필요함을 짐작할 수 있다.
이런 if 동작이 응답 객체를 만드는 과정, 요청객체를 내부 엔티티로 만드는 과정중에서 계속 해서 요구되고, 상품의 핵심 규칙을 검사하고 유효성을 확인하는 코드가 여러군데에 분산되게 되었다.또한 협업의 측면에서도 다른 사람이 보기에 코드를 파악하기 어려운 스파게티 코드가 되었다.
입시 방편으로 쓴 장바구니 엔티티는 다른 상품, 주문와 다르기 때문에 또다른 혼란이 생길 수가 있다.
분류 | DB에 두기 적합 | 앱(서비스) 쪽에 두기 적합 |
---|---|---|
본질 | 데이터 무결성·불변 규칙: 관계/카디널리티, 선택 최소·최대, 중복 금지, 존재성(FK), 가격 스냅샷 Not Null 등 | 흐름/정책/전략: 장바구니→주문 전환 절차, 프로모션/쿠폰 로직, A/B 테스트, 결제 연동, 권한·세션·캐싱 |
변동성 | 제품이 바뀌어도 장기간 유지되는 규칙(“기본 옵션은 최소 1개”, “옵션은 그룹 안에서 선택”) | 자주 바뀌는 캠페인/비즈니스 정책(“이번 주엔 2+1”, “등급별 할인율”) |
표현 방식 | 스키마/제약/메타데이터로 선언적 표현 가능 | 조건 분기/알고리즘/외부 API 통합 등 명령적 로직 필요 |
위반 시 영향 | 잘못 저장되면 회복 비용 큼 → DB에서 막아야 함 | 일시 오류 영향 제한적, 앱에서 검증/롤백으로 충분 |
내 서비스에서 “기본 옵션 최소 1개” 는 (쉽게) 변하지 않는 규칙이고, 상품의 값의 무결성과 관련된 규칙이다.
DB에는 올바른, 깨끗한, 무결성의 값들만 들어와야한다. 아래의 상황을 가정해보자
백엔드 코드에 문제가 생겨서, 디비에 기본옵션이 없고 추가 옵션만 가진 상품을 insert했다.
DB는 잘못된 상품이 들어왔지만, 이를 파악할 수 없다.
이후 사용자가 해당 데이터를 조회하는 요청을 보냈고, 서버에서는 디비에 값을 조회할 것이다.
...?? 프로그램이 정상적으로 동작하지 않는다.
데이터베이스는 무결성을 보장해야한다.
나의 사례는 도메인 규칙을 제약조건이나 연관관계로 표현하지 않고Enum 으로만 표현했다.
이로인해 데이터의 무결성을 보장할 수 없게 되었다.
무결성이란, 데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치함, 정확함을 의미한다.
즉, 데이터베이스가 무결성을 가진다는 것은
해당 데이터 베이스에 저장된 값들이 정확한 데이터들이 저장되어있고, 앞으로도 그럴 것을 보장함을 말한다.
무결성의 종류를 나눠본다면
- 개체 무결성(Entity integrity) : PK는 NULL 안 되고, 유일해야 한다.
- 참조 무결성(Referential integrity) : FK 값은 반드시 부모 테이블에 존재해야 헤야한다.
- 도메인 무결성(Domain integrity) : 속성 값은 정해진 범위/형식 안에 있어야 한다. (예: 수량 ≥ 0, 이메일 형식 체크.)
- 업무 무결성(Business integrity, Semantic integrity) : 특정 애플리케이션/업무 규칙에 따라 항상 참이어야 한다. (예: “주문은 최소 1개의 기본 옵션을 가져야 한다”, “마감일은 시작일보다 늦어야 한다”.)
위 무결성의 정의와 종류를 살펴봤을 때,
나의 디비 설계는 “기본 옵션 최소 1개”같은 업무 규칙을 제약 조건으로 보장하지 못했고, 따라서 무결성을 지키지 못했다는 문제점이 있다.
먼저 비즈니스 규칙을 주고 제약 조건을 줄 수 있도록 테이블을 분리한다.
@PrePersist
,@PreUpdate
어노테이션은 데이터베이스에 저장되기 직전에 필수 옵션 개수를 검사하도록 강제public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductBasicOption> basicOptions; //별도로 분리
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductAddOnOption> addOnOptions; //별도로 분리
//DB 저장/업데이트 전 호출되어 '기본 옵션 최소 1개' 규칙을 강제
@PrePersist
@PreUpdate
private void validateBasicOptions() {
if (basicOptions == null || basicOptions.isEmpty()) {
throw new IllegalStateException("상품은 반드시 1개 이상의 기본 옵션을 가진다.");
// 실무에서는 Custom Exception 을 권장
}
}
}
// 상품 기본 옵션 엔티티
public class ProductBasicOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false, length = 100)
private String name;
private int price;
}
// 상품 추가 옵션 엔티티
public class ProductAddOnOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false, length = 100)
private String name;
private int price;
}
다른 코드 또한 기본 옵션과 추가 옵션을 분리하도록 하자.
처음에 상품 테이블을 설계하면서 단순하게 테이블 개수를 줄이고 싶다는 판단을 내렸다.
그러나 이렇게 데이터베이스의 무결성등 기본 이론을 고려하지 않고 사용한다면 단순 저장소 정도로 사용하게 된다.
제약 조건과 여러 정규화를 통해 올바른 DB를 설계했을때 믿을 수 있는 DB를 사용할 수 있다.
덧붙이자면, 항상 DB를 설계할때 Join
의 성능을 걱정하게 되는데, 실제로는 얼마나 join
연산이 오래걸리는지 확인해본적이 없다.
고로 다음글은 !!! Join
의 성능을 테스트해보자 !