스프링 기반의 웹 애플리케이션을 개발하다 보면, JSON 형태의 데이터와 자바 객체 간의 변환이 자연스럽게 이루어집니다. 이 과정은 주로 Jackson 라이브러리에 의해 처리되는데, 오늘은 그 내부 동작 원리를 자세히 살펴보겠습니다. 특히 이번 글에서는 자바 객체가 JSON으로 직렬화되는 과정에 초점을 맞추겠습니다.
직렬화 과정
- 요청 처리 및 컨트롤러 실행
클라이언트로부터 JSON 응답을 요구하는 요청이 서버에 도착하면, 다음과 같은 과정을 거칩니다:
- 클라이언트는 일반적으로 Accept 헤더에 "application/json"을 포함하여 JSON 응답을 요청합니다.
- 서버의 서블릿 컨테이너(예: Tomcat)가 요청을 받아 Spring의 DispatcherServlet에 전달합니다.
- DispatcherServlet은 HandlerMapping을 통해 적절한 컨트롤러 메소드를 찾아 실행합니다.
@RestController
public class UserController {
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
이 예제에서 `/user/{id}` 경로로 요청이 오면, `getUser` 메소드가 실행되어 `User` 객체를 반환합니다.
- @ResponseBody 처리
Spring MVC는 컨트롤러 메소드의 반환값을 처리하기 위해 HandlerMethodReturnValueHandler를 사용합니다. @ResponseBody 어노테이션이 있는 경우(또는 @RestController를 사용한 경우), RequestResponseBodyMethodProcessor가 선택됩니다.
이 프로세서는 다음 작업을 수행합니다:
- @ResponseBody 어노테이션 확인
- 반환된 객체의 타입 분석
- MediaType 결정
MediaType 결정 과정은 다음과 같습니다:
- Accept 헤더 파싱
- @Produces 어노테이션 확인 (또는 @GetMapping의 produces 속성)
- ContentNegotiationManager를 통한 최종 MediaType 결정
- 여기서 부분 일치도 없는 상황에서 완전 불일치가 발생한다면 406 Not Acceptable을 반환합니다.
@GetMapping(value = "/user/{id}", produces = MediaType.APPLICATION\_JSON\_VALUE)
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
기본값의 경우 application/json 입니다. 이 예제에서는 명시적으로 JSON 응답을 생성하도록 지정했습니다.
@ResponseBody의 기능이 하나 더 있는데 @ResponseBody는 반환값이 View 이름이더라도 ViewResolver를 통한 처리를 건너 뛰고 HttpMessageConverter를 사용합니다.
- HttpMessageConverter 선택
Spring은 여러 HttpMessageConverter를 등록하고 있으며, 이들 중 적절한 컨버터를 선택합니다. JSON 응답의 경우 일반적으로 MappingJackson2HttpMessageConverter가 선택됩니다.
선택 과정은 다음과 같습니다:
- 등록된 HttpMessageConverter 순회
- 각 컨버터의 canWrite(Class<?>, MediaType) 메소드 호출
- MediaType 일치 여부 확인
- 조건을 만족하는 첫 번째 컨버터 선택
주의할 점은 컨버터의 등록 순서가 중요하다는 것입니다. 사용자 정의 컨버터를 사용하려면 이 점을 고려해야 합니다.
- ObjectMapper 사용
선택된 MappingJackson2HttpMessageConverter는 내부적으로 ObjectMapper를 사용하여 실제 직렬화를 수행합니다.
주요 과정은 다음과 같습니다:
- ObjectMapper의 writeValue() 메소드 호출
- SerializerProvider를 통한 직렬화 컨텍스트 설정
- JsonGenerator를 사용한 JSON 출력 생성
- JavaType 객체 생성 및 적절한 JsonSerializer 선택
- BeanSerializer를 통한 객체 속성 순회 및 직렬화
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(user);
이는 ObjectMapper를 직접 사용하는 예제입니다. Spring에서는 이 과정이 내부적으로 처리됩니다.
POJO의 경우 BeanSerializer가 선택됩니다. 이 때 BeanSerializer는 객체의 속성들을 순회하며 각 속성에 대한 이름을 JSON 필드 이름으로 변환하고 getter 또는 필드 직접 접근을 통해 속성 값을 가져옵니다.
getter 또는 필드 직접 접근 이라는 설명에서 알 수 있듯이 필드가 private인데 getter가 존재하지 않는다면 직렬화를 할 수 없습니다. 이 때 @JsonProperty 어노테이션을 사용하면 getter가 없어도 private 필드를 직렬화 할 수 있습니다.
5. 리플렉션 사용
Jackson은 Java 리플렉션 API를 사용하여 객체의 구조를 분석하고 데이터를 추출합니다.
public class User {
private String name;
@JsonIgnore
private String password;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date birthDate;
// getters and setters
}
이 예제에서 Jackson은 리플렉션을 통해 각 필드의 정보와 어노테이션을 분석합니다.
- JSON 트리 구성 및 문자열 생성
ObjectMapper는 분석된 데이터를 바탕으로 내부적으로 JSON 트리 구조(JsonNode)를 구성한 후, 이를 JSON 문자열로 변환합니다.
- 응답 전송
생성된 JSON 문자열은 HTTP 응답 본문에 포함되어 클라이언트에게 전송됩니다.
response.setContentType(MediaType.APPLICATION\_JSON\_VALUE);
response.getWriter().write(jsonString);
이는 low-level에서의 응답 전송 예제입니다. Spring MVC에서는 이 과정이 자동으로 처리됩니다.
개발자가 주의해야 할 사항
Jackson을 사용한 JSON 직렬화 과정에서 개발자들이 자주 마주치는 문제와 그 해결책을 살펴보겠습니다:
- 접근 제어자와 Getter 메소드
Jackson은 기본적으로 객체의 Getter 메소드나 public 필드를 통해 직렬화를 수행합니다. private 필드에 대한 Getter가 없으면 해당 필드는 직렬화되지 않습니다.
public class User {
private String name; // 이 필드는 직렬화되지 않습니다!
}
해결책:
- Getter 메소드 추가
- @JsonProperty 어노테이션 사용
- Jackson의 visibility 설정 변경
public class User {
@JsonProperty
private String name; // 이제 이 필드는 직렬화됩니다.
}
- 순환 참조
객체 간 양방향 관계가 있을 경우, 무한 재귀로 인한 StackOverflowError가 발생할 수 있습니다.
해결책:
- @JsonManagedReference와 @JsonBackReference 사용
- @JsonIgnore 사용
- DTO(Data Transfer Object) 패턴 적용
public class Parent {
@JsonManagedReference
private List<Child> children;
}
public class Child {
@JsonBackReference
private Parent parent;
}
- Lazy Loading
JPA의 지연 로딩(Lazy Loading)된 필드에 접근할 때, 세션이 닫혀 있으면 LazyInitializationException이 발생할 수 있습니다.
해결책:
- @JsonIgnore 사용
- DTO 패턴 적용
- Jackson-Hibernate 모듈 사용
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new Hibernate5Module());
- 날짜/시간 형식
날짜/시간 필드의 형식이 일관되지 않으면 클라이언트 측에서 파싱 오류가 발생할 수 있습니다.
해결책:
- @JsonFormat 어노테이션 사용
public class Event {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date eventDate;
}
- 불필요한 필드 제외
특정 상황에서 일부 필드를 제외해야 할 때가 있습니다.
해결책:
- @JsonIgnore 사용
- @JsonIgnoreProperties 클래스 레벨에서 사용
@JsonIgnoreProperties({"password", "secretKey"})
public class User {
private String username;
private String password; // 이 필드는 JSON에 포함되지 않습니다.
}
- 성능 최적화
대량의 객체 직렬화 시 성능 저하가 발생할 수 있습니다.
해결책:
- @JsonInclude를 사용하여 null 값 제외
- Jackson의 ObjectMapper 설정 최적화
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
private String name;
private String email; // null이면 JSON에 포함되지 않습니다.
}
- 타입 정보 포함
다형성 객체의 타입 정보가 손실될 수 있습니다.
해결책:
- @JsonTypeInfo 어노테이션 사용
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Dog.class, name = "dog"),
@JsonSubTypes.Type(value = Cat.class, name = "cat")
})
public abstract class Animal { }
이러한 주의사항들을 고려하면서 개발하면, Jackson을 이용한 JSON 직렬화 과정에서 발생할 수 있는 많은 문제들을 예방할 수 있습니다. 특히 복잡한 도메인 모델을 다룰 때는 DTO 패턴의 사용을 고려하는 것이 좋습니다. 이를 통해 도메인 객체와 API 응답을 명확히 분리하고, 직렬화 관련 문제를 더 쉽게 관리할 수 있습니다.
결론
Spring과 Jackson의 조합은 복잡한 직렬화 과정을 개발자로부터 숨기고, 편리한 API를 제공합니다. 그러나 이 과정을 이해하는 것은 더 효과적인 API 설계와 문제 해결에 도움이 됩니다.
다음 글에서는 JSON에서 자바 객체로의 역직렬화 과정을 다루겠습니다.
참고 자료:
'JAVA > SPRING' 카테고리의 다른 글
Spring과 Jackson: JSON 역직렬화의 내부 동작 원리 (1) | 2024.09.25 |
---|---|
@Transactional(readOnly = true)를 항상 써야할까? (0) | 2024.08.03 |