[Open source contribution] langchain4j 오픈소스 기여 경험기 - 형제 클래스 네 개만 빠져 있던 예외 변환: LangChain4j Jlama 모듈에 올린 기여 과정
같은 모듈 안에서 비슷한 일을 하는 클래스가 여럿일 때, 어느 하나에만 개선이 들어가고 나머지는 그대로 남는 일이 있습니다. 기능이 없는 것도 아니고 버그가 눈에 띄는 것도 아니라 한동안 지나가기 쉽습니다. 이번에 LangChain4j에 올려 머지된 변경이 그런 자리였습니다. Jlama 모듈에서 모델 다운로드가 실패할 때, 채팅 모델은 인증 오류인지 모델을 못 찾은 것인지 알려 주는데 나머지 네 개는 뭉뚱그린 예외만 던지고 있었습니다.
예외 타입이 뭉개지던 경로
Jlama는 자바에서 모델을 로컬로 내려받아 돌리는 라이브러리이고, LangChain4j의 langchain4j-jlama 모듈이 이를 감싸 줍니다. 모델 객체를 만들 때 필요한 모델 파일을 먼저 내려받는데, 이 다운로드가 실패하면 그 실패를 어떤 예외로 사용자에게 전할지가 문제가 됩니다.
저장소에는 이를 위한 장치가 이미 있었습니다. 코어의 ExceptionMapper가 HTTP 상태 코드를 보고 예외를 갈라 주는데, 401이나 403이면 AuthenticationException, 404면 ModelNotFoundException, 5xx면 InternalServerException 같은 식입니다. 그런데 Jlama가 던지는 실패는 상태 코드를 담은 전용 예외가 아니라 HTTP response code: 401 ... 같은 문자열 메시지를 가진 평범한 IOException입니다. 기본 매퍼는 이 메시지를 해석하지 못합니다.
그래서 모듈 안에는 이 메시지를 읽어 상태 코드를 뽑아내는 JlamaExceptionMapper가 따로 있었습니다. 문제는 이 매퍼가 다섯 클래스 중 채팅 모델 하나에만 연결돼 있었다는 점입니다. 연결 여부를 가르는 것은 재시도 유틸리티의 오버로드 선택이었습니다.
public static <T> T withRetryMappingExceptions(Callable<T> action, int maxRetries) {
return withRetryMappingExceptions(action, maxRetries, ExceptionMapper.DEFAULT);
}
인자를 두 개만 넘기면 기본 매퍼가 조용히 채워집니다. 스트리밍 채팅 모델, 언어 모델, 스트리밍 언어 모델, 임베딩 모델은 모두 이 두 인자 버전을 쓰고 있었습니다. 기본 매퍼는 Jlama의 IOException 메시지를 알아보지 못하니 마지막 갈래로 떨어지고, 결국 인증이 잘못됐든 모델 이름이 틀렸든 똑같이 뭉뚱그린 예외 하나가 올라옵니다. 호출하는 쪽에서 "토큰 문제인가, 이름 오타인가"를 예외 타입으로 구분할 수 없게 되는 것입니다.
여기서 눈에 띄는 점은 이것이 기능 누락이라기보다 일관성 문제라는 것이었습니다. 어떻게 해야 하는지는 이미 같은 모듈의 채팅 모델이 보여 주고 있었고, 나머지 네 개만 그 선을 넘지 못한 상태였습니다.
인자 하나씩, 네 군데
수정은 형제들에게도 같은 매퍼를 넘기는 것이었습니다.
JlamaModel jlamaModel = RetryUtils.withRetryMappingExceptions(
() -> registry.downloadModel(modelName, Optional.ofNullable(authToken)),
2,
JlamaExceptionMapper.INSTANCE);
재시도 횟수는 각 클래스가 원래 쓰던 값을 그대로 뒀습니다. 이번 변경의 목적은 예외 타입을 맞추는 것이지 재시도 정책을 손보는 것이 아니었기 때문입니다. 한 PR에 성격이 다른 변경을 섞지 말라는 기여 규칙과도 맞닿아 있습니다.
동작이 깨지지 않는지도 따져 봤습니다. 이 경로에서 나가는 것은 여전히 RuntimeException이고, 새로 나가게 되는 AuthenticationException과 ModelNotFoundException은 결국 예전에 던지던 LangChain4jException의 하위 타입입니다. 넓게 잡아 두었던 catch 블록은 그대로 잡습니다. 달라지는 것은 잡은 뒤에 원인을 더 좁게 구분할 수 있다는 점뿐입니다. 성공 경로는 건드리지 않았습니다.
돌릴 수 없는 테스트를 어떻게 다룰까
테스트는 고민이 필요했습니다. 이 모듈의 테스트 디렉터리는 사실상 통합 테스트로 채워져 있고, 대부분 실제로 모델을 내려받아야 돌아갑니다. 마침 존재하지 않는 모델 이름으로 채팅 모델을 만들 때 어떤 예외가 나는지 확인하는 통합 테스트가 이미 있었습니다. 그래서 같은 방식으로 나머지 네 형제에 대해 한 건씩 붙였습니다. 없는 모델 이름을 주고 빌더를 호출했을 때 기대한 예외가 나오는지 보는 구조입니다.
다만 이 테스트들은 네트워크에 의존해서 제 손으로 돌려 보지 못했습니다. PR 본문에는 이 사실을 그대로 적었습니다. 모듈의 단위 빌드는 통과했고 새 통합 테스트는 컴파일된다는 것, 통합 테스트 자체는 실행하지 않았다는 것을 구분해서 썼습니다. 템플릿의 체크박스도 그 구분대로 남겨 뒀습니다. 확인한 것과 확인하지 못한 것을 같은 표시로 덮어 버리면, 그 표시를 믿고 판단할 리뷰어에게 잘못된 정보를 주게 됩니다.
200줄짜리 diff를 설명해야 했던 일
막상 PR을 열려니 예상 밖의 문제가 있었습니다. 실제 기능 변경은 네 군데에 인자를 하나씩 추가한 것인데, diff는 수백 줄로 불어나 있었습니다.
원인은 저장소의 포매팅 규칙이었습니다. 이 프로젝트는 Spotless로 자동 포매팅을 강제하는데, 검사 범위를 origin/main과 달라진 파일로 좁혀 두었습니다. 그리고 langchain4j-jlama는 특정 JDK 프로파일에서만 빌드에 포함되는 모듈이라, 그 소스들이 현재 포매터 기준으로 정리된 적이 없는 상태였습니다. 그래서 제가 파일을 한 줄이라도 건드리는 순간 그 파일 전체가 포매팅 대상이 되어, 줄바꿈과 들여쓰기가 통째로 바뀌었습니다.
포매팅을 피하려고 규칙을 우회할 수는 없으니, 대신 PR 본문에 설명을 넣었습니다. 기능 변경은 인자 네 개와 새 테스트 파일이 전부이고 나머지는 포매터가 만든 결과이며, 손으로 정렬한 것이 아니라 포매팅 명령으로 생성했다는 점을 밝혔습니다. 같은 파일을 건드리는 다른 PR이 열려 있어 어느 쪽이 먼저 머지되면 재조정이 필요할 것 같다는 점도 함께 적었습니다. 리뷰어가 큰 diff를 열었을 때 "어디를 봐야 하는가"를 먼저 알 수 있게 하는 편이 낫다고 판단했습니다.
돌아보며
이 기여에서 제가 한 판단은 대부분 "새로 정하지 않기"였습니다. 예외를 어떻게 갈라야 하는지는 코어의 매퍼가 이미 정해 두었고, 이 모듈에서 어떻게 연결해야 하는지는 채팅 모델이 보여 주고 있었으며, 테스트를 어떤 모양으로 써야 하는지는 기존 통합 테스트가 있었습니다. 제가 한 일은 그 기준을 아직 적용되지 않은 네 자리로 옮긴 것에 가깝습니다.
낯선 저장소에 코드를 보탤 때 이 방식이 특히 마음이 편했습니다. 새로운 설계를 제안하면 그것이 이 프로젝트의 취향과 맞는지부터 설득해야 하는데, 이미 있는 기준을 따라가면 그 대화를 건너뛸 수 있습니다. 오히려 어려웠던 쪽은 코드가 아니라 설명이었습니다. 돌리지 못한 테스트를 어떻게 적을지, 커진 diff를 어떻게 안내할지 같은 것들 말입니다. 코드 못지않게 PR 본문을 신경 써야 한다는 것을 실제로 겪으며 배웠습니다.