본문 바로가기

프로젝트/멋사 개인 프로젝트 (mutsa-SNS)

[16] mutsa-SNS-2 1일차 - (1) 댓글 기능 구현

1주차 2주차 미션이 끝난 후 2일 휴강과 주말을 푹 쉬었다...

새로운 마음으로 새 기능을 구현해 보았다.

 

오늘 구현한 기능은 댓글 기능이다.

  • 댓글 목록은 모든 유저가 조회 가능
  • 댓글 작성은 로그인 한 유저만 가능
  • 댓글 수정/삭제는 댓글 작성 유저만 가능 (ADMIN유저는 모든 댓글에 대하여 수정/삭제 가능)

 

1. Configuration

SwaggerConfig

@Configuration
@RequiredArgsConstructor
public class SwaggerConfig {

    private final TypeResolver typeResolver;

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.OAS_30)
                //swagger pageable 설정
                .alternateTypeRules(AlternateTypeRules
                        .newRule(typeResolver.resolve(Pageable.class), typeResolver.resolve(Page.class)))
                .securityContexts(Arrays.asList(securityContext()))
                .securitySchemes(Arrays.asList(apiKey()))
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build();
    }

    @Getter
    @Setter
    @ApiModel
    static class Page {
        @ApiModelProperty(value = "페이지 번호")
        private Integer page;
    }
    
    ...
 }

댓글 조회를 swagger로 테스트 하던 도중 pageable이 Swagger에서 잘 작동하지 않은 것을 보았다.

pageable에는 ?page=0 과 같은 형태로 URL에 접근하였는데, swagger pageNumber와 같은 형태로 넘어가기 때문으로 보였다.

이를 해결하기 위해 아래 Reference를 참고하여 SwaggerConfiguration에 설정을 하였다.

Pageable Class를 아래 정의한 Page Class로 대체하도록 설정하였다.

 

<Reference>

https://blog.jiniworld.me/20

 

Swagger 2 에서 Pageable 이용하기

Swagger 에서 Pageable 이용하기 @RequestMapping("/stores") @Api(tags = "store") @RestController public class StoreController { @ApiOperation(value = "메뉴 조회 with paging", notes="store의 id를 이용하여 가맹점의 메뉴를 페이징 처

blog.jiniworld.me

https://wellbell.tistory.com/19

 

4. swagger를 통한 문서화

Swagger(스웨거)는 개발자가 REST API 서비스를 설계, 빌드, 문서화할 수 있도록 하는 프로젝트이다. 다른 개발팀과 협업을 진행하거나 이미 구축되어있는 프로젝트에 대한 유지보수를 진행할 경우

wellbell.tistory.com

 

2. Domain

Comment (Entity)

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name = "comment")
@Entity
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
//soft delete
@SQLDelete(sql = "UPDATE comment SET deleted_at = current_timestamp WHERE id = ?")
@Where(clause = "deleted_at is null")
public class Comment extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String comment;
    private LocalDateTime deletedAt;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    public CommentDto toResponse() {
        return CommentDto.builder()
                .id(this.getId())
                .comment(this.getComment())
                .userName(this.getUser().getUserName())
                .postId(this.getPost().getId())
                .createdAt(this.getCreatedAt())
                .build();
    }

    public CommentModifyResponse toModifiedResponse() {
        return CommentModifyResponse.builder()
                .id(this.getId())
                .comment(this.getComment())
                .userName(this.getUser().getUserName())
                .postId(this.getPost().getId())
                .createdAt(this.getCreatedAt())
                .lastModifiedAt(this.getLastModifiedAt())
                .build();
    }

    public void modify(String comment) {
        this.comment = comment;
    }

}

기존 User와 Post Entity처럼 생성시간과 수정시간을 갖고있는 BaseEntity를 extends해주었다.

 

다만 나중에 댓글이 있는 post삭제 시, 발생한 error를 대비하여 Post와 Comment Entity에 softe delete를 적용해 주었다.

