본문 바로가기

Project

[개인과제] 일정 관리 앱 Develop - 댓글 관리 및 트러블 슈팅

 

 과제의 필수기능 중에 하나인 댓글관리 기능이 있습니다. 기존에 회원과 할일 두 관계만 연결되어 있던 프로젝트에서 댓글 기능 하나가 늘어나면서 복잡도가 훨씬 증가하게 됩니다.

 

댓글은 일정이 반드시 있어야 작성이 될 수 있고, 하나의 일정은 여러 댓글을 가질 수 있게 됩니다.

또한, 댓글은 회원으로부터 작성되어야 하고, 회원은 댓글을  쓰지 않거나, 하나만 쓰거나, 여러개를 쓸 수도 있습니다.

 

이런 조건들을 생각하면서 ERD 테이블을 그려보면 아래 사진과 같습니다.

 

이런 관계를 토대로 댓글관리 CRUD를 구현하고, 그러던 중 발생했던 문제들을 해결해 나간 과정을 작성해보도록 하겠습니다.


1. 댓글관리 기능 구현

댓글은 생성, 불러오기, 수정, 삭제 기능을 순서대로 구현하였습니다.

댓글 CRUD를 구현하기 전에, 데이터베이스에 매핑이 될 Entity 클래스부터 구현했습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends TimeStamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne
    @JoinColumn(name="todo_id",nullable = false)
    private Todo todo;

    @ManyToOne
    @JoinColumn(name="member_id",nullable = false)
    private Member member;

    public Comment(String content, Todo todo, Member member){
        this.content = content;
        this.todo=todo;
        this.member=member;
    }
}


우선 댓글의 작성,수정 시간을 파악할 수 있도록 기존에 만들어 놓았던 TimeStamped Entity 를 상속받았습니다.

그리고 댓글이 생성될때 자동으로 댓글 아이디가 생성되게 기본키를 설정하였고, 댓글에 들어갈 내용인 content 를 필드로 선언하였습니다.

댓글은 한 일정에 여러개가 작성될 수 있기 때문에, 그리고 한 유저에게 여러개가 작성될 수 있기 때문에 연관관계를 ManyToOne으로 설정해두었습니다.

이제 Controller의 댓글 생성기능입니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/todo/comments")
public class CommentController {
    private final CommentService commentService;

    @PostMapping
    public ResponseEntity<CommentResponseDto> createComment(@RequestBody CommentRequestDto requestDto){
        CommentResponseDto commentDto = commentService.createComment(requestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(commentDto);
    }

}


Controller 의 기본 세팅은, @RestController 로 Json 데이터 형태로 객체를 감싸서 반환하기 위해 설정해두었습니다.
@RequiredArgsConstructor 를 사용하여 초기화 해야하는 객체를 생성자 없이 의존성 주입을 해주었습니다. 그리고 댓글은 기본적으로 일정의 하위 항목에 포함되어야 하기때문에, @RequestMapping("/api/todo/comments") 로 URL을 지정해주었습니다.

댓글 생성 API 는 다음과 같습니다.

