Backend Engineering · Spring

Spring Boot에서 JSON 응답하는 방법 (feat. Jackson)

Spring Boot에서 JSON 응답을 만들어주는 방법과 원리를 살펴보았습니다.

S
Ship Knowledge

조회 56

Spring Boot에서 JSON 응답하는 방법 (feat. Jackson)

JSON은 누가 만들어 줄까?

JSON은 서버에서 클라이언트에게 데이터를 보내줄 때 사용되는 표준 양식이죠. 그래서 JSON으로 데이터를 받는 일도 많고, 반대로 보내주는 일도 많습니다. 그러다가 문득 궁금해졌습니다.

스프링에서는 어떻게 데이터를 JSON으로 보내주는걸까?

스프링에서 JSON으로 응답하는 방법은 여러 가지가 있습니다. 먼저 대표적인 방법들을 살펴보겠습니다.

JSON 응답 방법(1) : Map

먼저 결과 값을 Map에 담아서 반환 해주는 방법이 있습니다. 어쩌면 단순하면서도 많이 쓰이는 방법일지도 모르겠습니다.

테스트를 해보기 위해 간단한 프로젝트를 생성하고, 아래와 같이 컨트롤러를 구성하였습니다.

@RestController
public class JsonResturnController {

	@PostMapping("/map")
	public Map<String,String> jsonMap(){
		
		Map<String, String> hello = new HashMap<>();
		
		hello.put("message", "hello");
		hello.put("success", "true");
		
		return hello;
		
	}		
}

그리고 Talend API Tester에서 요청해보니 아래와 같이 JSON 응답이 오는 것을 확인할 수 있었습니다.

JSON 응답 방법(2) : DTO

또 하나의 방법은 처리 결과를 DTO에 담아서 보내주는 것입니다. 테스트를 위해 아래와 같이 간단하게 DTO와 컨트롤러를 구성해 보았습니다.

@Getter
@Setter
public class JsonDTO {
	
	private String success;
	private String message;

}

	//dto로 보내기
	@PostMapping("/dto")
	public JsonDTO jsonDto() {
		
		String message = "hello";
		String success = "true";
		
		JsonDTO jsonDto = new JsonDTO();
		jsonDto.setMessage(message);
		jsonDto.setSuccess(success);
		
		return jsonDto;
	}
		
}

그리고 Talend API Tester에서 요청해보니, 역시 동일하게 JSON 응답을 확인할 수 있었습니다.

JSON 응답 방법(3) : ResponseEntity

ResponseEntity를 사용하여 JSON 응답을 만들 수도 있습니다. ResponseEntity를 사용하면 HTTP 상태 코드도 제어할 수가 있죠. 간단한 테스트를 위해 컨트롤러에 아래와 같이 추가하였습니다.

	//ResponseEntity 활용
	@PostMapping("/responseEntity")
	public ResponseEntity<JsonDTO> jsonResponse(){
		
		String message = "bye";
		String success = "false";
		
		JsonDTO jsonDto = new JsonDTO();
		jsonDto.setMessage(message);
		jsonDto.setSuccess(success);
		
		return ResponseEntity.ok(jsonDto);
		
	}

그리고 요청을 시도해보면 아래와 같이 정상적으로 JSON 응답이 온 것을 확인할 수 있습니다.

그렇다면 누가 JSON으로 바꿔주는 걸까?

앞서 살펴본 세 가지의 방법은 공통점이 있습니다. JSON을 직접 만든 적은 없다는 것입니다. 단지 Map, DTO, ResponseEntity를 반환했을 뿐입니다. 그런데 클라이언트는 JSON으로 응답을 받았습니다.

즉, 중간의 어딘가에서 객체를 JSON으로 변환해 주는 과정이 존재한다는 의미입니다. 내부적인 과정은 대략 아래와 같습니다.

DispatcherServlet과 HandlerMethodReturnValueHandler

스프링은 기본적으로 MVC 패턴이 구현되어 있습니다. MVC는 model, view, controller 로 프로그램이 계층화되어 동작하는 패턴으로 controller는 요청을 받고, model에서 비즈니스 로직을 처리한 후, 그 결과를 view(화면)로 사용자에게 보여주는 패턴이죠.

그런데 사실 스프링 MVC에서는 클라이언트의 요청은 컨트롤러로 바로 전달되지 않습니다. 가장 먼저 요청을 받는 것은 DispatcherServlet입니다.

