Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
Tags
- 코딩테스트
- 레벨1
- design-pattern
- HashMap
- 코엑스그랜드볼룸
- SpringBoot
- 글또
- static-factory-method
- DevOps
- 프로그래머스
- builder-pattern
- 글또10기
- 클린 아키텍처
- 회고
- 가용영역
- 3계층 아키텍처
- 다짐글
- object-creation
- ReverseNested
- Level2
- React
- 포트앤어댑터 아키텍처
- 클라우드아키텍처
- constructor
- axios
- OpenSearch
- 글쓰기세미나
- QueryDSL
- UserLand
- 헥사고날 아키텍처
Archives
- Today
- Total
oguri's garage
(Spring) Jackson 역직렬화 동작 방식과 안전한 코딩 패턴 본문
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": "빠른 배송 부탁드립니다"
}
문제 발생:
- 개발 환경: Spring Boot 기본 Jackson 설정 → 정상 동작
- 운영 환경: 보안상 Jackson 설정 변경 → 갑자기 500 에러
- 고객 불만: 주문이 안 들어가요!
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 기술적 이유
- Jackson 버전 호환성: 모든 Jackson 버전에서 동작 보장
- 설정 독립성: ObjectMapper 설정과 무관하게 동작
- 예측 가능성: 팀원이 코드를 수정해도 안전
10.2 비즈니스 이유
- 장애 예방: 프로덕션 환경에서 갑작스런 에러 방지
- 개발 생산성: 디버깅 시간 단축
- 유지보수성: 신입 개발자도 안전하게 수정 가능
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를 포함해야 합니다. "더 안전"이 아니라 "필수 요구사항"입니다! 🎯
'개발하다 > Spring' 카테고리의 다른 글
| MyBatis에서 JPA로 점진적 마이그레이션하기 (0) | 2025.10.12 |
|---|---|
| (Spring) @Transactional을 Service 계층에 적용해야 하는 이유와 올바른 사용법 (0) | 2025.10.10 |
| Spring vs Spring Boot 핵심 차이점 (0) | 2025.10.03 |
| Lombok 어노테이션(@Builder, @NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor, @Data) (0) | 2025.10.02 |
| QueryDSL Q-Type 클래스 - 개념, 생성, 활용법 (0) | 2025.10.01 |