스프링 기반의 웹 애플리케이션을 개발하다 보면, JSON 형태의 데이터와 자바 객체 간의 변환이 자연스럽게 이루어집니다. 이 과정은 주로 Jackson 라이브러리에 의해 처리되는데, 오늘은 그 내부 동작 원리를 자세히 살펴보겠습니다. 특히 이번 글에서는 자바 객체가 JSON으로 직렬화되는 과정에 초점을 맞추겠습니다.

직렬화 과정

  1. 요청 처리 및 컨트롤러 실행

클라이언트로부터 JSON 응답을 요구하는 요청이 서버에 도착하면, 다음과 같은 과정을 거칩니다:

  1. 클라이언트는 일반적으로 Accept 헤더에 "application/json"을 포함하여 JSON 응답을 요청합니다.
  2. 서버의 서블릿 컨테이너(예: Tomcat)가 요청을 받아 Spring의 DispatcherServlet에 전달합니다.
  3. DispatcherServlet은 HandlerMapping을 통해 적절한 컨트롤러 메소드를 찾아 실행합니다.
@RestController  
public class UserController {  
    @GetMapping("/user/{id}")  
    public User getUser(@PathVariable Long id) {  
        return userService.findById(id);  
    }  
}  

이 예제에서 `/user/{id}` 경로로 요청이 오면, `getUser` 메소드가 실행되어 `User` 객체를 반환합니다.

  1. @ResponseBody 처리

Spring MVC는 컨트롤러 메소드의 반환값을 처리하기 위해 HandlerMethodReturnValueHandler를 사용합니다. @ResponseBody 어노테이션이 있는 경우(또는 @RestController를 사용한 경우), RequestResponseBodyMethodProcessor가 선택됩니다.

이 프로세서는 다음 작업을 수행합니다:

  1. @ResponseBody 어노테이션 확인
  2. 반환된 객체의 타입 분석
  3. MediaType 결정

MediaType 결정 과정은 다음과 같습니다:

  1. Accept 헤더 파싱
  2. @Produces 어노테이션 확인 (또는 @GetMapping의 produces 속성)
  3. ContentNegotiationManager를 통한 최종 MediaType 결정
  4. 여기서 부분 일치도 없는 상황에서 완전 불일치가 발생한다면 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를 사용합니다.

  1. HttpMessageConverter 선택

Spring은 여러 HttpMessageConverter를 등록하고 있으며, 이들 중 적절한 컨버터를 선택합니다. JSON 응답의 경우 일반적으로 MappingJackson2HttpMessageConverter가 선택됩니다.

선택 과정은 다음과 같습니다:

  1. 등록된 HttpMessageConverter 순회
  2. 각 컨버터의 canWrite(Class<?>, MediaType) 메소드 호출
  3. MediaType 일치 여부 확인
  4. 조건을 만족하는 첫 번째 컨버터 선택

주의할 점은 컨버터의 등록 순서가 중요하다는 것입니다. 사용자 정의 컨버터를 사용하려면 이 점을 고려해야 합니다.

  1. ObjectMapper 사용

선택된 MappingJackson2HttpMessageConverter는 내부적으로 ObjectMapper를 사용하여 실제 직렬화를 수행합니다.

주요 과정은 다음과 같습니다:

  1. ObjectMapper의 writeValue() 메소드 호출
  2. SerializerProvider를 통한 직렬화 컨텍스트 설정
  3. JsonGenerator를 사용한 JSON 출력 생성
  4. JavaType 객체 생성 및 적절한 JsonSerializer 선택
  5. 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은 리플렉션을 통해 각 필드의 정보와 어노테이션을 분석합니다.

  1. JSON 트리 구성 및 문자열 생성

ObjectMapper는 분석된 데이터를 바탕으로 내부적으로 JSON 트리 구조(JsonNode)를 구성한 후, 이를 JSON 문자열로 변환합니다.

  1. 응답 전송

생성된 JSON 문자열은 HTTP 응답 본문에 포함되어 클라이언트에게 전송됩니다.

response.setContentType(MediaType.APPLICATION\_JSON\_VALUE);  
response.getWriter().write(jsonString);  

이는 low-level에서의 응답 전송 예제입니다. Spring MVC에서는 이 과정이 자동으로 처리됩니다.

 

개발자가 주의해야 할 사항

 

Jackson을 사용한 JSON 직렬화 과정에서 개발자들이 자주 마주치는 문제와 그 해결책을 살펴보겠습니다:

  1. 접근 제어자와 Getter 메소드

Jackson은 기본적으로 객체의 Getter 메소드나 public 필드를 통해 직렬화를 수행합니다. private 필드에 대한 Getter가 없으면 해당 필드는 직렬화되지 않습니다.

public class User {
    private String name; // 이 필드는 직렬화되지 않습니다!
}

해결책:

  • Getter 메소드 추가
  • @JsonProperty 어노테이션 사용
  • Jackson의 visibility 설정 변경
public class User {
    @JsonProperty
    private String name; // 이제 이 필드는 직렬화됩니다.
}
  1. 순환 참조

객체 간 양방향 관계가 있을 경우, 무한 재귀로 인한 StackOverflowError가 발생할 수 있습니다.

해결책:

  • @JsonManagedReference와 @JsonBackReference 사용
  • @JsonIgnore 사용
  • DTO(Data Transfer Object) 패턴 적용
public class Parent {
    @JsonManagedReference
    private List<Child> children;
}

public class Child {
    @JsonBackReference
    private Parent parent;
}
  1. Lazy Loading

JPA의 지연 로딩(Lazy Loading)된 필드에 접근할 때, 세션이 닫혀 있으면 LazyInitializationException이 발생할 수 있습니다.

해결책:

  • @JsonIgnore 사용
  • DTO 패턴 적용
  • Jackson-Hibernate 모듈 사용
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new Hibernate5Module());
  1. 날짜/시간 형식

날짜/시간 필드의 형식이 일관되지 않으면 클라이언트 측에서 파싱 오류가 발생할 수 있습니다.

해결책:

  • @JsonFormat 어노테이션 사용
public class Event {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date eventDate;
}
  1. 불필요한 필드 제외

특정 상황에서 일부 필드를 제외해야 할 때가 있습니다.

해결책:

  • @JsonIgnore 사용
  • @JsonIgnoreProperties 클래스 레벨에서 사용
@JsonIgnoreProperties({"password", "secretKey"})
public class User {
    private String username;
    private String password; // 이 필드는 JSON에 포함되지 않습니다.
}
  1. 성능 최적화

대량의 객체 직렬화 시 성능 저하가 발생할 수 있습니다.

해결책:

  • @JsonInclude를 사용하여 null 값 제외
  • Jackson의 ObjectMapper 설정 최적화
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
    private String name;
    private String email; // null이면 JSON에 포함되지 않습니다.
}
  1. 타입 정보 포함

다형성 객체의 타입 정보가 손실될 수 있습니다.

해결책:

  • @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에서 자바 객체로의 역직렬화 과정을 다루겠습니다.

 

 

 

참고 자료:

  1. Spring Framework Documentation
  2. Jackson Project Documentation
  3. Java Reflection API Documentation
  4. HTTP Specification (RFC 7231)

+ Recent posts