프로젝트

JPA 1 : N 관계를 분석해보자

ummchicken 2023. 2. 27. 16:14

안녕하세요.

 

오늘은 저번 JPA CRUD에 이어 

1 : N 관계를 알아보겠습니다.

 

 

✔️ 요구사항 분석

  • 게시글 : 댓글 = 1 : N
  • 댓글 CRUD
  • 게시글이 삭제되면, 연관된 댓글들이 모두 삭제된다.
  • 특정 게시글을 조회하면, 연관된 댓글들도 같이 조회된다.

 


 

일단 먼저 댓글 엔티티 및 CRUD 기능을 생성하겠습니다.

 

기존의 게시글 CRUD랑 똑같은데, 

다만 연관관계를 설정하는 부분만 추가됩니다.

 

 

 

제일 먼저, Comment 엔티티를 보겠습니다.

 

[Comment 엔티티]

@Getter
@NoArgsConstructor
@Entity
public class Comments extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comments_id")
    private Long id;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String comment;

    @ManyToOne
    @JoinColumn(name = "posts_id")
    private Posts posts;

    @Builder
    public Comments(String comment,  Posts posts) {
        this.comment = comment;
        this.posts = posts;
    }

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

}
  • String comment : 댓글 내용
  • post와 연관관계를 맺어줍니다.
    • post 하나당, 여러 개의 댓글을 생성할 수 있으므로 @ManyToOne 관계를 설정합니다.
    • Join은 post의 id로 할 것입니다. (FK)

 

 

 

다음은 Post 엔티티에 댓글 관련한 것들을 추가합니다.

[Post 엔티티]

@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "posts_id")
    private Long id;

    ...

    @OneToMany(mappedBy = "posts", cascade = CascadeType.REMOVE)
    private List<Comments> commentsList;
    
    ...
}
  • Comment와 @OneToMany로 양방향 관계를 맺어줍니다.
  • 게시글을 조회할 때, 연관된 댓글을 함께 조회하기 위해 양방향을 맺어줬습니다.
  • 그리고 cascade = CascadeType.REMOVE로 영속성 전이(제거) 설정을 합니다.
    • 부모 엔티티를 삭제하면, 연관된 자식 엔티티도 함께 삭제됩니다.
    • 게시글을 삭제하면, 연관된 댓글들이 함께 삭제되도록 하기 위함입니다.

 

 

 

다음은 CommnetRepository입니다.

[CommnetRepository]

public interface CommentsRepository extends JpaRepository<Comments, Long> {

}

 

 

 

다음은 DTO들을 생성할 겁니다.

총 4개를 만들 건데, 제 기준입니다.

(그냥 DTO 하나로도 되고, ResponseDTO & RequestDTO 두 개로 나눠도 됩니다. 본인 마음대로...)

전 다소 세분화된? DTO를 경험하고 싶어, PostDTO와 마찬가지로 4개로 나눴습니다.

  1. CommentsSaveRequestDto : Comment 저장 DTO
  2. CommentsUpdateRequestDto : Comment 수정 DTO
  3. CommentsResponseDto : Comment 조회 DTO (이건 댓글 단건 조회인데, 필요 있을지 모르겠습니다. 일단 그냥 만들었어요)
  4. CommentsListResponseDto : 댓글 리스트 조회 (게시글 조회시, 연관된 댓글 리스트를 조회하기 위함)

 

 

 

그럼 처음 CommentsSaveRequestDto부터 보겠습니다.

Post DTO들을 생성 했을 때와 거의 같습니다.

[CommentsSaveRequestDto]

@Getter
@NoArgsConstructor
public class CommentsSaveRequestDto {

    private String comment;
    private Posts posts;

    @Builder
    public CommentsSaveRequestDto(String comment, Posts posts) {
        this.comment = comment;
        this.posts = posts;
    }

    public Comments toEntity() {
        return Comments.builder()
                .comment(comment)
                .posts(posts)
                .build();
    }

    public void setPosts(Posts posts) {
        this.posts = posts;
    }

}

 

 

 

 

다음은 CommentsUpdateRequestDto 입니다.

[CommentsUpdateRequestDto]

@Getter
@NoArgsConstructor
public class CommentsUpdateRequestDto {

    private String comment;

    @Builder
    public CommentsUpdateRequestDto(String comment) {
        this.comment = comment;
    }

}

 

 

 

 

