티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - 필수 입력 누락이 null로 새던 버그를 잡은 LangChain4j MCP 기여 후기
ebson 2026. 6. 23. 14:55오픈소스 코드를 읽다 보면, 어떤 클래스 하나만 주변과 미묘하게 다르게 동작하는 자리를 만날 때가 있습니다. 문법적으로 틀린 것도 아니고 테스트가 빨갛게 깨지는 것도 아닙니다. 다만 같은 일을 하는 형제 코드들과 약속이 어긋나 있을 뿐입니다. 이번에 LangChain4j에 올린 수정도 그런 자리에서 시작했습니다. 고친 줄은 세 줄이지만, 그 세 줄을 확신을 가지고 적기까지가 이 기여의 전부였다고 느낍니다.
LangChain4j는 자바에서 대규모 언어 모델을 다루기 위한 라이브러리입니다. 그중 langchain4j-agentic-mcp 모듈은 MCP(Model Context Protocol)로 노출된 외부 도구를 에이전트처럼 호출할 수 있게 해 줍니다. 다른 에이전트들과 한 워크플로우 안에 섞여, 앞선 단계가 만들어 둔 값을 입력으로 받아 도구를 부르고 결과를 다음 단계로 넘기는 식입니다. 제가 손을 댄 부분은 그 입력을 모아 도구 호출 인자로 넘기는 길목이었습니다.
형제 코드와 어긋난 한 곳
문제의 메서드는 McpClientAgentInvoker.agentInvocationArguments였습니다. 타입이 정해진(typed) MCP 에이전트가 워크플로우에서 호출될 때, 선언된 입력 키들을 하나씩 돌면서 현재 상태(AgenticScope)에서 값을 읽어 인자 배열에 담는 짧은 메서드입니다. 당시 코드는 이런 모양이었습니다.
int i = 0;
for (String argName : inputKeys) {
Object argValue = agenticScope.readState(argName);
positionalArgs[i++] = argValue;
namedArgs.put(argName, argValue);
}
겉보기에는 평범합니다. 입력 키마다 값을 읽어 자리에 넣을 뿐입니다. 그런데 readState가 돌려준 값이 null이면 어떻게 되는지 따라가 보니 생각이 달라졌습니다. 값이 없어도 아무 검사 없이 그대로 null이 인자 배열에 담기고, 그 null이 MCP 도구 호출로 그대로 넘어갑니다. 즉 필수 입력이 빠져 있어도 누락이라고 알려 주는 대신, 도구는 null을 정상 인자처럼 받습니다.
이게 왜 문제인지는 같은 프레임워크의 다른 구현들과 비교했을 때 분명해졌습니다. 표준 untyped 구현인 UntypedAgentInvoker는 입력을 돌면서 값이 null이면 곧장 MissingArgumentException을 던집니다. 또 다른 표준 경로인 AgentUtil의 인자 처리도, 값이 없고 기본값도 없고 선택 입력도 아니라면 같은 예외를 던집니다. 비정상이면 어디서 무엇이 빠졌는지 분명한 예외로 즉시 멈춘다는 태도가 프레임워크 전반에 깔려 있었습니다. 그런데 유독 MCP의 typed 경로만 그 태도에서 벗어나 null을 조용히 흘려보내고 있었습니다.
이 어긋남이 실제로 사람을 괴롭히는 순간은 여러 에이전트를 한 워크플로우에 엮을 때입니다. 워크플로우 안에서 각 단계는 앞 단계가 상태에 써 둔 값을 입력으로 받습니다. 그러다 누군가 출력 키 이름을 한 글자 다르게 적거나 한 단계를 빠뜨리면, 그 입력은 상태에 존재하지 않게 됩니다. 이때 일반 에이전트라면 그 자리에서 곧장 멈추며 "이 입력이 없다"고 알려 줍니다. 그런데 같은 워크플로우에 섞인 MCP 에이전트만은 멈추지 않고 null을 도구로 넘긴 뒤, 도구 안쪽에서 한참 뒤에 엉뚱한 모양으로 실패합니다. 같은 실수인데 에이전트 종류에 따라 실패 시점도, 오류 메시지도 달라지니, 막상 원인을 추적할 때 가장 헷갈리는 부류가 됩니다. 표면적으로는 한 인자의 null 검사 누락이지만, 그 여파는 워크플로우 전체의 디버깅 일관성으로 번지는 셈입니다.
이게 계약 위반이라는 근거
코드 스타일이 조금 다른 것과 약속을 어긴 것은 다릅니다. 그래서 이게 단순한 취향 차이가 아니라 분명한 계약 위반인지부터 확인하고 싶었습니다. 근거는 인터페이스 시그니처 자체에 있었습니다. 이 모듈들이 공통으로 구현하는 AgentInvoker 인터페이스의 toInvocationArguments 메서드는 시그니처에 throws MissingArgumentException을 선언하고 있었습니다.
자바에서 메서드 시그니처에 검사 예외(checked exception)를 선언한다는 것은, 그 메서드를 구현하거나 호출하는 쪽에게 "이런 상황에서는 이 예외가 날 수 있다"를 코드로 약속하는 일입니다. 그러니까 "필수 입력이 빠지면 MissingArgumentException을 던진다"는 것은 이 프레임워크가 시그니처로 못 박아 둔 약속이었습니다. 표준 구현 두 곳은 그 약속을 지키고 있었고, MCP의 typed 구현만 약속해 놓고 이행하지 않고 있었습니다. 버그라기보다 구현이 빠진 자리에 가까웠습니다.
이 인터페이스를 구현하는 다른 인보커들도 시그니처에 같은 예외 선언을 그대로 달고 있었습니다. 그러니 "필수 입력이 빠지면 예외를 던진다"는 약속은 어느 한 구현의 사정이 아니라 인터페이스 차원에서 모든 구현에 똑같이 걸린 공통 계약이었습니다. 표준 경로들은 그 줄을 지켰고, MCP의 typed 경로만 홀로 그 줄에서 빠져 있었던 셈입니다. 그래서 이 수정을 두고 "동작을 바꾼다"기보다 "이미 모두가 따르던 약속에 한 곳을 마저 합류시킨다"는 쪽으로 마음이 기울었습니다.
이렇게 정리하고 나니, 고쳐야 할 동작이 무엇인지가 코드로 확정되었습니다. 제가 새로운 동작을 발명할 필요가 없었습니다. 형제 구현이 이미 하고 있는 그대로를, 빠진 한 곳에 채워 넣으면 되는 일이었습니다.
고친 모습
실제 수정은 값을 읽은 직후 null을 가려내는 가드 하나를 더한 것입니다.
for (String argName : inputKeys) {
Object argValue = agenticScope.readState(argName);
if (argValue == null) {
throw new MissingArgumentException(argName);
}
positionalArgs[i++] = argValue;
namedArgs.put(argName, argValue);
}
예외에는 어떤 입력이 빠졌는지를 담았습니다. 이제 워크플로우 앞단이 어떤 출력 키를 빠뜨려서 MCP 에이전트가 필요한 입력을 못 받으면, null이 도구까지 흘러가 뒤늦게 모호하게 실패하는 대신, 어느 인자가 없는지 분명히 적힌 MissingArgumentException이 그 자리에서 납니다. 같은 워크플로우에 섞인 다른 에이전트와 똑같은 시점에, 똑같은 방식으로 멈추게 된 셈입니다.
값이 있는 정상 경로는 한 글자도 바뀌지 않았습니다. 입력이 제대로 들어오는 평범한 호출은 예전과 똑같이 동작합니다. 그래서 이 변경은 동작을 깨지 않는, 빠져 있던 검증을 채우는 수정입니다.
왜 일괄 검사가 안전한지, 그리고 어디까지만 고쳤는지
여기서 한 가지 조심스러운 질문이 남습니다. 모든 null 입력을 다 예외로 막아 버리면, 원래 null이 허용되던 선택 입력까지 막아서 멀쩡하던 동작을 깨는 건 아닐까 하는 점입니다. 기여를 할 때 이런 의심을 건너뛰면, 버그 하나 고치면서 다른 동작을 망가뜨리기 쉽습니다.
typed 경로에 한해서는 이 일괄 검사가 안전하다는 것을 코드로 확인할 수 있었습니다. typed MCP 에이전트의 입력 인자는 AgentArgument로 표현되는데, 이 모듈에서는 그것이 항상 기본값 없이(defaultValue=null), 선택이 아닌(isOptional=false) 형태로만 만들어집니다. 즉 표준 구현이 거치는 "값이 없으면 기본값을 보고, 그래도 없으면 선택 입력인지 보고, 그것도 아니면 예외" 라는 3단계 검사가, typed MCP에서는 처음부터 마지막 단계 하나로 줄어듭니다. 그러니 null을 곧장 예외로 막는 것이 곧 표준 동작과 같습니다. 과하게 엄격해지는 것이 아니라, 표준이 typed MCP에서 자연스럽게 도달하는 결론을 그대로 적은 것입니다.
반면 타입이 정해지지 않은(untyped) 경로는 일부러 손대지 않았습니다. untyped 경로의 입력 키는 도구의 JSON 스키마에 선언된 모든 속성에서 나오는데, 거기에는 필수가 아닌 선택 파라미터도 섞여 있습니다. 이쪽에 똑같이 일괄 검사를 넣으면, 정당하게 비어 있을 수 있는 선택 파라미터에서 예외가 나 정상 동작이 깨집니다. 그래서 이번 변경은 typed 경로 하나로만 범위를 좁혔습니다. untyped 경로의 같은 결함은 필요하다면 선택 여부를 스키마에서 받아 따로 다루는 게 맞다고 보고, 별도 후속 작업으로 미뤄 두었습니다. 한 PR에는 한 가지 주제만 담는 편이, 검토하는 쪽에도 나중에 이 코드를 볼 사람에게도 친절하다고 느낍니다.
테스트로 못 박기
LangChain4j는 테스트 없는 변경은 리뷰하지 않는다는 원칙을 분명히 합니다. 정상 케이스와 비정상 케이스를 모두 덮어야 합니다. 그래서 고친 동작을 검증하는 단위 테스트가 필요했는데, 여기에 약간의 고민이 있었습니다. 수정한 typed 경로를 실제로 타게 하려면, typed MCP 에이전트를 워크플로우에 넣고 필수 입력이 빠진 채 호출해야 했습니다.
다행히 이 모듈의 기존 테스트가 길을 알려 주었습니다. McpClient를 목(mock)으로 만들면 실제 API 키나 외부 서버 없이도 에이전트를 구성할 수 있었습니다. 그래서 번역 도구를 흉내 낸 목 클라이언트로 typed 에이전트를 만들고, 그것을 sequenceBuilder 워크플로우에 넣은 다음, 필수 입력인 language를 일부러 빼고 호출하는 테스트를 더했습니다. 수정 전 코드에서는 language=null이 도구로 흘러가 버리지만, 수정 후에는 language가 빠졌다는 메시지를 담은 MissingArgumentException이 납니다. 테스트는 예외가 났다는 사실에서 그치지 않고, 그 메시지에 빠진 입력 이름인 language가 들어 있는지까지 확인하도록 적었습니다. 어느 인자가 비어 있는지가 예외에 정확히 담겨야 디버깅하는 사람에게 쓸모가 있는데, 이 수정이 노린 효과가 바로 거기에 있었기 때문입니다. 버그를 먼저 재현하는 테스트를 적고, 그 테스트가 통과하도록 고친 셈입니다. 기존 테스트들이 정상 입력 케이스를 이미 덮고 있어서, 정상과 비정상이 함께 검증되도록 맞췄습니다.
포맷이 데려온 군더더기
마무리하면서 예상하지 못한 일이 하나 있었습니다. 제가 건드린 두 파일은 저장소가 palantir 코드 포맷을 도입하기 전에 만들어진 오래된 파일이었습니다. CI가 쓰는 spotless 포맷 검사는 ratchetFrom=origin/main 설정 때문에, 한 번 손댄 파일을 새 포맷 기준으로 다시 정렬합니다. 그래서 제가 의미 있게 바꾼 곳은 가드 하나와 import 한 줄뿐인데, diff에는 메서드 체인을 다시 줄바꿈하고 import 순서를 바꾸는 변경이 함께 섞여 들어왔습니다.
처음에는 이 군더더기 같은 변경을 어떻게 설명해야 하나 망설였습니다. "한 줄 고쳤다"고 적어 놓고 diff에는 포맷 변경이 들어 있으면 검토하는 사람이 의아할 테니까요. 결국 PR 본문에 그대로 적었습니다. 이 파일들이 palantir 포맷 이전 것이라 손대는 순간 spotless가 일부 줄을 재포맷했고, 이는 CI가 요구하는 사항이라는 설명입니다. 일부러 인접 코드를 다듬은 것이 아니라 도구의 규칙 때문에 생긴 변경임을 분명히 해 두었습니다.
절차에 대해 배운 것
이번 기여도 LangChain4j가 권하는 절차를 차분히 밟아 본 경험이었습니다. 버그 수정도 이슈를 먼저 등록하고, 그 이슈를 Closes #번호로 연결한 Draft PR을 올린 다음, 승인을 받고 나서 문서나 예제를 더하는 흐름입니다. 그래서 먼저 이슈에 문제를 정리했습니다. 어떤 경로에서 null이 조용히 새는지, 그것이 시그니처의 throws MissingArgumentException 선언과 어떻게 어긋나는지, 형제 구현 두 곳은 어떻게 처리하는지를 적었습니다. 표준 구현의 정확한 위치를 함께 짚어 두니, "이게 정말 버그냐 의도된 동작이냐"는 논의를 미리 줄일 수 있었습니다.
변경 자체는 작게 유지했습니다. 가드 하나가 핵심이고, 리팩터링이나 다른 개선을 끼워 넣지 않았습니다. 새 의존성도 추가하지 않았습니다. MissingArgumentException은 이 모듈이 이미 의존하는 같은 프레임워크 안의 타입이라 import 한 줄로 충분했습니다. LangChain4j는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치 커밋 메시지가 됩니다. 그래서 제목도 "Throw MissingArgumentException for missing required input in McpClientAgentInvoker"처럼 무엇을 했는지가 한눈에 들어오는 영어 명령형으로 적었습니다. 이 변경은 결국 머지되었습니다(https://github.com/langchain4j/langchain4j/pull/5477).
남은 생각
돌이켜 보면, 이번 일에서 코드를 고친 시간보다 "고쳐도 되는 곳인지"를 확인한 시간이 훨씬 길었습니다. 형제 구현이 어떻게 동작하는지 읽고, 인터페이스 시그니처가 무엇을 약속하는지 확인하고, typed와 untyped 경로가 입력 키를 어디서 얻는지 따라가고 나서야 비로소 가드 한 줄을 자신 있게 적을 수 있었습니다. 그리고 그 확인 과정에서, 어디까지 고치고 어디는 남겨 두어야 하는지의 경계도 함께 그어졌습니다.
좋은 기여가 반드시 큰 변경일 필요는 없다는 것을 이번에도 다시 느꼈습니다. 빠져 있던 검증 한 줄을 채우고, 그 동작을 테스트로 못 박고, 일괄 검사가 안전한 범위만 골라 손대고, 위험한 범위는 정직하게 미뤄 둔다. 코드량으로 보면 작은 변경이지만, 이제 이 라이브러리로 MCP 에이전트를 엮는 누군가는 입력을 빠뜨렸을 때 정체불명의 null 대신 어느 인자가 없는지 적힌 예외를 곧바로 받습니다. 디버깅하는 사람에게 그 차이는 결코 작지 않습니다. 다음에 또 형제 코드와 어긋난 자리를 만난다면, 이번처럼 시그니처가 약속한 계약과 표준 구현을 근거로 삼아, 경계를 분명히 긋고 한 걸음씩 확인해 가며 고쳐 보려 합니다.
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Redis 성능 개선
- Eager Initialization
- Cache Avalanche
- Enum 기반 싱글톤
- 트래픽 처리
- Double-Checked Locking
- DB 인덱스 성능
- Cache Aside
- 캐시와 인덱스
- spring batch 5
- InterruptedException
- 동시성처리
- Hot Key 문제
- 스레드 생명주기
- 백엔드 성능
- DB 트랜잭션
- Redis vs DB
- 캐시 장애
- 백엔드 성능 설계
- 백엔드 아키텍처
- Cache Penetration
- mybatis
- 트랜잭션 관리
- TTL 설계
- Redis 캐시 전략
- Initialization-on-Demand Holder Idiom
- 백엔드 성능 튜닝
- 캐시 성능 비교
- Spring Batch
- Java Performance
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
