API 서버에서 응답 객체에 대한 고찰

API 개발 작업을 처음 의뢰 받게 되면 가장 먼저 눈에 들어오는 것은 일반적으로 요청과 응답에 대한 API SPEC 일 것이다 . 클라이언트에서 어떤 데이터를 요구하는지, 서버에서는 클라이언트의 요구에 어떻게 응답해줘야 하는지 고민 해야 한다.
이 글에 주된 내용은 API 응답 구조를 어떠한 형태로 그리는 것이 보다 나은지 에 대한 이야기이다. 특히 응답 객체 내에서 비즈니스 데이터가 아닌 필연적으로 가지게 되는 에러 코드, 에러 상태 등에 대한 것을 다룬다.
이 글은 저자의 경험에 비롯된 주관적인 글임으로 단순히 하나의 참고 글로써 읽어주시길 바란다.

에러 코드, 에러 설명 필드 명

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

{
"errorCode": 0,
"errorDesc": "정상입니다",
...
}
{
"errorNo": 1003,
"errorDescription": "카드번호 불일치 오류",
...
}
{
"status": 1003,
"description": "카드번호 불일치 오류",
...
}


내가 개발 초기에 자주 접했던 API 응답 구조는 보통 위와 같은 구조였다. 에러 코드와 에러 설명을 위한 두 값이 필연적으로 존재하고, 그 필드 명은 각 API 마다 상이했다.
여기서 내가 느낀 한 가지 문제점을 언급해보려고 한다.
API 서버를 바라보고 있는 클라이언트 개발자가 있다고 생각하자. API 마다 에러 코드, 에러 설명에 대한 필드 명이 모두 다르기 때문에 각 API 들을 쓸 때 서버 쪽에서 제공하는 API 공식 문서를 확인 해야 하고, 문서가 제대로 준비되어 있지 않다면 API 서버 개발자와 연락을 해서 정보를 얻어야 한다.

1
에러 코드, 에러 설명에 대한 필드 명을 각 API 마다 확인 해야 한다.

통일된 Response 응답 객체

그렇다면 에러 코드, 에러 설명에 해당하는 필드 명을 모든 API 에서 항상 동일하게 내려주는 것이 어떨까? 그렇게 된다면 API 사용자의 입장에서는 에러 코드, 에러 설명 필드 명을 바로 인지 할 수 있으며 이를 찾기 위한 시간을 줄일 수 있을 것이다.
그렇다면 이를 어떻게 구현할 수 있을까? 물론 파트너들과 개발 가이드 문서를 만들고 특정 필드 명을 사용하도록 규정을 정할 수도 있지만, 아래와 같이 공통의 Response 객체를 만들어 사용할 수도 있다.

1
2
3
4
5
6
7

class Response {
private int errorCode;
private String errorDesc;
private Object data;
}

대부분의 응답 객체가 가져야 하는 에러 코드, 에러 설명 정보를 ‘errorCode’, ‘errorDesc’ 필드 명으로 통일하여 API 사용자가 보다 알아보기 쉬운 획일적인 API 구조로 내려줄 수 있게 되었다.

서버 개발자 마음대로의 에러 코드

이제 다음 문제점을 생각해보자. 현재 API 사용자들은 이 ‘errorrCode’ 에 들어가는 각 숫자들의 의미를 API 가이드 공식 문서를 통해 이해해야 한다. ‘errorCode’ 값이 ‘1003’ 일 때 ‘카드 번호 불일치 오류’ 라고 한다면 API 사용자들은 단순히 받아들여야 한다. 즉 에러 코드에 대한 결정이 순전히 서버 개발자의 마음대로 이기 때문에 API 사용자는 새로운 API 를 접할 때마다 매번 새로운 에러 코드 규칙을 익혀야 한다.
에러 코드에 대한 설명을 보통은 API 문서를 통해 제공 받지만 경험적으로 API 문서가 잘 관리되는 것은 쉽지 않다. 항상 시간에 쫓겨 야근까지 하는 개발자에게 문서 작업도 완벽하게 하는 것은 쉽지 않다고 생각한다.
API 공식 문서가 잘 관리되지 않는다면 특정 ‘errorCode’ 가 반환 되었을 때 해당 오류에 대한 자세한 정보를 문서에서 확인하기 어려우며 오로지 ‘errorDesc’ 에서 내려오는 설명을 가지고 이해해야 한다.
물론 Swagger 등의 API 문서화를 자동화 해주는 다른 접근으로의 해결 방법들이 존재하지만 지금은 보다 근본적인 문제점을 생각하자.

