oguri's garage

(Spring) Jackson 역직렬화 동작 방식과 안전한 코딩 패턴 본문

개발하다/Spring

(Spring) Jackson 역직렬화 동작 방식과 안전한 코딩 패턴

oguri 2025. 10. 4. 22:44

1. Jackson 역직렬화 동작 원리

Jackson이 JSON을 Java 객체로 변환하는 과정을 조립 공장에 비유

1.1 기본 동작 흐름

JSON 데이터 → Jackson ObjectMapper → Java 객체

1. 생성자 찾기 (Constructor Discovery)
2. 객체 인스턴스 생성 (Object Creation)  
3. 필드 값 설정 (Property Setting)
4. 완성된 객체 반환

 

1.2 생성자 우선순위

 

Jackson은 다음 순서로 생성자를 찾습니다:

// 우선순위 1: @JsonCreator 어노테이션이 있는 생성자
@JsonCreator
public User(@JsonProperty("name") String name) { ... }

// 우선순위 2: 기본 생성자 (매개변수 없음)
public User() { }

// 우선순위 3: 매개변수 1개 생성자 (위험!)
public User(String name) { ... }

// 우선순위 4: 매개변수 여러 개 생성자 (조건부)
public User(String name, String email) { ... }

 

 


2. 문제가 발생하는 위험한 상황들

2.1 매개변수 1개 생성자만 있는 경우 (고위험 ⚠️)

// ❌ 위험한 코드 - Jackson이 혼란스러워함
@Getter
@AllArgsConstructor  // 매개변수 1개만 생성하는 경우
public class DangerousRequest {
    private String name;  // 필드가 1개뿐
}

// JSON 요청
{
    "name": "john"
}

 

발생하는 에러:

com.fasterxml.jackson.databind.exc.MismatchedInputException: 
Cannot construct instance of DangerousRequest 
(although at least one Creator exists): cannot deserialize from Object value 
(no delegate- or property-based Creator)

 

왜 에러가 날까? Jackson은 매개변수가 1개인 생성자를 "값 타입 생성자"로 인식하여 JSON 객체가 아닌 단일 값을 기대하기 때문입니다.

 

2.2 여러 생성자가 있는 경우 (중위험 ⚠️)

// ❌ 어떤 생성자를 써야 할지 Jackson이 모름
@Getter
public class AmbiguousRequest {
    private String name;
    private String email;

    public AmbiguousRequest(String name) {           // 생성자 1
        this.name = name;
        this.email = "default@email.com";
    }

    public AmbiguousRequest(String name, String email) {  // 생성자 2  
        this.name = name;
        this.email = email;
    }
}

 

발생하는 에러:

InvalidDefinitionException: Cannot construct instance of AmbiguousRequest 
(no Creators, like default constructor, exist)

 

2.3 Jackson 설정 변경에 취약한 코드 (중위험 ⚠️)

// ⚠️ Jackson 설정에 따라 동작이 달라짐
@Getter
@AllArgsConstructor
public class ConfigDependentRequest {
    private String name;
    private String email;
}

 

문제 상황:

  • 팀 A: Jackson ParameterNamesModule 사용 → 동작함
  • 팀 B: 해당 모듈 없음 → 에러 발생
  • 프로덕션 환경: 설정이 다르면 → 런타임 에러

 

2.4 Lombok 어노테이션 변경 시 문제 (고위험 ⚠️)

// 처음에 @Data 사용 (문제없음)
@Data  // Getter + Setter + NoArgsConstructor (final 필드 없을 때)
public class UserRequest {
    private String name;
    private String email;
}

// 나중에 불변 객체로 만들려고 수정 → 갑자기 에러!
@Getter  // Setter 제거
@AllArgsConstructor  // 기본 생성자 제거
public class UserRequest {
    private final String name;    // final 추가
    private final String email;   // final 추가
}
// → Jackson 역직렬화 실패!

 

 


3. 실제 프로덕션에서 발생한 장애 사례

3.1 장애 시나리오

