본문 바로가기

springboot

[springboot-thymeleaf] 쇼핑몰 상품 찜 기능 구현하기

있어야 하는 기능

  1. 찜하기
  2. 찜한 목록 보기
  3. 취소하기

찜은 상품 detail 화면에서 동작하고, 취소하는 방법은 상품 detail 화면과 찜한 목록 화면에서 동작해야한다. 

또한 상품을 찜하기 위해서는 로그인이 된 상태여야 한다. 

성능 최적화는 아직 고려하지 않았다. 

 

 


도메인 설계

 

 

member 객체가 like item의 list를 갖고, like item이 item과 member의 다대다 관계를 풀어준다. 

 

 

UI 구현

 

상품 화면에서 찜 했으면 버튼 색을 칠하고, 찜하지 않았거나 로그인 상태가 아니라면 칠하지 않도록 보이게 했다. 

 

목록도 단순하게 나열만 하고, 취소를 통해 바로 취소할 수 있도록 했다. 

 

 

상품 상세 페이지 UI

ItemController.java

@GetMapping(value = "/detail")
public String itemDetailsWithMember(@AuthenticationPrincipal LoginUserDetails member, @RequestParam("itemId") Long itemId, Model model) {
	Item findItem = itemService.findOne(itemId);
	ItemDetailDto item = new ItemDetailDto();
	item.setId(findItem.getId());
	item.setName(findItem.getName());
	item.setPrice(findItem.getPrice());
	item.setStockQuantity(findItem.getStockQuantity());
	item.setImageUrl("/images/"+findItem.getId()+".png");
	model.addAttribute("item", item);

	if(member!=null) {
		Member findMember = memberRepository.findByLoginId(member.getUsername()).get();
		List<LikeItem> likes = findMember.getLikes();
		for(LikeItem likeItem : likes) {
			if(likeItem.getItem().getId()==itemId) {
				model.addAttribute("isLikeItem", "btn-primary");
				return "item/detail";
            }
        }
    }
    model.addAttribute("isLikeItem", "btn-outline-primary");
    return "item/detail";
}​

상품 상세 페이지에서 상품과 관련된 view를 설정하고

@AuthenticationPrincipal로 UserDetails를 구현한 객체를 받아 로그인 한 상태인지 확인한다. 

만약 로그인하지 않았거나, 찜 상품에 해당 상품이 없다면 outline만 있는 button style을 return하고,
찜 상품에 해당 상품이 있다면 primary style을 return한다.

 

isLikeItem attribute에 class 값을 넣어놓고

	...
	<button type="submit" class="btn" th:classappend="${isLikeItem}" th:formaction="@{/like}">찜</button>
    ...

th:classappend를 통해 동적으로 class를 적용하도록 했다. 

 

 

찜한 목록 UI

 

LikeItemController.java

@GetMapping(value = "/likes")
public String likes(@AuthenticationPrincipal LoginUserDetails member, Model model) {
    Member findMember = memberService.findUser(member.getUsername());
    List<LikeItem> likeItems = findMember.getLikes();
    List<Item> items = likeItems.stream().map(likeItem -> likeItem.getItem()).collect(Collectors.toList());
    model.addAttribute("items", items);
    return "/user/likeList";
}

로그인된 유저 정보를 갖고 찜한 아이템 목록을 불러온다.

LikeItem으로 다대다 관계를 풀어줬기 때문에 LikeItem에서 Item 정보만 가져온다. 

 

 

비즈니스 로직 구현

 

먼저 Member Entity에 LikeItem을 추가하고 삭제할 수 있는 비즈니스 로직을 추가했다. 

 

Member.class

public void addLikeItem(LikeItem likeItem) {
	for(LikeItem like : likes) {
		if(like.getItem()== likeItem.getItem()) return;
	}
	likes.add(likeItem);
	likeItem.setMember(this);
}

public void removeLikeItem(LikeItem likeItem) {
    likes.remove(likeItem);
    likeItem.setMember(null);
}

사실 removeLikeItem에서 likeItem.setMember(null)은 필요가 없다.

Like Item을 지울 때 다른 부분에서 db에서도 없애도록 설계했기 때문이다. 

하지만 나중의 혹시모를 리팩토링을 위해 남겨놓았다. 

 

 

찜을 취소하면 db에서도 삭제하기 위해 remove 로직을 정의했다. 

LikeItem을 비식별관계를 통해 정의했기 때문에 memberId와 itemId로 likeItem을 찾을 수 있는 로직도 repository에 필요하다.

LikeItemRepository.java

...
public Optional<LikeItem> findByIds(Long memberId, Long itemId) {
    return em.createQuery("select li from LikeItem li" +
                        " where li.member.id = :memberId" +
                        " and li.item.id = :itemId", LikeItem.class)
                .setParameter("memberId", memberId)
                .setParameter("itemId", itemId)
                .getResultList().stream().findAny();
}

public void remove(Long likeItemId) {
    em.remove(em.find(LikeItem.class, likeItemId));
}
 ...

 

그리고 add, remove request를 처리하는 Controller이다.

 

LikeItemController.java

@PostMapping(value = "/like") // 상품 상세 페이지에서 요청하는 url
public String addLike(@AuthenticationPrincipal LoginUserDetails member,
                      @RequestParam("itemId") Long itemId){
	Member findMember = memberService.findUser(member.getUsername());
	Item findItem = itemService.findOne(itemId);

    LikeItem findLikeItem = likeItemService.findOne(findMember.getId(), itemId);
    if(findLikeItem==null) { // 찜하지 않았다면 새로 찜
        LikeItem likeItem = new LikeItem();
        likeItem.setItem(findItem);

        findMember.addLikeItem(likeItem);
        likeItemService.save(likeItem);
    }
    else { // 찜했다면 삭제
        findMember.removeLikeItem(findLikeItem);
        likeItemService.remove(findLikeItem.getId());
    }
    return "redirect:/items/detail?itemId="+itemId;
}

@PostMapping(value = "/like/{itemId}/cancel") // 내가 찜한 상품 목록 화면에서 취소 요청을 보내는 
public String cancelLike(@AuthenticationPrincipal LoginUserDetails member,
                          @PathVariable("itemId") Long itemId) {
    Member findMember = memberService.findUser(member.getUsername());
    LikeItem likeItem = likeItemService.findOne(findMember.getId(), itemId);
    findMember.removeLikeItem(likeItem);
    likeItemService.remove(likeItem.getId());

    return "redirect:/likes";
}

cancel은 찜한 목록 화면에서 취소 요청이 들어온 item에 대해서 취소 하도록 했고,

상품 상세 페이지에서 찜 버튼을 눌렀을 때, 이미 찜한 상품이라면 취소하도록 하고 찜하지 않았다면 새로 찜 목록에 추가하도록 했다.

 

 

더보기

사실 LikeItem에 추가할 필드가 없어서 Member와 Item을 JoinTable로 묶으려고 했지만

createQuery로 어떻게 조회해야할 지 몰라서 그냥 정석대로 LikeItem Entity를 만들어서 다대다 관계를 풀어줬다. 

관계형 데이터 베이스를 제대로 공부한 적이 이번이 처음이라 애먹었다. 

 

새로 알게된 건 UserDetails를 구현한 객체를 @AuthenticationPrincipal로 불러왔을 때 반드시 로그인한 상태가 아니라도 불러올 수 있고, null 체크를 통해 로그인 여부를 확인할 수 있다는 것이다.