[내일배움캠프 Spring 4기] 51일차 TIL - 투두앱 [todoParty] 리팩토링 | Global 예외처리 | Entity, DTO 테스트

서예진·2024년 2월 21일
0

오늘의 학습 키워드 💻

▸ 오늘의 코드카타
▸ 투두앱[todoParty] 리팩토링
▸ Global 예외처리
▸ Entity, DTO 테스트


▼ 오늘의 코드 카타

2024년 2월 21일 - [프로그래머스] 27 : H-Index | n^2 배열 자르기

알고리즘 문제를 풀면서 문제에서 나온 그대로 풀지 않아도 된다는 것을 깨달았다.
문제에서 2차원 배열을 만드는게 나왔다고 해서 풀이도 그대로 2차원 배열을 만들지 않아도 됐다. 문제의 정답을 얻기 위해 불필요한 부분을 제외하고 필요한 부분만 계산하는 것이 중요하다는 것을 깨달았다.


🎥 전에 만들었던 투두앱 [todoParty] 을 추가 기능 구현하고 심화 주차에 배운 테스트 코드 작성과 예외처리를 적용해보았다.

현재 상황

▶️ 기본 CRU 기능
▶️ 로그인된 유저만 할일카드 생성, 수정할 수 있음
▶️ 할일카드 완료 상태로 만들 수 있음

추가 기능 구현

▶️ 할일카드 삭제 기능 구현
▶️ 댓글 기능 구현
▶️ 로그인된 유저가 작성한 할일카드 조회 기능 구현
▶️ 생성, 수정, 삭제시 새로고침 기능 추가하기

목표

▶️ Global 예외 처리
▶️ DTO, Entity 테스트 추가하기
▶️ Controller 테스트 추가하기
▶️ Service 테스트 추가하기
▶️ Repository 테스트 추가하기
▶️ Mockito 사용해서 테스트용 객체 만들어보기
▶️ Spring Boot 통합 테스트 추가하기


✅ 유저가 작성한 할일카드 조회 기능 구현

TodoController

//유저의 전체 할일카드 조회
    @ResponseBody
    @GetMapping("/myTodos")
    public List<TodoResponseDto> getPostsByUserId(@AuthenticationPrincipal UserDetailsImpl userDetails){
        return todoService.getTodosByUserId(userDetails);
    }
  • 현재 로그인된 유저의 전체 할일카드 조회 기능을 구현하기 위해서는 로그인된 유저 정보가 필요하다.
  • 따라서 @AuthenticationPrincipal UserDetailsImpl userDetails을 매개변수로 전달했다.

TodoService

//유저의 전체 할일카드 조회
    public List<TodoResponseDto> getTodosByUserId(UserDetailsImpl userDetails) {
        List<Todo> todos = todoRepository.findByUserId(userDetails.getUser().getId());
        List<TodoResponseDto> responseDtoList = new ArrayList<>();

        for (Todo todo : todos) {
            responseDtoList.add(new TodoResponseDto(todo));
        }

        return responseDtoList;
    }
  • 먼저 전달받은 userDetails에서 유저의 Id 정보를 찾은 다음에 DB에서 해당 userid를 가진 할일카드를 찾아서 List로 반환하게 했다.
  • 이 리스트는 Todo 타입이므로 TodoResponseDto 타입의 리스트를 새로 만들어서 반환했다.

TodoRepository

public interface TodoRepository extends JpaRepository<Todo, Long> {
    List<Todo> findByUserId(Long userId);
}

✅ 댓글 기능 구현

1. 댓글 작성 기능 구현

• Controller 만들기 - CommentController

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

    public CommentController(CommentService commentService) {
        this.commentService = commentService;
    }

    //댓글 작성 기능
    @PostMapping("/todo/{todoId}/create")
    public CommentResponseDto createComment(@PathVariable Long todoId, @RequestBody CommentRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails){

        return commentService.createComment(todoId, requestDto, userDetails);
    }
}
  • @PathVariable 방식으로 todoId를 전달 받아서 댓글을 만들고 저장한다.

