아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.
강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.
@Entity
@Table(indexes = { @Index(name="idx_cart_email", columnList = "cart_owner")})
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long cardNo;
@OneToOne
@JoinColumn(name = "cart_owner") // 외래키 컬럼명
private Member owner;
}
@OneToOne
은 일대일 관계를 매핑할 때 사용됩니다. 여기서는 Cart 엔티티와 Member 엔티티 간의 1:1 관계
를 나타냅니다.
@JoinColumn(name = "cart_owner")
은 외래키를 정의합니다. cart_owner 컬럼은 Cart 의 외래 키로 사용되며, Member 의 기본 키와 매핑됩니다. 즉, cart_owner 의 값은 email 이 됩니다.
@Table(indexes = { @Index(name="idx_cart_email", columnList = "cart_owner")})
는 테이블에 인덱스를 생성하는 설정입니다.
name 은 인덱스의 이름을, columnList 는 해당 인덱스가 적용될 컬럼을 의미합니다. 여기서는 외래키인 cart_owner 컬럼에 대해 인덱스가 생성됩니다.
하나의 장바구니에는 여러 개의 상품이 들어갈 수 있습니다. 마찬가지로 하나의 상품이 여러 개의 장바구니에 들어갈 수 있는데 이는 M:N 관계
입니다.
CartItem 이라는 중간 엔티티를 설계하여 M:N 관계
를 두 개의 N:1 관계
로 만들어줍니다.
@Entity
@Table
public class CartItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long cartItemNo;
@ManyToOne
@JoinColumn(name="item_id")
private Item item;
@ManyToOne
@JoinColumn(name="cart_no")
private Cart cart;
private int quantity;
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
@ManyToOne
이나 @OneToMany
어노테이션은 사용된 객체를 중심으로 생각합니다.
그래서 ( CartItem ➜ Cart ) 으로 생각할 수 있고, @ManyToOne
에 의해 여러 개의 CartIem 이 하나의 Cart 에 들어갈 수 있다는 의미가 됩니다.
또 하나의 상품은 여러 개의 CartItem 에 들어갈 수 있습니다. 이를 ( CartItem ➜ Item ) 으로 보았을 때 CartItem 에서 Item 으로의 @ManyToOne
이 됩니다.
하나의 카트에 여러 개의 상품이 들어갈 수 있다
➜ 하나의 카트에 여러 개의 CartItem 이 될 수 있다.
하나의 상품이 여러 개의 카트에 들어갈 수 있다.
➜ 하나의 Item 이 여러 개의 CartItem 이 될 수 있다.
public class CartItemListDTO {
private Long cartItemNo;
private int quantity;
private Long itemId;
private String itemName;
private int price;
private String imageFile;
}
해당 객체는 Entity 가 아니고, 장바구니에 담긴 Item 의 정보를 담은 DTO 입니다. 이를 화면에 전달하여 장바구니에 담긴 상품들의 정보를 알 수 있습니다.
/**
* 특정 사용자의 모든 CartItem 을 가져올 경우
* @param : email
* @return : CartItemListDTO
*/
@Query("select new ghkwhd.apiServer.cart.dto.CartItemListDTO(ci.cartItemNo, ci.quantity, i.itemId, i.itemName, i.price, ii.fileName) " +
"from CartItem ci " +
"inner join Cart c on ci.cart = c " +
"left join Item i on ci.item = i " +
"left join i.imageList ii " +
"where c.owner.email = :email " +
"and (ii.imageOrder IS NULL OR ii.imageOrder = 0) " +
"order by ci.cartItemNo desc")
List<CartItemListDTO> getAllCartItemsByEmail(@Param("email") String email);
사용자의 email 을 통해 모든 CartItem 을 조회하고, 결과를 CartItemListDTO 로 매핑하기 위한 쿼리입니다.
CartItemDTO 에 존재하는 필드들은 CartItem 뿐만 아니라 Item, ItemImage 에 있는 필드들도 필요하기 때문에 JOIN 을 통해 필요한 값들을 가져옵니다.
CartItem 에서 Cart 와 @ManyToOne
으로 연결되어 있습니다. CartItem 과 Cart 를 JOIN 할 때 외래키가 같다고 표현하지 않고, 해당 필드에 들어가는 객체가 Cart 라고 표현합니다.
@ElementCollection
을 사용하여 정의된 imageList는 컬렉션 타입인데, JPQL에서는 컬렉션을 직접 조인할 수 없습니다. 따라서 @ElementCollection
으로 정의된 컬렉션을 JOIN으로 처리하기 위해 해당 컬렉션을 소유하고 있는 엔티티(Item)와 함께 조인하는 방식을 사용합니다.
/**
* CartItem 을 추가하는 경우, 이미 CartItem 이 존재하는지 여부 확인
* @param : email, itemId
* @return : CartItem
*/
@Query("select ci from CartItem ci left join Cart c on ci.cart = c where c.owner.email = :email and ci.item.itemId = :itemId")
Optional<CartItem> getItemByEmailAndItemId(@Param("email") String email, @Param("itemId") Long itemId);
위의 쿼리는 사용자 email 과 상품의 id 를 통해 해당 상품이 장바구니에 존재하는지 확인하는 쿼리입니다. 해당 쿼리는 장바구니에 상품을 추가할 때 사용됩니다.
void saveCartItem() {
// 테스트를 위한 데이터 INSERT
String email = "member1@email.com";
Long itemId = 76L;
int quantity = 1;
// 1. 해당 사용자의 장바구니에 해당 상품이 있는지 확인
CartItem cartItem = null;
Optional<CartItem> itemByEmailAndItemId = cartItemRepository.getItemByEmailAndItemId(email, itemId);
// 1-1. 장바구니에 해당 상품이 존재하는 경우
if (itemByEmailAndItemId.isPresent()) {
cartItem = itemByEmailAndItemId.get();
cartItem.setQuantity(cartItem.getQuantity() + 1);
} else {
// 1-2. 존재하지 않는 경우
// 장바구니 존재 여부 확인
Cart cart = null;
Optional<Cart> cartByEmail = cartRepository.getCartByEmail(email);
// 1-2-1. 장바구니 자체가 존재하지 않는 경우
if (cartByEmail.isEmpty()) {
Member member = Member.builder().email(email).build();
Cart newCart = Cart.builder().owner(member).build();
cart = cartRepository.save(newCart);
} else {
// 1-2-2. 장바구니가 있지만 해당 상품이 없는 경우
cart = cartByEmail.get();
}
// 이 시점에 장바구니는 존재하게 된다
// 장바구니에 저장 하려는 상품을 생성 및 장바구니에 추가 ( => CartItem 생성 )
Item item = Item.builder().itemId(itemId).build();
cartItem = CartItem.builder().cart(cart).item(item).quantity(quantity).build();
}
cartItemRepository.save(cartItem);
}
위의 테스트 코드는 장바구니에 상품을 추가하는 테스트입니다. 테스트는 아래와 같은 흐름대로 실행됩니다.
- 해당 상품이 장바구니에 존재하는지 확인 ( CartItem 확인 )
- 상품이 존재하는 경우 => CartItem 의 수량 증가
- 상품이 존재하지 않는 경우 => Cart 존재 여부 확인 및 가져오기
3-1. Cart 가 존재하지 않는 경우 해당 사용자로 Cart 생성 및 반환
3-2. Cart 가 존재하는 경우 Cart 반환
3-3. Cart, Item 객체를 이용해 CartItem 을 생성- 생성되거나 변경된 CartItem 저장
/**
* cartItemNo 을 통해 cartNo 를 가져오는 경우 ( CartItem 을 삭제 후, Cart 에 담긴 모든 CartItem 출력을 위해 )
* @param : cartItemNo
* @return : cartNo
*/
@Query("select c.cartNo from Cart c left join CartItem ci on ci.cart = c where ci.cartItemNo = :cartItemNo")
Long getCartNoByCartItemNo(@Param("cartItemNo") Long cartItemNo);
해당 강의는 CartItem 을 삭제한 후, 장바구니에 담겨있는 모든 CartItem 들을 다시 조회합니다. 이때 CartItem 을 조회하기 위한 삭제하는 CartItem 에 담긴 CartNo 를 조회하는 쿼리입니다.
이렇게 조회된 CartNo 는 4번에서 CartItem 을 조회할 때 사용합니다.
/**
* cartNo 로 모든 CartItem 을 조회하는 경우
* @param : cartNo
* @return : All CartItem
*/
@Query("select new ghkwhd.apiServer.cart.dto.CartItemListDTO(ci.cartItemNo, ci.quantity, i.itemId, i.itemName, i.price, ii.fileName) " +
"from CartItem ci " +
"inner join Cart c on ci.cart = c " +
"left join Item i on ci.item = i " +
"left join i.imageList ii " +
"where c.cartNo = :cartNo " +
"and (ii.imageOrder IS NULL OR ii.imageOrder = 0) " +
"order by ci.cartItemNo desc")
List<CartItemListDTO> getAllCartItemsByCartNo(@Param("cartNo") Long cartNo);
3번 쿼리에서 조회한 CartNo 를 통해 모든 CartItem 들을 조회하는 쿼리입니다.
1번에서 사용된 쿼리와 동일한데 where 조건을 보면 1번에서는 email 을 통해 조회했는데 이번에는 CartNo 를 사용했습니다.
void deleteCartItemAndGetAllCartItems() {
// 장바구니의 상품을 삭제하면 해당 장바구니의 모든 아이템들을 다시 가져오도록
Long cartItemNo = 3L;
// 1. 삭제할 아이템이 있었던 장바구니 번호를 구한다
Long cartNo = cartItemRepository.getCartNoByCartItemNo(cartItemNo);
// 2. 장바구니의 상품을 삭제
cartItemRepository.deleteById(cartItemNo);
// 3. 삭제 후, 해당 장바구니에 담긴 모든 상품을 조회
List<CartItemListDTO> allCartItemsByCartNo = cartItemRepository.getAllCartItemsByCartNo(cartNo);
for (CartItemListDTO cartItemListDTO : allCartItemsByCartNo) {
log.info("Cart Item = {}", cartItemListDTO);
}
}
위의 테스트 코드는 장바구니에 담긴 상품을 삭제하는 테스트코드 입니다.
삭제 전, 3번 쿼리를 통해 해당 상품이 담긴 CartNo 을 알아내고, 삭제 후에 4번 쿼리를 통해 CartNo 으로 다시 장바구니에 담긴 모든 CartItem 을 조회합니다.
public class CartController {
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/items")
public List<CartItemListDTO> getAllCartItems(Principal principal) {
String email = principal.getName();
log.info("################### CartController ####################");
log.info("email = {}", email);
return cartService.getCartItems(email);
}
}
위의 코드는 사용자가 로그인 했을 때, 장바구니에 담긴 상품들을 모두 가져오는 메서드입니다.
@PreAuthorize("hasRole(...)")
는 Spring Security 에서 제공하는 어노테이션 중 하나로, 해당 메서드에 접근할 수 있는 권한을 설정하는 데 사용됩니다.
이 경우에는 "ROLE_USER" 권한을 가진 사용자만이 이 메서드를 호출할 수 있습니다. 권한을 가진 사용자가 아니라면, 메서드 호출 시 권한이 없다는 예외가 발생합니다.
Principal
객체를 파라미터로 받아 principal.getName()
을 통해 현재 사용자의 식별자 정보를 얻습니다.
일반적으로 Spring Security가 인증된 사용자의 정보를 SecurityContextHolder 에 저장하고, Spring 의 의존성 주입( DI ) 메커니즘에 기반하여 Principal 객체가 컨트롤러 메서드에 자동으로 주입됩니다.
@PreAuthorize("#cartItemDTO.email == authentication.name")
@PostMapping("/change")
public List<CartItemListDTO> changeCart(@RequestBody CartItemDTO cartItemDTO) {
log.info("################### CartController ####################");
log.info("CartItemDTO = {}", cartItemDTO);
if (cartItemDTO.getQuantity() <= 0) {
log.info("수량에 의한 제거 호출");
return cartService.deleteCartItem(cartItemDTO.getCartItemNo());
}
return cartService.addOrModify(cartItemDTO);
}
@PreAuthorize
를 통해 현재 로그인 한 사용자와 CartItemDTO 에 담긴 email 값이 서로 같아야만 처리가 가능하도록 합니다.
#cartItemDTO
는 메서드의 파라미터인 cartItemDTO 객체를 나타냅니다. authentication.name
은 현재 사용자의 이름을 나타내는 Spring Security의 Authentication 객체에서 가져옵니다.
Principal 은 현재 사용자를 나타내는 인터페이스이며, 보통 사용자의 식별 정보를 가지고 있습니다.
Authentication 은 현재 사용자의 인증 정보를 나타냅니다. Authentication 객체는 Principal 과 함께 현재 사용자의 권한, 인증 여부 등 다양한 정보를 제공합니다.
위의 사진을 보면 Authentication 객체 안에 Principal 객체가 포함된 것을 볼 수 있습니다. 그렇기 때문에 아래처럼 Authentication 에서 getPrincipal()
메서드를 통해 Principal 객체를 얻을 수 있습니다.
Authentication authentication = ... ; // Authentication 객체
Principal principal = (Principal) authentication.getPrincipal();
public class CartServiceImpl implements CartService {
private final CartRepository cartRepository;
private final CartItemRepository cartItemRepository;
@Override
public List<CartItemListDTO> addOrModify(CartItemDTO cartItemDTO) {
String email = cartItemDTO.getEmail();
Long itemId = cartItemDTO.getItemId();
int quantity = cartItemDTO.getQuantity();
Long cartItemNo = cartItemDTO.getCartItemNo();
if (cartItemNo != null) {
// 우측 사이드바를 클릭( 사이드바에는 cartItemNo 가 존재 )
log.info("CartItemNo != NULL");
CartItem cartItem = cartItemRepository.findById(cartItemNo).orElseThrow();
cartItem.setQuantity(quantity);
cartItemRepository.save(cartItem);
} else {
// ReadPage 에서 Add Cart 를 클릭 ( ReadPage 에는 CartItemNo 가 없음 )
// 쿼리 2번의 테스트 코드와 동일
...
Optional<CartItem> itemByEmailAndItemId = cartItemRepository.getItemByEmailAndItemId(email, itemId);
...
}
return getCartItems(email);
}
}
위의 코드는 장바구니에 상품을 추가하거나 변경하는 코드입니다. 아래 else
부분의 코드는 이전 쿼리 2번의 테스트 코드와 동일합니다.
상품의 ReadPage 에서 Add Cart 버튼을 통해 장바구니에 상품을 추가하거나, 장바구니에서 버튼을 통해 수량을 늘리거나 줄일 수 있습니다.
이때 ReadPage 에는 CartItemNo 가 표현되지 않습니다. 하지만 표현되지 않을 뿐 실제 장바구니에 있는지 없는지는 확인을 해야 하기 때문에 else
에서 한 번 더 해당 상품에 해당하는 CartItem 조회를 거치게 됩니다.