Jackson 3 사용하기
Jackson 3 사용하기 (Using Jackson 3)
LangChain4j는 JSON을 많은 곳에서 읽고 써요: LLM 프로바이더와 주고받는 요청·응답, AI Service가 파싱하는 구조화된 출력, 영속화하는 채팅 메모리 등등. 기본적으로는 Jackson 2로 해요. LangChain4j가 JSON을 어떻게 쓰고, 자신만의 매퍼를 어떻게 끼워 넣는지는 JSON에서 다뤄요.
출처: 공식문서
애플리케이션이 Jackson 3를 쓰고 있다면 LangChain4j가 대신 그것을 쓰게 할 수 있어요.
켜기 (Turning it on)
의존성 하나를 추가해요:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-jackson3</artifactId>
<version>1.20.0-beta30</version>
</dependency>
langchain4j-agentic을 포함한 모든 모듈에서 이것이 전부예요. LangChain4j는 ServiceLoader로 모듈을 찾아 JSON을 Jackson 3로 라우팅해요. 구성할 것도, 호출할 API도 없고, 의존성을 제거하면 모든 게 Jackson 2로 돌아가요.
지원은 두 아티팩트로 나뉘고, 위의 langchain4j-jackson3이 둘 다 끌어오므로 특별한 이유가 없으면 그것을 추가하면 돼요:
| 아티팩트 | 담당 | 의존 |
|---|---|---|
langchain4j-core-jackson3 |
langchain4j-core의 모든 것: 프로바이더 요청·응답, 구조화된 출력, 채팅 메모리 직렬화, 에이전트 상태 |
langchain4j-core |
langchain4j-jackson3 |
위의 것들 + langchain4j 모듈에 있는 InMemoryEmbeddingStore 영속화 |
langchain4j-core-jackson3, langchain4j |
langchain4j-core와 langchain4j 모듈이 없는 프로바이더만 사용한다면 langchain4j-core-jackson3만 단독으로 추가해요 — 예를 들어 모델을 직접 만드는 프레임워크 통합. langchain4j-jackson3과 함께 추가하는 건 불필요하지만 무해해요.
그대로 유지되는 것
JSON 라이브러리를 바꾸는 건 실수로 동작을 바꾸는 좋은 방법이라, 이 모듈은 그래지 않도록 열심히 노력해요. Jackson 3는 몇 가지 기본값을 바꿨고, 그 모두가 Jackson 2가 했던 것으로 되돌려져요:
| 설정 | Jackson 3 기본값 | 이 모듈이 하는 일 |
|---|---|---|
ALLOW_FINAL_FIELDS_AS_MUTATORS |
비활성 | 활성 |
USE_GETTERS_AS_SETTERS |
비활성 | 활성 |
SORT_PROPERTIES_ALPHABETICALLY |
활성 | 비활성 |
FAIL_ON_TRAILING_TOKENS |
활성 | 비활성 |
FAIL_ON_NULL_FOR_PRIMITIVES |
활성 | 비활성 |
""를 enum으로 |
거부 | Jackson 2처럼 null로 읽음 |
첫 번째가 가장 중요해요: 그것이 없으면 final 컬렉션 필드는 채워지는 대신 비어 남고, 아무것도 알려주지 않아요.
실패는 LangChain4j 타입을 얻어요. 이것이 옵트인이 실제로 뭔가를 바꾸는 유일한 곳이에요. 기본적으로 JSON 실패는 Jackson 2 자신의 예외를 감싼 RuntimeException으로 드러나요 — 즉 반응하는 코드는 Jackson 2를 알아야 해요. 이 모듈을 쓰면 실패하는 JSON 읽기/쓰기가 대신 JsonReadException이나 JsonWriteException을 던져요. 둘 다 LangChain4jException이고, 라이브러리의 예외가 cause로 유지돼요.
이건 불일치가 아니라 의도적인 단계예요: 타입화된 예외는 다음 메이저 버전에서 LangChain4j가 가는 방향이고, Jackson 2 코덱은 기존 코드가 계속 동작하도록 그때까지 그대로 남아요. JSON 실패를 Jackson 타입으로 잡는다면, 이 모듈을 추가할 때 그것 하나는 재검토해야 해요:
- } catch (JsonParseException e) {
+ } catch (JsonReadException e) {
RuntimeException을 잡는 것은 어느 쪽이든 동작해요.
catch 블록 없이 애플리케이션 코드에 도달하는 두 곳이 있어, 전환 전에 확인할 가치가 있어요:
도구 인자 오류 핸들러. 모델이 유효한 JSON이 아닌 도구 인자를 만들면 LangChain4j는 AiServices.toolArgumentsErrorHandler(...)로 등록한 핸들러에 실패를 넘겨요. Jackson 2에서는 그 실패가 Jackson의 JsonParseException이고, 이 모듈에서는 JsonReadException이에요. 타입으로 분기하는 핸들러는 더 이상 매칭되지 않고, 아무도 경고하지 않아요 — 핸들러는 여전히 호출되고 그냥 다른 분기를 타니까요:
AiServices.builder(Assistant.class)
.toolArgumentsErrorHandler((error, context) -> {
- if (error instanceof JsonParseException) {
+ if (error instanceof JsonReadException) {
return ToolErrorHandlerResult.text("Please return valid JSON.");
}
throw error;
})
둘 다에서 동작하는 핸들러를 원한다면 메시지나 RuntimeException으로 매칭해요.
OutputParsingException의 원인. 구조화된 출력을 파싱할 수 없으면 LangChain4j는 어느 쪽이든 OutputParsingException을 던져요 — 그 타입은 바뀌지 않아요. 바뀌는 것은 그 아래의 예외로, Jackson 2의 JsonProcessingException에서 Jackson 3의 StreamReadException으로 바뀌어요. getRootCause()를 검사하는 코드는 같은 처리가 필요해요.
이미 저장한 데이터는 계속 읽을 수 있어요. Jackson 2가 쓴 채팅 메모리와 InMemoryEmbeddingStore 파일은 Jackson 3가 올바르게 읽고, Jackson 3가 쓰는 것은 Jackson 2가 썼을 것과 바이트 단위로 동일해요. 테스트로 덮여 있으니 계속 참이에요.
Jackson 2 제거
모듈을 추가해도 스스로 Jackson 2를 제거하진 않아요 — 여전히 일반 전이 의존성으로 도착하고, 둘은 무기한 공존할 수 있어요. 없애고 싶다면 보통 그렇게 할 수 있는데, 이 모듈이 있으면 LangChain4j는 더 이상 어떤 Jackson 2 클래스도 로드하지 않기 때문이에요.
실제로 떠날 수 있는지는 어떤 모듈을 쓰느냐에 달려 있어요:
| 모듈 | Jackson 2를 제외할 수 있나? |
|---|---|
langchain4j-core, langchain4j |
예 — Jackson 2 코덱을 동반하지만 그것들은 폴백이고 이 모듈이 클래스패스에 있는 동안 절대 로드되지 않음 |
| 프로바이더 모듈 — OpenAI, Anthropic, Mistral, Ollama, Gemini 등 | 예 |
| Embedding stores, web search, code execution | 예 |
langchain4j-agentic |
예 |
langchain4j-guardrails |
아니요 — JsonExtractorOutputGuardrail은 의도적으로 일반 Jackson 2 ObjectMapper로 파싱해서 가드레일이 예전처럼 엄격하게 유지됨 |
langchain4j-mcp |
아니요 — Jackson의 JsonNode가 공개 API에 나타나서 모듈이 모든 경로에서 Jackson 2를 로드함 |
langchain4j-vespa |
아니요 — HTTP 클라이언트가 Retrofit의 자체 Jackson 2 컨버터를 사용함 |
| 벤더 SDK를 쓰는 것 — AWS, Azure, Google | 아니요 — SDK 자체가 Jackson 2에 의존함 |
Maven 제외는 그것이 쓰여진 의존성에 적용되므로, 첫 번째뿐 아니라 선언하는 모든 LangChain4j 의존성에 넣어야 해요:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.20.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- and the same on langchain4j-open-ai, and on any other LangChain4j module you use -->
com.fasterxml.jackson.core:jackson-annotations는 제외하지 마세요. 2.x 좌표를 유지하지만 공유돼요: Jackson 3가 그것에 의존하고 그 어노테이션들을 읽어요.
이 구성은 믿어진다고만 여겨지는 게 아니라 매 PR마다 빌드·실행돼요: integration-tests/integration-tests-jackson3는 정확히 이렇게 조립된 애플리케이션이에요 — core, 메인 모듈, 프로바이더, 에이전트, 그리고 이 모듈, 전체 그래프에서 Jackson 2를 제외한 상태로 — 그 테스트는 AI Services 구조화된 출력, 도구 호출, 채팅 메모리 영속화, 임베딩 스토어 영속화, 에이전트 상태 영속화를 커버해요. 그것 중 어느 하나라도 Jackson 2가 필요해지기 시작하면 그 테스트들이 클래스를 찾지 못해 실패했을 거예요.
MCP, Vespa, 클라우드 SDK를 쓰면 Jackson 2는 남아요. 옵트인은 여전히 LangChain4j가 스스로 직렬화하는 모든 것에 대한 단일 Jackson 3 코드 경로를 줘요.
프로바이더나 DTO를 작성한다면
와이어 타입을 기여한다면 알아야 할 두 가지가 있어요.
이름 짓기는 타입이 아니라 코덱에 속해요. @JsonNaming은 Jackson 2의 databind 패키지에 있어 Jackson 3는 그것을 전혀 보지 못해요 — 필드 이름이 조용히 camelCase로 나와요. 대신 코덱에 네이밍을 설정해요:
ProviderJson.codec(ProviderJsonSpec.builder()
.propertyNaming(ProviderJsonSpec.PropertyNaming.SNAKE_CASE)
.build());
단일 필드가 다른 이름을 필요로 하면 @JsonProperty("...")가 둘 다에서 동작해요. 그것은 두 버전이 공유하는 아티팩트인 jackson-annotations에서 오기 때문이에요.
빌더 기반 DTO는 @JsonCreator가 필요해요. @JsonDeserialize(builder = ...)도 databind 어노테이션이라 Jackson 3에서는 DTO가 대신 빌더를 받는 생성자의 @JsonCreator를 통해 만들어져요. 둘 다 있어야 해요.
빌더 메서드가 실행되는지는 그것의 어노테이션에 달려 있어요. 이건 조용하고 규칙이 추측하는 것과 다르기 때문에 내면화할 부분이에요. Jackson 2는 세터를 호출한 다음 build()로 빌더를 채워요. Jackson 3는 프로퍼티 마커를 가질 때만 세터를 호출하고, 그렇지 않으면 필드를 직접 쓰며 build()를 전혀 호출하지 않아요:
| 빌더 세터에서 | Jackson 2 | Jackson 3 |
|---|---|---|
@JsonSetter("odd-name"), @JsonProperty, 맨 @JsonSetter |
호출됨 | 호출됨 |
단독 @JsonAlias |
호출됨 | 호출되지 않음 - 앨리어스 무시 |
| 어노테이션 없음 | 호출됨 | 호출되지 않음 - 필드 직접 쓰기 |
build() |
호출됨 | 절대 호출되지 않음 |
그래서 두 코덱은 한 타입 안에서 프로퍼티별로 갈라질 수 있어서, 이것은 집중보다 규칙이 가치 있는 이유예요:
타입이 보장하는 것은 두 경로 모두가 지나가는 곳에 두세요. 기본값은 build()가 아니라 빌더의 필드에 있어야 해요. 방어적 복사나 정규화는 세터가 아니라 생성자에 속해요. 앨리어스는 세터뿐 아니라 빌더의 필드에도 @JsonAlias가 필요해요.
각각 여기서 잘못됐었어요: build()에서 기본화된 타입이 모든 mistral-ai 도구 호출을 떨어뜨렸고, 세터의 @JsonAlias가 vLLM, OpenRouter, Groq의 추론 콘텐츠를 떨어뜨렸으며, 어노테이션 없는 세터의 unmodifiableList(...)가 같은 응답이 한 코덱에선 가변, 다른 쪽에선 불변으로 돌아오게 했어요. 그 중 어떤 것도 던지지 않았어요.
옵트인을 지원하는 모듈은 jackson3 Maven 프로파일을 선언하고, 그것은 langchain4j-jackson3을 그 모듈의 테스트 클래스패스에 놓아 기존 테스트가 Jackson 3에 대해 실행되게 해요. CI는 매 PR마다 전부를 실행하고, 당신도 같은 방식으로 한 모듈을 실행할 수 있어요:
mvn test -Pjackson3 -pl langchain4j-your-module
결과를 믿기 전에 모듈의 pom.xml이 실제로 그 프로파일을 선언하는지 확인하세요: Maven은 선택한 모듈이 선언하지 않은 프로파일을 무시하므로, 위 명령은 모든 것을 Jackson 2로 실행하고 성공했다고 보고해요. 프로파일을 추가하는 것이 모듈 마이그레이션의 일부예요.
프로파일은 langchain4j-jackson3이 아니라 langchain4j-core-jackson3을 테스트 클래스패스에 놓는데, 후자는 langchain4j 모듈에 의존해 그 모듈이 스스로 구축된 모듈에 대해 그래프를 순환하게 만들기 때문이에요. 전체 아티팩트가 필요한 InMemoryEmbeddingStore 영속화는 langchain4j-jackson3의 자체 테스트와 integration-tests/integration-tests-jackson3로 덮여요.
langchain4j-open-ai도 OpenAiBuilderCreatorParityTest를 지니는데, 빌더로 만든 모든 빌더 기반 DTO를 {}에서 파싱된 같은 DTO와 비교해요. 그것은 위 누락된 build() 호출이 만드는 차이라 테스트가 한 번에 한 필드가 아니라 전체 OpenAI 와이어 모델에 대해 잡아내요.
자신만의 JSON을 끼워 넣는다면
Jackson 3를 사용하기 위해 이 절은 필요 없어요 — 의존성 추가로 충분해요. 이건 LangChain4j가 하나를 고르게 하는 대신 자신이 구성한 JSON 매퍼를 공급하는 프레임워크를 위한 것이고, langchain4j-jackson3이 하는 일이 바로 그것이에요.
LangChain4j에는 단일 JSON 진입점이 없어요. 아래 각 위치에서 ServiceLoader에 코덱을 요청해서 각각 따로 응답받을 수 있게 해요:
| 서비스 인터페이스 | LangChain4j가 읽고 쓰는 방식을 결정 |
|---|---|
dev.langchain4j.spi.json.JsonCodecFactory |
범용 JSON — AI Service의 구조화된 출력, 모델의 도구 인자 |
dev.langchain4j.spi.json.ProviderJsonCodecFactory |
LLM 프로바이더와 주고받는 요청·응답 |
dev.langchain4j.spi.json.StateJsonCodecFactory |
타입을 미리 알 수 없어서 타입 이름을 지니는 상태 — 에이전트 상태 |
dev.langchain4j.spi.data.message.ChatMessageJsonCodecFactory |
영속화하는 채팅 메모리 |
dev.langchain4j.spi.agent.tool.ToolSpecificationJsonCodecFactory |
ToolSpecification.toJson()과 ToolSpecification.fromJson(String) |
dev.langchain4j.spi.prompt.structured.StructuredPromptFactory |
@StructuredPrompt 템플릿 |
dev.langchain4j.spi.store.embedding.inmemory.InMemoryEmbeddingStoreJsonCodecFactory |
InMemoryEmbeddingStore.serializeToJson() |
마지막은 langchain4j에 있고, 나머지는 langchain4j-core에 있어요. 모두 @Internal인데, 여기서는 애플리케이션이 아니라 통합을 위한 것이고 마이너 버전 사이에 바뀔 수 있다는 뜻이에요.
프레임워크 자신의 구현이 이겨요. 이 중 하나를 이미 공급하는 무언가가 있다면 — Quarkus는 네 개를 공급해요 — 이 모듈을 추가해도 그것을 빼앗지 않아요. Jackson 3 팩토리는 다른 무엇보다 낮은 우선순위를 선언해서 다른 누구도 주장하지 않은 서비스에만 적용되고, LangChain4j는 선택한 구현을 명명한 경고를 로그해요. 그런 프레임워크에서는 옵트인이 설계상 부분적이라는 뜻이에요: 프로바이더 트래픽과 에이전트 상태는 Jackson 3로, 프레임워크가 소유한 서비스는 자신의 코덱에 남아요. 전체 애플리케이션을 Jackson 3로 하려면 클래스패스 순서에 기대기보다 프레임워크의 등록을 제거해요.
그 전부를 구현하거나, 무엇을 빼는지 알거나. 각각 독립적으로 해석되고, 구현이 등록되지 않은 것은 Jackson 2로 폴백돼요. 일부만 응답하는 것은 오류도 경고도 아니에요 — 예를 들어 채팅 메모리는 당신의 매퍼가, 에이전트 상태는 다른 라이브러리가 쓰는 애플리케이션을 만들어요. 의도적으로 하나를 빼면 그 폴백이 당신이 얻는 것이에요.
이 중 두 개는 이미 더 오래된 버전과 통합하고 있다면 다시 볼 가치가 있어요: ProviderJsonCodecFactory와 StateJsonCodecFactory는 새 것이므로, 그것들을 모르는 기존 통합은 프로바이더 트래픽과 에이전트 상태에 조용히 Jackson 2를 쓰면서 계속 동작해요.