무결성을 지키지 못한 DB를 분석하고 해결하자

코린이서현이·2025년 9월 6일
0

백엔드 공부

목록 보기
15/15
post-thumbnail

들어가기전

외주를 받아 쇼핑몰 서비스를 개발하던 와중에 상품 도메인의 상품 옵션 도메인의 잘못된 설계로 이후, 장바구니, 구매 테이블에 문제가 생겼다.

그러나 당시에는 문제가 많다는 것을 뒤늦게 알게되었고, 재설계를 하기에는 마감 일자가 빠듯해 리팩토링하지 못했다.

내 데이터 베이스 설계에 어떤 문제점이 있는지와, 해당 문제로 인한 파급효과를 정리하고, 데이터 베이스 설계시 주의할 점을 정리해 앞으로는 해당 문제를 겪지 않도록 하고자 한다.

문제 설명

당시 요구사항


-해당 이미지는 네이버 쇼핑에서 들고온 사진으로 제가 실제로 개발한 서비스는 이닙니다._

상품

  • 상품은 기본 옵션과 추가 옵션을 가진다.
  • 기본 옵션은 상품이 필수로 가져야한다.
  • 추가 옵션은 선택적으로 가질 수 있다.

장바구니

  • 사용자 한명은 장바구니 하나를 가진다.
  • 장바구니에는 원하는 상품여러개를 담을 수 있다.
  • 담을 떄는 기본 옵션을 필수로 하나 이상 선택 후, 추가 상품은 자유롭게 선택할 수 있다.

주문

  • 사용자는 원하는 상품 여러개를 구매할 수 있다.
  • 상품은 기본 옵션을 필수로 하나 이상 선택, 추가 상품은 자유롭게 선택할 수 있다.

DB 설계

당시 나는 기본 옵션과 선택옵션의 비즈니스 로직에 차이가 있다는 것을 간과했고, 테이블 수가 줄어들면 엔티티나 관리해야하는 양이 줄어들 것이라고 생각했다.
따라서 기본 옵션과 추가 옵션을 같은 상품 옵션의 테이블로 넣고, 성격의 분리를 enum 값을 통해서 관리했다.

당시 설계도

실제 코드

실제로는 여러 필드가 있지만 단축해서 가져왔다.!

ProductEnum 클래스

  • 기본 옵션과 추가 옵션 종류를 나타내는 Enum 클래스이다
public enum OptionKind {
    OPTION,
    ADD_ON
}

Product 클래스

  • 양방향으로 뒀다고 가정하자
  • 옵션이 테이블별로 구분되지 않기 때문에 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();
    }
}

ProductOption 클래스

  • 상품옴셥은 옵션의 종류를 필드로 가진다.
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;


}

Order 클래스

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;               
}

OrderProducts 클래스

  • 주문 클래스는 상품의 가격이 변동될 수 있기 때문에 스냅샷으로 별도로 저장하였다.
  • 주문 상품 옵션은 옵션의 종류를 필드로 가진다.
  • 요구사항에 기본 옵션은 필수라는 조건 사항이 있지만 현재 엔티티로는 해당 요구사항을 제약조건으로 확인할 수 없다.
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 가격과 정보들을 스냅샷으로 저장
}

OrderProductOption 클래스

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;              // 수량
}

Cart클래스

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;
}

CartItem 클래스

  • 당시 상품 → 주문 → 장바구니 순으로 개발하였고 장바구니을 개발하면서는 해당 디비 설계에 문제가 있음을 알게되었다.
  • 급한대로 장바구니아이템 테이블이라도 기본옵션과 추가 옵션을 분리하였다.
  • 따라서 유일하게 장바구니 상품 옵션의 테이블은 옵션 종류를 가지고 있지 않다.
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<>(); // 추가 옵션 리스트
}

CartItemAddOption 클래스

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가 보장해야하는 핵심 불변 규칙을 모델로서 표현하지 못했다.

데이터베이스는 다음을 표현해야한다.

  1. 식별/정체성 : 식별키(유니크속성)
  2. 관계 : 참조 무결성 : 외래키
  3. 도메인 무결성 : 들어가 있는 값에 흠이 없다. 값의 범위와 타입, 유무와 관련된 제약
  4. 업무 무결성 중 변하지 않는 불변 규칙 : 항상 참이어야하는 규칙