DispatcherServlet은 스프링 MVC의 Front Controller로, 모든 HTTP 요청을 가장 앞 단에서 먼저 받아서 적절한 컨트롤러에게 전달하고, 컨트롤러의 처리 결과를 다시 클라이언트에게 응답하는 과정까지 전체적으로 조율합니다.

다만 컨트롤러가 다양한 타입의 값을 반환할 수 있기 때문에, 그 모든 경우를 DispatcherServlet이 처리하진 않습니다. 그래서 HandlerMethodReturnValueHandler에게 그 역할을 위임합니다.

위의 공식 문서 내용과 같이, HandlerMethodReturnValueHandler는 사실 인터페이스고 여러 종류의 구현체들이 있습니다. 각 구현체는 컨트롤러가 처리한 결과가 자신이 처리할 수 있는 타입인지 확인하는 supportReturnType() 메서드를 갖고 있고, 스프링에서는 각 구현체를 순서대로 검사하면서 지금 컨트롤러의 결과를 처리할 수 있는 구현체를 선택합니다. 그렇다면 앞서 실행하였던 controller의 처리 결과는 어떤 구현체에 의해 클라이언트에게 전달될까요?

현재 제가 만든 컨트롤러는 @RestController입니다. 사실 @RestController가 나오기 전에는 일반적인 @Controller에서 지금처럼 뷰를 거치지 않고 바로 결과를 응답하려면 @ResponseBody를 붙여야 했습니다. 말 그대로 HTTP Response의 바디에 결과를 보내주기 위함이었죠. 그런데 @Rest Controller 안에는 이미 @ResponseBody가 포함되어 있습니다. 그래서 굳이 @ResponseBody를 추가하지 않아도 되고, HandlerMethodReturnValueHandler의 구현체 중 @ResponseBody를 쓰는 Controller의 처리 결과를 담당하는 RequestResponseBodyMethodProcessor가 내부적으로 선택되는 것입니다.

HttpMesageConverter와 Jackson

그렇다면 RequestResponseBodyMethodProcessor는 어떻게 객체를 JSON으로 만드는 걸까요? 사실 이 클래스가 직접 JSON을 생성하는 것은 아닙니다.

공식 문서에 보면 이 클래스는 HttpMessageConverter를 통해서 HTTP 요청과 응답을 처리한다고 되어 있습니다. 그런데 참 재미있게도, HttpMessageConverter도 인터페이스입니다. 그리고 아래와 같이 수많은 구현체들이 존재합니다.

그렇다면 여기서 어떤 구현체가 선택을 받게 될까요? 여기서도 각 구현체들에 대한 검사가 순서대로 이뤄지면서 적절한 구현체를 찾게 되는데, 이 단계에서 MappingJackson2HttpMessageConverter가 선택됩니다.

사실 MappingJackson2HttpMessageConverter도 JSON을 직접 만들지는 않습니다. 이 클래스의 역할은 Jackson의 ObjectMapper에게 JSON 생성을 위임하는 것입니다. Jackson이 자바 객체를 JSON으로 만들 수 있는 라이브러리이기 때문이죠. 그렇다면 Jackson은 대체 언제 추가되었던 걸까요?

사실 그냥 스프링에서는 Jackson도 의존성을 직접 추가해줬어야 했는데, 스프링 부트로 프로젝트를 생성할 경우 spring-boot-starter-web이 Jackson을 기본적으로 의존성에 추가해줍니다. 그래서 아무런 추가 설정 없이도 Jackson을 이용해서 JSON 응답을 편하게 할 수 있는 것이죠.

그렇다면, JSON으로 응답해야 한다는 판단은 어떻게 한 걸까?

마지막으로, 그렇다면 스프링에서는 JSON으로 응답해야 한다는 판단을 어떻게 할 수 있었던 걸까요? 그 첫 시작은 @RestController 안에 포함되어 있는 @ResponseBody 때문입니다. @ResponseBody 때문에 '이 처리 결과는 뷰가 아닌 HTTP Response Body에 직접 보내줘야한다' 라고 스프링이 판단할 수 있는 것이죠.

