앞선 글에서 우리는 Spring 기반 웹 애플리케이션에서 자바 객체가 JSON으로 직렬화되는 과정을 살펴보았습니다. 이번에는 그 반대 과정, 즉 JSON 데이터가 자바 객체로 역직렬화되는 과정을 자세히 알아보겠습니다. 이 과정 역시 주로 Jackson 라이브러리에 의해 처리되며, Spring Framework와 긴밀히 통합되어 있습니다.

역직렬화 과정

1. 요청 수신 및 컨트롤러 매핑

클라이언트로부터 JSON 데이터를 포함한 HTTP 요청이 서버에 도착하면 다음과 같은 과정을 거칩니다:

  1. 클라이언트는 일반적으로 Content-Type 헤더에 "application/json"을 포함하여 JSON 데이터를 전송합니다.
  2. 서버의 서블릿 컨테이너(예: Tomcat)가 요청을 받아 Spring의 DispatcherServlet에 전달합니다.
  3. DispatcherServlet은 HandlerMapping을 통해 적절한 컨트롤러 메소드를 찾습니다.
@RestController  
public class UserController {  
    @PostMapping("/user")  
    public User createUser(@RequestBody User user) {  
        return userService.save(user);  
    }  
}  

이 예제에서 `/user` 경로로 POST 요청이 오면, `createUser` 메소드가 실행됩니다.

2. @RequestBody 처리

Spring MVC는 메소드 파라미터에 @RequestBody 어노테이션이 있는 경우 RequestResponseBodyMethodProcessor를 사용하여 처리합니다.

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

  1. @RequestBody 어노테이션 확인
  2. 파라미터의 타입 분석
  3. Content-Type 확인

3. HttpMessageConverter 선택

Spring은 등록된 여러 HttpMessageConverter 중에서 적절한 컨버터를 선택합니다. JSON 요청의 경우 일반적으로 MappingJackson2HttpMessageConverter가 선택됩니다.

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

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

4. ObjectMapper 사용

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

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

  1. ObjectMapper의 readValue() 메소드 호출
  2. JsonParser를 사용한 JSON 입력 처리
  3. DeserializationContext 설정
  4. JavaType 객체 생성 및 적절한 JsonDeserializer 선택
  5. BeanDeserializer를 통한 객체 속성 설정

```java
ObjectMapper objectMapper = new ObjectMapper();
User user = objectMapper.readValue(jsonString, User.class);
```

이는 ObjectMapper를 직접 사용하는 예제입니다. Spring에서는 이 과정이 내부적으로 처리됩니다.

5. JSON 파싱 및 객체 생성

Jackson은 JSON 데이터를 파싱하여 Java 객체를 생성합니다. 이 과정에서 다음과 같은 작업이 수행됩니다:

  1. JSON 토큰 읽기
  2. 객체의 기본 생성자 호출 또는 @JsonCreator 어노테이션이 붙은 생성자 사용
  3. JSON 필드와 Java 객체의 속성 매핑
  4. setter 메소드 호출 또는 필드에 직접 값 설정
public class User {  
    private String name;  
    private int age;  

    @JsonCreator  
    public User(@JsonProperty("name") String name, @JsonProperty("age") int age) {  
        this.name = name;  
        this.age = age;  
    }  

    // getters and setters  
}  

이 예제에서 Jackson은 @JsonCreator 어노테이션이 붙은 생성자를 사용하여 객체를 생성합니다.

6. 타입 변환

JSON의 데이터 타입과 Java 객체의 필드 타입이 다른 경우, Jackson은 적절한 타입 변환을 수행합니다.

  • 문자열 → 날짜
  • 숫자 → Enum
  • 문자열 → UUID 등
public class Event {  
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")  
    private Date eventDate;  

    @JsonDeserialize(using = CustomEnumDeserializer.class)  
    private EventType type;  

    // getters and setters  
}  

이 예제에서 `eventDate`는 지정된 패턴에 따라 문자열에서 Date 객체로 변환되며, `type`은 커스텀 Deserializer를 통해 변환됩니다.

7. 유효성 검증

Spring의 Validator 또는 Bean Validation(예: @Valid)을 사용하여 생성된 객체의 유효성을 검증할 수 있습니다.

@PostMapping("/user")  
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {  
    // ...  
}  

이 예제에서 @Valid 어노테이션은 User 객체의 유효성 검증을 트리거합니다.