이는 직접 DB에 있는 데이터를 삭제하는 것이 아니라 (delete query),

deletedAt을 업데이트하거나, delete를 true로 업데이트 하는 등 (update query) 논리적으로 삭제 처리를 한다.

또한 @SqlDelete annotation을 추가하여, JpaRepository에서 delete를 수행하면, 해당 query가 대신 수행되고,

조회시에는 @Where annotation에서 설정한 조건을 자동으로 추가해주도록 설정해 주었다.

 

그리고, user_id와 post_id를 Foreign key로 설정해주었다.

 

<Reference>

https://abbo.tistory.com/312

 

JPA Boolean/LocalDate Soft Delete 구현하기

JPA 를 사용하는 일이 많아지면서 데이터를 물리삭제 (repository.delete)보다 논리삭제를 진행하는 경우가 더 많아졌습니다. 왜냐면 데이터를 관리하는 입장에서 데이터가 사라지는 것보다 남은 데

abbo.tistory.com

https://velog.io/@max9106/JPA-soft-delete

 

[JPA] soft delete 자동으로 처리하기

데이터를 삭제하는 방법에는 hard delete, soft delete 2가지 종류가 있습니다. hard delete는 delete 쿼리를 날려서 데이터베이스에서 실제로 삭제하는 방법을 말합니다.soft delete는 실제로 데이터베이스에

velog.io

 

CommentRequest (dto)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentRequest {

    private String comment;
}

클라이언트가 댓글을 작성하거나 수정할 때 사용되는 Request dto 이다.

 

CommentDto (dto)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentDto {
    private int id;
    private String comment;
    private String userName;
    private int postId;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdAt;
}

댓글을 작성하거나 조회 할 때 service에서 controller로 필요 정보를 옮길 때 사용되는 dto겸 controller에서 return할 때 사용되는 response형태이다.

 

CommentModifyResponse (dto)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentModifyResponse {
    private int id;
    private String comment;
    private String userName;
    private int postId;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdAt;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime lastModifiedAt;
}

위의 CommentDto와 비슷하나, 댓글 수정 시 수정시간을 표시하기 위하여 lastModifiedAt을 추가하였다.

 

CommentDeleteResponse (dto)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentDeleteResponse {
    private String message;
    private int id;
}

댓글 삭제시 controller 에서 return할 때 사용되는 response이다.

 

3. Repository

CommentRepository

public interface CommentRepository extends JpaRepository<Comment, Integer> {

    Page<Comment> findAllByPost(Post post, Pageable pageable);
}

해당 post의 댓글을 찾기 위한 method를 추가하였다.

 

4. Service

PostService

@Service
@RequiredArgsConstructor
@Slf4j
public class PostService {

    private final PostRepository postRepository;
    private final UserRepository userRepository;
    private final CommentRepository commentRepository;
    
    ...
    
