일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 네이버
- DB 설계
- 이커머스
- api설계
- JPA
- 이커머스 api
- 자바
- API
- 멀티쓰레드
- Spring
- RDB
- db 스키마
- cmi
- 라이브커머스
- restful
- Gradle
- mariaDB
- 미디어서버
- wooza
- async
- 서버구축
- REST API
- 스트리밍서버
- AWS
- autowired
- 실검
- 스프링
- @async
- SpringBoot
- 인턴생활
- Today
- Total
Polymor!
[e-commerce] JPA 영속성 전이(feat.상품을 장바구니에 담기) 본문
본격적으로 구체적인 기술 구현에 초점을 둔 글을 써본다. 나와 같은 상황에 처해 고민을 하는 누군가에게 도움이 조금이나마 되길 바라며 공유의 차원에서 성실하게 작성해본다.
기본적으로, 이커머스에서 상품을 구입하는 과정에서 크게 세가지 프로세스를 거친다.
-
상품을 장바구니에 담는다.
-
장바구니에 담긴 아이템들을 주문한다.
-
주문한 아이템들에 대한 결제를 한다.
상품을 장바구니에 담는 것 vs 장바구니 아이템을 주문하는 것
이 두가지를 비교하고자 실제 상용화된 이커머스 서비스를 들여다보면, 후자보다 전자의 프로세스가 훨씬 가볍다는 것을 알 수 있다.
장바구니에선 수량을 변경하고 배송지를 수정하고 아이템을 삭제할 수 있다. 설령 품절이 되어도 주문전에만 감지하면 되니 빠른 동기화는 필요없다. 반면 오더창으로 넘어가게 되면 모든게 FIX된 정확한 데이터들이어야만 하므로 훨씬 견고하게 움직여야한다.
*상품을 장바구니에 담을 때 DB 에 쿼리를 날릴 것인가?
이 점을 두고 팀원들이랑 정말 생각 이상의 긴 회의를 하게 되었다. Frontend 에서 product 와 관련된 데이터들을 보내줄 것이고, 이걸 이용해서 cart item 을 만들면 된다라는 주장과, 아니다, DB 에서 해당 productid로 쿼리를 날려 데이터를 받아와야한다는 주장이었다.
결론적으로 말하면, 전자의 방법이 이상적이나, 현재 우리의 시스템에선 cart item 은 productid를 참조하고 있고 , 이를 위해선 필연적으로 DB에 쿼리를 날릴 수 밖에 없었다.
/***** cartitem entity ****/
@Entity
public class CartItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long cartItemId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id")
private Cart cart;
이하 생략
}
/****** product entity ******/
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productId;
@OneToMany(mappedBy= "productId", cascade = CascadeType.ALL, orphanRemoval= true)
private List<cartItem> cartItems = new ArrayList<cartItem>();
이하 생략
}
/***** cart entity *****/
@Entity
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long cartId;
@OneToMany(mappedBy= "cartId", cascade = CascadeType.ALL, orphanRemoval= true)
private List<cartItem> cartItems = new ArrayList<cartItem>();
이하 생략
}
cartItem 을 저장하려면, 즉 em.persist(cartItem) 이 가능해지려면, cartItem 엔티티와 연관관계를 가지고 있는 엔티티가 JPA 영속 상태여야만한다.
Frontend에서 아무리 product의 엔티티 모든 필드를 DTO로 전달하고 이를 '객체'엔티티로 만들었다해도, 이것은 그저 비영속 컨텍스트이다. 주의해야한다. 필연적으로 DB에 em.find(productid,product.class) 와 같이 쿼리를 보내야한다.
* Cartitem의 생명주기에 따른 관리
우선 , 현재 Cart와 CartItem은 '양방향 연관관계'를 가지고 있으며 그 연관관계의 주인은 FK를 가진 CartItem이다.
CartItem은 Cart가 먼저 생성이되어야 생성될 수 있고, Cart가 삭제되면 마찬가지로 삭제된다. 이로서 CartItem의 생명주기는 Cart에 의존적이다. 여기서 말하는 생명주기는 서비스 로직 내의 생명주기보다는 Cart 와의 의존성의 의미가 더크다.
이런 상황에서는 cascade = CascadeType.ALL(영속성전이) , orphanRemoval = true (고아객체) 옵션을 쓰기 시기 적절하다고 판단한다.
영속성 전이는 부모 엔티티를 영속화 하거나 삭제하는 등의 행위에 함께 자식 엔티티들도 적용시키는 옵션이다.
/****** 영속성 전이를 사용하지 않을 경우 *********/
// 부모(Cart) 저장
Cart cart = new Cart();
em.persist(cart);
// 1번 자식(CartItem1) 저장
CartItem cartItem1= new CartItem();
cartItem1.setCart(cart);
cart.getCartItem().add(cartItem1);
em.persist(cartItem1);
// 2번 자식(CartItem2) 저장
CartItem cartItem2 = new CartItem();
cartItem2.setCart(cart);
cart.getCartItem().add(cartItem2);
em.persist(cartItem2);
/****** 영속성 전이를 사용하는 경우 *********/
CartItem cartItem1= new CartItem();
CartItem cartItem2 = new CartItem();
Cart cart = new Cart();
cartItem1.setCart(cart);
cartItem2.setCart(cart);
cart.getCartItem().add(cartItem1);
cart.getCartItem().add(cartItem2);
// 부모(Cart) 저장, 연관된 자식들(1,2) 저장
em.persist(cart);
영속성 전이는 연관관계를 매핑하는 것과는 아무런 연관이 없으며, 그저 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공한다. 그래서 위와 같이 cartItem들을 cart에 연관관계를 추가한 후 cart를 영속 상태로 만드는 것을 확인할 수 있다.
위와 같이 한 트랜잭션에서 카트와 카트아이템들이 생성되는 경우는 없지만(Cart는 선 트랜잭션 상에서 만들어진 후, 다음 트랜잭션에서 cartItem을 추가함) , Cart에서 CartItem들을 '관리하고 책임하는 객체' 라는 객체지향적 관점에서 사용하게 되었다.
POST /api/cartitem vs POST /api/cart/{id}/cartitem
첫번째로 API로 쓰게 되면, Cart 객체의 책임이 적어진다. Cart 객체는 CartItem들을 효과적으로 관리하기 위해 존재하는 객체라고 생각하기 때문에 CartItem이 단독적으로 생성되고 수정되고 삭제되는 일은 어울리지 않는다.
또한, CartItem의 추가,수정,삭제 등에 따라 Cart의 TotalPrice,Quantity 등 값이 업데이트 되어야하는 점을 고려했을때도 일리가 있다.
따라서, CartItem 은 항상 Cart에 add를 해주고 영속성 전이로서 Cart가 관리하게 해야한다. 특히 Cart가 삭제되는 경우 빛을 발한다.
다음으로 고아 객체는 왜 사용하는게 좋을 것인가에 대한 이야기다. 고아 객체는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다. 정말 정말 중요한 것은 이 기능은 연관 엔티티를 참조하는 것이 나 하나일 때만 사용해야한다.
예를 들어, CartItem과 Product 가 양방향 연관관계를 가지고 있다면, 이 기능은 절대 사용해선 안된다.
Cart 가 CartItem 을 주도적으로 관리하고 있기 때문에 사용 가능한 것이고, 'CartItem'을 낱개 단위로 삭제하는 상황에서 매우 편리하다.
/***** 고아 객체 옵션 사용한 경우 ******/
Cart cart = em.find(Cart.class, cartId);
cart.getCartItems().remove(0); // 첫번째 아이템 제거됨
em.flush(cart); // 이 시점에 DB에 Delete 쿼리 날라감. 혹은 transaction끝날때
Cart의 첫번째 아이템을 삭제 하는 경우 위와 같이 Cart에서 연관관계를 끊어내면 자동으로 delete쿼리가 날라간다. 다시 이야기하지만, 이 기능은 특정엔티티가 개인 소유하는 엔티티에만 적용하여야한다. 즉 @OneToOne 혹은 @OneToMany 에서 말이다.
결론적으로, Cart 객체는 CartItem을 관리하는 유일의 부모의 역할을 해내야하는 객체로 바라보아야한다.
Cart없는 CartItem은 없고, CartItem없는 Cart는 무의미하다.
한가지 더 알아야하는 것은 'fetch = FetchType.LAZY' (지연로딩) 이다. Cart에서 지연로딩을 사용하지 않으면 매번 CartItem들이 즉시로딩되면 이는 N+1 문제를 야기한다.
CartItem.getCart().메소드() 를 통해서 CartItem이 주체가 되어 필요에 의해 Cart에 의존관계를 맺어야할지 등에대한 고민이 있다. 분명한건 CartItem이 FK를 들고있고 연관관계의 주인이기때문이다.
Cart의 List<CartItem>은 GET /api/cart 같은 상황에선 반드시 필요할 것 같으나, 실질적으로 cartitem 을 관리하는 건 cartitem 본인이 되어야 더 깔끔한 로직이 나올 수도 있을 것같다.
고민을 해결하고자 작성하였으나 결국 고민만 더 늘고 끝났다. JPA는 강력하고 견고하지만 , 이를 잘 써먹으려면 더 많이 깊게 공부를 해야한다는 생각뿐이다. 다만 한가지 얻은게 있다면,도메인을 면밀히 분석하면 그 위에 놓여진 객체들의 역할, 책임 분할에 대한 시각이 생기게 되고 그 한끝차이들이 설계를 좌우한다는 것이다. Cart와 CartItem의 관계 , 그리고 Order와 Payment에 비해 영속성에 대한 부담감이 비교적 적은 데이터들을 품고있다는 것을 생각하니 수월하게 로직의 방향성을 잡을 수 있었다.
* 유레카. 해결책을 찾았다!
Cart가 전적으로 Cartitem을 관리하는 방향으로 결정했다.
@OneToMany(fetch = FetchType.LAZY,mappedBy="cart",cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItem> cartItems;
Fetch를 LAZY로 설정해놓으면, 아래와 같이 새로운 cartitem을 add() 할때, 기존에 있던 cartitem들이 프록시엔티티이므로 효율성 문제는 사라진다. 이렇게 Cart만 Save하게 되면, 새로운 Caritem에 대한 INSERT 쿼리 하나만 날라가게된다. (영속성전이)
// CartService
public long addItem(long cartid,long productid) {
Cart cart = findOne(cartid);
cart.getCartItems().add(CartItem.createCartItem(cart,productRepository.findById(productid).get()));
return save(cart).getCartId();
}
마찬가지로 Delete도 아래와같이 구현하면, 해당 cartitem 이 삭제된다. (고아 객체)
public long delete(long cartid, long cartitemid) {
Cart cart = findOne(cartid);
cart.getCartItems().remove(cartItemRepository.findById(cartitemid).get());
return save(cart).getCartId();
}
'Web' 카테고리의 다른 글
비동기에 대한 이해( feat. Spring @Async) (0) | 2021.02.12 |
---|---|
[AWS] RDB 와 스프링 연동하기 (0) | 2021.02.10 |
[e-commerce] Rest API 설계 디자인 (0) | 2021.02.07 |
[e-commerce] ORM & DB 스키마 설계 (1) | 2021.02.06 |
[e-commerce] 개발 동기와 그 여정들 (0) | 2021.02.06 |