• Dto, Entity 만들기

Comment
@Entity
@Getter
@NoArgsConstructor
@Table(name = "comments")
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String content;

    @Column
    private String username;

    @Column
    private LocalDateTime createDate;

    @Column
    private Long todoId;

    public Comment(Long todoId, CommentRequestDto requestDto, UserDetailsImpl userDetails){
        this.todoId = todoId;
        this.content = requestDto.getContent();
        this.username = userDetails.getUsername();
        this.createDate = LocalDateTime.now();
    }
}
CommentRequestDto
@Getter
@Setter
public class CommentRequestDto {

    private String content;

}
CommentResponseDto
@Getter
@Setter
@AllArgsConstructor
public class CommentResponseDto {
    private Long id;

    private String content;

    private String username;

    private LocalDateTime createDate;

    private Long todoId;

    public CommentResponseDto(Comment comment){
        this.id = comment.getId();
        this.content = comment.getContent();
        this.username = comment.getUsername();
        this.createDate = comment.getCreateDate();
        this.todoId = comment.getTodoId();
    }
}

• Service 만들기 - CommentService

@Service
public class CommentService {
    private final CommentRepository commentRepository;

    public CommentService(CommentRepository commentRepository) {
        this.commentRepository = commentRepository;
    }

    public CommentResponseDto createComment(Long todoId, CommentRequestDto requestDto, UserDetailsImpl userDetails) {
        Comment comment = new Comment(todoId, requestDto, userDetails);
        commentRepository.save(comment);
        return new CommentResponseDto(comment);
    }
}

Repository 만들기 - CommentRepository

public interface CommentRepository extends JpaRepository<Comment, Long> {
}

2. 댓글 조회 기능 구현

  • 모든 댓글을 볼 필요 없으므로 할일카드 안에 있는 댓글만 조회할 수 있게 구현했다.

CommentController

//할일카드의 모든 댓글 조회 기능
    @GetMapping("{todoId}")
    public List<CommentResponseDto> getComments(@PathVariable Long todoId){
        return commentService.getComments(todoId);
    }

CommentService

public List<CommentResponseDto> getComments(Long todoId) {
        List<Comment> comments = commentRepository.findByTodoIdOrderByCreateDate(todoId);
        List<CommentResponseDto> responseDtoList = new ArrayList<>();
        for(Comment comment : comments){
            responseDtoList.add(new CommentResponseDto(comment));
        }
        return responseDtoList;
    }

CommentRepository

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findByTodoIdOrderByCreateDate(Long todoId);
}

3. 댓글 수정 기능 구현

CommentController

//댓글 수정 기능
    @Transactional
    @PutMapping("/{commentId}")
    public CommentResponseDto updateComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails){
        return commentService.updateComment(commentId, requestDto, userDetails);
    }

CommentService

public CommentResponseDto updateComment(Long commentId, CommentRequestDto requestDto, UserDetailsImpl userDetails) {
        Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다."));
        if(!userDetails.getUsername().equals(comment.getUsername())){
            throw new RejectedExecutionException("할일카드의 작성자만 수정이 가능합니다.");
        }
        comment.update(requestDto);
        return new CommentResponseDto(comment);
    }

Comment

public void update(CommentRequestDto requestDto) {
        this.content = requestDto.getContent();
    }

4. 댓글 삭제 기능 구현

CommentController

//댓글 삭제 기능
    @Transactional
    @DeleteMapping("/{commentId}")
    public void deleteComment(@PathVariable Long commentId, @AuthenticationPrincipal UserDetailsImpl userDetails){
        commentService.deleteComment(commentId, userDetails);
    }

CommentService

public void deleteComment(Long commentId, UserDetailsImpl userDetails) {
        Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다."));
        if(!userDetails.getUsername().equals(comment.getUsername())){
            throw new RejectedExecutionException("댓글의 작성자만 삭제가 가능합니다.");
        }
        commentRepository.delete(comment);
    }

✅ 할일카드 삭제 기능 구현

TodoController

