Project/토이 프로젝트

[Cinemagram] 댓글 등록 및 삭제 - (15)

Lea Hwang 2023. 2. 8. 01:55

이번 포스팅에서는 댓글(Commnet) 등록과 삭제 기능 구현을 하겠습니다. 

 

 

 

댓글 모델 만들기 

어떤 필드가 필요할까요?

  • 누가
  • 어떤 내용
  • 어떤 이미지
  • 몇 시에 적었는지

이렇게 필드를 구성한 후 연관관계를 잡아주면 됩니다.

 

 

 

Comment

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Entity
public class Comment extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(length = 100, nullable = false)
    private String content;

    @JoinColumn(name = "userId")
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @JoinColumn(name = "imageId")
    @ManyToOne(fetch = FetchType.LAZY)
    private Image image;
}

 

CommentRepository

import org.springframework.data.jpa.repository.JpaRepository;

public interface CommentRepository extends JpaRepository<Comment, Integer> {

}

 

❗❓ 여기서 한 의문이 들 수 있습니다. Image나 User 같은 이미 있는 오브젝트 안에 한 칼럼으로 comment를 만들면 쉽게 끝날 것 같은데 왜 따로 뺐는지 말이죠.

 

그건 댓글을 이루는 요소들을 보면 알 수 있습니다. 우리는 어떤 유저가 어떤 이미지에 어떠한 내용을 언제썼는지 다양하게 알아야 합니다. 간단히 필드로 content만 받지 않음을 알 수 있습니다.

따라서 여러 데이터들이 종합적으로 필요한 만큼 오브젝트로 만들어야합니다.

 

 

 

 

 

저는 항상 같은 순서로 코드의 뼈대를 잡고 있습니다.

  • 모델&Repository를 생성한 후 
  • Service
  • Controller

 

댓글은 Ajax통신을 이용할 것이므로 ApiController를 사용해 보겠습니다. 

 

 

CommentService

@RequiredArgsConstructor
@Service
public class CommentService {
    
    private final CommentRepository commentRepository;
    
    @Transactional
    public Comment comment() {
        return null;
    }

    @Transactional
    public void DeleteCommnet() {
        
    }
}

 

 

CommentApiController

@RequiredArgsConstructor
@RestController 
public class CommentApiController {

    private final CommentService commentService;

    @PostMapping("/api/comment")
    public ResponseEntity<?> comment(){
        return null;
    }

    @DeleteMapping("/api/comment/{id}")
    public ResponseEntity<?> deleteComment(@PathVariable int id) {
        return null;
    }

}
  • @RestController
    • 데이터를 응답합니다.

 

해당 컨트롤러에 세부 사항들을 적기 전에 관련 html, js에서 어떤 데이터들을 넘기는지 확인 후 추가 작성해 보겠습니다.

 

 

 

 

관련 코드를 보면서 말씀드리겠습니다.

<input type="text" placeholder="Add a comment ..." id="storyCommentInput-${image.id}" />
<button type="button" onClick="addComment()">게시</button>

 

로직

  • 댓글을 써서(text) 
  • addComment를 클릭하면
  • DB에 insert 하고
    • 유저 id (sessionId 사용)
    • 내용( id="storyCommentInput-${image.id}"를 통해서 type="text"를 가져오면 되고)
    • 이미지 Id
  • 그 댓글들을 prepend 합니다.
    • 왜? 최신댓글이 위로 올라가게 하기 위해서
    • 만약 뒤에다가 붙이고 싶으면 append 사용

 

DB에 Insert 할 때 필요한 것들이 유저 Id, 내용, 이미지 Id인데 이것들을 어떻게 넘길까요?

  • 어떤 유저인지는 sessionId을 이용합니다. 
  • 내용은 id="storyCommentInput-${image.id}"를 통해서 type="text"를 가져오면 됩니다. 
  • 이미지 Id는 어디서 가져올 수 없으므로 클릭 시 넣어줘야 합니다. 