    @PostMapping
    public ResponseEntity<CommentResponseDto> createComment(@RequestBody CommentRequestDto requestDto){
        CommentResponseDto commentDto = commentService.createComment(requestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(commentDto);
    }


Post 메서드를 사용하였고, ResponseEntity 로 반환하는 ResponseDto 를 감싸서 반환하도록 하였습니다. 그리고 responseDto에 Service에 있는 createComment 메서드를 사용하여 파라미터로 받은 requestDto 대입하여 응답을 만든다음 반환합니다.

이때 필요한 RequestDTO와 ResponseDTO는 다음과 같이 설계하였습니다.

RequestDTO 클래스

@Getter
@NoArgsConstructor
public class CommentRequestDto {
    private String content;
    private Long todoId;
    private Long memberId;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}



ResponseDTO 클래스

@Getter
public class CommentResponseDto {
    private Long id;
    private String content;
    private Long memberId;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    public CommentResponseDto(Comment comment) {
        this.id = comment.getId();
        this.content = comment.getContent();
        this.memberId = comment.getMember().getId();
        this.createdAt = comment.getCreatedAt();
        this.updatedAt = comment.getUpdatedAt();
    }
}


Service 클래스의 비즈니스 로직은 다음과 같습니다. 기본 설계는 Controller 와 유사하게 생성자 주입을 해주게 됩니다.

@Service
@RequiredArgsConstructor
public class CommentService {
    private final CommentRepository commentRepository;
    private final TodoRepository todoRepository;
    private final MemberRepository memberRepository;

    public CommentResponseDto createComment(CommentRequestDto requestDto) {
        Todo todo = todoRepository.findById(requestDto.getTodoId()).orElseThrow(
                ()->new EntityNotFoundException("일정이 없습니다.")
        );
        Member member = memberRepository.findById(requestDto.getMemberId()).orElseThrow(
                ()->new EntityNotFoundException("회원이 없습니다.")
        );

        Comment comment = new Comment(requestDto.getContent(), todo,member);
        commentRepository.save(comment);

        return new CommentResponseDto(comment);
    }
}


내부의 비즈니스 로직은 우선 일정이 존재하는지를 파악하고, 회원이 존재하는지 파악을 합니다. 이렇게 설계한 순서는, 추후에 다룰 기능인 하나의 일정을 다른 회원과 공유하게 되었을 때, 작성한 유저가 탈퇴를 해도 할일을 공유하던 관리자 유저가 있다면 일정이 존재할 수 있어서 일정을 먼저 체크하는 것으로 설정하였습니다. 

 

이 부분은 기능을 완성후 예외 테스트 케이스를 확인하면서 개선 해 나갈 예정입니다.

예외에서 통과되었다면 Comment 객체를 생성하고 거기에 유저의 요청인 requestDto 를 댓글의 내용과 작성한 유저의 ID, 어떤 일정에 댓글을 작성하는지 일정ID 를 받아옵니다.

그리고 JPA 기능을 사용하여 save 메서드를 통해 저장하고, 이것을 ResponseDto의 파라미터에 객체를 담아 반환합니다.

이렇게 댓글 작성 코드를 구현완료 하고, 테스트를 진행해 보았는데, 회원정보와 일정 간의 양방향 관계에서 발생하던 순환참조 문제가 다시 발생하였습니다.

그래서 Todo, Member, Comment Entity에 각각 순환참조 방지 또는 무시 어노테이션을 작성해 해결하였습니다.

수정된 Comment Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends TimeStamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne
    @JoinColumn(name="todo_id",nullable = false)
    @JsonBackReference //역참조를 직렬화 할때 무시
    private Todo todo;

    @ManyToOne
    @JoinColumn(name="member_id",nullable = false)
    @JsonBackReference //역참조를 직렬화 할때 무시
    private Member member;

    public Comment(String content, Todo todo, Member member){
        this.content = content;
        this.todo=todo;
        this.member=member;
    }
}


이렇게 댓글의 작성기능은 마무리 했습니다.


2. 댓글 불러오기

기존에 생성하는 기능을 이미 마무리 했기 때문에, 불러오기 기능은 간단하게 해결할 수 있었습니다.

댓글 불러오기 API

    @GetMapping("/{todoId}")
    public ResponseEntity<List<CommentResponseDto>> getCommentsByTodoId(@PathVariable Long todoId){
        List<CommentResponseDto> comments = commentService.getCommentByTodoId(todoId);
        return ResponseEntity.ok(comments);
    }


Get 메서드를 사용해서 일정에 등록된 댓글들을 모두 불러옵니다. 이때 불러올 댓글들은 List로 수집하여 가져올 수 있게 반환타입을 List로 선언하되, ResponseEntity 로 한번 감싸서 반환하도록 합니다. 

 

그리고 댓글을 조회하려면 일정의 ID값인 todoId 값이 필요하기 때문에 @PathVariable 로 선언하여 조회하도록 하였고, 반환 타입에 맞춘 객체 comments 를 생성하여 return 합니다.

Service 비즈니스 로직