개발자가 주의해야 할 사항

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

  1. 생성자와 setter 메소드

Jackson은 기본적으로 기본 생성자 또는 setter 메소드를 사용하여 객체를 생성하고 값을 설정합니다. 이들이 없으면 역직렬화에 실패할 수 있습니다.

해결책:

  • 기본 생성자 추가
  • @JsonCreator와 @JsonProperty 사용
  • Jackson의 visibility 설정 변경
  1. 알 수 없는 속성 처리

JSON에 Java 클래스에 정의되지 않은 필드가 있을 경우 UnrecognizedPropertyException이 발생할 수 있습니다.

해결책:

  • @JsonIgnoreProperties(ignoreUnknown = true) 사용
  • ObjectMapper 설정 변경
@JsonIgnoreProperties(ignoreUnknown = true)  
public class User {  
    // ...  
}  
  1. 다형성 객체 처리

추상 클래스나 인터페이스의 구현체를 역직렬화할 때 타입 정보가 필요할 수 있습니다.

해결책:

  • @JsonTypeInfo와 @JsonSubTypes 사용
@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 { }  
  1. 순환 참조

양방향 관계가 있는 객체를 역직렬화할 때 순환 참조로 인한 문제가 발생할 수 있습니다.

해결책:

  • @JsonManagedReference와 @JsonBackReference 사용
  • DTO 패턴 적용
  1. 날짜/시간 형식

날짜와 시간 데이터의 형식이 일치하지 않으면 파싱 오류가 발생할 수 있습니다.

해결책:

  • @JsonFormat 사용
  • 커스텀 Deserializer 구현
public class Event {  
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")  
    private Date eventDate;  
}  
  1. Immutable 객체 처리

final 필드를 가진 Immutable 객체의 경우 일반적인 역직렬화 방식으로는 처리가 어려울 수 있습니다.

해결책:

  • @JsonCreator와 @JsonProperty 조합 사용
  • Builder 패턴과 함께 @JsonDeserialize 사용
@JsonDeserialize(builder = ImmutableUser.Builder.class)  
public class ImmutableUser {  
    private final String name;  
    private final int age;  

    private ImmutableUser(Builder builder) {  
        this.name = builder.name;  
        this.age = builder.age;  
    }  

    public static class Builder {  
        private String name;  
        private int age;  

        @JsonProperty("name")  
        public Builder withName(String name) {  
            this.name = name;  
            return this;  
        }  

        @JsonProperty("age")  
        public Builder withAge(int age) {  
            this.age = age;  
            return this;  
        }  

        public ImmutableUser build() {  
            return new ImmutableUser(this);  
        }  
    }  
}  

결론

Spring과 Jackson의 조합은 JSON 데이터의 역직렬화 과정을 매우 유연하고 강력하게 만듭니다. 그러나 이 과정을 깊이 이해하고 있으면, 복잡한 데이터 구조를 다룰 때 발생할 수 있는 다양한 문제들을 효과적으로 해결할 수 있습니다.

특히 도메인 모델이 복잡한 경우, API 계층에서는 DTO 패턴을 사용하여 역직렬화를 단순화하고, 도메인 객체와 API 요청/응답을 명확히 분리하는 것이 좋습니다. 이를 통해 역직렬화 관련 문제를 더 쉽게 관리하고, API의 버전 관리도 용이해집니다.

참고 자료:

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

스프링 기반의 웹 애플리케이션을 개발하다 보면, 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)

스프링으로 개발을 하다 보면 SELECT 쿼리만을 필요로 하는 부분에 @Transactional(readOnly = true) 어노테이션을 사용하는 경우가 많습니다. 그런데 데이터베이스 관점에서 보면 단순히 SELECT 하는 쿼리라면 트랜잭션이 필요 없습니다. 그렇다면 @Transactional(readOnly = true)는 왜 사용하는 걸까요? 그냥 해당 어노테이션 없이 사용해도 되지 않을까요?

@Transactional 이해하기

우선 @Transactional에 대해 간단히 설명드리겠습니다.

@Transactional은 스프링 프레임워크에서 제공하는 선언적 트랜잭션 관리를 위한 어노테이션입니다. 이 어노테이션을 메서드나 클래스에 적용하면, 해당 메서드 또는 클래스의 모든 public 메서드가 트랜잭션 내에서 실행됩니다.
(claude)