분류DB에 두기 적합앱(서비스) 쪽에 두기 적합
본질데이터 무결성·불변 규칙: 관계/카디널리티, 선택 최소·최대, 중복 금지, 존재성(FK), 가격 스냅샷 Not Null 등흐름/정책/전략: 장바구니→주문 전환 절차, 프로모션/쿠폰 로직, A/B 테스트, 결제 연동, 권한·세션·캐싱
변동성제품이 바뀌어도 장기간 유지되는 규칙(“기본 옵션은 최소 1개”, “옵션은 그룹 안에서 선택”)자주 바뀌는 캠페인/비즈니스 정책(“이번 주엔 2+1”, “등급별 할인율”)
표현 방식스키마/제약/메타데이터로 선언적 표현 가능조건 분기/알고리즘/외부 API 통합 등 명령적 로직 필요
위반 시 영향잘못 저장되면 회복 비용 큼 → DB에서 막아야일시 오류 영향 제한적, 앱에서 검증/롤백으로 충분

내 서비스에서 “기본 옵션 최소 1개” 는 (쉽게) 변하지 않는 규칙이고, 상품의 값의 무결성과 관련된 규칙이다.

DB에는 올바른, 깨끗한, 무결성의 값들만 들어와야한다. 아래의 상황을 가정해보자

백엔드 코드에 문제가 생겨서, 디비에 기본옵션이 없고 추가 옵션만 가진 상품을 insert했다.
DB는 잘못된 상품이 들어왔지만, 이를 파악할 수 없다.
이후 사용자가 해당 데이터를 조회하는 요청을 보냈고, 서버에서는 디비에 값을 조회할 것이다.
...?? 프로그램이 정상적으로 동작하지 않는다.

데이터베이스는 무결성을 보장해야한다.
나의 사례는 도메인 규칙을 제약조건이나 연관관계로 표현하지 않고Enum 으로만 표현했다.

이로인해 데이터의 무결성을 보장할 수 없게 되었다.

관계형 데이터 베이스의 무결성

무결성이란, 데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치함, 정확함을 의미한다.

즉, 데이터베이스가 무결성을 가진다는 것은
해당 데이터 베이스에 저장된 값들이 정확한 데이터들이 저장되어있고, 앞으로도 그럴 것을 보장함을 말한다.

무결성의 종류를 나눠본다면

  1. 개체 무결성(Entity integrity) : PK는 NULL 안 되고, 유일해야 한다.
  2. 참조 무결성(Referential integrity) : FK 값은 반드시 부모 테이블에 존재해야 헤야한다.
  3. 도메인 무결성(Domain integrity) : 속성 값은 정해진 범위/형식 안에 있어야 한다. (예: 수량 ≥ 0, 이메일 형식 체크.)
  4. 업무 무결성(Business integrity, Semantic integrity) : 특정 애플리케이션/업무 규칙에 따라 항상 참이어야 한다. (예: “주문은 최소 1개의 기본 옵션을 가져야 한다”, “마감일은 시작일보다 늦어야 한다”.)

결국 비즈니스 무결성을 보장하지 못했다.

위 무결성의 정의와 종류를 살펴봤을 때,
나의 디비 설계는 “기본 옵션 최소 1개”같은 업무 규칙을 제약 조건으로 보장하지 못했고, 따라서 무결성을 지키지 못했다는 문제점이 있다.

해당 문제를 해결하기

먼저 비즈니스 규칙을 주고 제약 조건을 줄 수 있도록 테이블을 분리한다.

자바 코드에서도 수정하기 (상품 테이블만)

ProductEnum 클래스

  • 해당 클래스는 더이상 사용하지 않는다.

Product 클래스

  • "최소 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 을 권장
        }
    }
    
    
}

ProductBasicOption 클래스

  • 상품옴셥은 옵션의 종류를 필드로 가진다.
// 상품 기본 옵션 엔티티
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;
}

ProductAddOnOption 클래스

// 상품 추가 옵션 엔티티
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 의 성능을 테스트해보자 !

profile
포기만 하지 않는다면 언젠간 도달한다!

0개의 댓글