<button type="button" onClick="addComment(${image.id})">게시</button>

 

 

댓글을 적은 부분이 잘 넘어가는지 확인을 해봐야겠죠?

id를 통해 text를 commentInput에 넣고 alert를 띄어보았습니다.

function addComment(imageId) {

   let commentInput = $(`#storyCommentInput-${imageId}`);

   let data = {
      content: commentInput.val()
   }
   alert(data.content);
}

다행히 제가 원하는 대로 나오는 것을 확인했습니다. 

 

 

 

 

중요한 건 Ajax통신이죠!

  • data : 내가 보낼 데이터 형태
  • contentType : 내가 보낼 데이터에 대한 설명
  • dataType : 내가 응답받을 형태

 

 

 

다른 부분은 예전과 같은데 data가 추가되었습니다. 

 

단순히 data로 찍었을 때는 아래 박스와 같이 자바스크립트 오브젝트로 출력이 됩니다. (첫 번째 박스)

우리는 통신하기 위해 자바스크립트 오브젝트를 JSON으로 바꿔야 하는데(두 번째 박스) 그러기 위해서 JSON.stringify(data)를 추가한 것입니다. 

 

 

 

 

 

 

자 이제!

Ajax 통신을 통해 /api/comment로 data를 CommentApiController로 날려 INSERT를 하게 됩니다. 

 

여기서 그럼 어떤 data를 보내는지 살펴봐야겠죠.

let data = {
   imageId: imageId,
   content: commentInput.val()
}

(sessionId는 컨트롤러에서 @AuthenticationPrincipal을 이용하면 됨)

imageId와 content를 보내주고 있습니다. 

 

그럼 이제 우리가 해야 하는 건 뭘까요?

 

 

 

 

 

맞습니다, 해당 정보들을 받을 수 있는 DTO를 만들어야 합니다. 

 

CommentDto

@Getter
@NoArgsConstructor
public class CommentDto {
    @NotBlank
    private String content;
    private int imageId;
}
[참고]
@NotNull : null값 체크
@NotEmpty: 빈 값이거나 null체크
@NotBlank: 빈 값이거나 null체크 그리고 빈 공백(스페이스)까지 체크

 

 

그럼 이제 받아보겠습니다. 

CommentApiController

@PostMapping("/api/comment")
public ResponseEntity<?> comment(CommentDto commentDto) {
    return null;
}

 

 

 

❓❗ 퀴즈

이렇게 받을 수 있을까요? NO

그럼 왜 받지 못할까요, 여태까지 이렇게 잘 받아온 것 같은데 말이죠.

 

우리는 json으로 데이터를 받아옵니다. 

  • dataType : "json"

 

 

위와 같이 코드를 적는 건 key/value 형태를 받을 때 사용하고 우리는  json으로 받기 위해

@RequestBody앞에 붙여주면 됩니다. 

@RequestBody란?
클라이언트가 전송하는 Json(application/json) 형태의 HTTP Body내용을 Java Object로 변환시켜 주는 어노테이션입니다. 

- Json형태로 받은 HTTP Body 데이터를 MessageConverter를 통해 변환시킵니다.

 

 

그럼 잘 나오는지 확인해 봐야겠죠.

@PostMapping("/api/comment")
public ResponseEntity<?> comment(@RequestBody CommentDto commentDto){
    System.out.println("commentDto 내용 : " + commentDto);
    return null;
}

 

commentDto 내용 : CommentDto(content=일반로그인 이용자의 테스트 댓글입니다. , imageId=13)

 

 

원하는 데이터를 잘 끌고 와서 출력해 주는 것을 확인해 보았습니다. 이제 Repository에 네이티브쿼리를 짜고 나머지 부분들도 완성시켜 보겠습니다. 

 

 

 

 

두 가지 방법이 있습니다. 

1. nativeQuery 사용

CommentReposiroty