// 개발 환경에서는 잘 동작하던 코드
@Getter
@RequiredArgsConstructor
public class OrderRequest {
    @NonNull
    private final String productId;
    @NonNull  
    private final Integer quantity;
    private String memo;  // 선택사항
}

// JSON 요청
{
    "productId": "PROD-123",
    "quantity": 2,
    "memo": "빠른 배송 부탁드립니다"
}

 

문제 발생:

  1. 개발 환경: Spring Boot 기본 Jackson 설정 → 정상 동작
  2. 운영 환경: 보안상 Jackson 설정 변경 → 갑자기 500 에러
  3. 고객 불만: 주문이 안 들어가요!

 

3.2 디버깅의 어려움

// 에러 메시지만으로는 원인 파악이 어려움
{
    "timestamp": "2024-10-08T10:30:00.000+00:00",
    "status": 500,
    "error": "Internal Server Error", 
    "message": "JSON parse error: Cannot construct instance",
    "path": "/api/orders"
}

// 개발자가 찾아야 하는 실제 원인:
// 1. Jackson 설정 차이?
// 2. 생성자 문제?  
// 3. 어노테이션 누락?
// 4. 의존성 버전 차이?

 

 


4. 안전한 @RequestBody DTO 패턴

4.1 기본 권장 패턴 (가장 안전)

// ✅ 절대 실패하지 않는 안전한 패턴
@Getter
@NoArgsConstructor     // Jackson이 항상 사용할 수 있는 기본 생성자
@AllArgsConstructor    // 테스트나 Builder에서 사용
@Builder
public class SafeUserRequest {
    @NotBlank(message = "이름은 필수입니다")
    private String name;

    @NotBlank(message = "이메일은 필수입니다")
    @Email
    private String email;

    private String memo;  // 선택적 필드
}

 

장점:

  • Jackson 설정과 무관하게 항상 동작
  • 팀원이 어노테이션을 바꿔도 안전
  • 디버깅하기 쉬움
  • 테스트 코드 작성 편함

 

4.2 불변 객체가 필요한 경우 (고급 패턴)

// ✅ 완전한 불변 객체 + Jackson 호환
@Getter
@JsonDeserialize(builder = ImmutableUserRequest.ImmutableUserRequestBuilder.class)
public class ImmutableUserRequest {
    private final String name;
    private final String email;
    private final String memo;

    @Builder
    @JsonPOJOBuilder(withPrefix = "")  // builder 메소드 prefix 설정
    public ImmutableUserRequest(String name, String email, String memo) {
        this.name = name;
        this.email = email;
        this.memo = memo;
    }

    // Builder 클래스에 Jackson 어노테이션
    public static class ImmutableUserRequestBuilder {
        // Lombok이 자동 생성하는 Builder에 Jackson 설정 적용
    }
}

 

4.3 @JsonCreator 패턴 (전문가용)

// ✅ 명시적으로 Jackson에게 알려주는 패턴
@Getter
public class ExplicitUserRequest {
    private final String name;
    private final String email;

    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public ExplicitUserRequest(
            @JsonProperty("name") String name,
            @JsonProperty("email") String email) {
        this.name = name;
        this.email = email;
    }
}

 

 


5. 실무 장애 예방 체크리스트

5.1 DTO 클래스 작성 시 체크포인트

// ✅ 이 체크리스트를 모두 통과해야 함

// 1. 기본 생성자 존재 여부
@NoArgsConstructor  // ✅

// 2. 접근 제어자 적절성  
@NoArgsConstructor(access = AccessLevel.PUBLIC)  // ✅ Jackson 접근 가능

// 3. 필수 검증 어노테이션
@NotBlank, @NotNull, @Valid 등 적절히 사용  // ✅

// 4. 테스트 가능성
@AllArgsConstructor  // ✅ 테스트에서 객체 생성 편함

// 5. 빌더 패턴 지원
@Builder  // ✅ 가독성 좋은 객체 생성

 

5.2 위험 신호 감지

// 🚨 이런 코드를 보면 즉시 수정!

