응답이 변칙적이에요? 응답하라, 변칙 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에 데이터를 모두 나열하고, 이중에 실제 로직에서 필요한 것만 선택적으로 채우고 나머진 삭제해 봅시다. 다행히 우리가 메시지 컨버터로 사용하는 jacksongson 모두 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 등을 통해 스스로 만들었습니다. (그래서 영어가 어색할 수 있습니다.) 어색한 영어 문장은 남대문 열렸다고 알려 주듯 살짝 말씀해 주시면 얼른 고쳐 보겠습니다. 그러면 다들 모쪼록 착한 개발 하세요.