오늘은 게시판에 추가적인 작업을 할 것이다.
좋아요, 즐겨찾기, 조회수 기능을 추가하였다.
바뀐 코드와 중요한 부분을 위주로 자세히 알아보자.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers( "/v3/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.antMatchers("/swagger-ui/**", "/v3/**").permitAll() // swagger
.antMatchers("/api/sign-up", "/api/sign-in", "/api/reissue").permitAll()
.antMatchers(HttpMethod.GET, "/api/members").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/messages").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/boards").authenticated()
.antMatchers(HttpMethod.GET, "/api/boards/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/boards/likes").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/boards/{id}/favorites").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/boards/favorites").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/boards/search/{keyword}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
맨 아래 보드 uri가 추가되었다. 좋아요 기능하고 즐겨찾기 등등 여러 주소가 추가되었는데, 이는 컨트롤러에서 자세히 볼 수 있다.
Board
게시판에 있던 기존 필드는 제외하고 추가된 필드 넣었다.
private int likeCount;
private int viewCount;
public Board(String title, String content, Member member, List<Image> images) {
this.title = title;
this.content = content;
this.member = member;
this.likeCount = 0;
this.viewCount = 0;
this.images = new ArrayList<>();
addImages(images);
}
public void increaseLikeCount() {
this.likeCount += 1;
}
public void decreaseLikeCount() {
this.likeCount -= 1;
}
public void increaseViewCount(){
this.viewCount +=1;
}
우선 이전에 설명한 것처럼 좋아요, 조회수가 추가되었다.
게시판 생성 시 0을 초기값으로 갖게 된다.
즐겨찾기의 경우에 카운트를 할 필요가 없다고 생각하여 보드에는 추가하지 않았다.
Likes
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Builder
@Entity
public class Likes extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "likes_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id",nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id",nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
public Likes(Board board, Member member) {
this.board = board;
this.member = member;
}
}
게시판과 멤버를 다대일관계를 지니고 있다.
해당 LIkes를 가지고 있다면 좋아요를 누른 상태를 의미한다.
반대로 Likes가 존재하지 않는다면, 아직 좋아요를 누르지 않은 상태이다.
Board와 Member에 OnDelete 조건을 넣어주어, 유저나 보드가 삭제되면, Likes도 삭제하도록 하였다.
Favorite
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Builder
@Entity
public class Favorite extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "favorite_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
public Favorite(Board board, Member member) {
this.board = board;
this.member = member;
}
}
좋아요와 똑같은 기능을 한다.
LikeRepository
public interface LikeRepository extends JpaRepository<Likes,Long> {
Optional<Likes> findByBoardAndMember(Board board, Member member);
}
매우 단순하게 게시판과 유저를 가지고 있는 Likes를 조회한다.
FavoriteRepositoty
public interface FavoriteRepository extends JpaRepository<Favorite,Long> {
Optional<Favorite> findByBoardAndMember(Board board, Member member);
Page<Favorite> findAllByMember(Member member, Pageable pageable);
}
유저가 즐겨찾기 한 Favorite를 가져오는 것이 Likes와 다른 점이다.
BoardController
추가된 부분만 살펴보자.
@ApiOperation(value = "게시글 좋아요", notes = "사용자가 게시글 좋아요를 누릅니다")
@PostMapping("/boards/{id}")
@ResponseStatus(HttpStatus.OK)
public Response likeBoard(@ApiParam(value = "게시글 id",required = true) @PathVariable Long id){
return Response.success(boardService.updateLikeBoard(id, getPrincipal()));
}
@ApiOperation(value = "게시글 즐겨찾기", notes = "사용자가 게시글 즐겨찾기를 누릅니다.")
@PostMapping("/boards/{id}/favorites")
@ResponseStatus(HttpStatus.OK)
public Response favoriteBoard(@ApiParam(value = "게시글 id", required = true) @PathVariable Long id) {
return Response.success(boardService.updateFavoriteBoard(id, getPrincipal()));
}
@ApiOperation(value = "즐겨찾기 게시판을 조회",notes = "즐겨찾기로 등록한 게시판을 조회합니다.")
@GetMapping("/boards/favorites")
@ResponseStatus(HttpStatus.OK)
public Response findFavoriteBoards(@RequestParam(defaultValue = "0") Integer page){
return Response.success(boardService.findFavoriteBoards(page, getPrincipal()));
}
@ApiOperation(value = "좋아요가 많은 순으로 게시판조회", notes = "게시판을 좋아요순으로 조회합니다.")
@GetMapping("/boards/likes")
@ResponseStatus(HttpStatus.OK)
public Response findAllBoardsWithLikes(@RequestParam(defaultValue = "0") Integer page){
return Response.success(boardService.findAllBoardsWithLikes(page));
}
public Member getPrincipal(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return memberRepository.findByUsername(authentication.getName())
.orElseThrow(MemberNotFoundException::new);
}
좋아요, 즐겨찾기를 추가하였고 해당 유저가 즐겨찾기 한 목록을 페이징을 통해 가져오도록 하였다.
또한 모든 게시판을 조회할 때 인기순으로(likeCount)가 많은 순으로 정렬하게 하였다.
BoardService
@Transactional
public BoardFindResponseDto findBoard(Long id){
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
Member member = board.getMember();
board.increaseViewCount();
return BoardFindResponseDto.toDto(member.getUsername(), board);
}
@Transactional
public String updateLikeBoard(Long id, Member member){
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
if (!hasLikeBoard(board,member)){
board.increaseLikeCount();
return createLikeBoard(board, member);
}
board.decreaseLikeCount();
return removeLikeBoard(board, member);
}
@Transactional
public String updateFavoriteBoard(Long id, Member member) {
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
if (!hasFavoriteBoard(board, member)) {
return createFavoriteBoard(board, member);
}
return removeFavoriteBoard(board, member);
}
@Transactional(readOnly = true)
public BoardFindAllWithPagingResponseDto findFavoriteBoards(Integer page, Member member) {
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
Page<Favorite> favorites = favoriteRepository.findAllByMember(member,pageRequest);
List<BoardFindAllResponseDto> boardsWithDto = favorites.stream().map(Favorite::getBoard).map(BoardFindAllResponseDto::toDto)
.collect(toList());
return BoardFindAllWithPagingResponseDto.toDto(boardsWithDto, new PageInfoDto(favorites));
}
@Transactional(readOnly = true)
public BoardFindAllWithPagingResponseDto findAllBoardsWithLikes(Integer page) {
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("likeCount").descending());
Page<Board> boards = boardRepository.findAll(pageRequest);
List<BoardFindAllResponseDto> boardsWithDto = boards.stream().map(BoardFindAllResponseDto::toDto)
.collect(toList());
return BoardFindAllWithPagingResponseDto.toDto(boardsWithDto, new PageInfoDto(boards));
}
private boolean hasLikeBoard(Board board, Member member){
return likeRepository.findByBoardAndMember(board, member).isPresent();
}
private String createLikeBoard(Board board, Member member) {
Likes likesBoard = new Likes(board, member);
likeRepository.save(likesBoard);
return "좋아요를 눌렀습니다.";
}
private String removeLikeBoard(Board board, Member member) {
Likes likesBoard = likeRepository.findByBoardAndMember(board, member).orElseThrow(LikeNotFoundException::new);
likeRepository.delete(likesBoard);
return "좋아요를 취소했습니다.";
}
private boolean hasFavoriteBoard(Board board, Member member) {
return favoriteRepository.findByBoardAndMember(board, member).isPresent();
}
private String createFavoriteBoard(Board board, Member member) {
Favorite favorite = new Favorite(board, member);
favoriteRepository.save(favorite);
return "게시판을 즐겨찾기에 추가합니다.";
}
private String removeFavoriteBoard(Board board, Member member) {
Favorite favorite = favoriteRepository.findByBoardAndMember(board, member).orElseThrow(FavoriteNotFoundException::new);
favoriteRepository.delete(favorite);
return "게시판을 즐겨찾기에서 삭제합니다.";
}
우선 맨 처음 단건조회의 경우 조회수가 추가되어 viewCount를 조회마다 1씩 증가시키도록 설정하였다.
따라서 트랜잭션을 기존에 읽기 전용이었던 것을 해제하였다.
좋아요와 즐겨찾기는 간단하게 구현하였는데,
이미 좋아요나 즐겨찾기 객체가 존재한다면, 삭제하고 없다면 생성하는 것으로 하였다.
원래는 단순하게 void로 만들 생각이었는데, 좋아요나 즐겨찾기의 경우 메시지를 주는 것이 좋을 것 같아서 반환 값을 추가하였다.
즐겨찾기 목록조회의 경우 기존에 있던 Dto를 재사용하였다.
인기순으로 조회의 경우 단순하게 정렬 조건을 바꾸어 주었다.
저 부분도 메서드로 뽑을 수 있을 것 같으나 당장은 진행하지 않고, 추후에 진행하겠다.
이제 테스트 코드를 작성해 보자.
추가된 부분만 넣었다.
BoardControllerTest
@Test
public void 게시글좋아요_테스트() throws Exception{
//given
Long id =1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when
mockMvc.perform(
post("/api/boards/{id}", id)
).andExpect(status().isOk());
//then
verify(boardService).updateLikeBoard(id, member);
}
@Test
public void 게시글즐겨찾기_테스트() throws Exception{
//given
Long id =1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when
mockMvc.perform(
post("/api/boards/{id}/favorites", id)
).andExpect(status().isOk());
//then
verify(boardService).updateFavoriteBoard(id, member);
}
@Test
public void 게시글즐겨찾기목록조회_테스트() throws Exception{
//given
Integer page =0;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when
mockMvc.perform(
get("/api/boards/favorites")
).andExpect(status().isOk());
//then
verify(boardService).findFavoriteBoards(page,member);
}
@Test
public void 게시글좋아요순으로조회_테스트() throws Exception{
//given
Integer page = 0;
//when
mockMvc.perform(
get("/api/boards/likes"))
.andExpect(status().isOk());
//then
verify(boardService).findAllBoardsWithLikes(page);
}
BoardServiceTest
@Test
public void 게시판좋아요추가_테스트() throws Exception{
//given
Long id = 1L;
Member member = createMember();
Board board = createBoard();
given(boardRepository.findById(id)).willReturn(Optional.of(board));
//when
String result = boardService.updateLikeBoard(id, member);
// then
assertThat(result).isEqualTo("좋아요를 눌렀습니다.");
verify(likeRepository).save(any());
}
@Test
public void 게시판좋아요취소_테스트() throws Exception{
//given
Long id = 1L;
Member member = createMember();
Board board = createBoard();
Likes likes = new Likes(board, member);
given(boardRepository.findById(id)).willReturn(Optional.of(board));
given(likeRepository.findByBoardAndMember(board, member)).willReturn(Optional.of(likes));
//when
String result = boardService.updateLikeBoard(id, member);
// then
assertThat(result).isEqualTo("좋아요를 취소했습니다.");
verify(likeRepository).delete(any());
}
@Test
public void 게시판즐겨찾기추가_테스트() throws Exception{
//given
Long id = 1L;
Member member = createMember();
Board board = createBoard();
given(boardRepository.findById(id)).willReturn(Optional.of(board));
//when
String result = boardService.updateFavoriteBoard(id, member);
// then
assertThat(result).isEqualTo("게시판을 즐겨찾기에 추가합니다.");
verify(favoriteRepository).save(any());
}
@Test
public void 게시판즐겨찾기취소_테스트() throws Exception{
//given
Long id = 1L;
Member member = createMember();
Board board = createBoard();
Favorite favorite = new Favorite(board, member);
given(boardRepository.findById(id)).willReturn(Optional.of(board));
given(favoriteRepository.findByBoardAndMember(board, member)).willReturn(Optional.of(favorite));
//when
String result = boardService.updateFavoriteBoard(id, member);
// then
assertThat(result).isEqualTo("게시판을 즐겨찾기에서 삭제합니다.");
verify(favoriteRepository).delete(any());
}
@Test
public void 게시글즐겨찾기목록조회_테스트() throws Exception{
//given
Integer page = 0;
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
Member member = createMember();
List<Favorite> favorites = new ArrayList<>();
favorites.add(new Favorite(1L,createBoard(),member));
Page<Favorite> favoritesWithPaging = new PageImpl<>(favorites);
given(favoriteRepository.findAllByMember(member,pageRequest)).willReturn(favoritesWithPaging);
//when
BoardFindAllWithPagingResponseDto result = boardService.findFavoriteBoards(page,member);
//then
assertThat(result.getBoards().size()).isEqualTo(1);
}
@Test
public void 게시글인기순조회_테스트() throws Exception{
//given
Integer page = 0;
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("likeCount").descending());
List<Board> boards = new ArrayList<>();
Board board = new Board("likeBoard", "content", createMember(),List.of(createImage()));
board.increaseLikeCount();
boards.add(board);
boards.add(createBoard());
Page<Board> boardsWithPaging = new PageImpl<>(boards);
given(boardRepository.findAll(pageRequest)).willReturn(boardsWithPaging);
//when
BoardFindAllWithPagingResponseDto result = boardService.findAllBoardsWithLikes(page);
//then
assertThat(result.getBoards().get(0).getTitle()).isEqualTo("likeBoard");
}
테스트도 잘 성공하는 것을 볼 수 있다.
이제 포스트맨을 사용하여 한번 조회해 보자.
추가로 몇몇 ResponseDto에 조회수나 좋아요 수가 추가되었다. 자세한 코드는 맨 아래 깃허브에서 확인할 수 있다.
우선 backend1로 로그인을 하고, 추가된 기능을 테스트해 보자.
게시글을 3개 작성하였다.
이제 다른 유저로 로그인을 하고, 좋아요나 즐겨찾기를 해보자.
좋아요 수가 잘 증가한 것을 볼 수 있다.
또한 Likes도 잘 생성되었다.
이제 이 상태에서 다시 한번 눌러보자.
다시 한번 누르니까 좋아요가 취소되었다.
기존에 있던 좋아요도 사라지고, 좋아요 카운트도 0으로 줄어들었다.
이제 즐겨찾기를 테스트해 보자.
1번 게시물과 2번 게시물을 즐겨찾기 하였다.
잘 나오는 것을 확인할 수 있다.
이제 2번을 즐겨찾기 취소해 보자.
삭제 메시지가 잘 나오게 된다.
우리가 원하는 대로 잘 작동하는 것을 볼 수 있다.
현재 전부 좋아요가 0개일 때 인기순으로 조회해 보자.
이 상태에서 1번을 좋아요를 누르고 다시 조회해 보자.
좋아요가 많은 1번이 맨 위로 올라왔다.
마지막으로 조회수를 테스트해 보자.
기존에 만들었던 단건조회로 1번을 5번 방문해 보겠다.
조회수가 성공적으로 늘어나는 것을 볼 수 있다.
마지막으로 스웨거에 추가된 목록을 확인해 보자.
자세한 코드는 아래 깃허브를 통해 볼 수 있다.
https://github.com/kimtaesoo99/community
'프로젝트 > 커뮤니티' 카테고리의 다른 글
[프로젝트] 커뮤니티 REST API 서버만들기 #7 Report API 만들기 (0) | 2023.01.27 |
---|---|
[프로젝트] 커뮤니티 REST API 서버만들기 #6 Comment API만들기 (0) | 2023.01.25 |
[프로젝트] 커뮤니티 REST API 서버만들기 #4 Board API만들기 (0) | 2023.01.23 |
[프로젝트] 커뮤니티 REST API 서버만들기 #3 - Message API 만들기 (6) | 2023.01.21 |
[프로젝트]커뮤니티 REST API 서버만들기 #2 - Member API 만들기 (2) | 2023.01.20 |