// 위험 신호 1: 기본 생성자 없음
@AllArgsConstructor  // ❌ @NoArgsConstructor 없음
public class RiskyRequest { ... }

// 위험 신호 2: 매개변수 1개 생성자만 있음  
@RequiredArgsConstructor  // ❌ final 필드 1개만 있는 경우
public class SingleFieldRequest {
    @NonNull private final String name;  // 1개뿐
}

// 위험 신호 3: private 기본 생성자
@NoArgsConstructor(access = AccessLevel.PRIVATE)  // ❌ Jackson 접근 불가
public class InaccessibleRequest { ... }

 

 


6. 테스트로 검증하는 방법

6.1 Jackson 역직렬화 테스트

@Test
public void jackson_역직렬화_테스트() throws Exception {
    // Given
    String json = """
        {
            "name": "john",
            "email": "john@example.com"
        }
        """;

    ObjectMapper objectMapper = new ObjectMapper();

    // When & Then - 예외 없이 성공해야 함
    assertThatNoException().isThrownBy(() -> {
        UserRequest request = objectMapper.readValue(json, UserRequest.class);
        assertThat(request.getName()).isEqualTo("john");
        assertThat(request.getEmail()).isEqualTo("john@example.com");
    });
}

@Test  
public void 다양한_JSON_형태_테스트() throws Exception {
    ObjectMapper objectMapper = new ObjectMapper();

    // 빈 객체
    String emptyJson = "{}";
    assertThatNoException().isThrownBy(() -> 
        objectMapper.readValue(emptyJson, UserRequest.class));

    // null 필드
    String nullFieldJson = """
        {
            "name": "john",
            "email": null
        }
        """;
    assertThatNoException().isThrownBy(() -> 
        objectMapper.readValue(nullFieldJson, UserRequest.class));

    // 추가 필드 (미래 API 버전)
    String extraFieldJson = """
        {
            "name": "john", 
            "email": "john@example.com",
            "newField": "someValue"
        }
        """;
    assertThatNoException().isThrownBy(() -> 
        objectMapper.readValue(extraFieldJson, UserRequest.class));
}

 

6.2 Spring Boot 통합 테스트

@SpringBootTest
@AutoConfigureTestDatabase
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createUser_정상_요청() throws Exception {
        String requestJson = """
            {
                "name": "john",
                "email": "john@example.com"
            }
            """;

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestJson))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("john"));
    }

    @Test 
    void createUser_잘못된_JSON() throws Exception {
        String malformedJson = """
            {
                "name": "john"
                // email 필드 누락
            }
            """;

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(malformedJson))
                .andExpect(status().isBadRequest());
    }
}

 

 


7. 팀 개발에서 발생하는 실제 문제들

7.1 시나리오: 신입 개발자의 코드 수정

초기 코드 (선임이 작성)
@Getter
@AllArgsConstructor
public class ProductRequest {
    private String name;
    private BigDecimal price;  // 2개 필드 → 잘 동작
}
신입 개발자의 수정
@Getter
@AllArgsConstructor
public class ProductRequest {
    private String name;       // 1개 필드만 남김
    // private BigDecimal price;  // 삭제함
}

// 갑자기 API가 500 에러 발생!
// 신입: "저는 아무것도 안 바꿨는데요?" 😅

 

7.2 시나리오: 라이브러리 버전 업그레이드

// Spring Boot 2.x에서 잘 동작하던 코드
@Getter
@RequiredArgsConstructor
public class PaymentRequest {
    @NonNull private final String paymentId;
    @NonNull private final BigDecimal amount;
}

// Spring Boot 3.x로 업그레이드 후
// Jackson 설정이 바뀌어서 갑자기 에러 발생
// → 고객 결제 시스템 장애! 💸

 

 


8. 절대 안전한 코딩 패턴

8.1 표준 Request DTO 패턴

// ✅ 100% 안전한 표준 패턴
@Getter
@NoArgsConstructor     // Jackson 기본 생성자
@AllArgsConstructor    // 테스트/Builder용
@Builder
public class StandardUserRequest {

    @NotBlank(message = "이름은 필수입니다")
    private String name;

