티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - Bedrock 도구 인자에서 큰 정수가 손상되던 오버플로 버그를 고치다
ebson 2026. 6. 23. 14:59버그 중에는 시끄럽게 터지는 것이 있고 조용히 번지는 것이 있습니다. 예외를 던지며 멈추는 쪽은 적어도 스택 트레이스라는 단서를 남깁니다. 그런데 아무 오류 없이, 화면도 멀쩡하게, 다만 값 하나가 슬그머니 다른 값으로 바뀌어 흘러가는 종류의 버그는 단서를 거의 남기지 않습니다. 이번 글에서 다룰 LangChain4j의 Bedrock 모듈 수정이 바로 그런 경우였습니다. 평범한 크기의 숫자에는 멀쩡하던 변환 코드가, 큰 정수 하나를 만나면 그 값을 조용히 망가뜨려서 내보내고 있었습니다.
이 글은 그 한 건의 기여를, 무엇이 문제였고 그것을 어떻게 확인했으며 어떤 점을 신경 쓰며 고쳤는지 처음부터 끝까지 따라가며 정리한 기록입니다. 최종 변경은 표현식 한 줄을 바꾼 작은 수정이지만, 그 한 줄이 왜 틀렸는지를 납득하기까지 거친 확인 과정이 저에게는 더 오래 남았습니다.
도구 호출 인자가 숫자로 변환되는 길목에서 시작했습니다
먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j는 여러 LLM 공급자를 같은 방식으로 다룰 수 있게 해 주는데, 그중 Bedrock 모듈은 AWS의 Bedrock 서비스와 주고받는 데이터를 변환하는 계층을 가지고 있습니다. LLM에게 도구(함수)를 쓰게 하면, 모델은 "이 도구를 이런 인자로 호출하라"는 응답을 JSON 형태로 돌려줍니다. 이 JSON 인자를 Bedrock이 이해하는 형식으로 옮겨 담는 일을 AwsDocumentConverter라는 클래스가 맡습니다.
AWS SDK에는 임의의 구조화된 값을 담는 Document라는 타입이 있습니다. 문자열, 불리언, 숫자, 배열, 객체를 두루 담을 수 있는 그릇입니다. AwsDocumentConverter는 JSON의 각 노드를 들여다보고 그 종류에 맞는 Document로 바꿔 줍니다. 제가 들여다본 곳은 이 변환을 실제로 수행하는 getDocument(JsonNode) 메서드였습니다. JSON 노드 하나가 들어오면 그것이 불리언인지, 실수인지, 정수인지, 배열인지 차례로 판별해 알맞은 Document를 만들어 냅니다.
이 변환이 특별한 상황에서만 도는 게 아니라는 점이 중요합니다. Bedrock 채팅 모델은 도구 호출 요청을 처리할 때 convertToolRequests() 안에서 documentFromJson(...)을 호출해, 모델이 돌려준 도구 인자 JSON을 Document로 바꿉니다. 그리고 그 안에서 숫자 필드 하나하나가 방금 말한 getDocument()를 거칩니다. 즉 도구 인자에 숫자가 들어 있으면 반드시 이 길을 지나가고, 그렇게 변환된 값이 Bedrock으로 전송됩니다. 평범하고 정상적인 호출 경로라는 뜻입니다.
정수를 다루는 한 줄에서 폭이 잘려 나가고 있었습니다
문제의 자리는 getDocument()에서 정수를 처리하는 분기였습니다. 수정 전 코드는 대략 이런 모양이었습니다.
} else if (value.isDouble() || value.isFloat() || value.isBigDecimal()) {
doc = Document.fromNumber(value.asDouble());
} else if (value.isInt() || value.isLong() || value.isShort() || value.isBigInteger()) {
doc = Document.fromNumber(value.asInt());
}
윗줄의 실수 분기를 먼저 보면, 조건에서 double/float/BigDecimal을 받아들이고 변환도 asDouble()로 합니다. 받는 폭과 변환하는 폭이 일치합니다. 그런데 바로 아래 정수 분기를 보면 어긋남이 보입니다. 조건은 isInt()뿐 아니라 isLong(), isShort(), isBigInteger()까지 명시적으로 받아들입니다. "나는 long도, BigInteger도 처리하겠다"고 선언한 셈입니다. 그런데 정작 변환은 value.asInt()로 합니다.
여기서 Jackson의 동작 하나를 짚어 두면 문제가 분명해집니다. JsonNode.asInt()는 노드가 long이든 BigInteger든 상관없이 그 값을 int로 좁혀서 돌려줍니다. 자바의 int는 약 ±21억(정확히는 2,147,483,647)까지만 담을 수 있는데, 그 범위를 넘는 값을 int로 좁히면 비트가 잘려 나가며 전혀 다른 값이 됩니다. 예를 들어 Long.MAX_VALUE인 9223372036854775807은 이 좁힘을 거치면 -1이 되고, 30억(3000000000)은 음수인 -1294967296이 됩니다. 오류는 나지 않습니다. 그냥 다른 숫자가 조용히 만들어질 뿐입니다.
문제가 왜 생기는지를 한 문장으로 정리하면 이렇습니다. 분기 조건은 long과 BigInteger를 받겠다고 해 놓고, 실제 변환은 그것들을 담지 못하는 int로 좁히고 있었습니다. 조건과 구현의 폭이 어긋나 있었던 것입니다. 그래서 도구 인자에 타임스탬프나 큰 식별자처럼 21억을 넘는 정수가 들어오면, 그 값이 손상된 채로 Bedrock에 전달되고 있었습니다.
같은 클래스의 다른 길들과 비교하니 의도가 분명해졌습니다
코드를 읽다 보면 "이게 정말 버그일까, 아니면 의도된 동작일까" 하는 의심이 듭니다. 그래서 제가 자주 쓰는 방법은 같은 일을 하는 형제 코드와 나란히 놓고 비교하는 것입니다. 이 경우에는 비교 대상이 같은 클래스 안에 있었습니다.
첫 번째 비교 대상은 바로 위의 실수 분기입니다. 앞서 봤듯이 실수 쪽은 asDouble()로 폭을 그대로 보존합니다. 같은 메서드 안에서 실수는 폭을 지키는데 정수만 좁힌다면, 정수 분기가 일부러 값을 버리려 했다고 보기는 어렵습니다.
두 번째 비교 대상은 반대 방향의 변환입니다. AwsDocumentConverter에는 Document를 다시 객체로 되돌리는 documentToObject()도 있는데, 이쪽은 숫자를 꺼낼 때 asNumber()를 써서 SDK가 들고 있던 숫자 폭을 그대로 보존합니다. 즉 되돌아오는 길은 폭을 지키는데 나가는 길만 좁히고 있었습니다. JSON에서 Document로 갔다가 다시 돌아오는 왕복을 생각하면, 한쪽 방향에서만 값이 손상되는 비대칭이 생깁니다.
이 두 비교로 의도가 분명해졌습니다. 코드의 다른 부분은 모두 숫자의 폭을 보존하려 하고 있었고, 분기 조건도 long과 BigInteger를 받겠다고 명시했습니다. 오직 정수 분기의 변환 한 줄만 그 의도를 따라가지 못하고 있었습니다. 무언가 새로운 설계를 들일 문제가 아니라, 어긋난 한 줄을 나머지와 같은 결로 맞추면 되는 문제였습니다.
폭을 보존하는 변환으로 한 줄만 바꿨습니다
고치는 방향은 분명했습니다. 정수 분기에서 int로 좁히는 대신, long과 BigInteger를 모두 담을 수 있는 변환을 쓰면 됩니다. 처음에는 SdkNumber라는 타입을 거쳐 원문을 보존하는 방법도 떠올렸지만, 확인해 보니 더 단순한 길이 있었습니다. AWS SDK의 Document에는 BigInteger를 직접 받는 fromNumber 오버로드가 이미 있었습니다. 그래서 별도의 타입을 끌어들이거나 새 의존성을 더할 필요 없이, 노드에서 값을 꺼내는 방식만 바꾸면 됐습니다.
} else if (value.isInt() || value.isLong() || value.isShort() || value.isBigInteger()) {
doc = Document.fromNumber(value.bigIntegerValue());
}
value.asInt()를 value.bigIntegerValue()로 바꾼 것이 전부입니다. bigIntegerValue()는 노드의 정수 값을 BigInteger로 꺼내 주므로, long이든 그보다 더 큰 값이든 폭이 잘리지 않습니다. 이렇게 하면 세 가지가 한꺼번에 맞아떨어집니다. 분기 조건이 받겠다고 한 long과 BigInteger가 실제로 손실 없이 변환되고, 바로 위 실수 분기가 asDouble()로 폭을 지키는 것과 같은 결이 되며, 반대 방향 documentToObject()와의 왕복 대칭도 회복됩니다.
여기서 한 가지 분명히 해 둔 점은, 이 변경이 기존 동작을 깨지 않는다는 것입니다. int 범위 안에 들어오는 평범한 값, 예를 들어 42 같은 숫자는 asInt()로 꺼내든 bigIntegerValue()로 꺼내든 결국 같은 숫자입니다. 달라지는 것은 오직 int 범위를 넘던, 그래서 원래 손상되던 경로뿐입니다. 손상되던 길만 정상으로 되돌리고 멀쩡하던 길은 그대로 두는 것이, 변경을 작게 유지하면서 호환을 지키는 길이었습니다. 분기 구조도, 메서드 시그니처도 건드리지 않고 표현식 하나만 고친 이유가 여기에 있습니다.
왕복을 검증하는 테스트로 사각지대를 메웠습니다
코드를 한 줄 고친 만큼, 같은 비중으로 테스트를 챙겼습니다. 이 저장소는 테스트가 없는 변경은 검토하지 않는다는 규칙을 분명히 두고 있고, 무엇보다 이번 버그가 "정상 입력에 대해 조용히 잘못된 값을 내보내던" 종류라 재발 방지가 중요했습니다.
다행히 Bedrock 모듈의 변환 테스트는 AWS 자격 증명 없이도 돌릴 수 있는 순수 단위 테스트였습니다. 기존에도 큰 숫자를 다루는 테스트가 있긴 했지만, 그것은 Document에서 JSON으로 나가는 방향만 확인하고 있어서, 정작 이번에 문제가 된 JSON에서 Document로 들어오는 방향의 정수 왕복은 비어 있었습니다. 그래서 그 사각지대를 메우는 테스트를 더했습니다.
@Test
void documentFromJson_preserves_long_value() {
// Given - a JSON integer larger than Integer.MAX_VALUE
String json = "{\"id\":9223372036854775807}";
// When
Document document = AwsDocumentConverter.documentFromJson(json);
// Then - the full long value must be preserved (no narrowing to int)
assertThat(document.asMap().get("id").asNumber().longValue()).isEqualTo(Long.MAX_VALUE);
}
이 테스트는 Long.MAX_VALUE를 담은 JSON을 변환한 뒤, 그 값이 그대로 보존되는지를 단언합니다. 수정 전 코드라면 이 값이 -1로 좁혀져 단언이 곧장 실패하고, 수정 후에는 통과합니다. 같은 방식으로 Long.MAX_VALUE보다 한 단계 더 큰 값, 즉 long 범위마저 넘는 BigInteger가 보존되는지도 확인했습니다. 정수 분기를 BigInteger로 꺼내도록 고쳤으니, long보다 큰 값까지 지켜지는지를 직접 짚어 둔 것입니다.
마지막으로 회귀 케이스로, 42처럼 int 범위 안에 있는 평범한 값이 여전히 그대로 변환되는지를 확인하는 테스트를 더했습니다. 이 회귀 테스트가 "기존 동작은 바뀌지 않는다"는 약속을 코드로 증명해 줍니다. 검토하는 사람이 호환성 걱정을 머릿속으로 따져 보지 않아도, 테스트가 그 경계를 대신 지켜 줍니다.
형식 검사 도구가 끼워 넣은 변경을 정직하게 밝혔습니다
수정과 테스트를 마치고 나서 한 가지 더 챙긴 부분이 있습니다. 이 저장소는 코드 형식을 자동으로 검사하는 도구(spotless)를 쓰는데, 제가 테스트 파일을 건드리자 그 도구가 같은 파일 안에 이미 있던 텍스트 블록 세 군데의 형식을 저장소 기준에 맞게 다시 정리했습니다. 제가 의도해서 바꾼 줄은 아니지만, 형식 검사를 통과하려면 함께 반영되어야 하는 변경이었습니다.
이런 부수적인 형식 변경은 본질이 아니므로, PR 본문에 그 사실을 따로 적어 두었습니다. 정작 봐야 할 변경은 어디까지나 정수 분기의 한 줄과 새로 더한 왕복 테스트라는 점을, 검토하는 사람이 헷갈리지 않도록 미리 밝혀 두고 싶었습니다. 변경 목록에 형식 정리가 섞여 들어가면 diff가 실제보다 커 보이고, 무엇이 핵심인지 흐려질 수 있기 때문입니다.
그 밖의 기여 절차도 차례로 밟았습니다. 이 저장소는 버그를 발견하면 먼저 이슈로 문제를 공유하고, 그다음 그 이슈 번호를 Closes #이슈번호 형태로 연결한 PR을 올리는 흐름을 권합니다. 또 squash merge를 쓰기 때문에 PR 제목이 그대로 main 브랜치의 커밋 메시지로 남습니다. 그래서 제목을 영어 명령형으로, 무엇을 어디서 고쳤는지가 한 줄에 드러나도록 다듬었습니다. 막연히 "버그 수정"이 아니라, 큰 정수 도구 인자에서 일어나던 정수 오버플로를 AwsDocumentConverter에서 고친다는 내용이 제목만 봐도 전해지도록 했습니다. 새 의존성을 들이지 않았는지, 기존 동작을 깨지 않는지, 모듈 단위 테스트가 모두 통과하는지를 확인하는 것까지가 제 몫이었습니다.
작은 한 줄에서 배운 것
이 기여를 마치고 돌아보면, 바뀐 코드는 정말 한 줄입니다. 거기에 왕복을 검증하는 테스트 몇 개가 붙은 정도입니다. 그런데 그 한 줄에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 타입을 좁히는 변환이 얼마나 조용히 값을 망가뜨릴 수 있는지를 몸으로 느낀 경험입니다. 좁힘 캐스팅은 예외를 던지지 않습니다. 그냥 범위를 넘는 비트를 버리고 다른 값을 돌려줄 뿐입니다. 평소에 다루는 숫자가 대부분 작다면 이런 버그는 한참을 들키지 않고 살아남습니다. 그래서 노드에서 값을 꺼낼 때 그 폭이 원래 값을 담기에 충분한지를 한 번 더 확인하는 습관이 필요하다는 것을, 이 버그가 분명히 알려 주었습니다.
또 하나는, 분기 조건과 실제 구현이 같은 것을 말하고 있는지 맞춰 보는 일의 중요함입니다. 이번 버그의 핵심은 거창한 알고리즘이 아니라, "long을 받겠다"는 조건과 "int로 좁히겠다"는 변환이 어긋나 있었다는 단순한 불일치였습니다. 조건이 받아들이는 폭과 변환이 보존하는 폭이 같은지를 나란히 놓고 보면, 이런 어긋남은 의외로 눈에 잘 들어옵니다. 그리고 같은 클래스 안의 형제 코드, 반대 방향의 변환과 비교해 보는 것이 그 어긋남을 확신으로 바꿔 주었습니다.
마지막으로, 좋은 수정이란 새로운 무언가를 더하는 것이 아니라 이미 있는 일관성으로 되돌리는 것일 때가 많다는 점을 다시 느꼈습니다. 답은 멀리 있지 않았습니다. 같은 메서드의 실수 분기가, 반대 방향의 변환이 이미 폭을 지키고 있었고, 정수 분기를 그 결에 맞추기만 하면 됐습니다. 정답을 새로 발명하기보다 이미 있는 정답을 찾아 맞추는 쪽이, 검토하는 사람에게도 훨씬 설득력 있는 변경이 된다는 것을 이번에도 배웠습니다.
여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 한 줄짜리 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 코드를 천천히 읽고, 형제 코드와 비교하고, 변경을 꼭 필요한 자리에만 한정하고, 같은 실수가 다시 들어오면 곧장 걸리도록 테스트로 못 박아 두는 이 과정 자체가, 기능 하나를 새로 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 코드를 읽다가 폭이 어긋난 변환을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5503
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Cache Aside
- Cache Avalanche
- Initialization-on-Demand Holder Idiom
- Spring Batch
- 동시성처리
- DB 트랜잭션
- 캐시 성능 비교
- Double-Checked Locking
- spring batch 5
- Cache Penetration
- 캐시와 인덱스
- Redis 성능 개선
- mybatis
- Enum 기반 싱글톤
- Eager Initialization
- 캐시 장애
- TTL 설계
- 트랜잭션 관리
- Java Performance
- 스레드 생명주기
- 백엔드 성능 설계
- Redis 캐시 전략
- InterruptedException
- Hot Key 문제
- 트래픽 처리
- 백엔드 성능 튜닝
- Redis vs DB
- 백엔드 성능
- DB 인덱스 성능
- 백엔드 아키텍처
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
