본문 바로가기

Spring/Spring

REST API 응답은 어떻게 줘야할까? (표준 Response 객체를 만들 수 있을까?, 정확하게 응답 처리를 하는 방법, 성공과 실패 응답)

반응형

- 왜 이런 생각을 하였는가?

필자가 쓰는 Vue 프레임워크에서 axios라는 HTTP client 라이브러리가 있다.

해당 라이브러리에서 HTTP 요청에 성공했을 때는 왜 "response.data"에 http reponse의 body 값이 들어가고, HTTP 요청에 실패했을 때는 왜 "error.response"에 body내용이 들어가는지가 궁금했다.

HTTP 요청에 성공했든 실패했든 모든 응답은 response라고 하고 body 내용이면 "repsonse.body"로 통일하면 되는거 아닌가? 하는 생각이 들었다.

//success
{
    "payload" : "~~~~",
    "error" : null
}
//fail
{
    "payload" : null,
    "error" : {
        "message" : "Not found user"
        "code" : 400
    }
}
/* 또는 아래... */
{
    "success" : false,
    "data" : "~~~"
}

아니면 위와 같이 메세지 포맷을 사전에 정해서 준다면,

성공했을 때는 response내의 error값이 null이니까 정상적이라는 뜻으로 해석하고 payload만 보면되고

실패했을 때는 error값이 null이 아니니까 실패했다는 것으로 해석하고 그에 따른 알맞은 처리를 하면되는거 아닐까? 하는 생각도 들었다.

→ 지금 생각해보면 아주 멍청한 생각이다...

결론부터 말하면, 첫번째로 고민한 것의 해답은 "repsonse.data", "error.response" 라고 정한 건 HTTP client 라이브러리 '스키마' 마다 다르기 때문에 body 내용이 그냥 라이브러리 스키마에 맞춰서 "data" 속성으로 들어간 것이었다. (http header, body 값을 어떤 이름의 속성으로 파싱하는 건 라이브러리 마음...)

하지만 "data" 같은 속성 이름이 마냥 쌩뚱맞게 지은 네이밍이 아니라 다른 라이브러리를 참고해서 표준화(?)시켜서 나온 이름이 아닐까 하는 생각이 있다. (이건 추후에 알아보도록 하고...)

두 번째로 고민한 것의 해답은 HTTP status에 있었다. (마찬가지로 클라이언트 라이브러리에 해답이 있었다.)

위의 코드처럼 response 내용에서 response의 성공과 실패 여부를 나타내는 것은 잘못된 코드고 REST 스타일에 위배된다.

axios.get('/user?ID=12345')
  .then(function (response) {
    // handle success
    console.log(response);
  })
  .catch(function (error) {
    // handle error
    console.log(error);
  })
  .finally(function () {
    // always executed
  });

위 코드는 axios 사용법에서 발췌한 코드다.

client 라이브러리 사용법에서 어떤 response가 와야 .then (=성공) 메서드를 호출하고, 어떤 response가 와야 catch (=에러) 메서드로 호출되는지를 찾아봤다.

결론은 HTTP status 가 2xx번대가 아니면 error로 잡히고, 2xx번대면 성공적인 응답으로 잡히는 것이었다. 심지어 아래와 같이 설정값으로 줄 수도 있다.

validateStatus: function (status) {
  return status >= 200 && status < 300; // default
},

서론이 길고 다소 황당한 내용이었지만 이번 멍청한 생각을 한 기회에 REST API 서버를 구현하는 과정 중에 "응답(Response)"에 집중해서 몇 가지 테스트를 해보려고 한다.

다양한 response 만들어 보기

예제 코드를 나열해보겠다.

  • frontend

PostMan같은 client tool을 사용해도 됐지만, 조금 더 실무스럽게 vue의 axios를 이용해서 코드로 호출하고 response, status, headers, error를 화면에 출력하도록 구현했다. vue를 전혀모르는 사람도 상관없다. 위에서 말한 내용이 전부이고 의미만 전달하면 되므로 일부 코드만 아래에 첨부했다.

<template>
  <div class="home">
    <button @click="test">test</button>
    <div>response : {{ response }}</div>
    <div>status : {{ status }}</div>
    <div>headers : {{ headers }}</div>
    <div>err : {{ err }}</div>
  </div>
</template>