    @NotBlank(message = "이메일은 필수입니다")  
    @Email
    private String email;

    @Size(max = 500, message = "메모는 500자를 초과할 수 없습니다")
    private String memo;

    @Valid  // 중첩 객체 검증
    private UserProfile profile;
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder  
public class UserProfile {
    @NotBlank(message = "실명은 필수입니다")
    private String realName;

    @Min(value = 14, message = "최소 연령은 14세입니다")
    private Integer age;
}

 

8.2 컨트롤러 안전 패턴

@RestController
@RequestMapping("/api/users")
@Slf4j
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody StandardUserRequest request) {

        // 비즈니스 로직 전에 검증이 자동으로 완료됨
        log.info("사용자 생성 요청: {}", request.getName());

        UserResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // Jackson 관련 에러 전용 처리
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleJacksonException(
            HttpMessageNotReadableException ex) {

        log.error("JSON 파싱 실패", ex);

        ErrorResponse error = ErrorResponse.builder()
            .message("JSON 형식이 올바르지 않습니다")
            .detail(ex.getMessage())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.badRequest().body(error);
    }
}

 

8.3 불변 객체가 정말 필요한 경우

// ✅ 불변성 + Jackson 호환성 모두 만족
@Getter
@Builder(toBuilder = true)  // 복사 생성 지원
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)  // Jackson용
public class ImmutableRequest {

    @NotBlank private String name;
    @NotBlank @Email private String email;

    // Jackson이 사용할 정적 팩토리 메소드 제공
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public static ImmutableRequest of(
            @JsonProperty("name") String name,
            @JsonProperty("email") String email) {

        return ImmutableRequest.builder()
            .name(name)
            .email(email)
            .build();
    }
}

 

 


9. 프로젝트 표준 가이드라인

9.1 DTO 작성 규칙 (팀 컨벤션)

/**
 * Request DTO 작성 필수 규칙:
 * 1. @NoArgsConstructor 필수
 * 2. @AllArgsConstructor 권장 (테스트 편의성)
 * 3. @Builder 권장 (가독성)
 * 4. 모든 필수 필드에 검증 어노테이션 필수
 * 5. 중첩 객체에는 @Valid 필수
 */

// ✅ 표준 템플릿
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class [Domain]Request {

    @NotBlank(message = "필드명은 필수입니다")
    private String requiredField;

    private String optionalField;

    @Valid
    @NotNull(message = "중첩 객체는 필수입니다")
    private NestedObject nested;
}

 

9.2 코드 리뷰 체크리스트

[ ] @NoArgsConstructor 존재 여부
[ ] 모든 필수 필드에 검증 어노테이션 적용
[ ] 중첩 객체에 @Valid 적용  
[ ] 테스트 코드에서 JSON 역직렬화 검증
[ ] 빈 JSON {}으로도 테스트
[ ] 필드가 1개인 경우 특별히 주의해서 검토

 

 


10. 요약: 왜 항상 기본 생성자를 포함해야 하는가?

10.1 기술적 이유

  1. Jackson 버전 호환성: 모든 Jackson 버전에서 동작 보장
  2. 설정 독립성: ObjectMapper 설정과 무관하게 동작
  3. 예측 가능성: 팀원이 코드를 수정해도 안전

 

10.2 비즈니스 이유

  1. 장애 예방: 프로덕션 환경에서 갑작스런 에러 방지
  2. 개발 생산성: 디버깅 시간 단축
  3. 유지보수성: 신입 개발자도 안전하게 수정 가능

 

10.3 최종 권장사항

// ❌ 절대 이런 코드 작성 금지
@Getter
@RequiredArgsConstructor
public class DangerousRequest {
    @NonNull private final String singleField;  // 매우 위험!
}

// ✅ 항상 이렇게 작성
@Getter  
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SafeRequest {
    @NotBlank private String field;  // 안전함
}

 

결론: @RequestBody DTO는 반드시 @NoArgsConstructor를 포함해야 합니다. "더 안전"이 아니라 "필수 요구사항"입니다! 🎯