CATEGORY

카테고리 (656)
AI (54)
Language & Specs (260)
FrameWork (36)
Library (20)
App (41)
Git (10)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (17)
IT (120)
반응형
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

Library/Jackson

cannot deserialize value of type 원인과 해결

반응형

Cannot deserialize value of type 오류는 JSON의 모양과 자바 클래스의 타입이 어긋났다는 뜻입니다. 스프링에서는 앞에 JSON parse error: 가 붙어 400으로 떨어집니다. 원인은 대부분 넷 중 하나입니다 — ① 문자열 자리에 객체나 배열이 왔다, ② 리스트 자리에 객체가 왔다, ③ 날짜 형식이 안 맞는다, ④ 기본 생성자가 없다. 아래는 Java 21 · jackson-databind 2.21.5로 유형별로 직접 재현해 찍은 오류 메시지와, 실제로 통과시킨 해결책입니다.

1. 오류 메시지에서 원인 읽는 법

이 오류는 메시지 자체가 답을 반쯤 알려줍니다. 읽는 순서는 이렇습니다.

Cannot deserialize value of type `java.lang.String` from Object value (token `JsonToken.START_OBJECT`)
                                  └─ 자바가 기대한 타입 ─┘      └─ JSON에 실제로 온 것 ─┘

"String을 기대했는데 JSON에는 객체({)가 왔다"는 말입니다. 뒤의 token 값이 실제로 온 모양을 알려줍니다.

토큰 JSON에 실제로 온 것
START_OBJECT 중괄호로 시작하는 객체 { ... }
START_ARRAY 대괄호로 시작하는 배열 [ ... ]
VALUE_STRING 따옴표로 감싼 문자열

스프링 컨트롤러에서 나면 이렇게 보입니다. MockMvc로 실제 요청을 보내 확인한 결과입니다.

요청 본문 : {"name":{"first":"kim"}}
응답 상태 : 400
예외      : HttpMessageNotReadableException
메시지    : JSON parse error: Cannot deserialize value of type `java.lang.String`
            from Object value (token `JsonToken.START_OBJECT`)
💡 이건 서버 버그가 아니라 요청 본문이 스펙과 다르다는 신호입니다. 그래서 500이 아니라 400입니다. 프런트가 보내는 실제 본문부터 확인하는 게 빠릅니다.

2. 문자열 자리에 객체나 배열이 왔을 때

가장 흔한 경우입니다. DTO는 String name 인데 JSON은 이렇게 옵니다.

{"name": {"first": "kim"}}   →  from Object value (token `JsonToken.START_OBJECT`)
{"name": ["kim"]}            →  from Array value  (token `JsonToken.START_ARRAY`)

정석은 보내는 쪽을 고치는 것입니다. 자바스크립트에서 객체를 그대로 담아 보내는 실수가 잦습니다({name: userObj}처럼). 다만 외부 API라 본문을 못 바꾸는 상황이면, 값이 하나짜리 배열로 올 때에 한해 다음 설정으로 풀어서 받을 수 있습니다.

ObjectMapper om = new ObjectMapper()
        .enable(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS);

om.readValue("{\"name\":[\"kim\"]}", User.class);   // 통과 (name = kim)

다만 배열에 값이 둘 이상이면 여전히 막힙니다. 실제로 돌려 보면 이렇게 나옵니다.

Attempted to unwrap 'java.lang.String' value from an array
(with `DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS`) but it contains more than one value

3. 리스트 자리에 객체나 단일 값이 왔을 때

List<String> tags 인데 객체가 오면 이렇게 됩니다.

{"tags": {"a": 1}}
→ Cannot deserialize value of type `java.util.ArrayList<java.lang.String>`
  from Object value (token `JsonToken.START_OBJECT`)

반대로 값이 하나일 때 배열이 아니라 그냥 문자열로 오는 경우도 많습니다. 이건 설정 하나로 받을 수 있습니다.

// 설정 없이 {"tags":"a"} 를 넣으면
Cannot construct instance of `java.util.ArrayList` (although at least one Creator exists):
no String-argument constructor/factory method to deserialize from String value ('a')

// 설정을 켜면 통과 (tags = [a])
ObjectMapper om = new ObjectMapper()
        .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);

4. 날짜에서 나는 경우

LocalDateTime 필드는 오류가 두 단계로 납니다. 먼저 모듈이 없으면 이렇게 막힙니다.

Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling

모듈을 등록해도 형식이 다르면 다시 막힙니다. 기본은 ISO 형식(2026-08-29T10:00:00)이라, 가운데가 T가 아니라 공백이면 이렇게 나옵니다.

Cannot deserialize value of type `java.time.LocalDateTime` from String "2026-08-29 10:00:00":
Failed to deserialize java.time.LocalDateTime: (java.time.format.DateTimeParseException)
Text '2026-08-29 10:00:00' could not be parsed at index 10

해결은 필드에 형식을 명시하는 것입니다. 아래 형태로 "2026-08-29 10:00:00" 이 통과하는 것을 확인했습니다.

public class Event {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    public LocalDateTime startAt;
}

날짜가 응답에서 배열로 나가는 반대 상황은 원인이 달라서, Jackson LocalDateTime 직렬화 글에 따로 정리해 두었습니다.

5. 기본 생성자가 없을 때