표준화 되어있는 에러 코드

API 통신은 여러가지 형태로 존재할 수 있지만, 현대에 와서는 많은 서비스들이 HTTP 프로토콜을 기반으로 하여 통신을 한다. 그렇다면 이미 표준으로 지정되어 있는 HTTP STATUS 코드를 기반으로 ‘errorCode’ 값을 만들면 어떨까?그렇다면 API 사용자들도 받아들이기 쉬울 것이다.
HTTP STATUS 의 특성에 따라 ‘200’ 이면 성공,‘400’ 대역의 에러 코드가 나타났다면 API 사용자가 날린 요청에 오류가 있고, ‘500’ 대역의 에러 코드가 나타났다면 서버에 오류가 있음을 API 사용자들이 바로 인지할 수 있게 된다.

1
2
3
4
5
6
7
8
9
10
11
{
"errorCode": 200,
"errorDesc": "OK",
...
}

{
"errorCode": 500,
"errorDesc": "INTERNAL_SERVER_ERROR",
...
}

HTTP STATUS 에 대한 공식 문서를 찾아보면 이미 다양한 케이스에 대해서 미리 정의를 해두고 있다. 대부분의 경우에는 이를 활용해서 ‘errorCode’, ‘errorDesc’ 값을 내릴 수 있다고 생각한다.
그러나 특정 비즈니스에 맞춰져 있는 오류를 반환 해야 할 경우도 있다. 그런 경우에는 기존의 방식대로 서버 개발자가 임의의 에러 코드를 만들고 이를 사용해야 한다. 다만 이런 경우에는 클라이언트의 이해를 위해 ‘errorDesc’ 정보를 보다 자세하게 적거나, API 문서를 명확히 하는 것이 좋다.

동적타입의 활용

1
2
3
4
5
6
7

class Response {
private int errorCode;
private String errorDesc;
private Object data;
}

이전에 만든 Response 객체에서 data 필드 부분을 좀 더 살펴보자.
이 필드는 특정 비즈니스에 맞춰진 모든 데이터들을 받아야 하기 때문에 Object 타입으로 받고 있다. Response의 관점에서는 알 수 없는 구조를 받아야 하기 때문에 Object 타입을 사용하는 것은 불가피하다. 그러나 Object 타입의 근본적인 문제는 타입 세이프 하지 않아서 컴파일 시점에 오류를 찾아내지 못한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Getter
@Setter
@Builder
class Response {
private int errorCode;
private String errorDesc;
private Object data;
}

@RequestMapping
public Response goodExample() {
return Response.builder().errorCode(200).errorDesc("OK").data(150).build();
}

@RequestMapping
public Response badExample() {
return Response.builder().errorCode(200).errorDesc("OK").data("150").build();
}

‘data’ 필드에 들어갈 데이터의 형식이 ‘int’ 이기를 기대했지만 실제로는 ‘Object’ 타입이기 때문에 ‘String’, ‘int’ 모두를 고려하지 않고 받게 되어. 컴파일 시점에 오류를 검출하지 못한다. 이를 개선하기 위해 제네릭 문법을 활용해서 동적으로 타입을 지정할 수 있도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Getter
@Setter
@Builder
class Response<T> {
private int errorCode;
private String errorDesc;
private T data;
}

@RequestMapping
public Response<Integer> goodExample() {
return Response.<Integer>builder().errorCode(200).errorDesc("OK").data(150).build();
}

@RequestMapping
public Response<Integer> badExample() {
return Response.<Integer>builder().errorCode(200).errorDesc("OK").data("150").build();
}

‘badExample()’ 의 경우에는 ‘Integer’ 타입을 원하지만 ‘data’ 필드에 ‘String’ 객체를 넣었음으로 컴파일 시점에 오류가 나타날 것이다.

ErrorDesc 필드의 필요성

우리는 습관적으로 ‘errorCode’, ‘errorDesc’ 두 값을 마치 형제처럼 종종 함께 선언하고 사용했다. 이 구조에 대해서 좀 더 면밀히 생각해보자.
‘errorDesc’ 값은 API 통신에 문제가 없는 경우 사용될 일이 없다. 왜냐하면 필드 명에서 말해주듯이 에러가 발생했을 때 에러에 대한 설명을 넣기 위한 필드이기 때문이다. 반대로 data 필드를 생각해보자. 이 값은 일반적으로 API 통신이 성공적일 경우에만 사용이 될 수 있다.
그러면 ‘errorDesc’ 필드와 ‘data’ 필드를 하나의 필드로 묶어보는 것은 어떨까? API 통신이 성공했을 때는 해당 필드를 data 필드로서 사용하고 오류가 발생했을 때에는 ‘errorDesc’ 필드로 사용하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