    public List<CommentResponseDto> getCommentByTodoId(Long todoId) {
        List<Comment> comments = commentRepository.findByTodoId(todoId);
        return comments.stream()
                .map(CommentResponseDto::new)
                .collect(Collectors.toList());
    }


Controller 에서 받아야하는 방식을 고려하여 List<Comment> 타입의 comments 객체를 만들어서 Collectors.toList() 메서드로 반환타입에 맞춰서 반환해 줍니다.

그리고 Repository 에서는 메서드 명이 곧 쿼리가 되는 것을 활용하여 적절하게 메서드명을 지어준 다음, 자동생성 기능을 사용하여 JPA Repository 에 메서드를 생성해 줍니다.

이렇게 기능설계는 마치고, Postman 으로 테스트 한 결과

사진처럼 1번 일정에 작성된 댓글들 및 그 댓글들의 작성자들이 잘 조회되는것을 확인할 수 있습니다.


3. 댓글 수정 기능 구현

Controller 의 댓글 수정 API 는 아래와 같습니다.

    @PutMapping("/update/{commentId}")
    public ResponseEntity<CommentResponseDto> updateComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto){
        CommentResponseDto commentDto = commentService.updateComment(commentId,requestDto);
        return ResponseEntity.ok(commentDto);
    }


Put 메서드를 이용하여 요청을 합니다. 파라미터로는 수정할 댓글의 id 값과, 수정할 내용이 담길 RequestBody가 들어갑니다.

Service 의 비즈니스 로직은 아래와 같습니다.

    public CommentResponseDto updateComment(Long commentId, CommentRequestDto requestDto)  {
        Comment comment = commentRepository.findById(commentId).orElseThrow(
                ()->new EntityNotFoundException("댓글이 존재하지 않습니다.")
        );

        if(!comment.getMember().getId().equals(requestDto.getMemberId())) {
            throw new UnauthorizedAccessException("작성자만 수정할 수 있습니다.");
        }

        comment.setContent(requestDto.getContent());
        commentRepository.save(comment);
        return new CommentResponseDto(comment);
    }


Service 클래스의 updateComment 메서드를 통해 댓글의 존재유무 검증, 댓글 작성자인지 검증 후에 requestDto를 전달하여 comment 객체를 반환합니다.


4 댓글 삭제 기능 구현

Controller 의 댓글 삭제 API 는 아래와 같습니다.

    @DeleteMapping("/delete/{commentId}")
    public ResponseEntity<Void> deleteComment (@PathVariable Long commentId, @RequestParam Long memberId){
        commentService.deleteComment(commentId,memberId);
        return ResponseEntity.noContent().build();
    }


수정과 같은 방식이고, 비즈니스 로직에서 RequestDto 를 전달하는 대신 delete 메서드를 사용하는 차이만 있습니다.


5. TodoService, MemberService, CommentService 에 들어가는 예외처리

GlobalExceptionHandler 를 사용하여 예외처리 부분을 정리하였습니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<String> handleEntityNotFoundException(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body("요청한 리소스를 찾을 수 없습니다: "+e.getMessage());
    }

    @ExceptionHandler(UnauthorizedAccessException.class)
    public ResponseEntity<String> handleUnauthorizedAccessException(UnauthorizedAccessException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body("접근이 거부되었습니다: " + e.getMessage());
    }
}


우선은 존재 유무를 확인하는 예외처리와, 수정 또는 삭제 권한이 있는지 인가부분에 대한 예외처리만 추가해놓았습니다.

 

6. 마이너한 버그 수정

이후 전체일정을 조회할때, 작성자 이름과 작성일, 수정일이 나오지 않고, Null 값이 return 되던 버그를 발견하여 수정하였습니다. 그리고 전체적인 예외처리를 찾아서 적용하고, 각 Entity 부분에 Validation 을 지정하여 사용자입력을 검증한 후 메세지를 출력하는 기능도 추가하였습니다.