public interface CommentRepository extends JpaRepository<Comment, Integer> {

    @Modifying
    @Query(value = "INSERT INTO comment(userId, content, imageId, createdDate) VALUES(:userId, :content, :imageId, now())", nativeQuery = true)
    Comment cComment(int sessionId, String content, int imageId);
}

 

CommentService > comment()

컨트롤러에서 데이터 받아서 INSERT작업

@RequiredArgsConstructor
@Service
public class CommentService {

    private final CommentRepository commentRepository;

    @Transactional
    public Comment comment(int sessionId, String content, int imageId){
        return commentRepository.cComment(sessionId,content,imageId);
    }

    @Transactional
    public void DeleteComment(){

    }

}

 

CommentApiController

  • 누가(sessionId)
  • 어떤 이미지에 어떤 내용의 댓글을(CommentDto)
  • INSERT 할지
@RequiredArgsConstructor
@RestController
public class CommentApiController {

    private final CommentService commentService;

    
    @PostMapping("/api/comment")
    public ResponseEntity<?> comment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody CommentDto commentDto){
        Comment comment = commentService.comment(customUserDetails.getUser().getId(), commentDto.getContent(), commentDto.getImageId());
        return new ResponseEntity<>(new ResDto<>(1,"댓글 등록 성공", comment), HttpStatus.CREATED);
    }

    @DeleteMapping("/api/comment/{id}")
    public ResponseEntity<?> deleteComment(@PathVariable int id) {
        return null;
    }

}

 

 

[에러]

java.lang.IllegalArgumentException: Modifying queries can only use void or int/Integer as return type! Offending method: 

 

이유 :

네이티브 쿼리를 짜고 리턴 타입을 void 또는 int/Integer로 하지 않아서 생긴 에러입니다. 

그런데 우리는 객체로 리턴 받아야 합니다...

❗❓ 왜 객체로 리턴 받아야 할까요?
우리는 댓글을 작성하는 것뿐만 아니라 삭제도 할 수 있어야 합니다. 삭제 시 api를 보면 알 수 있듯 /api/comment/{id} 댓글 아이디가 필요합니다. 

객체로 리턴 받아야 PK를 알 수 있고 이를 댓글 삭제시 사용할 수 있기에 우리는 객체로 리턴 받아야 합니다. 

 

따라서 Comment로 리턴 받기 위해 네이티브쿼리 방법은 포기하고 다른 방법을 찾아보았습니다. 

 

 

 

 

2. commentRepository.save() 사용

위에서 nativeQuery부분을 삭제하고 Service부분에서 로직을 처리합니다. 

@Transactional
public Comment comment(){
 
    return commentRepository.save();
}

 

 

 

매개변수에는 어떤 값들을 받아와야 할까요?

그건 화면을 보면 쉽게 유추해 볼 수 있습니다. 

 

  • 로그인 한 유저가 댓글을 쓸 수 있으므로 sessionId가 필요합니다.
  • 댓글 형식이 [username] : [content]입니다. 
  • 어떤 이미지에 쓰는지 imageId도 필요하겠습니다. 

 

@Transactional
public Comment comment(CommentDto commentDto, int sessionId){

    User userEntity = userRepository.findById(sessionId).orElseThrow(
        new Supplier<IllegalArgumentException>() {

            @Override
            public IllegalArgumentException get() {
                return new IllegalArgumentException("사용자 아이디를 찾을 수 없습니다.");
            }
        });

    return commentRepository.save();
}
  • sessionId의 경우 Controller에서 @AuthenticationPrincipal을 이용해서 .getUser().getId()로 가져옵니다.
  • 우리는 username도 필요하므로 Repository.findById로 userEntity를 가져와서 사용합니다.
  • 댓글 데이터를 받기 위해 위에서 CommentDto를 만들었습니다. 
    • imageId, content를 받을 수 있습니다.

 

