5주차
카카오 테크 캠퍼스 2단계 - BE - 5주차 클론 과제
과제명
코드 리팩토링
과제 설명
카카오 쇼핑 프로젝트 전체 코드를 리팩토링한다
- AOP로 유효성검사 적용하기
- GlobalExceptionHanlder 구현하기
- 장바구니 담기 -> 예외 처리하기
- 장바구니 수정(주문하기) -> 예외처리하기
- 결재하기 기능 구현 (장바구니가 꼭 초기화 되어야함)
- 주문결과 확인 기능 구현
과제 상세 : 수강생들이 과제를 진행할 때, 유념해야할 것
아래 항목은 반드시 포함하여 과제 수행해주세요!
- AOP가 적용되었는가?
- GlobalExceptionHandler가 적용되었는가?
- 장바구니 담기시 모든 예외가 처리 완료되었는가?
- 장바구니 수정시 모든 예외가 처리 완료되었는가?
- 결재하기와 주문결과 확인 코드가 완료되었는가?
장바구니 - 장바구니 담기 + 예외 처리하기
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CartService {
private final OptionJPARepository optionJPARepository;
private final CartJPARepository cartJPARepository;
@Transactional //트랜잭션 시작
public void addCartList(List<CartRequest.SaveDTO> requestDTOs, User sessionUser) {
HashSet<Integer> set = new HashSet<>();
for (CartRequest.SaveDTO requestDTO : requestDTOs) {
int optionId = requestDTO.getOptionId();
int quantity = requestDTO.getQuantity();
// 1. 동일한 옵션이 들어오면 예외처리
// [ { optionId:1, quantity:5 }, { optionId:1, quantity:10 } ]
if (set.contains(optionId)) //중복 확인
throw new Exception400("동일한 옵션 여러개를 추가할 수 없습니다.");
else set.add(optionId);
// 2. cartJPARepository.findByOptionIdAndUserId() 조회 -> 존재하면 장바구니에 수량을 추가하는 업데이트를 해야함. (더티체킹하기)
// Cart {cartId:1, optionId:1, quantity:3, userId:1} -> DTO {optionId:1, quantity:5}
Optional<Cart> optional = cartJPARepository.findByOptionIdAndUserId(optionId, sessionUser.getId());
Option option = optionJPARepository.findById(optionId)
.orElseThrow(() -> new Exception404("해당 옵션을 찾을 수 없습니다 : " + optionId));
if(optional.isPresent()){ //이미 장바구니에 담긴 옵션이라면
Cart cart = optional.get();
int updateQuantity = cart.getQuantity() +quantity;
int price = quantity * option.getPrice();
cart.update(updateQuantity, price); //더티체킹 수행
}
// 3. [2번이 아니라면] 유저의 장바구니에 담기
else{
int price = option.getPrice() * quantity;
Cart cart = Cart.builder().user(sessionUser).option(option).quantity(quantity).price(price).build();
cartJPARepository.save(cart);
}
}
} //변경감지, 더티체킹, flush, 트랜잭션 종료
}
수행 쿼리
반복문마다 3번 나간다.
cart 조회 1번, option 조회 1번, insert(또는 update) 1번 수행
장바구니 - 장바구니 수정(update) + 예외처리하기
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CartService {
private final OptionJPARepository optionJPARepository;
private final CartJPARepository cartJPARepository;
@Transactional
public CartResponse.UpdateDTO update(List<CartRequest.UpdateDTO> requestDTOs, User user) {
List<Cart> cartList = cartJPARepository.findAllByUserId(user.getId());
// 1. 유저 장바구니에 아무것도 없으면 예외처리
if (cartList.isEmpty()) {
throw new Exception404("담은 장바구니가 없습니다.");
}
// 2. cartId:1, cartId:1 이렇게 requestDTOs에 동일한 장바구니 아이디가 두번 들어오면 예외처리
HashSet<Integer> set = new HashSet<>();
for (CartRequest.UpdateDTO requestDTO : requestDTOs) {
int cartId = requestDTO.getCartId();
if (set.contains(cartId)) //중복 확인
throw new Exception400("동일한 장바구니를 동시에 update할 수 없습니다.");
else set.add(cartId);
// 3. 유저 장바구니에 없는 cartId가 들어오면 예외처리
boolean b = cartList.stream().anyMatch(cart -> cart.getId() == cartId);
if(!b) throw new Exception404("존재하지않는 cartId입니다.");
}
// 위에 3개를 처리하지 않아도 프로그램은 잘돌아간다. 예를 들어 1번을 처리하지 않으면 for문을 돌지 않고, cartList가 빈배열 []로 정상응답이 나감.
for (Cart cart : cartList) {
for (CartRequest.UpdateDTO updateDTO : requestDTOs) {
if (cart.getId() == updateDTO.getCartId()) {
cart.update(updateDTO.getQuantity(), cart.getOption().getPrice() * updateDTO.getQuantity());
}
}
}
return new CartResponse.UpdateDTO(cartList);
} // 더티체킹
}
수행시 쿼리
1. cart 조회 2. 담은 장바구니수만큼 option 조회 3. update한 장바구니만큼 update 쿼리
join fetch 이용하여 쿼리 줄이기
Repository
public interface CartJPARepository extends JpaRepository<Cart, Integer> {
List<Cart> findAllByUserId(int userId);
@Query("SELECT c FROM Cart c JOIN FETCH c.option o WHERE c.user.id = :userId")
List<Cart> findAllByUserIdWithOption(@Param("userId") int userId);
}
join fetch 쿼리 - cart 조회시 option까지 join fetch
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CartService {
private final OptionJPARepository optionJPARepository;
private final CartJPARepository cartJPARepository;
@Transactional
public CartResponse.UpdateDTO update(List<CartRequest.UpdateDTO> requestDTOs, User user) {
List<Cart> cartList = cartJPARepository.findAllByUserIdWithOption(user.getId()); //쿼리 수정
// 1. 유저 장바구니에 아무것도 없으면 예외처리
if (cartList.isEmpty()) {
throw new Exception404("담은 장바구니가 없습니다.");
}
// 2. cartId:1, cartId:1 이렇게 requestDTOs에 동일한 장바구니 아이디가 두번 들어오면 예외처리
HashSet<Integer> set = new HashSet<>();
for (CartRequest.UpdateDTO requestDTO : requestDTOs) {
int cartId = requestDTO.getCartId();
if (set.contains(cartId)) //중복 확인
throw new Exception400("동일한 장바구니를 동시에 update할 수 없습니다.");
else set.add(cartId);
// 3. 유저 장바구니에 없는 cartId가 들어오면 예외처리
boolean b = cartList.stream().anyMatch(cart -> cart.getId() == cartId);
if(!b) throw new Exception404("존재하지않는 cartId입니다.");
}
// 위에 3개를 처리하지 않아도 프로그램은 잘돌아간다. 예를 들어 1번을 처리하지 않으면 for문을 돌지 않고, cartList가 빈배열 []로 정상응답이 나감.
for (Cart cart : cartList) {
for (CartRequest.UpdateDTO updateDTO : requestDTOs) {
if (cart.getId() == updateDTO.getCartId()) {
cart.update(updateDTO.getQuantity(), cart.getOption().getPrice() * updateDTO.getQuantity());
}
}
}
return new CartResponse.UpdateDTO(cartList);
} // 더티체킹
}
수행 쿼리
1. cart와 option을 한번에 가져오는 쿼리 2.장바구니마다 update 쿼리
쿼리가 굉장히 줄었다.
✅ 장바구니마다 update 쿼리를 날리지 않고 한번에 update 쿼리 날리는 방법
public interface CartRepository extends JpaRepository<Cart, Integer> {
@Modifying
@Query("UPDATE Cart c SET c.quantity = :quantity, c.price = :price WHERE c.id = :cartId")
void updateCartById(@Param("cartId") int cartId, @Param("quantity") int quantity, @Param("price") int price);
}
한번에 update하는 쿼리 작성 후 service에서 update쿼리 사용하기
✳️ 과제에서 더티 체킹을 통해 update를 수행하라고 하였으므로 위 방식은 사용할 수 없다.
주문 - 결제하기(주문 인서트)
응답 JSON
{
"success": true,
"response": {
"id": 8,
"products": [
{
"productName": "[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율",
"items": [
{
"id": 33,
"optionName": "밤깎기+다회용 구이판 세트",
"quantity": 2,
"price": 11000
},
{
"id": 34,
"optionName": "22년산 햇단밤 700g(한정판매)",
"quantity": 1,
"price": 9900
}
]
},
{
"productName": "[LIVE][5%쿠폰] 홈카페 Y3.3 캡슐머신 베이직 세트",
"items": [
{
"id": 35,
"optionName": "블랙",
"quantity": 1,
"price": 148000
},
{
"id": 36,
"optionName": "화이트",
"quantity": 3,
"price": 444000
}
]
}
],
"totalPrice": 612900
},
"error": null
}
응답 DTO 생성
public class OrderResponse {
@Getter
@Setter
public static class FindAllDTO {
private int id;
private List<OrderResponse.FindAllDTO.ProductDTO> products;
private int totalPrice;
public FindAllDTO(Order order, List<Item> itemList) {
this.id = order.getId();
this.products = itemList.stream()
// 중복되는 상품 걸러내기
.map(item -> item.getOption().getProduct()).distinct()
.map(product -> new ProductDTO(product, itemList)).collect(Collectors.toList());
this.totalPrice = itemList.stream().mapToInt(item -> item.getOption().getPrice() * item.getQuantity()).sum();
}
@Getter
@Setter
public class ProductDTO {
private String productName;
private List<OrderResponse.FindAllDTO.ProductDTO.ItemDTO> items;
public ProductDTO(Product product, List<Item> itemList) {
this.productName = product.getProductName();
// 현재 상품과 동일한 장바구니 내역만 담기
this.items = itemList.stream()
.filter(item -> item.getOption().getProduct().getId() == product.getId())
.map(item -> new ItemDTO(item, item.getOption()))
.collect(Collectors.toList());
}
@Getter
@Setter
public class ItemDTO {
private int id;
private String optionName;
private int quantity;
private int price;
public ItemDTO(Item item, Option option) {
this.id = item.getId();
this.optionName = option.getOptionName();
this.quantity = item.getQuantity();
this.price = quantity * option.getPrice();
}
}
}
}
}
DTO 클래스 구조를 먼저 작성한 후, 생성자에서 stream을 이용하여 주어진 order와 itemList로 DTO를 한번에 생성할 수 있다.
사용자가 매개변수 하나하나 넣어가며 만들 필요가 없어 편리하다.
✳️ Repository에서 가져올때 DTO에서 사용할 entity들은 프록시가 아닌 실제로 가져온 객체(join fetch또는 직접 조회)여야 직렬화 문제가 발생하지 않는다.
Controller
@RequiredArgsConstructor
@RestController
public class OrderRestController {
private final OrderService orderService;
// (기능9) 결재하기 - (주문 인서트) POST
// /orders/save
@PostMapping("/orders/save")
public ResponseEntity<?> save(@AuthenticationPrincipal CustomUserDetails userDetails) {
OrderResponse.FindAllDTO dto = orderService.save(userDetails.getUser());
return ResponseEntity.ok(ApiUtils.success(dto));
}
}
서비스 호출하여 리턴된 DTO 응답 전송
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class OrderService {
private final CartJPARepository cartJPARepository;
private final OrderJPARepository orderJPARepository;
private final ItemJPARepository itemJPARepository;
//결재하기(주문 인서트)
@Transactional
public OrderResponse.FindAllDTO save(User sessionUser) {
// 사용자가 담은 장바구니가 없을때 예외처리
int userId = sessionUser.getId();
List<Cart> cartList = cartJPARepository.findByUserIdOrderByOptionIdAsc(userId);
if(cartList.isEmpty()){
throw new Exception404("담은 장바구니가 없습니다");
}
//1. 주문 생성하기
Order order = Order.builder().user(sessionUser).build();
orderJPARepository.save(order);
//2. 장바구니를 Item에 저장하기
List<Item> itemList = new ArrayList<>();
for (Cart cart : cartList) {
Item item = Item.builder().option(cart.getOption()).order(order)
.quantity(cart.getQuantity()).price(cart.getPrice()).build();
itemJPARepository.save(item);
itemList.add(item);
}
//3. 장바구니 초기화하기
cartJPARepository.deleteByUserId(userId);
//4. DTO 응답
return new OrderResponse.FindAllDTO(order, itemList);
}
}
Repository
public interface CartJPARepository extends JpaRepository<Cart, Integer> {
@Query("select c from Cart c join fetch c.option o join fetch o.product p where c.user.id = :userId order by c.option.id asc")
List<Cart> findByUserIdOrderByOptionIdAsc(int userId);
}
option과 product 모두 join fetch
option은 Item 저장과 DTO에서 사용되고 product도 DTO에서 속성이 사용되므로 한번에 가져오는 것이 좋다.
✳️ 만약 join fetch를 하지 않으면 entity 사용시 따로 쿼리가 나가게 된다.
수행 쿼리
1. cart 조회(option, product join fetch) 2. order 저장 3. 장바구니만큼 item 저장
장바구니 초기화 - 4. 유저 id로 장바구니 조회 5. 장바구니마다 장바구니 삭제
✅ 유저 id는 이미 cart가 조회되었으므로 영속성 컨텍스트에 존재한다. (id이므로 user를 join fetch하지 않아도 가져옴)
그럼에도 조회 쿼리를 날리는 이유는 내가 네임쿼리를 사용했기 때문이다.
void deleteByUserId(int userId);
쿼리를 커스텀하여 쿼리수 줄이기
@Modifying
@Query("DELETE FROM Cart c WHERE c.user.id = :userId")
void deleteByUserId(int userId);
직접 쿼리를 작성했다.
@Modifying을 붙여준 이유는 JPA에게 INSERT, UPDATE, DELETE 쿼리를 수행함을 명시적으로 알려주어서 더 안전하게 트랜잭션을 처리하기 위해서이다.
수행 쿼리
1. cart 조회(option, product join fetch) 2. order 저장 3. 장바구니만큼 item 저장 4. 장바구니 삭제
✳️ 장바구니 초기화 시 장바구니를 user id로 조회하는 코드가 사라졌고, 장바구니 삭제도 장바구니마다 되는 것이 아닌 한번에 삭제가 수행되었다.
주문 - 주문 결과 확인
- 응답 JSON이 동일하므로 응답 DTO도 동일하다-
Controller
@RequiredArgsConstructor
@RestController
public class OrderRestController {
private final OrderService orderService;
// (기능10) 주문 결과 확인 GET
// /orders/{id}
@GetMapping("/orders/{id}")
public ResponseEntity<?> findById(@PathVariable int id, @AuthenticationPrincipal CustomUserDetails userDetails) {
OrderResponse.FindAllDTO dto = orderService.findById(id);
return ResponseEntity.ok(ApiUtils.success(dto));
}
}
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class OrderService {
private final CartJPARepository cartJPARepository;
private final OrderJPARepository orderJPARepository;
private final ItemJPARepository itemJPARepository;
//주문 결과 확인
public OrderResponse.FindAllDTO findById(int id) {
Order order = orderJPARepository.findById(id).orElseThrow(
() -> new Exception404("존재하지않는 orderId 입니다")
);
List<Item> itemList = itemJPARepository.findByOrderId(id);
return new OrderResponse.FindAllDTO(order, itemList);
}
}
Repository
public interface ItemJPARepository extends JpaRepository<Item, Integer> {
@Query("select i from Item i join fetch i.option o join fetch o.product p where i.order.id = :orderId")
List<Item> findByOrderId(int orderId);
}
DTO 생성을 위해 option과 product를 join fetch하여 item을 가져온다.
✳️ option과 product의 FetchType이 EAGER이라면 join fetch하지 않아도 함께 가져온다.
수행 쿼리
총 2번의 쿼리가 수행되었다.
1. order 조회 2. item 조회 (option, product join fetch)
'Spring > 카테캠 - TIL' 카테고리의 다른 글
응답값 검증 (0) | 2023.08.02 |
---|---|
TIL [0731, 0801] : 6주차 강의 (WebMvcConfig, 통합테스트, API Docs, 배포) (0) | 2023.08.01 |
카카오테크캠퍼스 4주차 코드리뷰 (0) | 2023.07.28 |
TIL [0727] : 기능 구현, AOP 개념 정리 (0) | 2023.07.27 |
TIL [0726] : 5주차 강의 - 상품 목록, 상세보기, 장바구니 담기, 조회, 업데이트 (0) | 2023.07.26 |