다음은 CommentsResponseDto이지만, 당장은 안 쓸 것 같아 

마지막 CommentsListResponseDto를 보겠습니다.

[CommentsListResponseDto]

@Getter
public class CommentsListResponseDto {

    private Long id;
    private Long postsId;
    private String comment;
    private LocalDateTime modifiedDate;

    public CommentsListResponseDto(Comments entity) {
        this.id = entity.getId();
        this.postsId = entity.getPosts().getId();
        this.comment = entity.getComment();
        this.modifiedDate = entity.getModifiedDate();
    }

}

 

 

 

 

그리고 PostResponseDto에 추가합니다.

게시물을 조회할 때, 댓글 리스트도 함께 조회하기 위함입니다.

[PostResponseDto]

@Getter
public class PostsResponseDto {

    ...

    private List<CommentsListResponseDto> comments;

    public PostsResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
        
        // 추가
        this.comments = entity.getCommentsList().stream()
                .map(CommentsListResponseDto::new)
                .collect(Collectors.toList());
    }

}
  • Comments를 commentListResponseDto 클래스로 해서 엔티티 간 무한 참조를 방지합니다.
    • 게시글을 조회할 때 댓글을 함께 조회합니다.
    • 댓글을 조회할 때 역시 게시글을 함께 조회합니다.
    • 이런 엔티티의 무한 참조를 방지하기 위해 DTO를 리턴하는 것입니다.
    • @JsonIgnoreProperties를 하는 방법도 있지만, 별도의 DTO를 만들어 반환하겠습니다.

 

 

 

 

다음은 CommentService를 보겠습니다.

PostService와 거의 같습니다.

[CommentService]

 

먼저 Comment 저장입니다.

@RequiredArgsConstructor
@Service
public class CommentsService {

    @Autowired
    CommentsRepository commentsRepository;

    @Autowired
    PostsRepository postsRepository;
    
    @Transactional
    public Long save(CommentsSaveRequestDto commentsSaveRequestDto, Long postId) {
    
        Posts posts = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. postId = " + postId));

        commentsSaveRequestDto.setPosts(posts);

        Comments comments = commentsSaveRequestDto.toEntity();
        commentsRepository.save(comments);

        return comments.getId();
    }

}
  • postId를 파라미터로 받아, 해당 게시물이 존재하지 않으면 IllegalArgumentException 예외를 발생시킵니다.
  • comment를 저장할 때, 관련한 post를 set(저장) 합니다.

 

 

다음은 update(수정) 코드입니다.

@RequiredArgsConstructor
@Service
public class CommentsService {

    @Autowired
    CommentsRepository commentsRepository;

    @Autowired
    PostsRepository postsRepository;
    
    ...
    
    @Transactional
    public Long update(Long postId, CommentsUpdateRequestDto commentsUpdateRequestDto, Long commentId) {
        Posts posts = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. postId = " + postId));

        Comments comments = commentsRepository.findById(commentId)
                .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 없습니다. id = " + commentId));

        comments.update(commentsUpdateRequestDto.getComment());

        return commentId;
    }

}
  • 마찬가지로, postId를 파라미터로 받아 존재하지 않으면 예외를 발생시킵니다.
    • 갑자기 든 의문인데, 굳이 Posts로 받아 저장할 필요가 있나 싶네요.
    • 그냥 받지 않고 해도 될 것 같습니다.
  • commentId 역시 존재하지 않으면 예외를 발생시킵니다.

 

 

 

다음은 해당 댓글을 조회하는 findById인데,

당장은 안 쓸 것 같아 delete(삭제)로 넘어가겠습니다.

@RequiredArgsConstructor
@Service
public class CommentsService {

    @Autowired
    CommentsRepository commentsRepository;

    @Autowired
    PostsRepository postsRepository;
    
    ...
    
    @Transactional
    public void delete(Long postId, Long commentId) {
        Posts posts = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. postId = " + postId));

        Comments comments = commentsRepository.findById(commentId)
                .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 없습니다. id = " + commentId));

        commentsRepository.delete(comments);
    }

}

 

 

 

 

마지막으로 Controller를 생성하겠습니다.

View를 만들지 않고, postman으로 테스트할 것이므로, RestController로 할 겁니다.

[CommentApiController]