    //댓글 기능
    public CommentDto createComment(Integer postId, String userName, String comment) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new AppException(ErrorCode.POST_NOT_FOUND, ""));

        User user = userRepository.findByUserName(userName)
                .orElseThrow(() -> new AppException(ErrorCode.NOT_FOUNDED_USER_NAME, ""));

        Comment commentEntity = Comment.builder()
                .comment(comment)
                .user(user)
                .post(post)
                .build();

        commentRepository.save(commentEntity);

        return commentEntity.toResponse();

    }


    public Page<CommentDto> getComment(Integer postId, Pageable pageable) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new AppException(ErrorCode.POST_NOT_FOUND, ""));


        Page<Comment> commentPages = commentRepository.findAllByPost(post, pageable);

        return new PageImpl<>(commentPages.stream()
                .map(Comment::toResponse)
                .collect(Collectors.toList()));

    }

    public void deleteComment(String userName, Integer postId, Integer commentId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new AppException(ErrorCode.POST_NOT_FOUND, ""));

        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(() -> new AppException(ErrorCode.COMMENT_NOT_FOUND, ""));

        User user = userRepository.findByUserName(userName)
                .orElseThrow(() -> new AppException(ErrorCode.NOT_FOUNDED_USER_NAME, ""));

        //userRole이 USER이고, 작성자와 삭제자 불일치시.
        //ADMIN은 모두 삭제 가능.
        if (user.getRole() == UserRole.USER && !Objects.equals(post.getUser().getUserName(), userName)) {
            throw new AppException(ErrorCode.INVALID_PERMISSION, "");
        }

        //soft delete로 처리됨.
        commentRepository.delete(comment);

    }

    public CommentModifyResponse modifyComment(String userName, Integer postId, Integer commentId, String comment) {

        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new AppException(ErrorCode.POST_NOT_FOUND, ""));

        Comment commentEntity = commentRepository.findById(commentId)
                .orElseThrow(() -> new AppException(ErrorCode.COMMENT_NOT_FOUND, ""));

        User user = userRepository.findByUserName(userName)
                .orElseThrow(() -> new AppException(ErrorCode.NOT_FOUNDED_USER_NAME, ""));

        //userRole이 USER이고, 작성자와 삭제자 불일치시.
        //ADMIN은 모두 수정 가능.
        if (user.getRole() == UserRole.USER && !Objects.equals(post.getUser().getUserName(), userName)) {
            throw new AppException(ErrorCode.INVALID_PERMISSION, "");
        }

        commentEntity.modify(comment);
        Comment savedComment = commentRepository.saveAndFlush(commentEntity);

        return savedComment.toModifiedResponse();
    }
}

댓글 작성, 수정, 삭제, 조회에 관한 비지니스 로직을 추가하였다.

exception을 throw하는 과정은 기존 post와 거의 동일하다.

 

5. Controller

PostController

@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;
    
    ...

    //댓글 기능
    @PostMapping("/{postId}/comments")
    public Response<CommentDto> createComment(@PathVariable Integer postId, @RequestBody CommentRequest req, @ApiIgnore Authentication authentication) {

        CommentDto commentDto = postService.createComment(postId, authentication.getName(), req.getComment());
        return Response.success(commentDto);

    }

    @GetMapping("/{postId}/comments")
    public Response<Page<CommentDto>> getComment(@PathVariable Integer postId,
                                                 @PageableDefault(size=10, sort="createdAt", direction = Sort.Direction.DESC) Pageable pageable) {

        Page<CommentDto> commentDtos = postService.getComment(postId, pageable);
        return Response.success(commentDtos);

    }

    @DeleteMapping("/{postId}/comments/{commentId}")
    public Response<CommentDeleteResponse> deleteComment(@PathVariable Integer postId, @PathVariable Integer commentId, @ApiIgnore Authentication authentication) {

        postService.deleteComment(authentication.getName(), postId, commentId);
        return Response.success(new CommentDeleteResponse("댓글 삭제 완료", commentId));
    }

    @PutMapping("/{postId}/comments/{commentId}")
    public Response<CommentModifyResponse> modifyComment(@PathVariable Integer postId, @PathVariable Integer commentId, @RequestBody CommentRequest req, @ApiIgnore Authentication authentication) {

        CommentModifyResponse commentModifyResponse = postService.modifyComment(authentication.getName(), postId, commentId, req.getComment());
        return Response.success(commentModifyResponse);

    }
}

댓글 작성, 수정, 삭제, 조회 관련 기능을 추가하였다. 기존 post작성, 수정, 삭제, 조회와 거의 비슷하다.

다만, 댓글의 page size는 10으로 설정하였다.

 

<전체 Reference>

https://blog.jiniworld.me/20
https://wellbell.tistory.com/19
https://abbo.tistory.com/312
https://velog.io/@max9106/JPA-soft-delete