<script>
export default {
  name: 'home',
  data () {
    return {
      count: 0,
      response: '',
      status: 0,
      headers: [],
      err: ''
    }
  },
  methods: {
    test: function () {
      this.count += 1
      this.$axios.get('http://localhost:8080/data')
        .then(res => {
          this.response = res.data
          this.headers = res.headers
          this.status = res.status
        })
        .catch(error => {
          this.err = error.response
          this.headers = error.response.headers
          this.status = error.response.status
        })
    },
    test2: function () {
      this.count += 1
      this.$axios.post('http://localhost:8080/void-add',{
        'sourceId': 'vue',
        'parameterName': 'axios'
      })
        .then(res => {
          this.response = res.data
          this.headers = res.headers
          this.status = res.status
        })
        .catch(error => {
          this.err = error.response
          this.headers = error.response.headers
          this.status = error.response.status
        })
    }
  }

}
</script>

post 요청 때문에 test2() 메서드를 만들었을 뿐, 단순하게 버튼 누르면 해당 URL로 get,post request를 보내고 response를 처리하는 구조다.

  • back-end (spring boot)

github 링크로 대체하려다가 모든 클래스를 올렸다. 각 메서드가 의미하는 바는 결과와 함께 분석해보면 좋을 것 같다. 대충 이런 코드구나 생각하면 된다.

→ MyController.java

package com.example.demo.ui.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.ui.domain.ErrorResponse;
import com.example.demo.ui.domain.NormalData;
import com.example.demo.ui.domain.Result;
import com.example.demo.ui.exception.CustomException;
import com.example.demo.ui.service.MyService;

@CrossOrigin(origins="*")
@RestController
public class MyController {
    @Autowired
    private MyService myService;

    @GetMapping("/data")
    public List<NormalData> getData() {
        return myService.getData();
    }
    @PostMapping("/data")
    public Result addData(@RequestBody NormalData normalData) {
        return myService.addData(normalData);
    }
    @GetMapping("/error-response")
    public ErrorResponse getErrorResponse() {
        return myService.getErrorResponse();
    }
    @GetMapping("/error-result")
    public Result getErrorInResult() {
        return myService.getErrorInResult();
    }
    @GetMapping("/exception")
    public String getException() {
        return myService.getException();
    }
    @GetMapping("/status")
    public String getStatus() {
        return "myStatus";
    }
    @PostMapping("/void-add")
    public void addException(@RequestBody NormalData normalData) {
        myService.addException(normalData);
    }
  @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(CustomException.class)
    public Object ex(Exception e) {
        System.err.println(e.getMessage());
        return e.getMessage();
    }
}

→ MyService.java

package com.example.demo.ui.service;

import java.util.List;

import com.example.demo.ui.domain.ErrorResponse;
import com.example.demo.ui.domain.NormalData;
import com.example.demo.ui.domain.Result;

public interface MyService {
    public List<NormalData> getData();
    public Result addData(NormalData normalData);
    public ErrorResponse getErrorResponse();
    public Result getErrorInResult();
    public String getException();
    public void addException(NormalData normalData);
}

→ MyServiceImpl.java

package com.example.demo.ui.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.stereotype.Service;

import com.example.demo.ui.domain.ErrorResponse;
import com.example.demo.ui.domain.NormalData;
import com.example.demo.ui.domain.Result;
import com.example.demo.ui.exception.CustomException;

@Service
public class MyServiceImpl implements MyService{
    //일반적인 데이터 리턴
    public List<NormalData> getData() {
        List<NormalData> list = new ArrayList<NormalData>();
        NormalData normalData = new NormalData();
        normalData.setParameterName("hello");
        normalData.setSourceId("world");
        list.add(normalData);
        NormalData normalData1 = new NormalData();
        normalData1.setParameterName("hello1");
        normalData1.setSourceId("world1");
        list.add(normalData1);
        return list;
    }
    //Result라는 공통된 응답 객체를 만들고 데이터를 해당 객체에 담아서 리턴
    public Result addData(NormalData normalData) {
        System.err.println(normalData);
        Result result = new Result();
        result.setPayload(normalData);
        return result;
    }
    //Error상황에서 사용할 클래스를 정의하고 해당 객체에 담아서 리턴
    public ErrorResponse getErrorResponse() {
        ErrorResponse errorResponse = new ErrorResponse();
        errorResponse.setCode(1000);
        errorResponse.setMessage("Unrecognized ID");
        return errorResponse;
    }
    //Result라는 공통된 응답 객체를 만들고 에러를 담아서 리턴
    public Result getErrorInResult() {
        Result result = new Result();
        ErrorResponse errorResponse = new ErrorResponse();
        errorResponse.setCode(1000);
        errorResponse.setMessage("Unrecognized ID");
        result.setError(errorResponse);
        return result;
    }
    //예외 발생
    public String getException() {
        throw new CustomException("message in exception");
    }
    //void가 리턴값일 때 예외 발생
    public void addException(NormalData normalData) {
        throw new CustomException("void return exception");
    }
}

→ CustomException.java

package com.example.demo.ui.exception;