메시지가 Cannot construct instance of 로 시작하면 타입이 아니라 객체를 만들 방법이 없다는 뜻입니다.

Cannot construct instance of `NoDefault` (although at least one Creator exists):
cannot deserialize from Object value (no delegate- or property-based Creator)

필드가 final 이라 기본 생성자를 못 만드는 경우가 대표적입니다. 생성자에 표시를 달아 주면 됩니다.

public class NoDefault {
    public final String name;

    @JsonCreator
    public NoDefault(@JsonProperty("name") String name) {
        this.name = name;
    }
}
💡 record를 쓰면 이 표시 없이도 동작합니다. Jackson이 record의 생성자를 알아보기 때문입니다.

6. 숫자·빈 본문에서 나는 경우

상황 실제 결과
int 자리에 문자열
{"age":"스무살"}
InvalidFormatExceptionCannot deserialize value of type `int` from String "스무살": not a valid `int` value
int 자리에 null
{"age":null}
예외가 안 납니다. 기본 설정에서는 age = 0 이 들어갑니다
본문이 비어 있음 No content to map due to end-of-input

두 번째 줄이 함정입니다. 널이 들어와도 조용히 0으로 채워지기 때문에, 값이 안 넘어온 것을 나중에야 발견하게 됩니다. 여기서 오류를 내고 싶다면 옵션을 켜야 하고, 그때 비로소 Cannot map `null` into type `int` 가 나옵니다.

// 기본값은 꺼져 있다 (직접 확인: isEnabled(...) == false)
ObjectMapper om = new ObjectMapper()
        .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);

// Cannot map `null` into type `int`
//  (set DeserializationConfig.DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES to 'false' to allow)
⚠️ 애초에 int 대신 Integer 를 쓰면 널이 널로 들어와 구분됩니다. "값이 0인 것"과 "값이 안 온 것"을 구분해야 하는 필드라면 래퍼 타입이 안전합니다.

7. 스프링 부트에서 옵션 켜기

위 옵션들은 코드에서 ObjectMapper 를 직접 만들지 않아도, 설정 파일로 켤 수 있습니다. 스프링 부트가 spring.jackson.deserialization.* 로 Jackson의 on/off 기능을 그대로 노출합니다.

# application.properties
spring.jackson.deserialization.accept-single-value-as-array=true
spring.jackson.deserialization.unwrap-single-value-arrays=true
spring.jackson.deserialization.fail-on-null-for-primitives=true

모르는 필드가 왔을 때 터지는 UnrecognizedPropertyException 도 같은 계열의 설정으로 끄고 켭니다. 그쪽은 Jackson UnrecognizedPropertyException — 모르는 필드 무시하기 글에 정리해 두었습니다.

자주 묻는 질문 (FAQ)

Q. 어느 필드에서 났는지 모르겠습니다.
메시지 뒤쪽의 참조 경로를 보면 됩니다. 중첩된 객체라도 필드 경로가 그대로 찍힙니다.

Cannot deserialize value of type `java.lang.String` from Object value (token `JsonToken.START_OBJECT`)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 19]
 (through reference chain: Order["items"]->java.util.ArrayList[0]->Item["code"])

리스트의 0번째 항목의 code 필드에서 막혔다는 뜻입니다. 참고로 Source: 자리가 REDACTED 로 나오는 건 정상입니다 — 요청 본문이 로그에 그대로 남지 않도록 Jackson이 기본적으로 가립니다.

Q. 로컬에서는 되는데 운영에서만 납니다.
같은 코드라면 요청 본문이 다른 것입니다. 위 참조 경로로 어느 필드인지 좁힌 뒤, 그 필드에 실제로 들어온 값을 로그로 남겨 비교하세요.

Q. 옵션을 켜서 넘기는 게 맞나요?
외부 API처럼 본문을 못 고칠 때만 권합니다. 우리 프런트가 보내는 요청이라면 보내는 쪽을 고치는 게 정답입니다. 옵션은 전역으로 적용되어 다른 DTO의 실수까지 덮어 버립니다.

Q. 400과 500 중 뭐가 맞나요?
400이 맞습니다. 스프링이 이 예외를 HttpMessageNotReadableException 으로 감싸 400으로 처리합니다. 500이 나온다면 예외 처리기에서 Exception 을 통째로 잡아 500으로 바꾸고 있지 않은지 보세요.

마무리

정리하면 메시지의 of type 뒤(기대한 타입)와 from 뒤(실제로 온 것)를 나란히 읽는 것만으로 원인의 90%가 잡힙니다. 문자열 자리에 객체·배열이 온 건 보내는 쪽 문제, 날짜는 모듈과 형식 문제, Cannot construct instance of 는 생성자 문제입니다. 옵션으로 덮기 전에 실제 요청 본문부터 한 번 찍어 보세요. 위 내용은 Java 21 · jackson-databind 2.21.5 · Spring 6.2.19 기준이며, 메시지 문구는 Jackson 버전에 따라 조금씩 달라질 수 있습니다.


📚 참고 출처 (2026년 8월 29일 확인 · Java 21.0.4 · jackson-databind 2.21.5 · Spring 6.2.19)
· Jackson databind — Deserialization Features
· Spring Boot — Application Properties (spring.jackson)

반응형

COMMENTS