응답이 변칙적이에요? 응답하라, 변칙 Response Body. Spring Boot API
Spring Boot API 유연한 Response Body
🇰🇷 Kor: 지금 보는 중!
🗺 Eng: Not yet!
🇯🇵 Jap: まだです。
API 데이터 입출력
여기 API 요청과 응답을 표현한 작은 그림이 있습니다.
웹 표준이 XML이고 뭐고, 우리는 어차피 JSON 양식으로 데이터를 주고 받는 편입니다. JSON은 자바스크립트 객체의 모습을 띤 문자열 데이터로, 읽기 쉬우면서도 여러 환경에서 쉽게 호환되는 양식이기 때문에 여러 곳에서 선호하거든요.
스프링 부트에서는 이 JSON 문자열을 요청의 데이터로 받으면, 이것을 자바 객체로 변환함으로써 쉽게 사용할 수 있습니다.
JSON 메시지를 해석하거나 생성한다는 것
이런 변환에 우리는 '직렬화', '역직렬화'라는 용어를 사용합니다. 데이터를 전송하기 좋은 형태로 나열하는 것을 '직렬화(Serialization)', 반대로 그것을 해석하여 프로그램 내에서 사용하는 모습으로 변환하는 것을 '역직렬화(Deserialization)'라고 해요.
이 경우엔 JSON 문자열로 만드는 게 직렬화, 그것을 해석해서 자바 객체로 만드는 게 역직렬화겠죠.
만약 이런 자바 클래스가 있고, 이것으로 어떤 객체를 만들었다고 합시다.
자바 클래스 예시
// 설명에 불필요한 세세한 구현은 모두 생략했습니다.
public class ExampleData {
String title;
String content;
Long views;
}
자바에선 클래스 대신 Map<...>
도 쓸 수 있습니다. 그래도 클래스를 쓰는 게 더 명시적이고 좋아요.
이 클래스의 객체를 하나 JSON 문자열로 직렬화해 보면 다음 예시처럼 나올 수 있습니다.
JSON 예시
{
"title": "난 손이 두 갠데...",
"content": "이건 제2 손(json)이야.",
"views": 999
}
Jackson, Gson
Jackson과 Gson은 모두 JSON 문자열을 자바 객체로 매핑해 주는 역할을 합니다. 변환해 준다고 보시면 돼요.
스프링 환경에서는 이 JSON 컨버터로 jackson
을 많이 사용하는 편입니다. 우리가 코드에서 직접 사용한다기보다도, 우리에게 안 보는 어느 영역에서 jackson
이 열일을 하고 있죠.
비슷한 일을 하는 기술로 구글에서 만든 gson
도 있습니다. 둘 모두 퍼포먼스에서도 환경과 조건에 따라 측정 결과가 다르고, 편의 기능에서도 무엇이 낫다 할 순 없고 체감에 차이가 있을 수 있습니다. 아래 설명에서는 둘 다 용법 정도는 취급하려고 합니다. Gson에 대해서도 생각보다 많이들 궁금해하시더라구요.
네? API 하나로 그때그때 다른 응답 데이터 목록이요?
"네, 되죠?"
📡 응답하라, 변칙 Response Body
복잡한 프로세스를 띠는 API를 설계하다 보면, 여러 케이스에 따라 서로 다른 데이터를 응답할 때가 있습니다!
🗣 API 설계한 사람 "어, 요청을 보낼 건데 이때는 (프론트에) 인증 코드랑 이런 정보를 그냥 주시고요, 이러이럴 땐 회원가입 코드를 (프론트가) 받고 다음 저 요청을 보낼 때 서버에 이 코드를 담아서 서로 거시기하기로 했거든요."
그런데 백엔드 서버가 자바처럼 강타입 언어로 되어 있다면, 응답 데이터 명세를 써야 할 수 있죠. 게다가 프론트엔드 개발자는 요즘 타입스크립트를 쓰면서 null
에 대한 기피를 보이는 분들도 계셔요. 그냥 json
에서 완전히 제거하길 바라는 거죠. 그럼 이제 "자바 스프링에서 그게 되냐."를 고민할 시점입니다.
변칙적으로 응답하는 방안들
이 글에서는 우리가 잘 아는 일반적인 컨트롤러에서 할 수 있는 방안을 다룹니다!
그 외 AOP(Exception Handler 포함)를 활용하거나 커스텀한 여러 방식을 쓰거나, 아니면 WebFlux 등 애초에 다른 환경일 때에 대한 이야기는 생략하고, 우리가 스프링 부트라고 할 때 흔히 떠올릴 수 있는 WebMVC 환경의 컨트롤러에 대해서만 쓸게요.
ResponseEntity
API 응답의 여러 요소를 직접 설정하고 싶을 때 선택할 수 있죠. 제네릭이 받는 타입은 바디 타입입니다!
ResponseEntity<바디_타입>
ResponseEntity<Object>
: 모든 자바 객체를 받을 수 있습니다. 대신,ResponseEntity<특정_타입>
으로 이미ResponseEntity
객체가 있다면, 이것을ResponseEntity<Object>
대신에 쓸 수는 없습니다. (약간 덜 유연합니다.)ResponseEntity<?>
: 얘도 모든 자바 객체를 받을 수 있어요. 게다가 얘는 와일드카드(<?>
)여서,ResponseEntity<특정_타입>
으로 되어 있는 객체도 받을 수 있어요.
@GetMapping("/example")
public ResponseEntity<?> iAmControllerMethod() { // [1] 반환 타입을 ResponseEntity<?>로 변경하고
// ...
return ResponseEntity
.status(httpStatus) // 상태코드와
.body(response); // [2] 반환할 객체를 담아 주면 됩니다.
// ResponseEntity의 다른 정적(static) 메서드를 이용하는 경우가 많습니다.
}
ResponseEntity
를 반환 타입으로 사용하면, 반환할 바디 데이터뿐 아니라 상태 코드도 변경할 수 있어서 유연한 응답에 좋습니다. 그래서 이것을 습관처럼 사용하는 분들도 있죠. (근데 사실 일반적인 경우는 대체할 편한 수단들이 있어요.)
이렇게 하면 여러 DTO 타입을 response
로 사용할 수 있기 때문에 많은 응답 유형을 처리할 수 있습니다! 예를 들면 간단한 분기를 만들어서 이렇게 할 수 있습니다.
@GetMapping("/example")
public ResponseEntity<?> iAmControllerMethod() {
ResponseEntity<?> responseEntity;
// ...
if (isSomething) { // 어떤 경우에는
ResponseDtoA dtoA = ...; // ResponseDtoA 타입 데이터를 응답할 데이터로 씁니다.
// ...
// (⬇️ ResponseEntity.ok(dtoA)로도 쓸 수 있어요.)
responseEntity = ResponseEntity
.status(HttpStatus.OK)
.body(dtoA);
} else { // 어떤 경우에는
ResponseDtoB dtoB = ...; // ResponseDtoB 타입 데이터를 응답할 데이터로 씁니다.
// ...
responseEntity = ResponseEntity
.status(HttpStatus.OK)
.body(dtoB);
}
return responseEntity;
}
어떤 경우에는 ResponseDtoA
타입을 반환에 쓰고, 어떤 경우에는 ResponseDtoB
타입을 반환에 쓰고 있어요.
이러면 작업 방식은 "반환할 데이터 타입을 반환 유형에 따라 모두 작성해 두었을 때" 용이합니다. 마치 "우리는 모두 TDD를 할 수 있고, 당신은 안정적일 거예요!"라고 해서 TDD를 했더니 막상 우리 회사는 그런 체계적인 기획과 사업 설계 속에 움직이는 게 아니었어서 고생하게 되는 것처럼, 아주 계획적이라면 문제 없는데, 그런 게 아니라면 생산성에서는 검토할 기획과 설계가 비교적 빼곡할 수 있습니다!
물론 작업 방식에 따라서는 이 방식도 괜찮아 보입니다.
하나의 DTO가 데이터를 선택적으로 직렬화
복잡한 응답 유형으로 세세히 나뉘고, 데이터 응답 유형이 다양하고, 어떤 것은 공통으로 필수인 줄 알았더니 어떤 응답 때는 포함할 필요가 없고, 아 이거 정말 API 응답 데이터 명세를 설계하는 것도 심플하게만 되는 건 아닐 때도 있습니다.
게다가 개발자의 의도와 달리 기획이라는 건 언제든 변경될 수 있어야 하는 점도 있죠. 이런 경우는 위에서 다룬 ResponseEntity<?>
로 여러 DTO 데이터 타입을 다루는 방식은 관리가 번거롭습니다. 그래서 개인적으로 위 방식은 (일반적인 경우) 오버스펙이라고 생각하고 있어요.
그래서 이번에는 하나의 DTO에 데이터를 모두 나열하고, 이중에 실제 로직에서 필요한 것만 선택적으로 채우고 나머진 삭제해 봅시다. 다행히 우리가 메시지 컨버터로 사용하는 jackson
과 gson
모두 null
데이터를 JSON 문자열에서 제외하는 직렬화 보조 기능들을 제공합니다.
@Getter
@AllArgsConstructor
// ...
public class ResponseDto {
@JsonInclude(Include.NON_EMPTY) // null 또는 빈 문자열은 제외
private final String title;
@JsonInclude(Include.NON_EMPTY) // null 또는 빈 문자열은 제외
private final String content;
@JsonInclude(Include.NON_NULL) // null은 제외
private final Long views;
}
이러면 우리가 만들 수 있는 JSON 종류는 다음과 같아요.
// 아무 데이터도 없는 경우
{}
// 하나의 데이터만 있는 경우 1
{"title": "..."}
// 하나의 데이터만 있는 경우 2
{"content": "..."}
// 하나의 데이터만 있는 경우 3
{"views": 999}
// 두 데이터만 있는 경우 1
{"title": "...", "content": "..."}
// 두 데이터만 있는 경우 2
{"title": "...", "views": 999}
// 두 데이터만 있는 경우 3
{"content": "...", "views": 999}
// 세 데이터가 모두 있는 경우
{"title": "...", "content": "...", "views": 999}
그리고 이런 세부적인 결정은 데이터를 만드는 시점에 할 수 있습니다. 제외할 데이터는 모두 null
을 넣으면 돼요. gson
이라면 @JsonInclude(...)
애노테이션이 없어도 됩니다(null
만 제외).
이러면 미리 계획한 케이스마다 그에 맞는 세부 DTO 대로 따로따로 클래스를 작성할 필요 없이, 로직 내에서 실제 데이터 목록을 결정할 수 있기 때문에 아주 유연한 대처가 가능합니다.
어떨 때 무엇을 쓸까?
API에 대한 설계가 분명하고, 변경을 빈번히 할 필요가 없을 때 (말이 된다면.)
ResponseEntity<Object>
ResponseEntity<?>
(권장)ResponseEntity<? extends SomeInterface>
(필수getter
등 설계를 모두 구현할 때)
API에 대해 변경 여지가 있거나 프로세스에 유연하게 대처하고 싶을 때
@JsonInclude(...)
(jackson)gson
(null
만 넣으면 알아서 JSON 직렬화 때 포함 안 함.)
사실 API는 유연하게 관리 가능하면서도 오버스펙이 없어야, 기획 변경에 (그저 기술적 이유로) 주저하는 일이 덜하다는 생각입니다. 그래서 ResponseEntity<...>
보다는 jackson
이나 gson
으로 불필요 데이터를 소거하는 쪽으로 택하는 편입니다. 물론 이러거나 저러거나 빈번한 변경은 싫긴 하구요.
ResponseEntity<?>
로 세부적인 여러 DTO를 따로따로 관리하는 방식은 어떤 데이터가 어떤 경우에 어디에 포함되어야 하나 모두 결정한 다음부터 작업이 가능하기 때문에, API 하나 개발에도 너무 많은 관리 대상을 생성하고 혼란스러울 수 있어요.
그래서 제 관점에서는 하나의 DTO에서 유동적으로 데이터를 채우는 방식이 낫다고 보고 있습니다.
참고로 Gson은 기본 메시지 컨버터로 쓸 때...
스프링 부트에서는 기본적으로 Jackson을 메시지 컨버터로 사용하는데, 이러면 @JsonInclude(...)
또는 @JsonIgnore(...)
를 써야 하고, 만약 이를 Gson으로 변경한다면 그냥 데이터에 null
만 넣으면 알아서 JSON에서 제외합니다. 이거 참 진입장벽 낮고 편해 보이죠. 어차피 null
은 일반적으로 프론트엔드에서 원하는 데이터가 아니니까 그냥 제외하는 게 낫잖아요.
그런데 한 가지 조심할 것은, Gson은 Jackson에 비해 초기 설정을 추가해야 할 수 있다는 겁니다. 지정한 타입에 대한 직렬화 객체와 역직렬화 객체를 모두 작성해야 할 수 있습니다. 이런 점은 선택하시면 됩니다. 그리고 Jackson에서 @JsonProperty("new_alias")
애노테이션은 Gson에서 @SerializedName("new_alias")
인 등 차이점도 숙지해야 합니다.
이번 글은 급히 작성해서 두서가 없습니다. 나중에 한 차례 정리하는 기회를 둘게요! 하고 싶은 말은 결국 @JsonInclude(...)
와 Gson이었는데! 배경지식이 다른 분들께도 설명하려다 보니 부연설명도 충분히 붙이며 쓰려고 했습니다! 필요하다면 ResponseEntity<...>
사용은 아예 제거하는 것도 생각해 보겠습니다!
자료는 모두 DALL·E, excalidraw, Power Point 등을 통해 스스로 만들었습니다. (그래서 영어가 어색할 수 있습니다.) 어색한 영어 문장은 남대문 열렸다고 알려 주듯 살짝 말씀해 주시면 얼른 고쳐 보겠습니다. 그러면 다들 모쪼록 착한 개발 하세요.