//할일카드 삭제
    @Transactional
    @DeleteMapping("/{todoId}")
    public String deleteTodo(@PathVariable Long todoId, @AuthenticationPrincipal UserDetailsImpl userDetails){
        todoService.deleteTodo(todoId, userDetails);
        return "redirect:/api/todos/myTodos";
    }

CommentRepository

void deleteByTodoId(Long todoId);

CommentService

public void deleteCommentByTodoId(Long todoId) {
        commentRepository.deleteByTodoId(todoId);
    }

TodoService

public void deleteTodo(Long todoId, UserDetailsImpl userDetails) {
        Todo todo = todoRepository.findById(todoId).orElseThrow(()-> new IllegalArgumentException("존재하지 않는 할일카드 입니다."));
        if(!userDetails.getUsername().equals(todo.getUsername())){
            throw new RejectedExecutionException("게시물의 작성자만 삭제가 가능합니다.");
        }
        commentService.deleteCommentByTodoId(todoId);
        todoRepository.delete(todo);
    }

✅ 생성, 수정, 삭제시 새로고침 기능 추가하기

TodoController

    @PostMapping
    public String postTodo(@RequestBody TodoRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails){
        //UserDetailsImpl에 Getter 추가
        TodoResponseDto todoResponseDto = todoService.postTodo(requestDto, userDetails);
        return "redirect:/api/todos/myTodos";
    }
    
    //할일카드 수정
    @PutMapping("/{todoId}")
    public String putTodo(@PathVariable Long todoId, @RequestBody TodoRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails){
        //UserDetailsImpl에 Getter 추가
        todoService.updateTodo(todoId, requestDto, userDetails);
        return "redirect:/api/todos/myTodos";
    }
    
    //할일카드 삭제
    @Transactional
    @DeleteMapping("/{todoId}")
    public String deleteTodo(@PathVariable Long todoId, @AuthenticationPrincipal UserDetailsImpl userDetails){
        todoService.deleteTodo(todoId, userDetails);
        return "redirect:/api/todos/myTodos";
    }

✅ Global 예외 처리

1. exception > RestApiException

@Getter
@AllArgsConstructor
public class RestApiException {
    private String errorMessage;
    private int statusCode;
}
  • 예외처리를 위해서 에러 메세지와 status code가 담긴 예외 객체를 만들었다.

2. exception > GlobalExceptionHandler

RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({IllegalArgumentException.class})
    public ResponseEntity<RestApiException> handleIllegalArgumentException(IllegalArgumentException ex) {
        RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                // HTTP body
                restApiException,
                // HTTP status code
                HttpStatus.BAD_REQUEST
        );
    }

    @ExceptionHandler({RejectedExecutionException.class})
    public ResponseEntity<RestApiException> handleRejectedExecutionException(RejectedExecutionException ex){
        RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                restApiException,
                HttpStatus.BAD_REQUEST
        );
    }
}
  • 예외처리를 모듈화 하고 JSON 형태로 반환하기 위해서 @RestControllerAdvice를 사용했다.
  • @ControllerAdvice(@RestControllerAdvice)를 사용하면 모든 Controller에서 발생한 예외를 여기서 처리할 수 있다.
  • @ExceptionHandler를 활용하여 각 예외를 처리했다.

✅ Exception 클래스 구현

1. resources > messages.properties

not.found.comment=해당 댓글이 존재하지 않습니다.

2. exception > CommentNotFoundException

public class CommentNotFoundException extends IllegalArgumentException{
    public CommentNotFoundException(String message){
        super(message);
    }
}
  • 댓글 관련 IllegalArgumentException을 새롭게 구현했다.

3. CommentService

private final MessageSource messageSource;

