지난 포스팅에서는 가장 기본적인 API (흔히 Rest라고 부르는)를 만들어보았습니다. Board 객체를 CRUD 기준으로 5가지의 API를 개발해보았는데요.
자세한 내용은 지난 포스팅을 확인해주시면 좋을 것 같습니다!
오늘 진행해볼 내용은 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를 통해서 파라미터를 넘기는 부분은 POST와 PUT 뿐입니다.(당연한 이야기지만..) 흠.. ADD와 EDIT이라고 이야기하는게 더 맞는지 모르겠습니다.
- 위에서 AddParam, EditParam 에 유효성 어노테이션을 달아놓았는데, 이제 스프링 컨테이너가 돌면서 로직을 수행할 때, 이 컨트롤러의 Parameter에 선언된 Add, Edit Param들의 정보를 보면서 특정 유효성을 실행하라는 표시가 있어야 합니다.
- 그 역할을 @Validated 어노테이션이 해줍니다. 사실 하나의 어노테이션이 더 존재하는데 @Valid 이것과 @Validated의 차이점은 아래에 설명된 게시글을 읽어보시는게 더 좋을 것 같습니다.
그룹을 지정할 수 있고 없고의 차이점이 있는데 이 부분은 상황적인 설명이 필요한 부분이라서 생략하도록 하겠습니다.
저는 개인적으로 스프링에서 지원해주는 @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()
정답화면
감사합니다.!