API 응답 코드 계층 구조 설계
1
2
3
4
5
6
7
8
9
10
11
12
HTTP | |
<--- Status | Response.code | Response.data.code
Code | |
{
httpStatusCode: ?, --> httpStatusCode는 해당 서버로부터 오는 모든 api 응답의 일관된 처리를 위한 error code로서 의미를 가짐
body: {
code: ?, --> body.code는 해당 서버로부터 오는 모든 api 응답의 일관된 처리를 위한 error code로서 의미를 가짐
data: {
code: ? --> body.data.code는 api의 성공/실패 보다는 코드값 자체로서의 의미.
}
}
}
1
2
3
4
5
6
7
8
9
10
11
// @FE에서 받는 다면
http.get(`/api-1/example`)
.then(resultCodeHandler( --> body.code 에러 처리
data => {
data.code = ...
},
this.failUrl))
.catch(() => {}); --> httpStatusCode 에러코드 처리
http.post(`/api-2/bar`)
.then(resultCodeHandler( --> api는 다르지만, body.code 에러 처리는 일원화하여 처리한다.
...
1
2
3
4
5
6
Response<T> 타입
{
"code" : {CommonCode}, // body.code가 된다.
"message" : {String},
"data" : T
}
1
2
3
4
5
6
7
8
9
10
예제) Response<FooResult> 타입
{
"code" : {CommonCode}, // 서버 전역적으로 사용되는 공통 응답 코드.
"message" : {String},
"data" : { // 해당 API specific한 데이터
"code": {FooEnum},
"field1": bla,
"field2": bla,
}
}
HTTP status code와 Response.code
- HTTP status code와 Response.code는 둘 다 ‘해당 서버로부터 오는 모든 api 응답의 일관된 처리를 위한 error code로서 의미’를 가진다.
- 의미 중복이므로 가능하면 한 쪽으로 통합하는 것이 좋다.
- 한 쪽으로 통합한다면 어느 쪽으로 통합하는 것이 나은가?
- HTTP status code를 이용해서
Response<T>
layer를 없애는 것이 좋다. - http code는 숫자가 제한 되어 있고, 세세하게 커스텀이 불가능하다는 단점이 있지만, 그 보다는 아래
Response<T>
의 단점이 더 커보인다.
- HTTP status code를 이용해서
1. Response<T>
을 반환하게 되면, 받는 쪽에서도 보통 제네릭으로 받아야 하므로 ParameterizedTypeReference
를 사용해야 한다.
1
2
3
4
5
6
7
8
9
// Http status code를 사용하는 restful API의 경우
.bodyToMono(BarResponse::class.java)
.doOnError(WebClientResponseException::class.java) {
...it.responseBodyAsString...
}
// Response<T>를 쓰는 경우 (항상 200 응답. 그 안에 결과 code가 포함)
.bodyToMono(ptypeReference<FooResponse<List<AssetStatus>>>())
.doOnSuccess { checkSuccess(it) }
- API 성공 시에는 ptypeReference를 쓰고 별도 check를 해주어야 한다는 것이 약간 번거롭기는 하지만,
Response<T>
를 써도 큰 문제는 없다. - 하지만 API 실패 시에는 응답으로 내려가는 필드가 성공 일 때와는 달라서,
Response<T>
방식에 단점이 있다.- HTTP status code를 사용하는 restful 방식은
responseBodyAsString
로 실패 응답 body를 string으로 간주하기 때문에, 그냥 로그 찍으면 모든 필드가 다 로깅 된다. - 반면
Response<T>
방식은, 일단 dto로 받고, dto.isFailure 이면 로그를 찍게 되는데, 이 때 dto 필드에 없는 필드는 로그에 찍히지 않는다. - 이를 방지하기 위해서는 일단
Map
으로 받아서 실패이면 로그를 찍도록 해야 하는데, 어느쪽이든 조금 애매하다.
- HTTP status code를 사용하는 restful 방식은
[!tip] HTTP status code를 쓰더라도, 실패 메시지를 body에 주면 된다.
2. 보통은 애초에 실패에 대한 정보를 그렇게 계층적으로 구조화 할 필요가 없어서 불필요한 체크가 늘어난다. (HttpStatus 만으로도 충분하다)
1
2
3
4
5
6
7
ResponseEntity<Response<MyResponse>> responseEntity = ...
if (!responseEntity.getStatusCode().is2xxSuccessful()
|| !ResultStatus.OK.getCode().equals(responseEntity.getBody().getCode())
|| responseEntity.getBody().getData().getAuthLevel() != AuthLevel.ONE) {
new FooException("오류");
}
예제를 보면 체크를 3번이나 수행했다.
- 외부 API 응답 자체가 정상인지 (200 OK)
- 외부 API의 Response.code 가 정상인지
- 외부 API의 Response.data.code(authLevel) 이 정상인지
- 물론 어디서 에러가 발생했는지를 구분해서 처리가 달라야 한다면 위와 같이 3번 체크하는 것이 의미가 있을 수 있다.
- 하지만 이렇게 3단계로 계층화 해서 처리해야 하는 경우는 잘 없다. 보통 내가 관심 있는 것은 “외부 API가 결과 data를 줬냐 안줬냐” 이다. (1,2번 묶음)
- 보통 그냥 외부 API에서 응답 제대로 주지 않는 [1, 2] 상황은 굳이 구분 할 필요가 없어서 묶어서 체크하는게 낫다. => REST api
- [2, 3]을 묶어서 처리하는 것은 당연히 불가능하다. authLevel은 꼭 봐야 하니까.
이렇게 여러 체크 중 어디서 실패하든 종단 처리로 가야하는 경우, Optional.filter 사용하는게 더 깔끔할 수 있다.
Response.code와 Response.data.code
Response.data.code는 꼭 필요한 순간들이 있다.
위 예제에서 authLevel은 클라이언트에서 반드시 참조해야 하는 data code 이므로 [2, 3]을 묶어서 처리하는 것은 불가능함을 확인했다.
하지만 Response.data.code가 authLevel 같은 data code가 아니라, 단순한 성공/실패 응답 코드의 의미를 지닐 때에도 Response.data.code를 별도로 두는 것이 나은가?
예를 들어 bin 조회 같은 경우, 응답 케이스를 다음과 같이 세분화 할 수 있는데
- 성공 및 결과 반환
- 성공이나 해당 bin 찾을 수 없음 (NOT FOUND)
- 성공이나 미지원 카드사
- 실패로 입력값 에러 (PARAM ERROR)
- 외부 에러 (EXTERNAL ERROR)
클라이언트 측에서 1~5 응답에 따른 분기 처리가 필요한 상황이라고 가정할 때, 사용 할 수 있는 방법은 아래와 같다. (즉, 1~5 응답은 반드시 클라이언트에서 구분되어야 한다.)
- [1-5] 를 모두 통합 코드 enum에 추가한다. (
Response<T>
layer가 있는 경우)- 해당 API에서만 발생하는 오류(특히 3번)임에도 통합 코드에 넣는 것이 적절하지 않아 보인다.
- 이런 식으로 각 API에서 모두 통합 코드에 추가하다 보면 통합 코드 표가 너무 비대해진다.
- [1-5] 각각 Http status code를 하나씩 부여한다. (
Response<T>
layer가 없는 경우)- 1, 2, 3은 모두 성공인데, 각각 어떤 status code를 내려 줄 것인가?
- [1=200, 2=204, 3=???…] 애매하다. 이처럼 HTTP status 정의와 서버에서 의도한 응답의 의미는 100% 일치하지 않는 경우가 잦은데, API docs에 “이 API에서 발생하는
xxx
status는 이런 의미를 가진다.” 라고 반드시 명시해주어야 한다. - docs에 명시 한다고 하더라도, 따로 message를 응답으로 주는 것이 아니기 때문에 어떤 Error message를 띄울지는 클라이언트에서 모두 관리해야 한다.
- 클라이언트에서 HTTP status에 따른 통합 handler를 사용하고 있다면, 거기에 들어맞지 않을 가능성도 크다.
- [1-3]은 Http status 200으로 처리하고, 그 안에서 Response.data.code를 사용해 구분한다.
- 이런 상황에서 사용했을 때 여러모로 장점이 많다.
- 세부 응답 코드는 API에 종속적인 코드이므로, 변경이 있을 때 해당 API 이용자 에게만 고지하면 된다. (공통 응답 코드 기조를 바꾸는 것 보다 부담이 적다)
- 코드를 2개 이상 반환할 수 있다는 장점도 있다.
- 메시지도 함께 반환 가능하다.
즉 HTTP status(또는 Response.code)의 하위 집합으로 Response.data.code를 사용하는게 도움이 되는 경우가 많다.
기타
중요 ) 응답 코드를 구성할 때는 클라이언트의 관심사가 무엇이냐를 생각해야 한다.
외부에서 받은 응답 코드를 그대로 내려주는 것이 항상 좋은 방법은 아닐 수 있음.
클라이언트가 필요로 하지 않는 정보라면 응답 코드를 적당히 추상화해서 내려주는 것도 필요하다.
즉, 공통 코드 + 세부 코드로 내려줄 수 있는 상황이지만, 공통 코드로 merge 해서 내려주는 것.
이를 판단하는 좋은 기준은, 어떤 클라이언트를 사용하더라도(클라이언트가 n개가 되더라도) 모두 같은 처리를 할 것 같은가?를 생각해보는 것이다.
공통 응답 코드를 사용할 때는, 해당 공통 응답 코드를 깊은 layer에서 반환 할 가능성에 대해서도 생각해야 한다.
그래서 공통 응답 코드는 반드시 의미와 역할이 명확해야 한다. 의미가 명확한 공통 응답 코드라면, 어느 layer에서 반환하든 같은 의미를 지닐 것이므로 클라이언트에서 받았을 때 처리가 달라지지 않아 문제를 예방할 수 있다.
공통 응답 코드 vs 개별 응답 코드
서버에서 제공하는 api 이더라도 각 api 마다 응답 코드도 다르고 의미도 다르다면, 사용하는 쪽에서 일관된 처리 인터페이스를 작성할 수 없다.
그래서 상기한 응답 구조는 해당 서버로부터 오는 모든 api 응답에 대해 일관된 처리를 공유할 수 있도록 일종의 공통 응답 코드(CommonResponseCode)가 존재해야 함을 전제하고 있다.
공통 응답 코드가 있는 경우 | 개별 응답 코드만 사용하는 경우 | |
---|---|---|
응답 코드 Enum | 대부분의 응답은 공통 응답 코드 Enum을 참조하여 해결할 수 있다. | 각 API 마다 모두 각자의 응답 코드 Enum을 정의해야 한다. |
클라이언트 처리 | 클라이언트 코드에서 공통 처리부 CommonHandler를 정의할 수 있다. | 공통 처리부를 정의할 수 없다. 그러나 어차피 같은 공통 응답 코드(e.g., 404)반환하더라도 API 마다 그 응답의 의미가 조금씩 다르기 때문에 공통 처리부는 크게 의미가 없을 수 있다. |
이러한 장단점을 생각해 보면 공통 응답 코드가 존재하는 것이
최상위 응답 코드(http status든, res.code든)는 공통 응답 코드의 의미를 지니게 하는 것이
관리하기 더 나은 경우가 많았다.