백엔드 파트는 혼자인 이커머스 개발 프로젝트를 진행하고 있습니다.
이번 포스팅에서는 도메인, 테이블을 설계하고 개선한 부분에 대한 글을 쓰겠습니다.
혼자 생각하고 여러 자료를 참고하며 진행했으므로 미흡한 부분이 있습니다.
도메인 설계
현재 요구사항에 따르면 도메인은 다음과 같다.
- 회원 도메인
- 상품 도메인
- 장바구니 도메인
- 주문 도메인
설계 과정을 살펴보고 상위 도메인에 포함된 하위 도메인도 살펴보겠다.
회원 도메인
회원 도메인은 회원과 이메일로 이루어진다.
회원은 일반 회원과 판매자 회원으로 구분된다.
테이블을 살펴보면 1:1 관계다.
상품 도메인
상품 도메인은 상점, 상품, 상품 옵션 그룹, 상품 옵션, 재고로 이루어진다.
- 상점은 상품과 1:N 관계다.
- 상품은 상품 옵션 그룹과 1:N 관계다. 예시를 들어보면 옷이라는 상품이 존재하면 사이즈와 색깔이라는 옵션 그룹이 존재한다.
- 상품 옵션 그룹과 상품 옵션은 1:N 관계다. 색깔이라는 옵션 그룹에는 빨강, 노랑, 초록 등 옵션이 다양하게 존재할 수 있다.
- 재고와 상품 옵션은 M : N 관계다.
재고와 상품 옵션을 더 살펴보자.
빨간색인데 Large 사이즈인 옵션의 옷이 10개의 재고가 있다. 여기서 살펴본 것처럼 재고는 여러 개의 옵션을 가진다.
빨간색인데 Small 사이즈의 옵션의 옷이 20개의 재고가 있다. 빨간색인 옵션이 여러 재고에서 사용되는 것이다.
따라서 재고와 상품 옵션이 M:N 관계이다. M:N 관계이므로 중간 테이블을 두어 테이블을 설계했다.
상점은 상점 소유자가 존재하므로 회원 id를 외래키로 가진다.
장바구니 도메인
장바구니 도메인은 상품을 담아두고 주문을 진행할 수 있게 도와주는 역할을 맡는다.
반드시 장바구니에 담고 주문을 진행할 수 있도록 요구사항을 정의했다.
장바구니 도메인은 장바구니, 장바구니에 담긴 상품, 담긴 상품의 옵션 그룹, 담긴 상품의 옵션이 존재한다.
- 장바구니 도메인과 장바구니 상품은 1:N 관계다. 장바구니에는 다양한 상품이 담길 수 있다.
- 장바구니 상품과 담긴 상품의 옵션 그룹은 1:N이다. 담긴 상품에는 색깔, 사이즈 같이 많은 옵션 그룹이 존재할 수 있다.
- 담긴 상품의 옵션 그룹과 옵션은 1:1이다. 노랑, 빨강 등 여러 옵션이 담길 수 없고 한 가지만 골라야하기 때문이다.
1:1이라고는 하지만 옵션 그룹과 옵션은 필수적인 관계이다. 따라서 하나의 테이블로 묶었다.
장바구니에 담긴 상품은 상품 id를 외래키로 가지고 있다.
장바구니도 주인이 필요하므로 회원 id를 외래키로 가진다.
주문 도메인
주문 도메인은 추후에 결제 도메인을 하위 도메인으로 추가할 예정이다.
현재까지의 설계를 살펴보면 주문도메인에는 주문, 주문 상품 옵션 그룹, 주문 상품 옵션이 존재한다.
- 결제와 주문은 1:1 관계다.
- 다양한 상품을 동시에 주문 해도 주문 취소를 개별적으로 진행하기 위해 상품과 주문을 1:N 관계로 만들었다.
즉 하나의 주문은 1개의 상품만 주문할 수 있다. - 주문과 주문 옵션은 1:N 관계다. 장바구니와 비슷하게 주문 옵션은 1개만 골라야하므로 주문 옵션 그룹 테이블에 주문 옵션을 포함했다.
주문은 주문한 사람의 id를 가지고 있다.
설계를 개선한 부분
프로젝트에서는 도메인 모델을 JPA를 사용해 테이블과 매핑하고 있다.
현재는 객체의 생명주기가 동일하다면 클래스 타입으로 연관관계를 매핑하고,
동일하지 않다면 id의 타입인 Long 타입으로 연관 관계를 매핑하고 있다.
이번에는 본인이 도메인 설계와 테이블 설계를 고민하고 생각한 끝에 개선한 프로젝트의 내용이다.
하위 도메인 모델
몇 가지 설계에서 공통적인 부분이 보인다.
상점은 상점 주인 회원의 pk를 가지고 있고, 장바구니는 장바구니 주인 회원의 pk를 가지고 있다.
주문도 주문한 회원의 pk를 외래키로 가지고 있다.
상점 도메인, 주문 도메인, 장바구니 도메인 모두 동일하므로 JPA로 작성한 주문 도메인 코드를 살펴보겠다.
@Entity
public Member{
//생략..
}
//상점 도메인
@Entity
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderingMemberId;
private Long itemId;
//생략..
}
orderingMemberId로 연관 관계를 끊었다.
그러나 이는 객체 그룹의 조회 경계가 모호해지는 것을 막고 지연 로딩을 막기 위해서 끊은 것이다.
논리적으로는 Member 엔티티를 참조하고 있는 것과 다를 것이 없다.
그렇다면 만약 주문한 멤버가 어떤 행동을 취하도록 요구사항이 추가되면 어떨까?
Member 클래스 코드에 주문 비즈니스 로직이 녹아들어가게 된다.
도메인의 응집성을 낮출 뿐만 아니라 도메인의 중요 비즈니스 로직 코드가 다른 도메인으로 넘쳐버린다.
매우 좋지 않은 코드가 탄생해버린다.
이를 예방하기 위해 고민 끝에 하위 도메인에 적합한 모델을 매번 만드는 것이다.
쉽게 풀자면 다음과 같다.
- 회원이 존재한다.
- 회원은 상점의 주인이 될 수도 있고,
- 주문자가 될 수도 있고,
- 장바구니의 주인이 될 수 있다.
논리적으로 같은 개념이지만 취하는 행동이 전혀 다르다.
그러므로 주문자라는 모델을 만들고 장바구니 주인이라는 모델을 만들어 요구사항을 하위 도메인에서 만든 모델에 적용하는 것이다.
수정한 주문 엔티티 코드는 다음과 같다.
//상점 도메인
@Entity
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Orderer orderer;
private Long itemId;
//생략..
}
동일한 개념의 상위 도메인 모델이 존재하더라도 하위 도메인에 매번 모델을 만들면서
요구 사항 변화에 좀 더 유연하고 튼튼한 설계를 구축할 수 있었다.
재고 도메인 분리
현재는 재고 모델이 상품 도메인에 들어가있다.
재고와 관련된 모델을 전부 빼서 재고 도메인으로 분리하려고 한다.
도메인으로 분리하려는 이유는 다음과 같다.
생명 주기
먼저 재고 관련 모델과 상품 관련 모델의 생명 주기가 미묘하게 다른 것 같다.
상품 옵션의 경우의 수에 따라 재고 객체가 생성되는 것은 맞지만 재고를 변경하는 것은 생명주기가 다르다고 생각한다.
테스트 코드
사실 제일 큰 이유다. 좋은 테스트 코드는 좋은 설계일 확률이 높다고 한다.
현재 재고 테스트 코드를 작성하는데 매번 상품의 옵션 클래스의 인스턴스를 생성해 영속 상태로 만들고 테스트를 진행해야 한다.
이 부분이 꽤 귀찮고 시간이 많이 들어서 분리하려고 한다.
도메인을 분리하므로 패키지를 구분하고 이제 클래스 타입으로 참조하는 것이 아닌 id의 타입인 Long으로 참조하려고 한다.
먼저 변경하기 전 코드를 간단하게 살펴보자.
//재고
@Entity
public class Stock extends BaseEntity { //옵션과 Stock은 N:M 인듯
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL)
private List<StockOption> stockOptions = new ArrayList<>();
private Integer quantity;
}
//옵션과 재고 중간 테이블
@Entity
public class StockOption extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@JoinColumn(name = "stock_id")
@ManyToOne(fetch = FetchType.LAZY)
private Stock stock;
@JoinColumn(name = "option_id")
@ManyToOne(fetch = FetchType.LAZY)
private Option option;
}
//상품 옵션
@Entity
public class Option extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer price;
@OneToMany(mappedBy = "option")
private List<StockOption> stockOptions = new ArrayList<>();
}
다음은 재고 도메인을 분리하면서 변경한 엔티티 클래스다.
@Entity
public class Stock extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer quantity;
public Stock(Integer quantity) {
this.quantity = quantity;
}
}
@Entity
public class StockOption extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long stockId;
private Long optionId;
}
//상품 옵션
@Entity
public class Option extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer price;
}
재고 엔티티와 상품 옵션 엔티티에서 중간 테이블에 관련된 코드가 모두 사라졌다.
변경을 하면서 생각해보니 재고 엔티티와 상품 옵션 엔티티는 중간 테이블을 참조할 필요가 없었다.
재고 엔티티의 경우는 Cascade를 사용해 일대다 관계에서 편하게 중간 테이블을 DB에 저장하려고 사용하고 있었다.
그러나 개선하면서 생각을 해보니 중간 테이블 엔티티의 레포지토리를 사용하면 이 같은 연관관계는 꼭 필요하지 않았다.
Cascade를 사용해서 매번 다대다 관계를 풀어낸 중간 테이블 엔티티를 저장했는데,
직접적으로 중간 테이블 엔티티의 레포지토리를 사용해 저장하는 것도 나쁘지 않은 방법 같다.
이번에는 테스트 코드를 비교해보겠다.
아래는 재고 도메인을 분리하기 전의 테스트 코드다.
상품을 테스트 데이터로 준비하는데 많은 코드가 필요하다.
@Test
void createStock(){
//given
RegisterItemRequestDto dto = RegisterItemRequestFactory.createSuccessCase().toServiceDto();
Shop findShop = shopRepository.save(new Shop(1L, "shop"));
Item item = itemRepository.save(new ItemMapper().mapOf(dto, findShop));
OptionGroup optionGroup = item.getOptionGroups().get(0);
Option option1 = optionGroup.getOptions().get(0); //white
Option option2 = optionGroup.getOptions().get(1); //black
OptionGroup optionGroup1 = item.getOptionGroups().get(1);
Option option3 = optionGroup1.getOptions().get(0); // s
Option option4 = optionGroup1.getOptions().get(1); // m
//when
stockService.fillStockUseOnlyTest(
new FillStockRequestDto(
List.of(
new FillStockInfoRequestDto(List.of(option1.getId(), option3.getId()), 5),
new FillStockInfoRequestDto(List.of(option1.getId(), option4.getId()), 5),
new FillStockInfoRequestDto(List.of(option2.getId(), option3.getId()), 5),
new FillStockInfoRequestDto(List.of(option2.getId(), option4.getId()), 5)
)
)
);
//then
assertThat(stockRepository.findAll().size()).isEqualTo(4);
}
다음은 재고 도메인을 분리하고 작성한 테스트 코드다.
훨씬 깔끔해지고 명료해진 것을 확인할 수 있다.
이는 상품 도메인과의 강한 의존성을 제거했기 때문이다.
@Test
void createStock(){
//when
stockService.fillStockUseOnlyTest(
new FillStockRequestDto(
List.of(
new FillStockInfoRequestDto(List.of(1L, 2L), 5),
new FillStockInfoRequestDto(List.of(1L, 3L), 4),
new FillStockInfoRequestDto(List.of(2L, 3L), 3),
new FillStockInfoRequestDto(List.of(3L, 4L), 2)
)
)
);
//then
assertThat(stockRepository.findAll().size()).isEqualTo(4);
}
물론 개선하면서 장점만 존재하지 않는다. 단점도 존재한다.
애플리케이션 계층의 재고 서비스 코드에서 재고를 저장하는 코드가 조금 길어졌다.
그러나 치명적인 수준은 아니라고 생각한다.
이렇게 도메인과 테이블 설계를 진행해봤으며, 이를 개선해봤습니다.
진행하면서 사전에 작성해둔 재고 도메인 테스트 코드가 굉장히 큰 도움이 되었습니다.
도메인을 분리하면서 엄청나게 많은 코드를 변경했는제 테스트 코드 덕분에 자신감을 가지고 빠르게 코드를 리팩토링할 수 있었습니다.
이상으로 포스팅을 마칩니다. 감사합니다.
댓글