❗❓ 여기서 생각해 볼 것이 있습니다. imageId는 어떻게 가져와야 할까요?
Image오브젝트에서 다른 것들은 필요가 없고 id만 가져오면 되는 상황인데, 
userEntity처럼 User오브젝트 안의 모든 필드들을 가져오는 건 리소스낭비라 생각됩니다. 

이땐 한 방법으로,  Image 가짜 객체를 만들고 id만 넣어주면 됩니다.
@Transactional
public Comment comment(CommentDto commentDto, int sessionId){

    User userEntity = userRepository.findById(sessionId).orElseThrow(
        new Supplier<IllegalArgumentException>() {

            @Override
            public IllegalArgumentException get() {
                return new IllegalArgumentException("사용자 아이디를 찾을 수 없습니다.");
            }
        });

    Image image = new Image();
    image.setId(commentDto.getImageId());

    Comment comment = Comment.builder()
            .content(commentDto.getContent())
            .user(userEntity)
            .image(image)
            .build();

    return commentRepository.save(comment);
}

 

코드가 짧아서 괜찮아 보이긴 하는데 이렇게. set을 해도 되는지... 걱정입니다. 프로젝트가 끝나더라도 계속 생각해고 리팩토링을 진행해야겠습니다.

 

 

 

 

 

이젠 화면에 출력해 보겠습니다. 

이렇게 잘 올라가는 걸 확인할 수 있지만 F5 하면 없어지는데요! 그 이유는 feed페이지로 올 때 댓글 데이터도 같이 들고 와서 뿌려줘야 합니다. 

 

 

이 페이지로 오는 부분을 보면, 

ImageApiContorller

@GetMapping("/api/feed")
public ResponseEntity<?> Feed(@AuthenticationPrincipal CustomUserDetails customUserDetails,
                              @PageableDefault(size=4, sort="id", direction = Sort.Direction.DESC) Pageable pageable){
    Page<Image> images = imageService.Feed(customUserDetails.getUser().getId(), pageable);
    return new ResponseEntity<>(new ResDto<>(1,"피드 성공", images), HttpStatus.OK);
}

 

images안에 comment가 있어야 함을 알 수 있습니다. Image오브젝트에 댓글 필드를 만들고 연관관계매핑을 추가합니다. 

@OneToMany(mappedBy = "image")
private List<Comment> comments;

 

 

순환참조 오류 막기
1. Comment
댓글을 가져올 때 우리는 User오브젝트 안에 있는 모든 필드를 가져옵니다. 다른 건 문제가 없지만 안에 images를 가져올 필요는 없으므로 @JsonIgnoreProperties로 무시합니다.

@JsonIgnoreProperties({"images"})
@JoinColumn(name = "userId")
@ManyToOne(fetch = FetchType.LAZY)
private User user;

2. Image
Image → List <Comment> → Image를 해서 또 이미지를 들고 올 필요는 없습니다. 이 부분도  
@JsonIgnoreProperties로 무시합니다.

@JsonIgnoreProperties({"image"})
@OneToMany(mappedBy = "image")
private List<Comment> comments;

 

 

여기까지 해서 댓글 등록 부분이 끝이 났습니다. 

 

이이서 댓글 삭제를 해보겠습니다.

 

 

 

 

 

 

댓글 삭제

내가 쓴 댓글만 삭제 가능하도록 해야 합니다. 그럼 js에서도 sessionId를 받아와서 체크해야 하는데요.

저는 이 부분에서 많이 방황했는데, 혹시 이 글을 보시는 분은 바로 사용할 수 있게 따로 포스팅하였습니다.

[타임리프] Session Id를 html, js에 가져다 쓰는 방법 이 포스팅을 참고하시면 바로 해결 가능합니다. 

 

 

 

 이 부분이 가능해진다면

if(sessionId == comment.user.id) {
    item += `<button onclick="deleteComment(${comment.id})">
                 <i class="fas fa-times"></i>
             </button>`;

}