7. 도전과제 시도!

우선 첫번째 도전과제는 JWT 를 사용하여 회원가입 기능을 만드는 것이었습니다. 현재 제가 사용하고 있는 DTO 에 어떻게 적용해야할지 감이 잡히지 않아서 우선은 강의에 나오는대로 의존성 추가, 기본세팅을 하고 DTO를 추가적으로 만들어서 기능을 구현하였습니다.


포스트맨에서 테스트한 결과 정상적으로 회원가입이 완료되는것을 확인했고, 데이터베이스에서도 제대로 조회되었습니다.

회원가입하는 로직에서 관리자 권한도 테스트 하다가 발견한 것이, 관리자 기능을 true 로 설정하고 관리자 토큰을 넣고 가입을 시도해도, 계속 admin 권한이 아닌 user 권한으로 가입된다는 것을 확인했습니다.

그래서 처음엔 어떤부분이 실행되지 않는지 확인하기 위해서, print 메서드를 사용해서 포인트를 찾으려고 해도 print를 찍지 않고있었고, log 메서드를 이용해서 로그를 찍어보는것도 해보았지만 제대로 나오지 않는것을 발견했습니다.

더 자세히 알아보기 위해서 중단점을 지정하고 디버깅을 시도해보았습니다. 처음에는 계속 제가 작성한 코드가 아니라 Springframework의 라이브러리가 나오고, 확인하고 싶은 부분을 조회할 수 없어서 어려움을 겪었습니다. 

 

이 부분은 튜터님을 찾아가서 어떻게 디버깅을 하면되는지 물어봤고, 방법은 다음과 같았습니다.

1. 중단점을 찍은다음, 디버그
2. 확인하고 싶지 않은 라이브러리가 나올땐, 왼쪽 줄 표시 부분에 있는 라이브러리를 체크해제
3. 원하는 클래스로 들어온게 확인이 되면 Postman 으로 요청보내기
4. 요청을 따라서 코드를 확인해보고, 액츄에이터로 들어가는 값 검증해보기

이렇게 해서 어느부분에 문제가 있는지 확인하는데 성공하였고, 문제는 역시 필드에 선언된 메서드 이름의 대소문자나 컨벤션을 지키지 않은부분에서 값이 제대로 전달되지 않는다는 것이었습니다. 이부분을 해결하고 나서 회원가입하는 기능의 버그를 수정하는데 성공했습니다.

 


과제 마감이 얼마남지 않아서, 도전기능을 구현하는 것은 여기까지만 진행하였습니다. 이번 프로젝트와 지난 프로젝트의 차이는 JPA를 사용하는 것과, JWT 토큰을 적용하여 회원관리 기능을 업그레이드 하는것, 그리고 DB의 테이블간의 연관관계와 영속성 전이 등 여러가지 개념이 추가되는 것이었습니다.

 

사실 Spring 을 입문하고 처음 했던 과제에서는 내가 뭘 만들고 있는지, 이게 백엔드 개발자가 하는게 맞는건지, 어떤코드가 어떻게 동작되고있는건지, 어떻게 설계를 시작해야하는지 모든 부분이 감이 잡히지 않아서 힘들었습니다.

 

하지만 지난 과제를 복습하고, 강의를 따라 이번과제를 진행해 보니 Spring 에 대해서 어떤것인지 감이 잡히고 내가 뭘 배웠는지와 뭘 만들어야 하는지, 어떤부분이 부족한지 알게되었던 것 같습니다. 사실 지금 이 회고를 작성하는 중에 떠오른것이 한 회원이 다른 회원에게 일정을 작성하는 권한을 부여하며 N:M 관계를 가지는 기능을 구현하는 것을 깜빡하고 반영하지 않았지만, 이제는 어떻게 반영하고 어떻게 개발해야하는지 알고있어서 다음 걸음이 좀 더 자신있는 것 같습니다.

728x90
반응형