...
public CommentResponseDto updateComment(Long commentId, CommentRequestDto requestDto, UserDetailsImpl userDetails) {
        Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new CommentNotFoundException(messageSource.getMessage(
                "not.found.comment",
                null,
                "Not Found Comment",
                Locale.getDefault()
                ))
        );
        if(!userDetails.getUsername().equals(comment.getUsername())){
            throw new RejectedExecutionException("댓글의 작성자만 수정이 가능합니다.");
        }
        comment.update(requestDto);
        return new CommentResponseDto(comment);
    }

    public void deleteComment(Long commentId, UserDetailsImpl userDetails) {
        Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new CommentNotFoundException(messageSource.getMessage(
                "not.found.comment",
                null,
                "Not Found Comment",
                Locale.getDefault()
                ))
        );
        if(!userDetails.getUsername().equals(comment.getUsername())){
            throw new RejectedExecutionException("댓글의 작성자만 삭제가 가능합니다.");
        }
        commentRepository.delete(comment);
    }

  • 테스트를 진행하기에 앞서 validation을 적용해주지 않아 적용했다.

✅ 유효성 검사에 대한 예외처리

  • 유효성 검사에 대한 예외는 MethodArgumentNotValidException를 발생시킨다.
  • 또한, 잘못된 요청에 대한 응답이기 때문에 상태 코드는 400(Bad Request)이다.

GlobalExceptionHandler

@ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<RestApiException> handleValidationException(MethodArgumentNotValidException ex){
        RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                restApiException,
                HttpStatus.BAD_REQUEST
        );
    }
  • 위와 같이 작성했더니 errorMessage가 너무 길게 나왔다.
  • 원인은 restApiException 생성할 때 ex.getMessage()를 사용했기 때문에 모든 메세지를 가져왔다고 생각한다.
  • 여기서 default meassage만 가져오고 싶었다.

❗️해결

  • 찾아보니 defalut message에 접근하기 위해서는 다음과 같이 작성하면 된다.
e.getBindingResult().getFieldErrors().get(0).getDefaultMessage()
//여기서 e는 에러
  • 수정된 코드는 다음과 같다.
@ExceptionHandler({MethodArgumentNotValidException.class})
    public ResponseEntity<RestApiException> handleValidationException(MethodArgumentNotValidException ex){
        RestApiException restApiException = new RestApiException(ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage(), HttpStatus.BAD_REQUEST.value());
        return new ResponseEntity<>(
                restApiException,
                HttpStatus.BAD_REQUEST
        );
    }

  • 해결된 것을 볼 수 있다.

✅ DTO, Entity 테스트

  • Entity와 DTO 내의 메서드를 테스트 한다.

Entity의 update 메서드 테스트

  • Entity 내의 RequestDto를 전달받아 update하는 메서드를 테스트 했다.
import static org.junit.jupiter.api.Assertions.assertEquals;

public class EntityTest {
    @Test
    @DisplayName("Todo update 메서드 - title, content 수정")
    void Test1(){
        //given
        String title = "제목";
        String content = "내용";
        Todo todo = new Todo();
        todo.setTitle(title);
        todo.setContent(content);

        String modifiedTitle = "수정 제목";
        String modifiedContent = "수정 내용";
        TodoRequestDto todoRequestDto = new TodoRequestDto();
        todoRequestDto.setTitle(modifiedTitle);
        todoRequestDto.setContent(modifiedContent);

        //when
        todo.update(todoRequestDto);

        //then
        assertEquals(todo.getTitle(),"수정 제목");
        assertEquals(todo.getContent(), "수정 내용");
    }

    @Test
    @DisplayName("Comment update 메서드 - content 수정")
    void Test2(){
        //given
        String content = "내용";
        Comment comment = new Comment();
        comment.setContent(content);

        String modifiedContent = "수정 내용";
        CommentRequestDto commentRequestDto = new CommentRequestDto();
        commentRequestDto.setContent(modifiedContent);

        //when
        comment.update(commentRequestDto);

        //then
        assertEquals(comment.getContent(),"수정 내용");
    }
}
  • Entity, DTO 내의 메서드를 테스트 하고자 할때 이렇게 테스트 코드를 작성하는 것이 맞는지 잘 모르겠다.
  • 일단 테스트를 통과하기는 했다.

🔎 Todo 생성 시, UserDetailsImpl을 받아와서 생성하는 것과 User entity를 받아와서 생성하는 것의 차이는 무엇일까?

profile
안녕하세요

0개의 댓글