@Transactional은 어노테이션 기반으로 트랜잭션 관리를 쉽게 도와줍니다. 이 어노테이션은 프록시를 기반으로 동작하며, 주요 특징은 다음과 같습니다:

  1. 프록시 생성: 스프링은 @Transactional이 적용된 빈에 대해 프록시를 생성합니다. 이 프록시는 원본 객체를 감싸고 있으며, 메서드 호출을 가로채서 트랜잭션 로직을 추가합니다.
  2. 트랜잭션 매니저: 프록시는 PlatformTransactionManager를 사용하여 트랜잭션을 관리합니다. 이 매니저는 트랜잭션의 시작, 커밋, 롤백을 담당합니다.
  3. AOP (Aspect-Oriented Programming): @Transactional의 동작은 AOP를 기반으로 합니다. 트랜잭션 관리는 횡단 관심사로 처리되며, 이를 통해 비즈니스 로직과 트랜잭션 처리 로직을 분리할 수 있습니다.
  4. 트랜잭션 전파: @Transactional은 다양한 전파 옵션을 가지고 있습니다. 기본값은 REQUIRED로, 이미 진행 중인 트랜잭션이 있으면 그 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 시작합니다.
  5. 예외 처리: 기본적으로 런타임 예외가 발생하면 트랜잭션이 롤백됩니다. 체크 예외는 롤백되지 않지만, rollbackFor 속성을 사용하여 롤백 동작을 커스터마이즈할 수 있습니다.
  6. 트랜잭션 동기화: 스프링은 ThreadLocal을 사용하여 트랜잭션 컨텍스트를 관리합니다. 이를 통해 동일한 스레드 내에서 여러 데이터 액세스 작업을 하나의 트랜잭션으로 묶을 수 있습니다.

 

(출처:

https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-decl-explained.html

)

readOnly 옵션의 역할

이제 readOnly 옵션에 대해 자세히 살펴보겠습니다.

This just serves as a hint for the actual transaction subsystem; it will not necessarily cause failure of write access attempts. A transaction manager which cannot interpret the read-only hint will not throw an exception when asked for a read-only transaction.
(Javadoc)

readOnly 옵션은 실제로 데이터베이스에 대한 쓰기를 물리적으로 방지하지는 않습니다. 하지만 힌트를 제공함으로써 데이터베이스 수준의 최적화를 할 수 있습니다. 특히 Hibernate에서는 최적화 효과가 있습니다.

readOnly 옵션이 설정된 트랜잭션에서 Hibernate는 영속성 컨텍스트의 동작을 크게 최적화합니다:

  • 더티 체킹의 비활성화: 엔티티의 변경사항을 추적하지 않음으로써 성능이 향상됩니다.
  • 엔티티의 초기 상태 스냅샷 관리 최소화: 메모리 사용량이 감소합니다.
  • FlushMode를 MANUAL로 설정: 불필요한 데이터베이스 동기화가 방지됩니다.
  • 2차 캐시에 엔티티를 저장하지 않음: 캐시 오염을 막을 수 있습니다.
  • 쓰기 지연 저장소 비활성화: 추가적인 메모리 최적화가 이루어집니다.

그러나 이러한 최적화 효과를 얻기 위해서는 주의할 점이 있습니다. readOnly 옵션은 Propagation.REQUIRED 또는 REQUIRES_NEW일 때만 효과적으로 동작합니다.

읽기 작업에서 @Transactional(readOnly = true)의 필요성

데이터베이스 관점에서는 순수한 읽기 작업에 대해 트랜잭션을 사용하는 것은 일반적으로 오버헤드를 증가시킵니다. 그러나 일부 상황에서는 읽기 작업에도 트랜잭션이 필요할 수 있습니다:

  • 특정 시점의 일관된 데이터 스냅샷이 필요한 경우
  • 특정 격리 수준을 보장해야 하는 경우

또한 Hibernate와 같은 ORM을 사용할 때 readOnly 옵션은 앞서 설명한 최적화를 제공합니다. 이는 특히 대용량 데이터를 읽을 때 유용할 수 있습니다. MySQL의 InnoDB 엔진은 readOnly 트랜잭션에 대해 특정 최적화를 수행해주기도 합니다.

결론

단순 조회 쿼리에도 @Transactional(readOnly = true)를 사용하는 것이 여러 이점이 있습니다. 그러나 최종적으로는 항상 성능 테스트를 통해 결정을 내리는 것이 좋습니다.

참고 자료

+ Recent posts