springboot

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

_dare 2022. 4. 21. 23:45

있어야 하는 기능

  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 체크를 통해 로그인 여부를 확인할 수 있다는 것이다.