[Spring Boot] 게시판 예제를 이용하여 더욱 RESTFul API 개발하기#2 (API Validation, ExceptionHandler)

지난 포스팅에서는 가장 기본적인 API (흔히 Rest라고 부르는)를 만들어보았습니다. Board 객체를 CRUD 기준으로 5가지의 API를 개발해보았는데요. 

 

자세한 내용은 지난 포스팅을 확인해주시면 좋을 것 같습니다!

sas-study.tistory.com/366

 

[Spring Boot] 게시판 예제를 이용하여 더욱 RESTFul API 개발하기#1 (흔히 REST API라고 불리는 API 예제)

안녕하세요. 몇달전에 썻던 Board 게시판 예제를 이용하여 rest api를 작성했던 포스팅에 이어 두번째 더욱 restful api 개발 예제를 작성해보려고 합니다. 최근 백기선님의 REST API 강의를 보고 많은 영

sas-study.tistory.com

 

오늘 진행해볼 내용은 Add 또는 Edit 시에 넘어오는 Fields들에 대해서 유효성 처리를 진행해보고 이를 API가 반환할 때 어떤식으로 표시해주어야 하는지 알아보겠습니다.

 

모든 소스코드 여기에서 확인하실 수 있습니다. git branch 별로 stage를 나눠놓았으니 확인하실 수 있으실 겁니다.

 

 

 


 

 

API 유효성 Validation 처리 및 ExceptionHandler 

 

프로젝트 구조

 

프로젝트 구조를 보면 두가지 클래스가 새로 생성된 모습을 볼 수 있을 것입니다. 이 클래스들이 어떻게 사용되는지 확인하는게 이번 포스팅의 목표입니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

