티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - Anthropic tool 스키마에서 사라지던 $defs를 되살린 과정
ebson 2026. 6. 23. 14:57오픈소스에 기여할 만한 지점을 찾는 일은, 새 기능을 떠올리는 것보다 이미 있는 코드에서 어긋난 부분을 알아채는 쪽이 저에게는 더 수월했습니다. 그중에서도 특히 단서가 분명했던 방법이 하나 있습니다. 이미 머지된 버그 수정 하나를 골라서, 그 수정이 손댄 곳과 똑같은 일을 하는 "형제 경로"가 같은 모듈 안에 또 있는지 살펴보는 것입니다. 같은 종류의 실수는 보통 한 군데에만 있지 않습니다. 누군가 한쪽을 고쳤다면, 같은 패턴을 쓰는 다른 쪽은 아직 안 고쳐졌을 가능성이 꽤 높습니다. 이번 글에서 다룰 LangChain4j Anthropic 모듈의 tool 스키마 수정도 바로 그런 식으로, 이미 머지된 다른 수정을 따라가다 발견한 빈자리에서 출발했습니다.
이 글은 그 한 건의 기여를, 무엇이 문제였고 그것을 어떻게 확인했으며 어떤 점을 신경 쓰며 고쳤는지 처음부터 끝까지 따라가며 정리한 기록입니다. 최종 변경은 필드 하나와 분기 하나를 더한 작은 수정이지만, 그 작은 변경이 왜 필요한지를 납득하기까지 거쳐야 했던 확인 과정이 저에게는 더 오래 남았습니다.
tool에 넘기는 입력 스키마라는 한 조각에서 시작했습니다
LangChain4j는 여러 LLM 공급자를 같은 추상화로 다룰 수 있게 해 주는 라이브러리입니다. 그중 Anthropic(Claude) 모듈은 LangChain4j가 표현하는 요청을 Anthropic API가 이해하는 JSON 형태로 바꿔 주는 변환 계층을 가지고 있습니다. 그 변환을 담당하는 클래스가 AnthropicMapper입니다.
제가 들여다본 부분은 tool(함수 호출) 정의를 변환하는 길목이었습니다. LLM에게 "이런 도구를 쓸 수 있다"고 알려 줄 때는, 그 도구가 어떤 인자를 받는지를 JSON Schema로 적어 보냅니다. LangChain4j에서는 이 인자 명세를 ToolSpecification이 들고 있고, 그 안의 파라미터는 JsonObjectSchema로 표현됩니다. Anthropic으로 보낼 때는 이걸 AnthropicToolSchema라는 객체로 옮겨 담아 input_schema 자리에 넣습니다.
여기서 잠깐 JSON Schema의 작은 문법 하나를 짚어 두면 이후 이야기가 분명해집니다. 스키마가 복잡해지면, 같은 모양의 객체를 여러 군데에서 반복해 쓰는 일이 생깁니다. 이럴 때 JSON Schema는 공통 정의를 한곳에 모아 두고 그걸 가리키는 방식을 제공합니다. 공통 정의는 최상위의 $defs라는 블록에 이름을 붙여 모아 두고, 실제로 쓰는 자리에서는 {"$ref": "#/$defs/이름"} 형태로 그 정의를 참조합니다. 중첩되거나 재귀적인 구조, 예를 들어 트리처럼 자기 자신을 다시 품는 모델을 표현할 때 이 참조 방식이 특히 쓸모가 있습니다. 핵심은 둘이 한 쌍이라는 점입니다. $ref로 무언가를 가리키면, 그 가리키는 대상이 $defs 안에 반드시 함께 있어야 합니다. 가리키기만 하고 대상이 빠지면, 받는 쪽은 어디로도 닿지 못하는 참조를 손에 쥐게 됩니다.
그런데 LangChain4j가 만들어 Anthropic으로 보내는 tool 입력 스키마를 따라가 보니, 바로 이 한 쌍이 끊어져 있었습니다. $ref는 보내면서 정작 그 대상이 담긴 $defs 블록은 빠진 채로 나가고 있었습니다.
참조는 보내면서 정의는 빼고 보냈습니다
문제의 자리는 AnthropicMapper.toAnthropicTool()이 입력 스키마를 조립하는 부분이었습니다. 수정 전 코드는 대략 이런 모양이었습니다.
.inputSchema(AnthropicToolSchema.builder()
.properties(parameters != null ? toMap(parameters.properties(), strict) : emptyMap())
.required(parameters != null ? parameters.required() : emptyList())
.additionalProperties(strict ? Boolean.FALSE : null)
.build());
이 코드는 파라미터 스키마에서 속성 목록(properties)과 필수 항목(required), 그리고 추가 속성 허용 여부(additionalProperties)만 옮겨 담습니다. 어디에도 parameters.definitions(), 그러니까 최상위 $defs에 해당하는 정의 묶음을 읽는 부분이 없습니다. 그래서 파라미터 안에 $ref를 쓰는 속성이 하나라도 있으면, 그 $ref는 그대로 직렬화되어 나가지만 그것이 가리키는 정의는 함께 실리지 못합니다. 받는 입장에서는 가리키는 곳이 비어 있는 참조가 도착하는 셈입니다.
여기서 한 가지 더 확인이 필요했습니다. properties를 옮길 때 호출하는 toMap(parameters.properties(), strict)가 혹시 그 안에서 $defs까지 챙겨 주지는 않을까 하는 점이었습니다. core 모듈의 변환 유틸리티를 따라가 보니, 이 호출은 속성 맵을 인자로 받는 오버로드였고 속성 하나하나를 변환할 뿐 최상위 $defs는 전혀 내보내지 않았습니다. 같은 이름의 다른 오버로드, 즉 JsonObjectSchema 전체를 통째로 받는 쪽은 $defs를 내보내 주지만, tool 경로는 그 오버로드를 쓰지 않고 속성 맵만 떼어 넘기고 있었습니다. 결국 어느 단계에서도 tool의 $defs를 실어 줄 곳이 없었던 것입니다.
같은 결함을 이미 한 번 고친 흔적이 있었습니다
이 지점에서 제가 형제 경로를 찾는 방법이 효과를 봤습니다. Anthropic 모듈에는 tool 스키마 말고도 비슷한 변환이 하나 더 있습니다. 구조화 출력(structured output)을 위해 응답 스키마를 만들어 보내는 경로로, toAnthropicSchema()가 담당합니다. 이쪽을 열어 보니 흥미로운 부분이 있었습니다. 정의가 비어 있지 않으면 $defs를 채워 넣는 처리가 이미 들어가 있었고, 그 처리는 mapDefs()라는 작은 헬퍼를 호출하고 있었습니다.
이력을 따라가 보니 이 처리는 그냥 처음부터 있던 게 아니라, 한 번 빠졌다가 다시 채워진 것이었습니다. 앞서 머지된 한 PR이 바로 이 구조화 출력 경로에 $defs 직렬화를 되살려 넣은 변경이었습니다. 다만 그 수정은 toAnthropicSchema 쪽만 손봤고, 같은 결함을 똑같이 안고 있던 tool 경로(toAnthropicTool)는 건드리지 않았습니다. 한 모듈 안에서 거의 같은 일을 하는 두 길 중 한쪽만 고쳐진, 전형적인 형제 경로 누락이었습니다.
방향을 한 번 더 확실히 하기 위해 다른 공급자와도 비교해 봤습니다. OpenAI 매퍼는 tool 파라미터를 변환할 때 JsonObjectSchema 전체를 그대로 넘기는 방식이라, 같은 입력을 줘도 $defs가 보존됩니다. 즉 같은 $ref 기반 스키마를 쓰더라도 OpenAI tool은 정상적으로 동작하고 Anthropic tool만 끊어진 참조를 보내고 있었습니다. 형제 경로 비교에서 이미 확신이 섰지만, 다른 공급자가 멀쩡히 보존하는 정보를 한쪽만 흘리고 있다는 사실이 수정의 정당성을 한 번 더 받쳐 주었습니다.
빠진 자리를 메우되, 그 이상은 건드리지 않았습니다
고치는 방향은 분명했습니다. 이미 구조화 출력 경로가 쓰고 있는 방식을 tool 경로에도 똑같이 적용하면 되는 것이었습니다. 새 설계를 들일 이유가 전혀 없었고, 오히려 두 경로를 같은 모양으로 맞추는 쪽이 검토하는 사람에게도 설명하기 쉬운 변경이었습니다.
먼저 AnthropicToolSchema에 정의를 담을 자리가 필요했습니다. 이 클래스에는 $defs에 해당하는 필드 자체가 없었기 때문입니다. 그래서 정의 묶음을 담을 필드 하나와 그에 맞는 빌더 메서드를 더했습니다.
@JsonProperty("$defs")
public Map<String, Map<String, Object>> defs;
이 한 줄의 어노테이션이 생각보다 중요했습니다. AnthropicToolSchema는 클래스 차원에서 스네이크 케이스 이름 전략(@JsonNaming(SnakeCaseStrategy.class))을 쓰도록 되어 있습니다. 만약 필드 이름만 defs로 두고 별도 표시를 하지 않으면, 직렬화될 때 이름이 그대로 defs로 나갑니다. 그런데 JSON Schema가 약속한 키는 $defs입니다. defs로 나가면 Anthropic은 그 블록을 정의 묶음으로 알아보지 못하고, 결국 $ref는 다시 갈 곳을 잃습니다. 그래서 직렬화 이름을 정확히 $defs로 고정하려고 @JsonProperty("$defs")를 붙였습니다. 같은 클래스가 쓰는 이름 전략과 충돌하지 않도록 한 작은 장치였습니다.
그다음 매퍼 쪽에서 이 필드를 채웠습니다. 파라미터에 정의가 들어 있을 때에만, 구조화 출력 경로가 이미 쓰던 mapDefs() 헬퍼를 그대로 재사용해 정의를 옮겨 담도록 했습니다.
AnthropicToolSchema.Builder inputSchemaBuilder = AnthropicToolSchema.builder()
.properties(parameters != null ? toMap(parameters.properties(), strict) : emptyMap())
.required(parameters != null ? parameters.required() : emptyList())
.additionalProperties(strict ? Boolean.FALSE : null);
if (parameters != null && !parameters.definitions().isEmpty()) {
inputSchemaBuilder.defs(mapDefs(parameters.definitions()));
}
여기서 신경 쓴 부분이 두 가지 있습니다. 하나는 새 변환 로직을 직접 짜지 않고 이미 검증되어 머지된 mapDefs()를 그대로 가져다 썼다는 점입니다. 같은 일을 하는 코드를 두 벌 두는 대신, 형제 경로가 쓰던 도구를 빌려 오는 쪽이 변경을 작게 유지하고 두 경로의 동작을 한곳에 묶어 두는 길이었습니다. 다른 하나는 정의가 비어 있지 않을 때에만 필드를 채우도록 분기를 둔 점입니다. AnthropicToolSchema는 값이 null인 필드를 직렬화에서 빼도록(@JsonInclude(NON_NULL)) 설정되어 있어서, 정의가 없는 평범한 tool은 이전과 글자 하나 다르지 않은 JSON을 만들어 냅니다. 즉 이 변경은 $ref를 쓰는 스키마에만 영향을 주고, 기존 동작은 그대로 둡니다. 기여 규칙에서 가장 강조하는 "기존 동작을 깨지 않는다"는 조건을 지키려면 이 경계가 분명해야 했습니다.
AnthropicToolSchema에는 이전 버전과의 호환을 위해 남겨 둔 예전 생성자도 있었는데, 그쪽은 일부러 손대지 않았습니다. 필요한 것은 필드와 빌더, 그리고 그것을 비교·표현에 반영하는 정도였고, 기존에 잘 동작하던 부분까지 따라 고칠 이유는 없었기 때문입니다.
테스트가 직렬화 키까지 확인하도록 했습니다
코드를 고친 만큼, 같은 비중으로 테스트를 챙겼습니다. 이 저장소는 테스트가 없는 변경은 아예 검토하지 않는다는 규칙을 분명히 두고 있고, 무엇보다 이번 버그 자체가 "정상 입력에 대해 잘못된 결과를 내보내던" 종류라 재발 방지가 중요했습니다.
다행히 Anthropic 모듈에는 API 키 없이도 돌릴 수 있는 순수 단위 테스트가 이미 있었습니다. 매퍼 변환을 직접 검증하는 테스트들이라, 여기에 두 가지 경우를 더했습니다.
하나는 정의가 있는 경우입니다. $ref로 다른 정의를 가리키는 속성을 가진 ToolSpecification을 만들어 변환한 뒤, 결과 입력 스키마에 $defs가 실제로 들어 있고 가리키던 이름을 담고 있는지를 단언했습니다. 그리고 한 걸음 더 들어가, 변환 결과를 실제 JSON 문자열로 직렬화했을 때 키가 정확히 "$defs"로 나오는지, 혹시라도 "defs"로 새어 나가지는 않는지까지 확인했습니다.
AnthropicTool anthropicTool = toAnthropicTool(toolSpecification, AnthropicCacheType.NO_CACHE, Set.of(), null);
assertThat(anthropicTool.inputSchema.defs).isNotNull();
assertThat(anthropicTool.inputSchema.defs).containsKey(reference);
String json = new ObjectMapper().writeValueAsString(anthropicTool.inputSchema);
assertThat(json).contains("\"$defs\"");
assertThat(json).doesNotContain("\"defs\"");
이 마지막 두 줄이 앞서 어노테이션으로 막아 둔 함정을 그대로 지키는 그물입니다. 만약 누군가 나중에 @JsonProperty("$defs")를 무심코 지운다면, 직렬화 키가 defs로 바뀌면서 이 단언이 곧장 실패합니다. 필드가 채워졌는지만 보는 것이 아니라, 바깥으로 나가는 JSON의 키 이름까지 못 박아 둔 셈입니다.
다른 하나는 정의가 없는 평범한 경우입니다. 정의가 없는 tool을 변환했을 때 defs가 비어 있고, 직렬화된 JSON에 $defs라는 글자가 아예 나타나지 않는지를 단언했습니다. 이 음성 케이스가 "기존 동작은 그대로"라는 약속을 코드로 증명해 줍니다. 검토하는 사람이 호환성 걱정을 굳이 머릿속으로 따져 보지 않아도, 테스트가 대신 그 경계를 지켜 줍니다.
고치는 일보다 절차를 지키는 일에 더 마음을 썼습니다
수정 자체는 작았지만, 이 저장소에 변경을 올리는 절차를 제대로 밟는 데에는 그만큼의 주의가 들었습니다. LangChain4j는 버그를 발견하면 곧장 PR을 올리는 대신, 먼저 이슈로 문제를 공유하고 그다음 그 이슈 번호를 연결한 PR을 올리는 흐름을 권합니다. PR 본문 맨 위에는 어떤 이슈를 닫는 변경인지 Closes #이슈번호 형태로 적습니다. 또 이 저장소는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지로 남습니다. 그래서 제목을 영어 명령형으로, 무엇을 어디서 고쳤는지가 한 줄에 드러나도록 다듬었습니다. 막연히 "버그 수정"이 아니라, 스키마 참조를 쓰는 tool의 입력 스키마에 $defs를 포함시킨다는 내용이 제목만 봐도 전달되도록 했습니다.
PR 본문에서 한 가지 의식적으로 챙긴 부분은, 이 변경이 앞서 구조화 출력 경로를 고친 그 PR의 빠진 형제 경로를 마저 채우는 작업이라는 점을 분명히 밝힌 것입니다. 변경만 떼어 놓고 보면 필드 하나 추가에 불과하지만, "왜 지금 이걸 고치는가"의 맥락은 그 앞선 수정과 이어 놓아야 온전히 전달됩니다. 검토하는 사람이 같은 결함의 절반이 이미 머지되었다는 사실을 알면, 이 변경의 필요성을 코드보다 먼저 이해할 수 있다고 생각했습니다.
변경이 기존 동작을 깨지 않는지, 새 의존성을 들이지 않는지, Java 17 호환을 유지하는지 같은 기본 조건들도 차례로 확인했습니다. 새로 더한 필드는 값이 없으면 직렬화되지 않으니 동작 호환이 유지되고, 변환 로직은 이미 있던 헬퍼를 재사용했으니 새 의존성도 없습니다. 모듈 단위 테스트를 돌려 초록불을 확인하는 것까지가 제 몫이었고, 실제 API를 호출하는 통합 테스트는 키가 필요해 그 자리에서는 돌리지 않았습니다.
작은 필드 하나에서 배운 것
이 기여를 마치고 돌아보면, 더해진 코드의 양은 정말 적습니다. 필드 하나와 빌더 하나, 그리고 정의가 있을 때만 그 필드를 채우는 분기 하나가 본질의 전부입니다. 그런데 그 작은 변경에 도달하기까지의 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 이미 머지된 수정을 단서로 같은 결함의 다른 자리를 찾는 방법이 실제로 통한다는 경험입니다. 한 번 발생한 종류의 실수는 비슷한 일을 하는 다른 경로에도 같은 모양으로 숨어 있곤 합니다. 구조화 출력 경로가 고쳐졌다는 사실 하나가, 거의 같은 일을 하는 tool 경로를 의심할 충분한 이유가 되어 주었습니다. 무언가를 처음부터 발명하기보다, 저장소가 이미 내린 결정을 따라가며 그 결정이 빠뜨린 자리를 메우는 쪽이, 검토하는 사람에게도 훨씬 받아들이기 쉬운 변경이 된다는 것을 다시 한 번 느꼈습니다.
또 하나는, 직렬화처럼 눈에 잘 안 띄는 세부가 결과를 좌우할 수 있다는 점입니다. 필드를 추가하는 것만으로는 부족했고, 그 필드가 바깥으로 나갈 때 정확히 $defs라는 이름을 달도록 못 박는 한 줄이 있어야 비로소 버그가 닫혔습니다. 객체 안에서 값이 맞게 채워졌는지와, 그 값이 약속된 형식으로 직렬화되어 나가는지는 다른 문제였습니다. 그래서 테스트도 필드 값만이 아니라 최종 JSON의 키 이름까지 확인하도록 두었습니다.
여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 작은 수정 한 건으로 무언가를 다 안다고 말할 수는 없습니다. 다만 이미 고쳐진 수정에서 단서를 얻고, 형제 경로와 다른 공급자의 동작을 나란히 놓고 비교하며, 변경을 꼭 필요한 자리에만 한정하고, 같은 실수가 다시 들어오면 곧장 걸리도록 테스트로 못 박아 두는 이 과정 자체가, 기능 하나를 새로 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 머지된 수정을 따라가다 빈자리를 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5499
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Redis vs DB
- Java Performance
- 백엔드 성능
- spring batch 5
- 캐시 장애
- Double-Checked Locking
- TTL 설계
- 캐시 성능 비교
- Cache Avalanche
- 스레드 생명주기
- mybatis
- InterruptedException
- 백엔드 성능 튜닝
- Redis 성능 개선
- 트랜잭션 관리
- DB 인덱스 성능
- Enum 기반 싱글톤
- 백엔드 성능 설계
- Hot Key 문제
- Initialization-on-Demand Holder Idiom
- Eager Initialization
- Spring Batch
- Redis 캐시 전략
- 트래픽 처리
- 백엔드 아키텍처
- DB 트랜잭션
- 캐시와 인덱스
- 동시성처리
- Cache Penetration
- Cache Aside
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