@Getter
@Setter
@Builder
class Response<T> {
private int status; /* errorCode와 동일하며, 필드 이름만 바꾸었다. */
private T body; /* errorDesc, data 성격을 모두 가지고 있는 필드이다. */
}

/* 예시를 위한 샘플 데이터 객체이다. */
@Getter
@Setter
@Builder
class Person {
private String name;
private int age;
}

/** 성공인 경우 예시 */
@RequestMapping
public Response<Person> goodExample() {
Person person = Person.builder().name("홍길동").age(30).build();
return Response.<Person>builder().status(200).body(person).build();
}

/** 실패인 경우 예시 */
@RequestMapping
public Response<String> badExample() {
return Response.<String>builder().status(500).body("INTERNAL_SERVER_ERROR").build();
}

/** 한 컨트롤러에서 실제 사용되는 예시 */
@RequestMapping
public Response<?> example() {
try {
Person person = Person.builder().name("홍길동").age(30).build();
return Response.<Person>builder().status(200).body(person).build();
} catch (Exception e) {
return Response.<String>builder().status(500).body("INTERNAL_SERVER_ERROR").build();
}
}

에러 코드를 표현하는 필드 명이 ‘errorCode’ 이였지만 ‘status’ 필드 명으로 변경 하였고 ‘errorDesc’, ‘data’ 두 필드 명을 합쳐서 ‘body’ 필드 명으로 변경하였다.
‘한 컨트롤러에서 실제 사용되는 예시’ 이 곳을 보면 컨트롤러의 반환 형에 타입에 Response<?> 형태를 사용하고 있다. 위와 같이 ‘body’ 필드 명을 사용하게 되면 성공했을 때와, 실패했을 때의 ‘body’ 필드 타입이 다르기 때문에 모두 수용할 수 있도록 와일드카드 문법을 사용하고 있다.

ResponseEntity

지금까지 응답 객체에 대한 고찰을 하면서 ‘Response’ 객체를 다듬어 왔다. 이 객체를 항상 직접 만들어서 사용할 수도 있지만 Spring Framework 에서 제공하는 ‘ResponseEntity’ 객체가 ‘Response’ 객체와 거의 유사함으로 이를 사용하는 것을 권장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

/* 예시를 위한 샘플 데이터 구조이다. */
@Getter
@Setter
@Builder
class Person {
private String name;
private int age;
}

@RequestMapping
public ResponseEntity<?> example() {
try {
Person person = Person.builder().name("홍길동").age(30).build();
return ResponseEntity.status(HttpStatus.OK).body(person);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
}
}

‘ResponseEntity’ 객체는 지금까지 우리가 만든 Response 객체와 굉장히 유사하나, 가장 결정적인 차이가 있고 이 차이 때문에 사실 이 객체를 사용해야 한다.

HTTP Header

현대의 대부분 API 통신은 HTTP 통신 기반 하에 이루어진다. HTTP 구조는 Header, Body 두 부분으로 나누어지는데 Header 에는 통신을 원활히 하기 위해 필요한 제반 정보들이 존재하고, Body 에는 실제 데이터가 들어간다. 여기서 HTTP STATUS 코드는 본래 Header 영역에 들어가는 정보이고, 우리가 만든 API 응답 객체는 Body 안에 들어간다.
즉 다시 말하자면 Response 객체 내부에 있는 status 필드 값을 ‘200’, ‘500’ 같이 특정 코드로 변경한다고 해서 Header 에 있는 HTTP STATUS 코드가 변경되는 것은 아니라는 거다. Response 객체는 우리가 만든 API 응답 객체 이기 때문에 Body 안에서만 영향을 미친다.
위처럼 status 필드를 HTTP STATUS 코드를 사용할 때 발생하는 한 가지 문제점은 Header 안에 있는 HTTP STATUS 값과 Body 안에 있는 status 필드 값이 서로 안 맞는 경우가 생길 수 있다.
‘ResponseEntity’ 객체가 우리가 만든 ‘Response’ 객체와 가장 다른 점은 status 필드 값이 이 Header의 HTTP STATUS 값이랑 연계가 되어 있다는 것이다. 이 객체를 활용하면 API 안에 status 필드 값에 맞추어 Header 도 변경이 가능하다.

Share