[Spring Boot] 게시판 예제를 이용하여 더욱 RESTFul API 개발하기#3 (ExceptionHandler 내용 추가, Spring HATEOAS 추가)

더욱 RESTFul API를 개발하고있는 코딩하는 흑구입니다. 해당 시리즈의 모든 소스코드는 여기에서 확인하실 수 있습니다.

 

지난 시간에는 API Validation과 ExceptionHandler를 이용한 예외처리에 대해서 다뤄보았습니다.

 

sas-study.tistory.com/368

 

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

지난 포스팅에서는 가장 기본적인 API (흔히 Rest라고 부르는)를 만들어보았습니다. Board 객체를 CRUD 기준으로 5가지의 API를 개발해보았는데요. 자세한 내용은 지난 포스팅을 확인해주시면 좋을 것

sas-study.tistory.com

 

그리고 마지막에 숙제로 7번 테스트의 응답 내용을 BindingResult 객체를 이용하여 완성시켜보는 숙제를 드려보았는데요. 이미 BindingResult라는 객체에 대해서 해보셨던 분들은 쉽게 알 수 있었을 것입니다!

 

숙제 정답!

/**
 * 메소드 파라미터 valid 불통
 * @param e
 * @param req
 * @return
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ErrorResponse handlerMethodArgumentNotValidException(MethodArgumentNotValidException e,
                                                            HttpServletRequest req) {
    log.error("===================== Handler MethodArgumentNotValidException =====================");
    e.printStackTrace();
    return getErrorResponseByBindingResult(e.getBindingResult(), HttpStatus.BAD_REQUEST, "유효하지 않은 값이 있습니다.");
}

private ErrorResponse getErrorResponseByBindingResult(BindingResult bindingResult,
                                                      HttpStatus httpStatus,
                                                      String message) {
    // BindingResult 객체를 이용한 여러가지 validation 정보를 처리한다.
    List<String> errorDetails = bindingResult.getFieldErrors()
            .stream()
            .map(DefaultMessageSourceResolvable::getDefaultMessage)
            .collect(Collectors.toList());
    return new ErrorResponse(httpStatus, message, errorDetails);
}

- 일단 어떤 Exception이 발생하는지 확인해야하는데 로그를 보면 MethodArgumentNotValidException이 발생했음을 알 수 있습니다. 이는 @RestController에서 발생했으므로 @ExceptionHandler에서 처리할 수 있는 예외이고 Param 객체에서 @Length 어노테이션에 의해 던져진 예외 메시지를 받아와야 했습니다.

 

- Exception에서 BindingResult 객체를 모든 Exception에서 얻을 수 있는 것이 아니라 특정 객체에만 얻을 수 있는 객체인데, 이들의 FieldErrors에서 걸러진 예외들을 확인할 수 있습니다.

 

- 메소드 파라미터 valid 예외는 메시지가 여러가지값을 가질 수 있는 배열값입니다. 따라서 errorDetails에 각각의 예외 메시지를 저장하고 대표적인 메시지인 "유효하지 않은 값이 있습니다"의 러프한 메시지로 처리하였습니다.

 

 


 

스프링 HATEOAS 적용하기

먼저 HATEOAS라는 단어는 Hypermedia As The Engine Of Application State의 약자입니다. 상당히 추상적인데 사실 저희가 Request/Response에서 확인할 수 있는 application/hal+json이라는 content-type에서 그 답을 얻을 수 있습니다.

 

저희 TestBoardController의 코드를 보면

위와 같이 HAL_JSON_VALUE라는 content-type을 넘겨주었는데요. 이는 Controller에서 해당 content-type이랑 맞춰줘야 했던 점도 있지만 이번에 적용할 HATEOAS에 대해서 미리 익숙해지시라고 넣어놓은 것이기도 합니다. 

기존에 api에서는 application/json 이라는 content-type을 많이 사용했겠지만 여기에는 hal이라는 단어가 들어갑니다.

 

HAL : JSON, XML 코드 내의 외부 리소스에 대한 링크와 같은 하이퍼 미디어를 정의하기 위한 규칙

참조 : dotheright.tistory.com/302

 

HAL(Hypertext Application Language)

참고 : https://tools.ietf.org/html/draft-kelly-json-hal-08 참고 : http://stateless.co/hal_specification.html 참고 : https://en.wikipedia.org/wiki/Hypertext_Application_Language 1. HAL? 1.1. 정의  -..

dotheright.tistory.com

제가 현재까지 이해한 HATEOAS라는 개념에 대해서 설명해보자면

 

1. API는 특정한 화면에서 데이터를 불러와 화면을 구성할 데이터들을 화면의 각각에 배치하게 된다.

2. 각각의 화면에는 여러 이벤트를 동작하게하는 API들을 호출할 수 있게 된다. 

3. 첫번째 API에서 호출한 내용이 *다른 API 호출로 이어지게 될 경우* 에 API Response에 전이될 API Resource(link)에 대한 정보를 넣어주어 어플리케이션 상태 전이를 용이하게 한다.

4. 그로인해 하나의 어플리케이션은 각각의 Server 프로그램과 Client 프로그램의 독립적인 진화로 이어질 수 있다.

 

이러한 흐름을 느꼈습니다. 

 

물론 어딘가가 아직 구멍이 숭숭 뚫리고 완전하지 못한 설명일 테지만 가장 크게 와닿았던 점은 다른 API호출로 이어지게 될 경우 그 상태를 전이한다. 라는 부분입니다. 

 

그럼 이제부터 BoardController에 Spring Hateoas 개념을 적용해보겠습니다.

 

 

BoardController

- API를 하나씩 설명하기전에 EntityModel 이라는 클래스에 대해서 살펴보겠습니다.

 

EntityModel 객체는 일단 Hateoas를 구현하기 위해 사용되는 Spring Hateoas 프로젝트의 패키지 소속입니다. 결과적으로 content 라는 Response Body에 담겨질 내용과 Link 정보를 가지게 되는데요. 

 

Link에 대한 정보는 밑줄친 부모 객체에서 확인할 수 있습니다.

 

이러한 객체가 json으로 Serialize 될 때, 특정한 모양을 만들어내게 되는데 이는 테스트 코드에서 확인하도록 하겠습니다.

 

getLinksAddress 메소드

private WebMvcLinkBuilder getLinkAddress() {
    return linkTo(BoardController.class);
}

- linkTo 메소드는 BoardController에서 @RequestMapping()으로 설정한 "/boards"라는 prefix로 하는 http://localhost/boards의 모습과 같은 해당 컨트롤러의 uri정보를 만들어주게 됩니다.

 

 

addBoard 메소드

@PostMapping(produces = MediaTypes.HAL_JSON_VALUE, consumes = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity addBoard(@RequestBody @Validated AddBoardParam param) throws Exception {
    BoardResult boardResult = boardService.addBoard(param);
    URI createdURI = getLinkAddress().slash(boardResult.getSeq()).toUri();
    EntityModel<BoardResult> entityModel = EntityModel.of(boardResult,
            getLinkAddress().slash(boardResult.getSeq()).withSelfRel(),
            getLinkAddress().slash(boardResult.getSeq()).withRel("get"),
            getLinkAddress().slash(boardResult.getSeq()).withRel("delete"),
            getLinkAddress().slash(boardResult.getSeq()).withRel("edit"));

    return ResponseEntity.created(createdURI).body(entityModel);
}

- EntityModel 객체는 of 메소드 패턴을 제공합니다. 생성자 초기화는 @Deprecated 처리되었고 of 메소드의 내부 파라미터에 (T content, Link... links) 타입을 받습니다.

- of 메소드의 content에 body에 들어갈 boardResult 인스턴스를 넣어주고 해당 response 결과에서 전이될 수 있는 API 주소들을 넣어줍니다. 

- 이 프로젝트에서는 화면에 대한 정보가 없으므로 발생할수 있는 상황을 가정하여 self(자기자신의 API 주소), get(상세), delete(삭제), edit(수정)에 대한 정보를 넣어주었습니다.

 

 

getBoardList 메소드

@GetMapping(produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity getBoardList() throws Exception {
    List<BoardResult> boardList = boardService.getBoardList();
    // 각 요소를 EntityModel로 변환.
    List<EntityModel> collect = boardList.stream()
                                    .map(board -> EntityModel.of(board,
                                            getLinkAddress().slash(board.getSeq()).withRel("get"),
                                            getLinkAddress().slash(board.getSeq()).withRel("delete")))
                                    .collect(Collectors.toList());

    // 리스트를 CollectionModel로 변환. -> response body에 담는다.
    CollectionModel entityModel = CollectionModel.of(collect, getLinkAddress().withSelfRel());
    return ResponseEntity.ok(entityModel);
}

- 마찬가지로 리스트 화면에서 발생할 수 있는 여러 API들을 예상하여 담아줍니다.

- List는 여러가지 게시글 정보를 포함한 화면이므로 각각의 요소에 삭제버튼이 달려있다고 예상한 경우, 각 요소마다 삭제 url 정보가 다르게 되므로 collection 안에 이 각 요소의 삭제 url 정보를 포함할 수 있도록 EntityModel로 BoardResult 객체를 감싸고 그 안에 각 요소의 get, delete에 대한 링크정보가 들어갈 수 있도록 하였습니다.

- list는 아직 어떠한 상태로 전이될 지 모르기 때문에 일단은 self 만 해두었습니다.

 

 

getBoard 메소드

@GetMapping(value = "/{seq}", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity getBoard(@PathVariable("seq") Long seq) throws Exception {
    BoardResult boardResult = boardService.getBoard(seq);
    if (boardResult == null) {
        return ResponseEntity.notFound().build();
    } else {
        EntityModel entityModel = EntityModel.of(boardResult,
                getLinkAddress().slash(boardResult.getSeq()).withSelfRel(),
                getLinkAddress().slash(boardResult.getSeq()).withRel("delete"),
                getLinkAddress().slash(boardResult.getSeq()).withRel("edit"));
        return ResponseEntity.ok(entityModel);
    }
}

- 상세화면에서 예상되는 api의 전이는 delete와 edit입니다. 따라서 deleteedit의 url 정보를 link에 담아주었습니다.

 

 

editBoard 메소드

@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 {
    BoardResult boardResult = boardService.editBoard(param, seq);
    if (boardResult == null) {
        return ResponseEntity.notFound().build();
    }

    EntityModel<BoardResult> entityModel = EntityModel.of(boardResult,
            getLinkAddress().slash(boardResult.getSeq()).withSelfRel(),
            getLinkAddress().slash(boardResult.getSeq()).withRel("get"),
            getLinkAddress().withRel("list"));
    return ResponseEntity.ok(entityModel);
}

- 수정화면에서는 수정 후 상세로 가거나 리스트로 가는 상황을 전이하기 위해 get, list api에 대한 링크 정보를 추가하였습니다.

 

 

deleteBoard 메소드

@DeleteMapping(value = "{seq}", produces = MediaTypes.HAL_JSON_VALUE)
public ResponseEntity deleteBoard(@PathVariable("seq") Long seq) throws Exception {
    if (boardService.deleteBoard(seq)) {
        Map<String, Long> resultMap = new HashMap<>();
        resultMap.put("deletedSeq", seq);
        EntityModel entityModel = EntityModel.of(resultMap, getLinkAddress().slash(seq).withSelfRel());
        return ResponseEntity.ok(entityModel);
    }
    return ResponseEntity.notFound().build();
}

- delete의 경우 해당 요소가 이미 처리가 되었고 더이상 조회, 수정, 삭제, 리스트에도 나오지 않게 될 요소이기 때문에 호출한 자기 자신의 정보인 self만 넣어두었습니다.

 

 

 

 

TestBoardController

테스트를 돌려보면 이제 다른 정보들이 함께 표현되기 시작할 것입니다.

 

대표적으로 addBoard를 돌렸을 때,

 

{
  "seq": 11,
  "username": "유저1",
  "content": "게시글을 등록합니다.",
  "createdAt": "2020-10-03 01:43:11",
  "_links": {
    "self": {
      "href": "http://localhost/boards/11"
    },
    "get": {
      "href": "http://localhost/boards/11"
    },
    "delete": {
      "href": "http://localhost/boards/11"
    },
    "edit": {
      "href": "http://localhost/boards/11"
    }
  }
}

다음과 같은 response 모양을 확인할 수 있습니다.

 

self는 호출한 자기자신의 url 링크 정보,

get은 게시글 상세의 url 링크 정보

delete는 해당 요소의 삭제 url 링크 정보,

edit는 해당 요소의 수정 url 링크 정보

 

이를 _links라는 json 프로퍼티로 감싸고 있습니다.

 

굳이 HATEOAS를 적용한 이유

상당수의 REST API라는 API들이 이러한 링크정보에 많이 소홀한 부분이 있습니다.

 

현재 REST API를 고안한 로이필딩의 논문에 따르면 REST API에 대한 정의는 다음과 같습니다.

참고 출처 : haah.kr/2017/08/05/rest-series-summary/

 

REST 연구 요약본

REST에 관해 연구했던 내용을 공개하고 가장 많이 받은 피드백은, ‘간단히 말해보라’는 것이었다. 간단한 문제가 아니라서 여전히 간단히는 말할 수는 없지만, 널리 퍼지기 좋게 몇 가지 문장��

haah.kr

 

요청/응답에 필요한 모든 정보와 어플리케이션의 상태변경이 가능하도록 하는 hypertext가 바로 Hateoas에서 적용했던 link들에 대한 정보들입니다.

 

어플리케이션의 상태변경이라함은 API를 호출하여 비밀번호를 변경하거나 글 내용을 변경하거나 등 DB에 대한 변경을 의미하는 것이라고 생각합니다.

 

그것을 유도하는 hypertext라함은 호출하는 어플리케이션의 API URL이 될 것 같습니다.

 

그래서 제가 더욱 Restful API라고 지은 답을 여기서 찾을 수 있습니다.!

 

가장 Rest한 api에 가까운 API를 개발하는 개발기입니다. 다음시간에는 Spring Rest Docs를 이용하여 API 문서를 뽑아보고 그것으로는 또 어떤 RESTFul API를 개발하는데 충족할 요소가 있는지 알아보겠습니다.

 

 

 

* PS

- 위의 REST 연구 요약본의 글 내용은 해당 필자의 의견은 "당신이 디자인한 API가 REST API가 아니어도 당신이 디자인한 API는 충분히 가치있는 API이다" 라는 내용으로 끝을 맺습니다. 이러한 저러한 REST API를 개발하기 힘든 우리 사회의 환경도 충분히 이야기해주고 있습니다. 읽어보시면 많은 도움이 될 것 같구요!

 

또한 제가 이 시리즈를 작성하게 된 계기는 다음의 영상에서도 찾아볼 수 있습니다.!!!

www.youtube.com/watch?v=RP_f5dMoHFc

 

하지만 제가 이 포스팅에서 초점을 두고 있는 것은 로이필딩의 명세에서 정의하고 있는 REST API를 개발해보자는 것일 뿐 반드시 이렇게 개발해야한다는 의미는 아닙니다!

 

이상입니다.

댓글

Designed by JB FACTORY