Spring에서 API를 호출할 때 Json <-> Object 변환 파고들기, reflection
들어가기
저번에 FeignClient로 다른 API를 호출할 때 그 API에서 반환한 Json 객체를 Java Object(Response객체)로 담는 과정에서 Java Object에 빈 생성자를 넣지 않아 에러가 난 적이 있었다. 지난 번엔 이걸 알아보기 전에 자바 소스 코드가 실행되는 과정을 다시 공부했었다. 보다보니 기본 API에 대해서도 잘 모르는 것 같아서 기본적인 API를 개발한 뒤 이걸 호출할 때 java 내에서 무슨 일이 발생하는지 순차적으로 알아보려 한다.
https://eocoding.tistory.com/117
학습 순서
어떤 것부터 공부할지를 고민했는데... 일단 기본적인 형태의 API를 개발해놓고 그것의 원리를 알아보는 형식으로 공부해야겠다.
- 롬복 없이 기본 형태의 API 개발.
(단 @RestController 등의 스프링 data bind 어노테이션은 어쩔 수 없이 활용한다.) - API 호출 전 컴파일 타임에 발생하는 일 (HttpMessageConveter 초기화)
- API 호출 시 런타임에서 발생하는 일 (만들어진 응답 Java 객체를 json으로 직렬화할 때)
- 롬복 사용, 롬복 원리
- Serialization, SerialiVersionUID 필요성
이번 포스팅에서는 1,2,3까지 해결된다. 맨 처음 의문이었던 빈 생성자의 필요성에 대해서는 다른 API 예제가 필요할 것 같다. 여기서는 직렬화되는 객체에 Getter 메소드가 왜 필요한지를 알게 된다. 빈 생성자의 필요성과 4번 5번 내용에 대해서는 또 다시 다음 포스팅으로 이어야겠다. ㅠㅠ
API 개발
일단 기본적인 Post API를 개발해보자. Spring Initializr 로 Spring Web 이랑 lombok 만 의존성을 추가했다.
@RestController
public class TestController {
@PostMapping(value = "/test")
public ResponseEntity<TestResponse> postTest(@RequestBody TestRequest request) {
TestResponse response = new TestResponse(1L, "success");
return new ResponseEntity<>(response, HttpStatus.OK);
}
}
-------------------------------------------------
public class TestResponse {
private Long id;
private String name;
public TestResponse(Long id, String name) {
this.id = id;
this.name = name;
}
}
-------------------------------------------------
public class TestRequest {
private Long id;
private String name;
}
Response와 Request는 완전히 기본만 갖도록 만들었다. TestResponse는 controller에서 응답값 내줄 때 써야하니까 부득이하게 생성자만 추가했다.
컴파일타임, 런타임의 Dependencies
컴파일타임과 런타임에 동작하는 의존성은 다음과 같다.
컴파일타임에는 lombok이랑 spring web이 동작하고, 런타임에는 spring web 만 동작하나보다? 각각의 타임에 어떤 일이 벌어지는 아직 모르겠다. 일단 spring web 을 통해 이뤄지는 json 데이터와 java object 변환 내용을 먼저 공부하면서 파 보다 보면 결국 알게 되지 않을까 싶다.
컴파일 타임에 일어나는 일 : MappingJackson2HttpMessageConverter의 초기화
컴파일 시점에 breakpoint를 걸어보니 앞서 살펴본 것처럼 spring-web 라이브러리 내에 있는 MappingJackson2HttpMessageConverter의 메소드들이 거쳐지는 걸 확인할 수 있었다. 방금 위에서 dependencies 컴파일 쪽에 spring-starter-web이 있었다는걸 상기할 수 있다.
왼쪽 사진을 통해 spring-web 내에 MappingJackson2HttpMessageConveter가 있다는 걸 확인하면 되고, 오른쪽 사진을 통해 그게 우리가 자주 들어본 HttpMessageConverter의 구현체 중에 하나라는 걸 확인하면 된다. 그리고 그 안에서 json과 Object를 변환할 땐 아마도 ObjectMapper를 통해 직렬화/역직렬화를 할 텐데 그 ObjectMapper 필드는 상위의 추상클래스가 가지고 있다는 것도 확인할 수 있다.
여기서 사전지식 정리해주고 가자면,
- HttpMessageConverter는 json 등으로 받은 Request Body를 DTO 객체로 변환해서 서버 쪽에서 쓸 수 있게도 해주고, 서버에서 전달할 응답 데이터를 json 형태의 Response Body로 변환해주기도 한다. Spring 원리를 보면 디스패처 서블릿하고 막 이런저런 동작 과정 나오는데 일단 이건 스킵함..ㅎ 내가 MVC를 더 공부해야 하므로ㅋㅋ
- MappingJackson2HttpMessageConverter는 응답들이 객체라서 쓰이게 된 것이다. 응답이 그냥 string이었다면 StringHttpMessageConverter가 쓰인다. 이것도 일단 패스.
- Jackson의 ObjectMapper
- 스프링 MVC는 직접 라이브러리를 추가해야 하지만 스프링 부트는 자동으로 jackson 라이브러리가 추가된다.
- 얘는 스프링에서 역직렬화/직렬화를 하기 위한 라이브러리이다.
- ObjectMapper.readValue, writeValue 등을 통해 Java Object에서 JSON으로 변환해줄 수 있다.
이제 진짜 디버깅 run 버튼을 눌러 컴파일을 시작했다. 그렇게 살펴봤더니 HttpMessageConverter를 상속하고 구현한 MappingJackson2HttpMessageConveter의 생성자를 타는 걸 볼 수 있었다. 여기서 super를 통해 부모의 생성자가 호출되는데 잘은 모르지만 이름만 봐서는 json과 관련한 ObjectMapper가 들어가는 듯 하다.
저 파라미터로 넘겨지는 Jackson2ObjectMapperBuilder는 ObjectMapper를 만들기 위한 Builer이고 Jackson의 default 설정을 따른다고 한다. 나도 몰겠다 자세한건 ㅠㅠ. 암튼 ObjectMapper가 HttpMessageConverter 구현체에 넘겨지는 것까지 오케이.
추가로, Exception을 핸들링하기 위한 ExceptionResolver도 컴파일 시점에 추가된다.
API 를 호출 후 : postman의 요청 Json 데이터를 Java Request Object로 변환 (역직렬화)
이제 API를 호출하고 breakpoint들을 살펴봤다.
위 사진을 보면 맨 처음 AbstractJackson2HttpMessageConverter의 read 메소드가 호출됐다.
여기서 JavaType 가져오는 건 Controller에 Request로 설정해놓은 그 객체 타입을 가져오는 것으로 보여진다.
그리고 마지막에 리턴하면서 호출되는 readJavaType의 내부를 보면 실제 input으로 들어온 메시지에 대해 header의 content-type도 읽어오고 getBody를 통해 body 내용도 InputStream으로 변환하는 부분이 보인다. InputStreamReader를 만든 뒤 isUnicode가 true가 되면서 초기화해둔 objectMapper의 readValue가 호출된다. 지금 내 상황에서는 isUnicode == true 일때의 로직을 탄다.
이제 그렇게 얻은 json 형태의 request를 Java Object로 역직렬화하는 과정이 필요하니까 DeserialzationConfig 처럼 뭔가 역직렬화와 관련된 코드들이 보인다. 얘의 반환 타입이 Obejct인 걸로 보아 TestRequest 라는 Object 타입으로 역직렬화가 되는 거 같다. 이렇게 return result 하면서 controller 단으로 잘 들어온 걸 확인했다.
API 호출 후 : controller에서 다 처리된 Java Response Object를 json으로 변환하는 과정 (직렬화)
Controller에서 new ResponseEntity로 response를 주고 있었으니 이게 실행되는데, ResponseEntity가 extends한 건 HttpEntity 이므로 super 생성자가 호출된다. body에 TestResponse 객체가 잘 들어온 게 보인다. 아무튼 이렇게 ResponseEntity가 잘 만들어지면 이걸 이제 json 객체로 반환해야 한다. 그 과정을 step into로 들여다봤다.
setResponseStatus도 하고 쭈우욱 들어가다보면
드디어 ObjectWriter.writeValue!! 뭔가 직렬화를 할 거 같은 메소드를 만났다.
ResponseDTO의 속성에 접근하기 위한 메소드로, 받은 bean 객체. 즉 TestResponse를 JSON 객체로 변환해주기 위한 메소드다. 여기서 reflection의 invoke를 통해 TestResponse 객체의 getter 메소드들을 실행하고, serialization 하는 것으로 보여진다. 그래서 여기서 getter 메소드가 없으면 에러가 발생한다.
getter 메소드가 없을 때 에러가 발생하는 과정도 대략적으로 봤다.
ResponseBody 가 될 DTO 객체에 Getter 필요
위처럼 한 상태에서 postman으로 post method api 를 호출했더니 아래와 같은 에러가 발생했다. 이는 TestResponse에 @Getter를 붙여주면 해결된다.
2023-04-09 19:32:35.942 WARN 14971 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]
HttpMediaTypeNotAcceptableException는 클라이언트가 접근할 수 있는 형태의 response를 생성할 수 없을 때 발생하는 에러다.
위 사진을 설명하자면, 먼저 실행된 메소드가 아래에서부터 위로 쌓여올라가는데, filter 작업 이후에 진행되는 것이 DispatcherServlet의 processDispatchResult다.
여기서는 processHandlerException이라는 메소드를 통해 모든 HandlerExceptionResolver를 돌면서 exception을 잡아낸다. 그때 DefaultHandlerExceptionResolver에 의해 HttpMediaTypeNotAcceptableException이 발생한다. reflection으로 getter 메소드를 호출하고 그걸로 얻은 필드로 직렬화를 해야하는데 그걸 못하니까 결국 지금 원하는 media type인 json 객체로 만들 때 접근할 수 없다는 내용의 에러가 발생하는 것이다.
오늘은 여기까지 봤다. 다음엔 본래 의문이었던 빈 생성자의 필요성, 그리고 직렬화 공부하면서 궁금하게 된 SerialVersionUID, 그리고 리플렉션 본 김에 롬복이 컴파일타임에 무슨 일을 벌려 놓는지도 한번 정리하면 좋을 거 같다.