@RequiredArgsConstructor
@RestController
public class CommentsApiController {

    private final CommentsService commentsService;

    /* 댓글 생성 */
    @PostMapping("/api/v1/posts/{postId}/comments")
    public Long save(@RequestBody CommentsSaveRequestDto commentsSaveRequestDto,
                     @PathVariable("postId") Long postId) {

        return commentsService.save(commentsSaveRequestDto,  postId);
    }

    /* 댓글 수정 */
    @PutMapping("/api/v1/posts/{postId}/comments/{commentId}")
    public Long update(@PathVariable("postId") Long postId,
                       @PathVariable("commentId") Long commentId,
                       @RequestBody CommentsUpdateRequestDto dto) {

        return commentsService.update(postId, dto, commentId);
    }

    /* 댓글 단건 조회 */
    @GetMapping("/api/v1/posts/{postId}/comments/{commentId}")
    public CommentsResponseDto findById(@PathVariable("postId") Long postId,
                                        @PathVariable("commentId") Long commentId) {

        return commentsService.findById(postId, commentId);
    }

    /* 댓글 삭제 */
    @DeleteMapping("/api/v1/posts/{postId}/comments/{commentId}")
    public Long delete(@PathVariable("postId") Long postId,
                       @PathVariable("commentId") Long commentId) {

        commentsService.delete(postId, commentId);

        return commentId;
    }

}
  • 댓글 단건 조회는 없어도 됩니다. 
    • 그냥 습관적으로 만듦...

 

 

 

 

다 만들었으니 Postman으로 테스트 해봅니다.

TestCode로 테스트 하고 싶지만,

그것도 또하나의 코드를 짜는 것이므로 다음 포스팅으로 넘기겠습니다.

 

 

먼저 Post(게시물)을 두 개 생성합니다.

영속성 전이를 테스트하기 위해 여러 개를 만든 겁니다.

{
  "title":"test1",
  "content":"testContent",
  "author":"ummchicken"
}
 
{
  "title":"test2",
  "content":"testContent2",
  "author":"ummchicken2"
}

 

잘 생성이 됩니다.

 

insert 쿼리도 두 번 잘 나갑니다.

 

 

 

 

다음은 post_id 1번 댓글 2개와 

post_id 2번 댓글 1개를 생성합니다.

혹시 몰라 순서는 뒤죽박죽 생성해볼 겁니다.

{
    "comment":"comment1"
}

 

잘 생성이 됩니다.

 

 

쿼리도 잘 나갑니다.

Service에서 postId가 없으면 예외를 발생시키게 했으므로, 

postId를 조회하는 select 쿼리도 잘 나갑니다.

 

 

 

다음은 post를 조회해보겠습니다.

연관된 댓글이 함께 조회되면 됩니다.

먼저, post_id가 1인 것을 조회하겠습니다.

잘 조회가 됩니다.

 

 

다음은 post_id 2를 조회해보겠습니다.

연관된 댓글 2개가 잘 조회 됩니다.

 

 

그리고 저는 index를 post 리스트를 조회하게 했습니다.

하지만, post 단건 조회를 할 땐 연관된 comment를 함께 조회를 하고, 

index를 조회할 때는 단순 post들만 조회하도록 했는데, 테스트를 해보겠습니다.

댓글을 제외한 post들만 잘 조회가 됩니다.

 

 

 

 

다음은 댓글 수정을 해보겠습니다.

{
    "comment":"update comment1"
}
 

잘 수정 되었고, 수정 시간도 잘 업데이트 되었습니다.

 

 

 

다음은 id가 3인 댓글 삭제를 해보겠습니다.

잘 삭제 되었고, 

delete 쿼리도 잘 나갑니다.

 

 

 

 

마지막으로 영속성 전이를 테스트하기 위해 

댓글들을 몇개 더 추가해보겠습니다.

추가했습니다.

 

 

그리고 post_id가 1인 것을 삭제해보겠습니다.

연관된 comment 3개가 삭제되면 됩니다.

한 번에 모두 삭제가 잘 되었습니다.

 

 

delete 쿼리도 3번 잘 나갔습니다.

++ 지금 다시보니까 캡쳐를 잘못한 거 같네요;;

delete comment 3개인 줄 알았는데, post가 하나 껴있네요.

 

 

 

 

 

- JPA 1 : N 관계 알아보기 끝 -