Springboot + JPA + Querydsl로 좋아요 기능 만들기 1 - 등록
개인 프로젝트에 좋아요 기능을 추가했다. Springboot2.3.1, SpringDataJPA, Querydsl을 사용하여 구현했다. 테스트는 JUnit5로 진행했다.
코드는 전체 코드는 github에서 확인할 수 있다.
화면 처리나 환경설정은 이 글에서 다루지 않는다.
내가 생각한 요구사항은 다음과 같다.
-
사용자는 게시물에 좋아요를 누를 수 있다.
-
사용자는 자신의 좋아요를 취소할 수 있다.
-
사용자는 자신이 좋아요를 누른 게시물을 조회할 수 있다.
-
사용자는 게시물 당 한 번만 좋아요를 누를 수 있다.
-
사용자는 전체 게시물을 조회할 때 좋아요 카운트를 확인할 수 있다.
우선 구현을 위해 세 개의 엔티티(테이블)가 필요하다.
- 게시물(Recipe)
- 사용자(Member)
- 좋아요(Likes)
처음엔 Recipe에 like필드를 추가하면 되지 않을까? 생각했다. 그럴 경우 누가 좋아요를 눌렀는지 알 수 없고 좋아요 취소가 불가능하다는 문제가 생긴다.
Likes를 중심으로 Recipe, Member와 다대일(N:1)관계로 설정했다.
ERD
왜 다대일(N:1)인가?
위 그림처럼 Likes는 한 Row에 Member, Recipe를 하나씩만 가질 수 있다. 반면 Member나 Recipe는 여러 개의 Likes를 가질 수 있다.
Entity
Like.java
@NoArgsConstructor @Getter @Setter @AllArgsConstructor
@Table(name = "likes")
@Entity
public class Like {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Recipe recipe;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
public Like(Recipe recipe, Member member) {
this.recipe = recipe;
this.member = member;
}
}
Recipe.java
@NoArgsConstructor @AllArgsConstructor
@Getter @Setter
@Entity
public class Recipe extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
//...중략
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
private int viewCount;
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL)
Set<Like> likes = new HashSet<>();
//....생략
}
최대한 기존 엔티티(Recipe, Member)에 손을 대지 않으려고 신경썼다. Member엔티티는 Likes의 존재를 모르기 때문에 따로 코드를 첨부하지 않았다.
Recipe에도 Likes를 추가하지 않으려고 했지만 전체 조회 화면에서 좋아요 수를 노출하기 위해 어쩔 수 없이 Recipe에만 Like필드를 추가하여 양방향 관계로 설정했다.
좋아요 등록
Recipe, Member, Likes에 대한 Repository 인터페이스가 필요하다.
LikeRepository.java
@Transactional(readOnly = true)
public interface LikeRepository extends JpaRepository<Like, Long> {
Optional<Like> findByMemberAndRecipe(Member member, Recipe recipe);
}
JpaRepository
를 상속 받고 findByMemberAndRecipe()
라는 이름으로 추상 메서드를 만든다.
이 메서드는 member와 recipe를 인자로 받아서 해당 레시피 게시물에, 해당 회원이 좋아요를 등록한 적이 있는지 체크하는 용도로 사용할 것이다.
tip:이 메서드는 JPA의 쿼리 메소드 네이밍 컨벤션에 맞춰서 만든 것이므로 다른 이름으로 지으면 동작하지 않는다.
findBy로 지정했지만 existsBy로 지정하여 boolean타입으로 반환받아도 문제 없다.
LikeService.java
@Transactional
@RequiredArgsConstructor
@Service
public class LikeService {
private final LikeRepository likeRepository;
private final RecipeRepository recipeRepository;
public boolean addLike(Member member, Long recipeId) {
Recipe recipe = recipeRepository.findById(recipeId).orElseThrow();
//중복 좋아요 방지
if(isNotAlreadyLike(member, recipe)) {
likeRepository.save(new Like(recipe, member));
return true;
}
return false;
}
//사용자가 이미 좋아요 한 게시물인지 체크
private boolean isNotAlreadyLike(Member member, Recipe recipe) {
return likeRepository.findByMemberAndRecipe(member, recipe).isEmpty();
}
}
addLike()가 좋아요 등록을 책임지는 메서드다. 간단한 로직은 이렇다.
- 메서드 인자로 받은 recipeId로 좋아요를 등록할 레시피를 불러온다.
- 사용자가 이미 좋아요를 누른 게시물은 아닌지 체크한다.
- 1, 2번을 모두 통과했다면 좋아요를 등록한다.
LikeApiController.java
@Slf4j
@RequiredArgsConstructor
@RestController
public class LikeApiController {
private final LikeService likeService;
@PostMapping("/like/{recipeId}")
public ResponseEntity<String> addLike(
@AuthenticationPrincipal MemberAdapter memberAdapter,
@PathVariable Long recipeId) {
boolean result = false;
if (memberAdapter != null) {
result = likeService.addLike(memberAdapter.getMember(), recipeId);
}
return result ?
new ResponseEntity<>(HttpStatus.OK)
: new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
클라이언트의 좋아요 요청을 핸들링할 컨트롤러다. 로그인한 사용자 정보와 좋아요를 추가할 Recipe의 id값을 받아서 서비스에 위임한다. @AuthenticationPrincipal MemberAdapter memberAdapter
에 대해 잘 모른다면 [Security] 현재 로그인한 사용자 정보 가져오기에 정리해뒀으니 참고.
Test
@Transactional
@AutoConfigureMockMvc
@SpringBootTest
public class LikeApiControllerTest {
@Autowired MemberRepository memberRepository;
@Autowired LikeRepository likeRepository;
@Autowired MockMvc mockMvc;
@Autowired RecipeRepository recipeRepository;
@DisplayName("좋아요 테스트")
@WithMockCutstomUser
@Test
void testCreateLike() throws Exception {
Recipe recipe = addRecipe();
mockMvc.perform(post("/like/"+recipe.getId()))
.andExpect(status().isOk());
Like like = likeRepository.findAll().get(0);
assertNotNull(like);
assertNotNull(like.getMember().getId());
assertNotNull(like.getRecipe().getId());
}
@DisplayName("좋아요 중복 테스트 - fail")
@WithMockCutstomUser
@Test
void testDuplicateLike() throws Exception {
Recipe recipe = addRecipe();
mockMvc.perform(post("/like/"+recipe.getId()))
.andExpect(status().isOk());
mockMvc.perform(post("/like/"+recipe.getId()))
.andExpect(status().isBadRequest());
Like like = likeRepository.findAll().get(0);
assertNotNull(like);
assertNotNull(like.getMember().getId());
assertNotNull(like.getRecipe().getId());
}
private Recipe addRecipe() {
Recipe recipe = Recipe.builder()
.thumbnail("test")
.title("test-recipe")
.fullDescription("test")
.description("test")
.ingredients(new HashSet<Ingredient>(Arrays.asList(new Ingredient("a"), new Ingredient("b"), new Ingredient("c"))))
.cookingTime(11)
.member(memberRepository.findAll().get(0))
.build();
Recipe save = recipeRepository.save(recipe);
return save;
}
}
중복 테스트를 진행하여 예외 상황에도 잘 동작하는 지까지 테스트 해봤다.
등록은 여기까지다. 취소 기능은 다음 글에서 다루겠다.
'Spring' 카테고리의 다른 글
@ControllerAdivce를 사용한 예외 처리 로직 분리 (0) | 2021.05.15 |
---|---|
초간단 Spring Scheduler 적용 (0) | 2021.03.02 |
[Security] 현재 로그인한 사용자 정보 가져오기 (0) | 2021.01.23 |
머스태치Mustache (0) | 2020.09.16 |
JPA Auditing으로 생성 시간/수정시간 자동화 (0) | 2020.09.15 |