티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - A2A 클라이언트의 조용한 실패(silent failure)를 잡아낸 기여 후기
ebson 2026. 6. 23. 14:54오픈소스에 기여할 때 가장 어려운 부분은 코드를 고치는 일이 아니라, 고칠 만한 지점을 찾는 일이라고 느낍니다. 큰 기능을 새로 넣는 일은 메인테이너의 설계 방향과 부딪히기 쉽고, 사소한 오타 수정은 도움은 되지만 깊이 배우기는 어렵습니다. 그 사이 어딘가, 코드를 한참 들여다봐야 비로소 보이는 작은 논리 오류가 기여하기 좋은 자리라고 생각합니다. 이번 글에서 다루는 LangChain4j의 A2A 클라이언트 수정도 그런 자리에서 출발했습니다.
LangChain4j는 자바에서 대규모 언어 모델을 다루기 위한 라이브러리입니다. 그중 langchain4j-agentic-a2a 모듈은 A2A(agent-to-agent) 프로토콜을 통해 원격에 있는 다른 에이전트를 마치 로컬 객체처럼 호출할 수 있게 해 줍니다. 원격 에이전트에게 작업을 보내고, 그쪽에서 처리한 결과를 받아 오는 통신 계층입니다. 제가 손을 댄 부분은 그 결과를 받아서 호출자에게 돌려주는 마지막 단계였습니다.
처음 의심이 든 지점
코드를 읽다가 눈에 걸린 메서드는 DefaultA2AClientBuilder.completeFromTask였습니다. 원격에서 돌아온 Task 객체를 보고, 그 안에서 텍스트를 뽑아 CompletableFuture를 완료시키는 짧은 메서드입니다. 당시 코드는 대략 이런 모양이었습니다.
private static void completeFromTask(Task task, CompletableFuture<String> messageResponse) {
if (!isTerminalState(task.status().state()) && task.artifacts().isEmpty()) {
return;
}
messageResponse.complete(extractTextFromParts(
task.artifacts().stream().flatMap(a -> a.parts().stream()).toList()));
}
처음 읽었을 때는 평범해 보였습니다. 아직 끝나지 않았고 결과물(artifacts)도 없으면 그냥 돌아가서 다음 이벤트를 기다리고, 그렇지 않으면 결과물에서 텍스트를 모아 future를 완료한다. 흐름 자체는 자연스럽습니다. 그런데 isTerminalState가 어떤 상태를 terminal로 보는지 따라가 보니 생각이 달라졌습니다.
private static boolean isTerminalState(TaskState state) {
return state == TaskState.TASK_STATE_COMPLETED
|| state == TaskState.TASK_STATE_FAILED
|| state == TaskState.TASK_STATE_CANCELED
|| state == TaskState.TASK_STATE_REJECTED;
}
t
erminal 상태에는 정상 완료(COMPLETED)뿐 아니라 실패(FAILED), 취소(CANCELED), 거부(REJECTED)도 포함되어 있었습니다. 다시 가드 조건으로 돌아가 보면, early-return은 !isTerminalState(state) && artifacts.isEmpty()일 때만 동작합니다. 즉 상태가 terminal이기만 하면, 그게 실패든 취소든 상관없이 가드를 그냥 통과합니다. 그러고 나서 실패한 작업에는 보통 결과물이 없으니, extractTextFromParts는 빈 리스트에서 빈 문자열을 만들어 내고, future는 complete("")로 정상 완료됩니다.
정리하면 이렇습니다. 원격 에이전트가 작업에 실패해서 FAILED 상태로 응답을 돌려보내도, 그 호출을 한 쪽은 예외도 받지 못하고 빈 문자열 하나를 정상 결과처럼 받습니다. 실패가 빈 성공으로 둔갑하는 셈입니다. 이런 종류의 버그를 흔히 silent failure라고 부르는데, 에러가 어디에서도 드러나지 않고 조용히 삼켜지기 때문에 정작 디버깅할 때 가장 골치 아픈 부류입니다. 호출한 쪽에서는 왜 결과가 비었는지 단서조차 잡을 수 없습니다.
이게 정말 의도된 동작인지부터 확인하기
여기서 곧장 코드를 고치고 싶은 마음이 들었지만, 먼저 멈춰서 확인할 것이 있었습니다. 빈 결과를 허용하는 동작이 혹시 누군가 의도해서 만든 것은 아닐까 하는 점입니다. 기여를 하다 보면, 버그처럼 보이는 코드가 사실은 과거에 어떤 이유로 그렇게 만들어진 경우가 적지 않습니다. 그런 코드를 확인 없이 되돌리면, 정작 막아 둔 다른 문제를 다시 열어 버립니다.
저장소 이력을 따라가 보니 이슈 #3867이 보였습니다. "A2A agent task artifacts should not be mandatory"라는 제목의 이슈로, 결과물이 비어 있거나 null일 때 발생하던 문제를 다룬 기록이었습니다. 그 이슈의 의도는 명확했습니다. 정상적으로 완료(COMPLETED)된 작업이라면 결과물이 비어 있어도 예외 없이 받아들여야 한다는 것입니다. 결과물이 비었다는 이유만으로 죽어서는 안 된다는 취지였습니다.
이 대목이 중요했습니다. #3867이 보호하려는 동작은 "성공했는데 결과물이 빈 경우"였고, 제가 문제 삼는 동작은 "실패했는데 빈 결과로 끝나는 경우"였습니다. 둘은 겉보기에 비슷하지만 전혀 다른 상황입니다. 그래서 제 수정의 범위를 처음부터 분명히 그었습니다. COMPLETED 경로는 손대지 않고, 오직 FAILED, CANCELED, REJECTED라는 실패 terminal 상태만 다르게 다루기로 했습니다. 이렇게 경계를 그어 두면, #3867이 의도한 관용은 그대로 살아 있으면서 silent failure만 걷어 낼 수 있습니다.
같은 클래스가 이미 알려 주던 정답
수정 방향을 잡을 때 가장 든든했던 근거는 같은 클래스 안에 이미 있었습니다. DefaultA2AClientBuilder는 비정상적인 상황을 만나면 대부분 future를 예외로 완료시킵니다. 스트리밍 도중 에러가 나면 completeExceptionally(error)를 부르고, 예상치 못한 이벤트 타입이 오면 예외를 던지며, 응답을 받아 오다 실패하면 RuntimeException을 던집니다. 비정상이면 예외로 알린다는 일관된 태도가 클래스 전반에 깔려 있었습니다.
그런데 유독 실패한 terminal 작업만 그 패턴에서 벗어나 조용히 빈 문자열로 끝나고 있었습니다. 그러니 제 수정은 새로운 규칙을 만드는 일이 아니라, 빠져 있던 한 경로를 나머지와 맞춰 주는 일에 가까웠습니다. 기여를 검토하는 입장에서도 이런 종류의 변경은 받아들이기 편합니다. 취향의 문제가 아니라, 같은 클래스 안에서 이미 합의된 방식을 따르는 것이기 때문입니다.
고친 모습
실제 수정은 가드 다음에 실패 상태를 가려내는 분기 하나를 더하고, 그 분기에서 future를 예외로 완료시키는 것이었습니다.
static void completeFromTask(Task task, CompletableFuture<String> messageResponse) {
TaskState state = task.status().state();
if (!isTerminalState(state) && task.artifacts().isEmpty()) {
return;
}
if (isFailureState(state)) {
Message statusMessage = task.status().message();
String reason = statusMessage != null ? extractTextFromParts(statusMessage.parts()) : "";
messageResponse.completeExceptionally(new RuntimeException("A2A task " + task.id()
+ " ended in terminal state " + state + (reason.isEmpty() ? "" : ": " + reason)));
return;
}
messageResponse.complete(extractTextFromParts(
task.artifacts().stream().flatMap(a -> a.parts().stream()).toList()));
}
private static boolean isFailureState(TaskState state) {
return state == TaskState.TASK_STATE_FAILED
|| state == TaskState.TASK_STATE_CANCELED
|| state == TaskState.TASK_STATE_REJECTED;
}
예외 메시지에는 작업 id와 어떤 terminal 상태로 끝났는지를 담았고, 가능하면 실패 사유까지 붙였습니다. A2A 프로토콜의 Task는 상태와 함께 status().message()로 실패 사유 메시지를 전달할 수 있는데, 기존 코드는 이 정보를 그냥 버리고 있었습니다. 다만 이 메시지는 없을 수도 있어서, null인지 먼저 확인한 다음에만 텍스트를 뽑도록 했습니다. 사유가 있으면 "이러이러해서 실패했다"까지 알려 주고, 없으면 적어도 어떤 작업이 어떤 상태로 끝났는지는 알려 줍니다. 호출한 쪽에서 디버깅할 때 손에 쥘 단서가 빈손에서 두 가지로 늘어난 셈입니다.
COMPLETED 경로는 한 글자도 바꾸지 않았습니다. 정상 완료된 작업은 결과물이 비어 있어도 예전 그대로 빈 문자열로 완료됩니다. #3867이 지키려던 동작이 그대로 유지된다는 뜻입니다.
테스트를 위해 가시성을 한 칸만 연 이유
LangChain4j는 "테스트 없는 변경은 리뷰하지 않는다(no tests, no review)"는 원칙을 분명히 합니다. 그것도 정상 케이스와 비정상 케이스를 모두 덮어야 합니다. 그래서 고친 동작을 검증하는 단위 테스트가 반드시 필요했는데, 여기서 한 가지 고민이 있었습니다.
completeFromTask는 원래 private이었고, 이 메서드까지 가 닿으려면 실제 원격 서버에 연결해 메시지를 주고받아야 했습니다. 그렇게 하면 테스트가 외부 서버에 의존하게 되어, 서버 상태에 따라 통과했다 실패했다 하는 불안정한 테스트가 됩니다. 실패 상태를 결정적으로 재현하기도 어렵습니다.
다행히 A2A SDK가 제공하는 Task, TaskStatus, TaskState 같은 타입은 모두 공개된 빌더로 직접 만들 수 있었습니다. 그래서 네트워크 없이 FAILED 상태의 Task를 손으로 조립해 completeFromTask에 바로 넘기면, future가 예외로 완료되는지를 결정적으로 확인할 수 있었습니다. 이렇게 테스트하려면 메서드에 접근할 수 있어야 했고, 그래서 private을 같은 패키지에서만 보이는 package-private static으로 한 칸만 넓혔습니다.
이 부분은 솔직하게 짚고 넘어가야 할 지점이라고 봤습니다. 테스트를 위해 가시성을 넓히는 것은 그 자체로 마냥 깔끔한 선택은 아닙니다. 프로덕션 코드가 요구하지 않는 변경이기 때문입니다. 그래서 PR 본문에 이 변경이 결정적 단위 테스트를 위한 것이며 공개 API를 바꾸는 것은 아니라는 점을 분명히 적었습니다. 숨기기보다 드러내고 설명하는 편이 검토하는 사람에게도, 나중에 이 코드를 볼 사람에게도 정직하다고 생각했습니다.
테스트는 네 가지 경우를 덮었습니다. 사유가 있는 실패 작업, 사유가 없는 취소 작업, 텍스트 결과물이 있는 정상 완료 작업, 그리고 결과물이 빈 정상 완료 작업입니다. 앞의 둘은 future가 예외로 끝나면서 메시지에 작업 id와 상태, 사유가 담기는지를 확인하고, 뒤의 둘은 예전처럼 정상 완료되는지를 확인합니다. 고친 동작과 보존한 동작을 함께 못 박아 두는 셈입니다.
포맷이 데려온 뜻밖의 변경
작업을 마무리하면서 예상하지 못한 일이 하나 있었습니다. DefaultA2AClientBuilder.java는 저장소가 palantir 코드 포맷을 도입하기 전에 만들어진 오래된 파일이었습니다. LangChain4j의 CI는 spotless라는 포맷 검사를 쓰는데, ratchetFrom=origin/main 설정 때문에 한 번 건드린 파일은 전체가 새 포맷 기준으로 다시 정렬됩니다. 그래서 제가 바꾼 곳은 메서드 하나뿐인데도, import 순서가 바뀌고 줄바꿈이 다시 잡히는 변경이 diff에 함께 섞여 들어왔습니다.
처음에는 이 군더더기 같은 변경을 어떻게 설명해야 하나 고민했습니다. "한 메서드만 고쳤다"고 적어 놓고 diff에는 포맷 변경이 잔뜩 들어 있으면 검토하는 사람이 의아할 테니까요. 결국 이것도 PR 본문에 그대로 적었습니다. 이 파일이 palantir 포맷 이전 것이라 touch하는 순간 spotless가 전체를 재포맷했고, 이는 CI가 요구하는 사항이라는 설명입니다. 일부러 인접 코드를 손댄 것이 아니라 도구의 규칙 때문에 생긴 변경임을 분명히 해 둔 것입니다.
절차에 대해 배운 것
이번 기여는 LangChain4j가 요구하는 절차를 한 번 차분히 밟아 본 경험이기도 했습니다. 이 저장소는 버그 수정도 이슈를 먼저 등록하고, 그 이슈를 Closes #번호로 연결한 Draft PR을 올린 다음, 승인을 받고 나서야 문서나 예제를 추가하는 흐름을 권합니다. 그래서 먼저 이슈에 문제를 정리했습니다. 어떤 상태에서 silent failure가 나는지, 그것이 #3867과 어떻게 다른지, 기대 동작은 무엇인지를 적었습니다. #3867과의 구분을 이슈 단계에서 명확히 해 둔 덕분에, 나중에 PR에서 "이건 이미 의도된 동작 아니냐"는 오해를 미리 줄일 수 있었습니다.
변경 자체는 작게 유지하려 애썼습니다. 분기 하나와 헬퍼 메서드 하나가 핵심이고, 리팩터링이나 다른 개선을 끼워 넣지 않았습니다. 새 의존성도 추가하지 않았습니다. 테스트에 쓴 JUnit과 AssertJ는 테스트 범위에서만 쓰는 것이라 별도 의존성 추가가 필요 없었습니다. LangChain4j는 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지가 됩니다. 그래서 제목도 "fix: Surface failed A2A tasks as exceptions instead of empty results"처럼 변경 종류를 앞에 붙이고 뒤는 영어 명령형으로, 무엇을 했는지가 한눈에 들어오도록 적었습니다.
이 변경은 결국 머지되었습니다(https://github.com/langchain4j/langchain4j/pull/5471). 돌이켜 보면 코드를 고친 시간보다 "이게 정말 버그인지, 의도된 동작은 아닌지"를 확인하는 데 더 많은 시간을 들였습니다. 그리고 그 시간이 가장 쓸모 있었다고 느낍니다.
남은 생각
이번 일을 통해 다시 확인한 것은, 좋은 기여가 반드시 화려한 변경일 필요는 없다는 점입니다. 실패를 빈 성공으로 둔갑시키던 분기 하나를 바로잡고, 그 동작을 테스트로 못 박고, 보존해야 할 동작은 건드리지 않는다. 코드량으로 보면 작은 변경이지만, 이 라이브러리를 쓰는 누군가는 이제 원격 작업이 실패했을 때 빈 문자열 대신 작업 id와 실패 사유가 담긴 예외를 받습니다. 디버깅하는 사람에게는 그 차이가 결코 작지 않습니다.
기여를 시작하기 전에는 오픈소스에 무언가를 보탠다는 일이 막연하게 거창하게 느껴졌습니다. 막상 해 보니, 코드를 천천히 읽으면서 "여기 이상한데?" 싶은 지점을 붙잡고, 그게 정말 문제인지 공식 기록과 코드로 확인하고, 고친 뒤에는 그 변경이 다른 동작을 깨지 않는지 테스트로 증명하는 일의 반복이었습니다. 한 번에 하나씩, 확인할 수 있는 만큼만 손대는 태도가 결국 가장 멀리 가더라는 것을 이번 기여에서 배웠습니다. 다음에 또 코드를 읽다가 조용히 삼켜지는 실패를 만난다면, 이번처럼 차분히 경계를 긋고 한 걸음씩 확인해 가며 고쳐 보려 합니다.
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 스레드 생명주기
- Cache Penetration
- 백엔드 아키텍처
- 트랜잭션 관리
- Hot Key 문제
- Redis 성능 개선
- Enum 기반 싱글톤
- DB 트랜잭션
- 백엔드 성능 설계
- 동시성처리
- spring batch 5
- 백엔드 성능 튜닝
- Java Performance
- Eager Initialization
- Cache Aside
- mybatis
- Double-Checked Locking
- Redis 캐시 전략
- Cache Avalanche
- Spring Batch
- InterruptedException
- 캐시 성능 비교
- 캐시 장애
- Redis vs DB
- Initialization-on-Demand Holder Idiom
- DB 인덱스 성능
- 트래픽 처리
- 백엔드 성능
- 캐시와 인덱스
- TTL 설계
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