TestBoardController

    @Order(7)
    @DisplayName("게시글 수정(content length over)")
    @Test
    public void editBoard_ContentLengthError() throws Exception {
        EditBoardParam param = EditBoardParam.builder().content("아").build();

        mockMvc.perform(put(BASE_URL + "/{seq}", 1L)
                .contentType(MediaTypes.HAL_JSON_VALUE)
                .accept(MediaTypes.HAL_JSON_VALUE)
                .content(objectMapper.writeValueAsString(param)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

- Test 소스에서는 다음의 코드를 추가해주시면 됩니다. 이는 Board 엔티티의 소스에 아래와 같이 content의 길이를 300으로 제한한 것이 있었는데 이에 대한 유효성 처리를 검사하는 부분이 될 것입니다.

// Board 클래스 중에서..
@Column(length = 300)
private String content;

 

 

 

AddBoardParam, EditBoardParam

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class AddBoardParam {

    private String username;
    
    @Length(min = 2, max = 300, message = "최소 {min}자 이상 최대 {max}자 이하로 입력해주시기 바랍니다.")
    @NotEmpty(message = "글 내용을 입력해주시기 바랍니다.")
    private String content;
}

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
public class EditBoardParam {
    @Length(min = 2, max = 300, message = "최소 {min}자 이상 최대 {max}자 이하로 입력해주시기 바랍니다.")
    @NotEmpty(message = "글 내용을 입력해주시기 바랍니다.")
    private String content;
}

- @Length 어노테이션은 hibernate의 유효성 어노테이션입니다. min과 max 속성을 가지며 String 문자열이 min과 max 사이의 길이를 갖는지 체크하는 어노테이션입니다.

- @NotEmpty 어노테이션은 String 문자열이 ""(빈문자열 -> length == 0) 혹은 null을 가지는지 체크하는 어노테이션입니다.

- @Length와 @NotEmpty 어노테이션은 같은 validation용 어노테이션이지만 뿌리는 다릅니다. @Length 어노테이션의 패키지는 hibernate.validator쪽이고 @NotEmpty 어노테이션은 javax.validation.constraints 쪽 패키지 소속입니다.

 

아무튼 이런식으로 Request Fields의 유효성 처리를 진행하였습니다.

 

 

 

 

BoardController

... 생략 ...
    
@PostMapping(produces = MediaTypes.HAL_JSON_VALUE, consumes = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity addBoard(@RequestBody @Validated AddBoardParam param) throws Exception {
    Board board = boardService.addBoard(param);
    URI createdURI = linkTo(BoardController.class).slash(board.getSeq()).toUri();
    return ResponseEntity.created(createdURI).body(board);
}
    
... 생략 ...
    
@PutMapping(value = "/{seq}", produces = MediaTypes.HAL_JSON_VALUE, consumes = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity editBoard(@PathVariable("seq") Long seq, 
                                @RequestBody @Validated EditBoardParam param) throws Exception {
    Board board = boardService.editBoard(param, seq);
    if (board == null) {
        return ResponseEntity.notFound().build();
    } else {
        return ResponseEntity.ok(board);
    }
}

- 현재까지 Request Fields를 통해서 파라미터를 넘기는 부분은 POSTPUT 뿐입니다.(당연한 이야기지만..) 흠.. ADDEDIT이라고 이야기하는게 더 맞는지 모르겠습니다. 

 

- 위에서 AddParam, EditParam 에 유효성 어노테이션을 달아놓았는데, 이제 스프링 컨테이너가 돌면서 로직을 수행할 때, 이 컨트롤러의 Parameter에 선언된 Add, Edit Param들의 정보를 보면서 특정 유효성을 실행하라는 표시가 있어야 합니다.

 

- 그 역할을 @Validated 어노테이션이 해줍니다. 사실 하나의 어노테이션이 더 존재하는데 @Valid 이것과 @Validated의 차이점은 아래에 설명된 게시글을 읽어보시는게 더 좋을 것 같습니다.

 

yoonemong.tistory.com/232

 

[Spring MVC] @Valid vs @Validated

[ Valid와 Validated 공통점 ] : 유효성 검증가능 [ Valid와 Validated 차이점 ] : 그룹지정이 불가능 : @Valid는 스프링이 만든 기술은 아니며 JSR-303이란 이름으로 채택된 서블릿 2.3 표준스펙 중 하나...

yoonemong.tistory.com

그룹을 지정할 수 있고 없고의 차이점이 있는데 이 부분은 상황적인 설명이 필요한 부분이라서 생략하도록 하겠습니다.

저는 개인적으로 스프링에서 지원해주는 @Validated 어노테이션을 사용하는 편입니다.

 

 

BoardService

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository boardRepository;

    public List<Board> getBoardList() throws Exception {
        return boardRepository.findAll();
    }

    public Board getBoard(Long seq) throws Exception {
        return getBoardOrElseThrow(seq);
    }

    @Transactional
    public Board editBoard(EditBoardParam param, Long seq) throws Exception {
        Board board = getBoardOrElseThrow(seq);
        if(board != null) {
            board.setContent(param.getContent());
        }
        return board;
    }

    @Transactional
    public Board addBoard(AddBoardParam param) throws Exception {
        return boardRepository.save(new Board(param));
    }

    @Transactional
    public boolean deleteBoard(Long seq) throws Exception {
        Board board = getBoardOrElseThrow(seq);
        if (board == null) {
            return false;
        }
        boardRepository.delete(board);
        return true;
    }

    private Board getBoardOrElseThrow(Long seq) {
        return boardRepository
                .findById(seq)
                .orElseThrow(() -> new EntityNotFoundException("해당 게시글이 존재하지 않습니다."));
    }
}

- 서비스 로직에서 처리된 부분은 이전과는 달리 엔티티 조회 후 없는 경우를 Exception을 발생시켜 처리하는 부분입니다.

 

- 해당 Exception이 발생하면 Exception을 처리하는 로직에서 "해당 게시글이 존재하지 않습니다." 라는 에러메시지를 API 호출자에게 Response로 상황설명을 내려주는 처리를 해줄 것입니다.

 

- 이 부분은 개발자가 Null로 넘겨줘서 200을 내리느냐(조회 후 null 처리는 개발자가 알아서..) 404를 내려주느냐의 차이 정도로 좁힐 수 있지만 후자로 진행하는 것을 제가 선택한 것 뿐이라는 것을 말씀드리고 싶습니다.

 

 

 

RestExceptionHandler

@Slf4j
@RestControllerAdvice
public class RestExceptionHandler {

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(EntityNotFoundException.class)
    public ErrorResponse handlerRuntimeException(EntityNotFoundException e, 
                                                 HttpServletRequest req) {
        log.error("===================== Handler RuntimeException =====================");
        e.printStackTrace();
        return new ErrorResponse(HttpStatus.NOT_FOUND, e.getMessage(), null);
    }
}

- 이는 @RestController라는 어노테이션이 붙어있는 클래스에서 발생한 에러를 모두 처리하는 로직입니다. Service 클래스에서 예외를 던지면 Controller에서 받도록 설계한 이유가 @RestControllerAdvice라는 어노테이션을 이용하여 한번에 예외를 모아 처리해주기 위해서입니다.

 

- @ResponseStatus 어노테이션은 해당 Response가 내려질 때, API 호출자가 받을 HttpStatus를 정해주는 어노테이션입니다.

 

- @ExceptionHandler는 @RestController 클래스에서 예외가 발생했을 때, 특정한 Exception 종류를 커스텀하게 처리할 수 있도록 해줍니다. value에 Exception 클래스 타입을 넣어주면 됩니다.^^

 

- 현재는 EntityNotFoundException에 대한 예외상황만 개발자가 확인이 가능한 예외이기 때문에 점점 추가해 나가도록 하겠습니다.

 

 

 

ErrorResponse

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
    private int code;
    private String message;
    private List<String> errorDetails;
    private String responseTime;

    public ErrorResponse(HttpStatus httpStatus, String message, List<String >errorDetails) {
        this.code = httpStatus.value();
        this.message = message;
        this.errorDetails = errorDetails;
        this.responseTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"));
    }
}

- 예외 처리시에 리턴될 응답 Response 모습입니다. 

- 기본적인 응답은 모두 이러한 모습입니다. RestController에서 리턴되는 모든 객체는 빨간 네모칸안에 밖에 들어갈 수 없습니다.(따로 ResponseEntity에 넣어주지 않는 한..?) 

- 이에 대한 모습을 API 사용자가 예외가 발생한 상황에서도 response를 확인할 수 있도록 포맷을 정해놓은 클래스입니다.

 

 


 

 

테스트 결과

- 예외가 발생하더라도 message에 어떤 에러가 발생했는지 코드는 무엇인지 언제 발생했는지 body에서 확인할 수 있게 되었습니다.

 

 


 

 

숙제

 

Test에서 새로 추가된 7번테스트의 응답이 아래와 같을 것입니다.

Body에 담아주지 못한 부분인데요. 이를 담아서 아래와 같이 결과가 나오도록 수정해보세요!! 다음 포스팅에서 정답은 다음 포스팅에서 확인하실 수 있습니다.

 

힌트 : e.getBindingResult()

 

 

 

 

정답화면

 

 

 

감사합니다.!

댓글

Designed by JB FACTORY