티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - 에이전트 인자 변환에서 short와 byte가 빠진 자리를 채우다
ebson 2026. 6. 20. 15:31코드를 읽다 보면 "여기에 당연히 있어야 할 것이 왜 없지" 싶은 순간이 있습니다. 비슷한 항목들이 나란히 줄지어 있는데, 그중 두 개만 빠져 있는 식입니다. 빠진 자리는 평소에는 아무 표시도 내지 않습니다. 그 자리를 실제로 밟는 입력이 들어오기 전까지는요. 그러다 어느 날 누군가 하필 그 빠진 자리에 해당하는 값을 넘기면, 갑자기 알 수 없는 오류가 튀어나옵니다.
이번 글에서 다룰 LangChain4j의 에이전트 모듈 수정은 바로 그런 종류의 버그였습니다. 값을 알맞은 타입으로 바꿔 주는 코드가 있는데, 그 안에서 처리하는 타입 목록에 두 가지가 빠져 있었습니다. 빠진 타입을 쓰는 경우에만 변환이 제대로 되지 않아, 엉뚱한 타입의 값이 그대로 흘러가 오류로 이어졌습니다. 결과만 보면 두 줄을 더하고 테스트 두 개를 추가한 작은 변경이지만, 왜 그 두 줄이 빠져 있었고 그 빠짐이 어떻게 오류로 이어지는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.
에이전트들이 값을 주고받는 방식
먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j에는 여러 에이전트를 엮어 하나의 흐름을 구성하는 기능이 있습니다. 이때 에이전트들은 공용 저장 공간을 통해 값을 주고받습니다. 한 에이전트가 결과를 이 공간에 적어 두면, 다음 에이전트가 그 값을 읽어 자기 일에 씁니다. 이 공간은 이름표가 붙은 값들의 묶음, 즉 키와 값의 모음이라고 보면 됩니다.
에이전트의 동작은 메서드로 정의되고, 그 메서드의 매개변수에는 "이 자리에는 공용 공간의 어떤 이름표에 해당하는 값을 넣어 달라"는 표시가 붙습니다. 그래서 에이전트를 실행할 때 프레임워크는, 메서드가 요구하는 매개변수의 타입과 공용 공간에 담긴 값을 맞춰 봅니다. 그런데 공용 공간에 담긴 값의 타입과 메서드가 요구하는 타입이 늘 똑같지는 않습니다. 예를 들어 공간에는 정수가 들어 있는데 메서드는 더 작은 정수 타입을 요구할 수 있습니다. 이 어긋남을 메워 주는 것이, 값을 요구된 타입으로 바꿔 주는 변환 코드였습니다. 이번 버그는 바로 이 변환 코드 안에 있었습니다.
숫자를 타입별로 바꿔 주는 목록에 두 자리가 비어 있었습니다
값을 요구된 타입으로 바꿔 주는 메서드는 값의 종류에 따라 갈래가 나뉘어 있습니다. 그중 값이 숫자일 때를 다루는 부분은 다음과 같이 생겼습니다.
if (value instanceof Number n) {
return switch (type.getName()) {
case "java.lang.String" -> "" + n;
case "int", "java.lang.Integer" -> n.intValue();
case "long", "java.lang.Long" -> n.longValue();
case "double", "java.lang.Double" -> n.doubleValue();
case "float", "java.lang.Float" -> n.floatValue();
default -> value;
};
}
요구된 타입의 이름을 보고, 그에 맞춰 숫자를 해당 타입의 값으로 바꿔 돌려주는 구조입니다. 요구된 타입이 정수면 정수 값으로, 긴 정수면 긴 정수 값으로, 실수면 실수 값으로 바꿉니다. 자바의 기본 숫자 타입들이 여기에 줄지어 들어 있습니다.
그런데 이 목록을 가만히 보면, 자바의 숫자 타입 중 두 가지가 빠져 있었습니다. 짧은 정수와 한 바이트짜리 정수, 흔히 short와 byte라고 부르는 타입입니다. 다른 정수 타입과 실수 타입은 모두 자기 자리가 있는데, 이 두 가지만 목록에 없었습니다. 목록에 없는 타입이 요구되면 어떻게 될까요. 맨 아래의 기본 처리가 받아, 값을 아무것도 바꾸지 않고 원래 그대로 돌려줍니다.
여기서 문제가 생깁니다. 공용 공간에 담긴 숫자는 흔히 일반 정수 형태입니다. 데이터를 외부에서 읽어 들이거나 형식을 주고받는 과정에서, 정수는 보통 일반 정수 타입으로 만들어지기 때문입니다. 그런데 메서드가 요구하는 타입이 short나 byte라면, 변환 목록에 그 자리가 없으니 일반 정수가 변환되지 않고 그대로 돌아옵니다. 요구된 것은 짧은 정수인데 손에 쥔 것은 일반 정수인, 타입이 어긋난 상태가 되는 것입니다.
리플렉션은 타입에 엄격합니다
타입이 어긋난 값이 어떻게 오류로 이어지는지를 짚어 둘 필요가 있습니다. 프레임워크는 에이전트 메서드를 직접 호출하는 것이 아니라, 실행 시점에 메서드 정보를 보고 호출하는 방식을 씁니다. 흔히 리플렉션이라고 부르는 방식입니다. 그리고 이 방식은 타입에 무척 엄격합니다. 평범하게 코드를 작성할 때라면, 일반 정수를 더 작은 정수 자리에 넣으려 할 때 컴파일러나 언어 차원의 도움을 어느 정도 받을 수 있습니다.
하지만 실행 시점에 메서드 정보를 보고 호출하는 방식에서는 그런 도움이 없습니다. 메서드가 짧은 정수를 요구하면, 넘기는 값도 정확히 짧은 정수 타입이어야 합니다. 일반 정수를 그 자리에 넘기면, 그 값이 표현하는 숫자가 아무리 작아도 타입이 맞지 않는다는 이유로 거절됩니다. "인자 타입이 맞지 않는다"는 오류가 바로 그것입니다.
그래서 변환 목록에서 short와 byte가 빠진 것이 곧장 실행 오류로 이어졌습니다. 짧은 정수 매개변수를 가진 에이전트 메서드에, 공용 공간의 일반 정수 값을 넣어 실행하면, 변환되지 않은 일반 정수가 그대로 전달되고, 엄격한 호출 방식이 그것을 타입 불일치로 거절합니다. 사용자 입장에서는 단지 짧은 정수 매개변수를 썼을 뿐인데 알 수 없는 타입 오류를 만나게 되는 것입니다. 더 흔히 쓰는 정수나 긴 정수 매개변수를 썼다면 멀쩡했을 텐데, 덜 쓰이는 두 타입을 골랐다는 이유만으로 막히는 셈이었습니다.
형제 분기들이 답을 보여 주고 있었습니다
이 버그를 두고 마음이 놓였던 점은, 고칠 방향을 바로 그 코드 자신이 분명히 보여 주고 있었다는 것입니다. 변환 목록에는 이미 일반 정수, 긴 정수, 실수 타입을 다루는 자리들이 나란히 있었습니다. 각 자리는 숫자를 해당 타입의 값으로 바꿔 돌려주는, 똑같은 모양의 처리를 하고 있었습니다. 짧은 정수와 한 바이트짜리 정수도 자바가 똑같이 제공하는 숫자 타입이고, 숫자를 그 타입의 값으로 바꾸는 방법도 다른 타입들과 똑같이 마련되어 있습니다. 즉 빠진 두 자리를 채우는 일은, 옆에 이미 있는 형제 자리들과 같은 모양으로 두 줄을 더하면 끝나는 것이었습니다.
같은 메서드 안의 다른 갈래, 곧 값이 글자일 때를 다루는 부분과 비교해 보아도 이 빠짐이 드러났습니다. 글자를 다루는 갈래는 더 넓은 범위의 타입을 다루고 있었는데, 숫자를 다루는 갈래만 두 타입에서 비어 있어 둘 사이가 어긋나 있었습니다. 한쪽은 폭넓게 처리하는데 다른 쪽만 일부 타입을 빠뜨린, 일관되지 않은 상태였던 것입니다.
이 비교를 통해 이번 수정이 새로운 무언가를 만드는 일이 아니라, 이미 자리 잡은 패턴에서 빠진 두 칸을 마저 채우는 일이라는 점이 분명해졌습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 자연스러운지를, 바로 옆에 있는 형제 자리들로 설명할 수 있었습니다. 이런 종류의 수정은 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.
빠진 두 줄을 채웠습니다
방향이 분명해지니 수정 자체는 두 줄이 전부였습니다. 변환 목록에 짧은 정수와 한 바이트짜리 정수를 다루는 자리를 더했습니다.
case "short", "java.lang.Short" -> n.shortValue();
case "byte", "java.lang.Byte" -> n.byteValue();
이렇게 하면 요구된 타입이 짧은 정수일 때 숫자를 짧은 정수 값으로, 한 바이트짜리 정수일 때 그에 맞는 값으로 바꿔 돌려줍니다. 각 타입은 기본형과 그에 대응하는 객체형 두 가지 이름으로 들어올 수 있어, 두 이름을 한 자리에서 함께 받도록 했습니다. 옆에 이미 있던 일반 정수나 실수 처리와 정확히 같은 모양입니다. 빠져 있던 두 칸을 형제들과 똑같은 모양으로 채운 것입니다.
여기서 마음을 놓을 수 있었던 점은, 이 변경이 기존 동작을 조금도 건드리지 않는다는 것입니다. 원래 처리되던 타입들은 전과 똑같이 동작합니다. 달라지는 것은 그동안 변환되지 않고 그대로 돌아오던 두 타입뿐이고, 이제 그 두 타입도 다른 타입들과 똑같이 올바르게 변환됩니다. 잘 동작하던 경우는 그대로 두고 막혀 있던 경우만 열어 주는, 더하기만 하는 변경이라 호환성을 걱정할 부분이 없었습니다. 메서드의 바깥 모습이나 다른 어떤 줄도 손대지 않았습니다.
고치기 전에는 실패하고 고친 뒤에는 통과하는 테스트
코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 "테스트가 없으면 리뷰하지 않는다"는 원칙이 있고, 이런 변환 버그는 잘못된 타입이 정말로 올바른 타입으로 바뀌는지를 테스트로 못 박아 두는 것이 핵심이었습니다. 다행히 이 변환 코드는 외부 모델 호출 없이 순수하게 동작을 확인할 수 있어, 인증 키나 네트워크 없이도 결과를 들여다볼 수 있었습니다.
그래서 기존에 있던 비슷한 테스트의 모양을 그대로 따라, 두 개의 테스트를 더했습니다. 공용 공간에 일반 정수를 적어 두고, 짧은 정수 매개변수를 요구하는 상황을 만든 다음, 변환된 값이 정확히 짧은 정수가 되는지를 확인하는 식입니다.
@Test
void should_coerce_integer_to_short() throws Exception {
DefaultAgenticScope scope = DefaultAgenticScope.ephemeralAgenticScope();
scope.writeState("count", 42);
AgentInvocationArguments args =
AgentUtil.agentInvocationArguments(scope, List.of(new AgentArgument(short.class, "count")));
assertThat(args.positionalArgs()[0]).isEqualTo((short) 42);
}
한 바이트짜리 정수에 대해서도 같은 모양의 테스트를 더했습니다. 이 테스트들이 의미가 있었던 이유는, 고치기 전에는 분명히 실패하고 고친 뒤에는 통과한다는 점이었습니다. 고치기 전이라면 변환되지 않은 일반 정수가 돌아오므로, 짧은 정수와 비교하는 단언이 타입이 다르다는 이유로 실패합니다. 고친 뒤에는 제대로 짧은 정수로 바뀌므로 통과합니다. 단언이 통과하는지뿐 아니라, 그 단언이 버그를 실제로 잡아내는지를 확인할 수 있는 형태였습니다. 같은 숫자라도 타입이 다르면 같지 않다고 보는 비교를 써서, 값뿐 아니라 타입까지 정확히 검증했습니다.
저장소의 절차를 따라가며 배운 것
코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. 이번처럼 특정 타입에서만 나타나는 미묘한 버그는, 어떤 조건에서 어떤 오류가 나는지를 글로 차근차근 정리해 두는 것이 중요했습니다. 짧은 정수 매개변수에 일반 정수 값이 들어오면 타입 불일치 오류가 난다는 재현 조건을 먼저 적어 두면, 검토하는 사람도 코드를 보기 전에 문제의 그림을 그릴 수 있기 때문입니다.
변경을 작게 유지하라는 원칙도 자연스럽게 지켜졌습니다. 이번 수정은 변환 목록에 두 줄을 더하고 그것을 확인하는 테스트 두 개를 추가한 것이 전부였습니다. 변환과 무관한 다른 손질을 섞지 않았고, 바뀐 줄 하나하나가 "빠진 두 타입을 다른 타입과 똑같이 변환한다"는 목적으로 곧장 설명될 수 있었습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하기 때문에 PR 제목이 그대로 기록에 남는데, 그 제목 역시 어떤 변환에 어떤 타입을 더했는지가 한눈에 드러나도록 다듬었습니다.
작은 수정에서 배운 것
이 기여를 마치고 돌아보면, 바뀐 코드의 양은 정말 적습니다. 변환 목록에 두 줄을 더하고, 그것을 확인하는 테스트 두 개를 추가한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 여러 경우를 나누어 처리하는 코드에서는 "빠진 경우"가 곧 잠재적인 버그라는 감각입니다. 비슷한 항목들이 줄지어 있는 목록은 편리하지만, 그중 일부가 빠지면 그 자리를 밟는 입력이 들어올 때까지 아무도 모르게 숨어 있다가 어느 순간 드러납니다. 특히 맨 아래의 기본 처리가 "아무것도 하지 않고 원래 값을 돌려주는" 형태일 때는, 빠진 경우가 조용히 잘못된 값을 흘려보내기 쉽습니다. 가능한 경우들을 나열할 때는 빠진 것이 없는지를 형제 항목들과 견주어 확인해야 한다는 것을 이번에 배웠습니다.
또 하나는, 실행 시점에 타입을 다루는 코드는 평소의 너그러움을 기대할 수 없다는 점입니다. 평범하게 짠 코드라면 언어가 어느 정도 알아서 맞춰 주던 타입 변환도, 실행 시점에 메서드 정보를 보고 호출하는 방식에서는 정확히 맞아떨어져야 합니다. 값이 표현하는 내용이 아니라 값의 타입 그 자체가 문제가 되는 상황을, 이번 버그가 분명하게 보여 주었습니다.
마지막으로, 테스트가 버그를 실제로 잡아내는지를 확인하는 일의 중요함입니다. 고치기 전에 실패하고 고친 뒤에 통과하는 테스트는, 단지 동작을 확인하는 데 그치지 않고 그 테스트가 정말로 의미 있는 검증인지를 함께 증명합니다. 만약 고치기 전에도 통과하는 테스트였다면, 그것은 버그를 잡지 못하는 헛된 그물이었을 것입니다. 테스트를 더할 때는 그 테스트가 고치기 전 코드에서 실패하는지를 한번 확인해 보는 습관이 왜 필요한지를, 이번에 다시 느꼈습니다.
여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 타입 변환 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 빠진 경우를 형제 항목들과 비교해 찾아내고, 실행 시점의 엄격한 타입 규칙을 이해하고, 버그를 실제로 잡아내는 테스트를 남기는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 빠진 자리를 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5469
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- InterruptedException
- 백엔드 성능
- 캐시 성능 비교
- 백엔드 아키텍처
- Redis 캐시 전략
- Redis vs DB
- 동시성처리
- Redis 성능 개선
- Initialization-on-Demand Holder Idiom
- Enum 기반 싱글톤
- spring batch 5
- Cache Penetration
- DB 인덱스 성능
- TTL 설계
- 스레드 생명주기
- 캐시 장애
- 트랜잭션 관리
- 백엔드 성능 설계
- 캐시와 인덱스
- Eager Initialization
- Cache Aside
- Double-Checked Locking
- Java Performance
- Cache Avalanche
- Hot Key 문제
- DB 트랜잭션
- Spring Batch
- 백엔드 성능 튜닝
- 트래픽 처리
- mybatis
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