하지만 그것이 전부는 아닙니다. @ResponseBody는 응답을 HTTP Response Body에 직접 보내야한다고 명시해주는 역할이기 때문입니다. 앞서 살펴보았듯이 DispatchServlet은 HandlerMethodReturnValueHandler에게 처리 결과에 대한 일을 넘겨줍니다. 그리고 HandlerMethodReturnValueHandler는 이 일을 처리할 수 있는 구현체를 선택합니다. 그 구현체인 RequestResponseBodyMethodProcessor는 HttpMessageConverter에게 일을 맡깁니다.

이 단계에서 판단이 이뤄집니다. HttpMessageConverter 역시 인터페이스이고, 그 안에 수많은 구현체들이 있다는 것을 앞서 살펴보았습니다. 이때 어떤 구현체가 최종적으로 일을 처리하도록 맡길지는 아래의 기준을 갖고 판단합니다.

  • 반환 타입
  • Content-type
  • Accept 헤더
  • 등록된 Converter

만약 컨트롤러에서 반환해주는 값의 타입이 String이었다면 어떻게 될까요? 테스트를 위해 아래의 간단한 코드를 추가해보았습니다.

@GetMapping("/stringTest")
	public String returnTest() {
		
		return "hello world!";
	}

그리고 요청하면 아래와 같이 문자열로 받습니다.

이 경우에는 @ResponseBody 덕분에 뷰를 반환하지는 않지만, HttpMessageConverter의 구현체 중 String 타입의 결과를 처리하는 StringHttpMessageConverter 가 선택된 것이죠.

하지만 스프링은 기본적으로 Jackson 라이브러리를 의존성에 포함시키고 있고, MappingJackson2HttpMessageConverter는 자바 객체를 Jackson을 통해 JSON으로 처리가 가능하기 때문에 Map이나 DTO를 반환해주면 내부적으로 JSON으로 보내야겠다는 판단을 하게 되는 것입니다.

하지만 만약 응답 형식을 XML로 명시한다면 결과가 XML로 오게 될 것입니다. 테스트를 위해 앞서 작성하였던 컨트롤러 일부를 아래와 같이 수정하였습니다.

	//map으로 보내기
	@PostMapping(value = "/map", produces = MediaType.APPLICATION_XML_VALUE)
	public Map<String,String> jsonMap(){
		
		Map<String, String> hello = new HashMap<>();
		
		hello.put("message", "hello");
		hello.put("success", "true");
		
		return hello;
		
	}

그리고 Jakson에서 XML 처리를 지원해주는 모듈에 대한 의존성을 추가해줍니다. spring-starter-web은 Jakson에서 JSON을 처리해주는 부분은 기본적으로 가져오지만 XML 처리에 대한 부분은 가져오지 않기 때문입니다.

	<dependency>
		    <groupId>com.fasterxml.jackson.dataformat</groupId>
		    <artifactId>jackson-dataformat-xml</artifactId>
		</dependency>

그리고 테스트를 해보면 아래와 같이 결과가 XML로 오는 것을 볼 수 있습니다.

마무리

정리해보면, 스프링에서 JSON 응답을 하는 방법은 간단합니다. 결과를 map이나 DTO로 리턴해주면 되고, HTTP 응답을 더 세밀하게 제어하고 싶다면 ResponseEntity를 함께 사용해주면 됩니다. 이 상태에서 요청을 하면 내부적으로는 대략 아래와 같이 실행됩니다.

  • DispatcherServlet이 요청을 받아서 Controller에 전달
  • Controller가 처리 결과를 Map 또는 DTO로 리턴
  • 이 경우 @ResponseBody가 있다면 뷰가 아닌 바디로 결과를 보내야 함을 알게 됨
  • DispatcherServlet은 처리를 위해 RequestResponseBodyMethodProcessor에게 위임
  • RequestResponseBodyMethodProcessor는 HttpMessageConverter에게 위임
  • HttpMessageConverter는 MappingJackson2HttpMessageConverter를 선택
  • MappingJackson2HttpMessageConverter는 Jackson을 사용, Jackson이 JSON을 만들어줌

사실 방법은 간단하지만, 내부적인 동작 원리를 살펴보려고 하니 많은 것을 찾아보아야 했습니다. 한 가지 재미있는 것은 스프링 MVC 패턴에서 결과를 처리하게 될 때, 특정 인터페이스에게 위임되고 해당 인터페이스는 자신의 구현체 중 적절한 클래스에게 다시 위임하여 최종적으로 처리되도록 동작된다는 점을 보게 된 것입니다.