지금 로그인 한 유저와 댓글을 쓰는 유저가 같은지 확인 후에 삭제 버튼을 보이게 할 수 있습니다. 

 

 

 

백엔드 코드는 간단합니다. 

CommnetService

@Transactional
public void DeleteComment(int id){
    try{
        commentRepository.deleteById(id);
    }catch (Exception e) {
        throw new CustomApiException(e.getMessage());
    }
}
👏
만약 commentRepository.deleteById(id); 했는데 터지면 try ~ catch로 묶어서 throw Exception 처리합니다.
* html파일을 리턴하는 컨트롤러일 경우 - CustomException
* 데이터를 리턴하는 컨트롤러일 경우 - CustomApiException
* ValidationException데이터를 받을 때 검증 사용 * */

 

 

html코드 부분을 js에 옮기는 과정을 반복하니 코드가 너무 지저분해 보입니다... 이러한 이유로 개발자들이 리액트를 배우는 것일까요? 프로젝트 끝나고 리액트는 어떻게 처리하는지 찾아보고 공부해 보도록 하겠습니다. 

 

 

 

 

CommentDto의 Validation체크(유효성 검사)

이제 거의 다 왔습니다. 우리가 만든 CommentDto의 Validation체크(유효성 검사)만 하면 됩니다. 

음... Valida....?? 기억이 안 나실 수 있습니다. 혹시 몰라 예전 포스팅인 [Cinemagram] 회원 정보 수정, 필수 값 유효성 검사 및 예외처리 - (5) Validation부분을 가져와봤습니다. 

 

 

 

보통 프런트에서 다 막아주겠지만 Postman과 같이 비 정상적으로 접근 시 처리해 주기 위해서 하는 작업입니다.

 

 

위와 같은 순서대로 처리해 보겠습니다.

 

CommentDto

@Getter
@NoArgsConstructor
public class CommentDto {
    @NotBlank
    private String content;
    @NotNull
    private Integer imageId;
}
[참고]
@NotNull : null값 체크
@NotEmpty: 빈 값이거나 null체크
@NotBlank: 빈 값이거나 null체크 그리고 빈 공백(스페이스)까지 체크

 

❓❗ 처음에는 imageId도 @NotBlank를 적었는데 아래와 같은 에러문구가 떠서 @NotNull로 수정하였습니다.

에러 문구

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.NotBlank' validating type 'java.lang.Integer'. Check configuration for 'imageId'

 

 

 

CommentApiController

 @PostMapping("/api/comment")
    public Comment comment(@Valid @RequestBody CommentDto commentDto, BindingResult bindingResult, @AuthenticationPrincipal CustomUserDetails customUserDetails){
        if(bindingResult.hasErrors()) {
            Map<String,String> errors = new HashMap<>();

            for(FieldError error : bindingResult.getFieldErrors()) {
                errors.put(error.getField(),error.getDefaultMessage());
            }
            throw new CustomValidationApiException("유효성 검사에 실패하였습니다.", errors);
        }else {
            return commentService.comment(commentDto, customUserDetails.getUser().getId());
        }
    }
  • 유효성 검사를 하고 싶은 클래스 앞에 @Valid 어노테이션을 추가합니다.
  • 바로 옆 파라미터에 BindingResult를 추가합니다.
  • if 문은 UserApiController에서 사용한 부분을 재사용했습니다. 

 

 

테스트

프런트에서 댓글 content가 없을 시 alert를 띄우는 코드는 잠시 주석처리 해줍니다.

 

content부분에 아무것도 쓰지 않고 게시를 클릭했을 때 유효성 검사가 잘 작동하는 것을 확인할 수 있습니다. 

 

 

 

 

 

여기까지 해서 댓글 등록 및 삭제 구현이 끝났습니다. 처음부터 모든 기능들이 잘 작동하는지 확인 후 댓글 등록 및 삭제 기능 구현으로 push 하였습니다.