Spring

Springboot + JPA + Querydsl로 좋아요 기능 만들기 1 - 등록

voider 2021. 2. 7. 15:57

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()가 좋아요 등록을 책임지는 메서드다. 간단한 로직은 이렇다.

  1. 메서드 인자로 받은 recipeId로 좋아요를 등록할 레시피를 불러온다.
  2. 사용자가 이미 좋아요를 누른 게시물은 아닌지 체크한다.
  3. 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;
    }
} 

중복 테스트를 진행하여 예외 상황에도 잘 동작하는 지까지 테스트 해봤다.

등록은 여기까지다. 취소 기능은 다음 글에서 다루겠다.