public class CustomException extends RuntimeException{
    private static final long serialVersionUID = 1L;
    public CustomException(String message) {
        super(message);
    }
}

→ ErrorResponse.java

package com.example.demo.ui.domain;

public class ErrorResponse {
    private int code;
    private String message;
    private Source source;
    //getter, setter, constructor, toString 생략
}

→ NormalData.java

package com.example.demo.ui.domain;

public class NormalData {
    private String sourceId;
    private String parameterName;
    //getter, setter, constructor, toString 생략
}

→ Result.java

package com.example.demo.ui.domain;

public class Result {
    private ErrorResponse error;
    private Object payload;
    //getter, setter, constructor, toString 생략
}

→ Source.java

package com.example.demo.ui.domain;

public class Source {
    private String pointer;
    public String getPointer() {
        return pointer;
    }
    //getter, setter, constructor, toString 생략
}

관심있게 봐야할 것은 MyServiceImpl.java에서 어떤 테스트들이 있는지 보는 것과 CustomException.java으로 커스텀으로 정의한 예외에 @ResponseStatus로 HTTP Status를 나타내는 것이다.

결과 화면

  1. /data의 get은 일반적으로 서버가 제공하는 데이터를 컬렉션 그대로 넘긴 것이다. 특별한 response 클래스가 없는 상황이고 header 정보를 보면 알 수 있듯 단순히 content-type이 'application/json' 인 것을 알 수 있다.
  2. /data의 post요청도 get과 유사하지만, Result라는 응답 객체를 만들어서 리턴해본 것이다. 이 또한 특별한 것이 없다.
  3. /error-response는 서버에서 로직상 에러가 발생한 상황에 에러 응답 객체를 별도로 만들어서 리턴한 상황이다. 문제가 되는 부분은 에러임에도 불구하고 status code값이 200으로 client입장에서 정상적인 상황으로 오해하게 되는 부분이다.
  4. /errorResult는 Result라는 공통 응답 객체안에 에러가 들어가 있을 뿐 /error-response에서 존재하는 문제가 여기에도 존재한다.
  5. /exception은 서버에서 문제가 생겼을 때 예외를 발생시켰고 그 예외를 캐치해서 메세지와 함께 HTTP status를 400으로 설정하고 리턴한 것이다. 올바르게 status를 400으로 인지했고 client 소스에서 response가 아닌 error에 바인딩된 것을 알 수 있다. (물론 response로 준 String값이 있기 때문에 "response : message in exception"이 출력되기도 한다.)
  6. /void-add는 5와 유사한데 한 번 테스트해봤다. 기존 서비스가 void를 리턴하는 add 메서드지만 문제 상황을 client에게 제대로 전달하기 위해 예외를 캐치해서 데이터를 보내보면 어떨까 한 것이다. message값은 error안에 data로 들어가 있는 것을 볼 수 있다.

→ 하나의 이상한 점을 발견했다.

Server(spring)에서 @ExceptionHandler부분에 "@ResponseStatus(HttpStatus.BAD_REQUEST)" annotation을 적용하면 400 status로 잘 인식하고 응답을 하는데, CustomException에 "@ResponseStatus(HttpStatus.BAD_REQUEST)" annotation을 적용하면 200으로 응답해버린다.

둘 다 되야 정상인 것(?) 같은데 추측하기로는 400으로 설정한 커스텀 예외가 1차적으로 발생했지만, ExceptionHandler가 해당 예외를 잡은 후에 예외 처리를 해버리니까 정상적인 응답으로 설정해서 status를 200으로 리턴하는 것 같다.

이 포스트에서 공부한 것

사실 카카오API나 네이버API, 공공데이터 OPEN API를 참고하면 더 빨리 눈치챘을지 모르겠다.

REST API 응답을 아름답게(?) 구현하려면 HTTP Status를 명확하게 지정해주는 방법이 가장 옳은 방법이라는 것이다.

추가적으로 응답(Response) 역시 별도의 응답 객체를 만들어주기보다는 객체 그 자체를 응답으로 보내주는 것이 적절하다.

실제로 위에서 언급한 API 서비스들을 보면 HTTP Status를 잘 작성했다.

2XX이 아닌 status 값일 때 즉, 예외 상황에서 API 서비스를 제공하는 업체에서 custom한 에러 코드를 값으로 리턴해주기도 했다.

성공적인 상황이어도 적절한 http status를 주도록 하는 것이 좋겠다.

ps. 면접에서 http status code에 대해서 아는대로 설명해보라는 기업이 있었다... 문득 드는 생각이 REST API를 구현하며 심도있게 고민해봤다면 여러 status를 알 수 밖에 없으니 물어본게 아닌가 하는 생각도 든다.

(근데 아무리 잘해도 까먹지... 그 많은걸...)

반응형