티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - ensureNotBlank와 fail-fast로 본 LangChain4j 기여 경험
ebson 2026. 6. 19. 10:36오픈소스 코드를 읽다 보면, 큰 결함보다 오히려 "여기는 왜 다른 곳과 다르게 생겼지" 하는 작은 어긋남이 더 눈에 들어올 때가 있습니다. 같은 일을 하는 함수가 둘 있는데 한쪽만 어떤 처리를 하고 다른 쪽은 그냥 지나친다거나, 형제처럼 나란히 놓인 클래스들이 미묘하게 다른 습관을 가진 경우입니다. 이런 어긋남은 당장 프로그램을 멈추게 하지는 않지만, 누군가 그 코드를 믿고 쓰다가 예상치 못한 곳에서 발이 걸리게 만듭니다.
이번 글에서 다룰 LangChain4j의 Tencent COS 문서 로더 수정도 그런 어긋남에서 출발했습니다. 한 클래스 안에서 두 갈래의 길이 서로 다른 규칙을 따르고 있었고, 그중 한쪽 길에만 안전장치가 빠져 있었습니다. 결과만 보면 코드 한 줄을 감싸고 테스트 몇 개를 더한 작은 변경이지만, 그 한 줄에 도달하기까지 코드를 비교하며 확인한 과정과 저장소의 기여 규칙을 익히며 배운 것이 저에게는 더 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.
클라우드 저장소에서 문서를 읽어오는 로더들
LangChain4j는 여러 곳에 흩어진 문서를 읽어와 LLM이 다룰 수 있는 형태로 바꿔주는 라이브러리입니다. 그중 document-loaders 묶음에는 클라우드 저장소에서 파일을 가져오는 로더들이 모여 있습니다. Amazon S3, Google Cloud Storage, Azure Blob Storage, 그리고 이번에 들여다본 Tencent COS(Cloud Object Storage)까지, 각 클라우드 사업자의 저장소마다 짝이 되는 로더가 하나씩 있습니다.
이 로더들은 생김새가 서로 닮아 있습니다. 클라우드마다 SDK는 다르지만, "버킷(bucket)이라는 통에서 키(key)로 지정한 파일 하나를 읽어온다"는 큰 흐름은 똑같기 때문입니다. 그래서 한 로더를 이해하면 다른 로더도 비슷한 방식으로 읽힙니다. 저는 이 닮음을 비교의 도구로 삼아, 한 로더에서 본 패턴이 다른 로더에도 똑같이 적용되어 있는지를 나란히 놓고 확인하는 식으로 코드를 읽어 나갔습니다. 어긋남은 대개 이런 비교 속에서 드러납니다.
Tencent COS 로더인 TencentCosDocumentLoader를 읽을 때도 그렇게 형제 로더들과 견주어 보았습니다. 그리고 이 클래스 안에서, 같은 클래스가 스스로와 어긋나 있는 지점을 발견했습니다.
같은 클래스인데 한쪽만 검증하고 있었습니다
TencentCosDocumentLoader에는 문서를 읽어오는 두 갈래의 길이 있습니다. 하나는 키 하나를 지정해 파일 한 개만 읽는 loadDocument(bucket, key, parser)이고, 다른 하나는 버킷 전체(혹은 특정 접두사로 시작하는 것들)를 훑어 여러 파일을 한꺼번에 읽는 loadDocuments(bucket, prefix, parser)입니다.
여러 파일을 읽는 loadDocuments 쪽을 먼저 보면, 버킷 이름을 다룰 때 다음과 같이 검증을 거칩니다.
ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
.withBucketName(ensureNotBlank(bucket, "bucket"))
.withPrefix(prefix);
ensureNotBlank는 LangChain4j가 자체적으로 가진 검증 도구로, 값이 null이거나 공백뿐이면 그 자리에서 의미 있는 메시지를 담은 예외를 던집니다. 두 번째 인자로 넘긴 "bucket"이 그 메시지에 들어가, 어떤 값이 잘못됐는지를 호출한 쪽에 곧장 알려줍니다. 즉 여러 파일을 읽는 경로는 버킷 이름이 비어 있으면 일을 시작하기도 전에 분명하게 멈춰 섭니다.
그런데 파일 하나를 읽는 loadDocument 쪽은 사정이 달랐습니다. 수정 전 코드는 이렇게 생겨 있었습니다.
public Document loadDocument(String bucket, String key, DocumentParser parser) {
GetObjectRequest getObjectRequest = new GetObjectRequest(bucket, key);
COSObject cosObject = cosClient.getObject(getObjectRequest);
TencentCosSource source = new TencentCosSource(cosObject.getObjectContent(), bucket, key);
return DocumentLoader.load(source, parser);
}
여기서는 bucket과 key를 아무 확인 없이 그대로 GetObjectRequest에 넘깁니다. 버킷 이름이 null이든 공백이든, 키가 비어 있든, 이 코드는 그것을 걸러내지 않고 일단 Tencent COS SDK에 요청을 만들어 넘깁니다. 같은 클래스의 두 길이 입력을 대하는 태도가 서로 달랐던 것입니다. 한쪽 길에는 들머리에 안전장치가 있고, 다른 쪽 길에는 없었습니다.
검증이 없으면 실패가 불친절해집니다
검증이 빠졌을 때 정확히 무엇이 문제인지 짚어 둘 필요가 있습니다. 사실 잘못된 값을 넘긴다고 해서 프로그램이 조용히 잘못된 결과를 내놓는 것은 아닙니다. 어딘가에서는 결국 실패합니다. 문제는 그 실패가 "언제, 어떤 모습으로" 일어나느냐입니다.
검증이 없으면 null이나 공백인 버킷·키가 곧장 SDK 내부로 흘러 들어갑니다. 그러면 실제로 멈추는 지점은 SDK 깊숙한 어딘가가 되고, 거기서 나오는 오류는 "어떤 인자가 왜 잘못됐는지"를 호출한 개발자의 언어로 설명해 주지 않습니다. SDK 내부 사정에 맞춘, 맥락에서 벗어난 메시지가 돌아오기 쉽습니다. 코드를 쓰는 사람 입장에서는 "내가 넘긴 버킷 이름이 비어 있었구나"를 바로 알아채지 못하고, 낯선 오류를 거슬러 올라가며 원인을 추적해야 합니다.
반대로 들머리에서 ensureNotBlank로 검증하면, 잘못된 입력은 SDK에 닿기도 전에 걸러집니다. 그리고 돌아오는 예외는 "bucket이 비어 있다"처럼 호출한 사람이 곧바로 이해할 수 있는 말로 문제를 알려줍니다. 잘못을 일으킨 곳과 가장 가까운 자리에서, 그 잘못을 가장 잘 설명할 수 있는 형태로 멈추는 셈입니다. 같은 클래스의 loadDocuments가 이미 그렇게 동작하고 있었으니, 단건 로드 경로만 이 친절함에서 빠져 있던 것이 어색하게 느껴졌습니다.
형제 로더와 비교하며 확신을 얻었습니다
"내가 보기에 어색하다"는 느낌만으로 코드를 고치기에는 늘 마음이 놓이지 않습니다. 유지보수하는 사람들이 일부러 그렇게 둔 것일 수도 있기 때문입니다. 그래서 이번에도 같은 일을 하는 형제 로더와 나란히 놓고 비교해 보았습니다. 가장 가까운 비교 대상은 Amazon S3 로더였습니다.
AmazonS3DocumentLoader의 단건 로드 메서드를 보면, 버킷과 키를 다룰 때 검증을 거친 뒤에 요청을 만듭니다. 즉 S3 로더는 단건 로드 경로에서도 입력을 먼저 확인합니다. 형제 로더가 이미 그렇게 하고 있다는 것은, 입력 검증이 이 묶음의 로더들이 공유하는 약속에 가깝다는 뜻이었습니다. Tencent COS 로더만 단건 경로에서 그 약속을 지키지 않고 있었습니다.
여기서 한 가지 분명해진 점이 있습니다. 이번 수정은 새로운 무언가를 발명하는 일이 아니라, 이미 저장소 곳곳에 존재하는 패턴으로 어긋난 한 곳을 되돌리는 일이라는 것입니다. 같은 클래스의 loadDocuments가 이미 따르고 있고, 형제인 S3 로더도 따르고 있는 그 방식으로 단건 로드 경로를 맞추면 되는 것이었습니다. 기여를 할 때 "저장소가 이미 가진 일관성으로 수렴시키는 변경"은 검토하는 사람에게도 받아들이기 쉽다는 점을, 이 비교 과정에서 다시 한번 느꼈습니다. 무엇을 고쳐야 하는지뿐 아니라, 왜 그렇게 고치는 것이 자연스러운지를 같은 저장소 안의 선례로 설명할 수 있었기 때문입니다.
고치는 일은 한 줄을 감싸는 것이었습니다
방향이 분명해지니 수정 자체는 단출했습니다. GetObjectRequest에 넘기는 두 인자를 각각 ensureNotBlank로 감싸는 것이 전부였습니다.
GetObjectRequest getObjectRequest =
new GetObjectRequest(ensureNotBlank(bucket, "bucket"), ensureNotBlank(key, "key"));
이렇게 하면 버킷이나 키가 null이거나 공백뿐일 때, 요청 객체를 만드는 그 자리에서 곧바로 예외가 발생합니다. 그리고 그 예외 메시지에는 어떤 값이 잘못됐는지가 담깁니다. loadDocuments가 버킷에 대해 이미 하고 있던 일을, 단건 로드 경로에서 버킷과 키 양쪽에 똑같이 적용한 것입니다.
여기서 신경 쓴 점이 두 가지 있었습니다. 하나는 새 도구를 들여오지 않았다는 것입니다. ensureNotBlank는 이 클래스가 이미 쓰고 있던 검증 도구였습니다. 클래스의 생성자부터가 ensureNotNull로 클라이언트를 검증하고 있었고, 앞서 본 것처럼 loadDocuments도 ensureNotBlank를 쓰고 있었습니다. 그러니 이번 수정은 이미 그 자리에 있던 도구를 빠진 한 곳에 마저 적용한 것이지, 무언가를 새로 끌어온 것이 아니었습니다. 외부 라이브러리를 추가하지 않는 것은 이 저장소의 중요한 규칙이기도 한데, 이번 변경은 그 규칙과 부딪힐 일이 애초에 없었습니다.
다른 하나는, 이 변경이 기존 동작을 깨지 않는다는 점입니다. 정상적인 버킷과 키를 넘기던 기존 사용자에게는 아무 변화가 없습니다. 검증은 어차피 잘못된 입력, 즉 원래대로라면 어딘가에서 실패했을 입력에 대해서만 동작하기 때문입니다. 달라지는 것은 그 실패가 더 일찍, 더 분명한 모습으로 일어난다는 점뿐입니다. 정상 경로의 계약은 그대로 두고 비정상 경로의 메시지만 또렷하게 만드는 변경이라, 호환성을 걱정할 부분이 없었습니다.
검증을 어디에 두느냐가 테스트까지 정했습니다
코드 한 줄을 고치는 것만큼이나 신경 쓴 것이 테스트였습니다. LangChain4j의 기여 규칙 중에는 "테스트가 없으면 리뷰하지 않는다"는 원칙이 있습니다. 처음에는 이렇게 작은 수정에도 테스트가 꼭 필요한가 싶었지만, 생각해 보면 검증을 더하는 변경이야말로 테스트로 그 동작을 못 박아 두기에 적절한 경우였습니다. 잘못된 입력에 대해 정말로 예외가 나는지, 그리고 나중에 누군가 이 검증을 무심코 걷어내면 곧바로 빨간불이 켜지는지를 테스트가 지켜 주어야 했습니다.
그래서 잘못된 입력을 다루는 단위 테스트를 새로 작성했습니다. 버킷이 null인 경우, 버킷이 공백뿐인 경우, 키가 null인 경우, 키가 공백뿐인 경우, 이렇게 네 가지 상황 각각에 대해 IllegalArgumentException이 발생하는지를 확인하는 테스트입니다.
@Test
void should_throw_when_bucket_is_null() {
assertThatThrownBy(() -> loader.loadDocument(null, "some-key", parser))
.isInstanceOf(IllegalArgumentException.class);
}
여기서 흥미로웠던 점은, 검증을 들머리에 둔 덕분에 테스트가 오히려 단순해졌다는 것입니다. 이 테스트는 Tencent COS 클라이언트를 가짜 객체(mock)로 대신 세워 두는데, 그 가짜 객체에 어떤 동작도 미리 정의해 둘 필요가 없었습니다. 왜냐하면 ensureNotBlank가 클라이언트를 건드리기 전에, 즉 getObject 같은 실제 호출이 일어나기도 전에 예외를 던지기 때문입니다. 잘못된 입력은 클라우드에 닿기 전에 차단되므로, 테스트는 실제 Tencent 계정도, 네트워크 연결도, 가짜 객체의 세세한 흉내 내기도 필요로 하지 않았습니다.
이 경험은 검증을 어디에 두느냐가 단지 코드가 얼마나 튼튼한지만이 아니라 테스트의 난이도까지 좌우한다는 것을 알게 해 주었습니다. 만약 검증이 SDK 호출 뒤편에 있었다면, 그 동작을 테스트하기 위해 클라이언트의 반응을 일일이 흉내 내야 했을 것입니다. 입력을 가장 앞에서 막아 두니, 그 막는 동작을 검증하는 일도 가장 앞에서 간단히 끝났습니다. 빠른 실패(fail-fast)가 코드를 쓰는 사람뿐 아니라 코드를 검증하는 사람에게도 선물이 된다는 것을, 이 작은 테스트를 쓰며 체감했습니다.
저장소의 절차를 따라가며 배운 것
코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. PR 본문에는 어떤 이슈를 닫는 변경인지를 Closes #이슈번호 형태로 명시합니다. 처음에는 작은 수정 하나에 이슈까지 따로 만드는 일이 번거롭게 느껴졌지만, 변경의 맥락을 글로 먼저 정리해 두면 검토하는 사람이 코드를 보기 전에 "무엇을, 왜" 고치는지를 이해할 수 있다는 점에서 이 순서에 수긍하게 됐습니다.
PR 제목을 다루는 방식도 새로 배운 부분이었습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하는 정책을 쓰기 때문에, PR 제목이 그대로 기본 브랜치의 커밋 메시지로 남습니다. 그래서 제목을 영어 명령형으로, 무엇을 어디서 고쳤는지가 한눈에 드러나도록 써야 했습니다. 막연히 "버그 수정"이라고 적는 대신, 어떤 클래스의 어떤 메서드에 무엇을 더했는지가 제목만 보고도 전달되도록 다듬었습니다. 커밋 기록에 영구히 남는 한 줄이라고 생각하니 제목 한 문장에도 손이 갔습니다.
변경을 작게 유지하라는 원칙도 내내 의식했습니다. 코드를 읽다 보면 손대고 싶은 다른 부분이 눈에 띄기 마련이지만, 이번 PR은 단건 로드 경로의 입력 검증이라는 한 가지 목적에만 집중했습니다. 검증과 무관한 리팩터링이나 다른 개선을 같은 PR에 섞지 않는 것이, 검토하는 사람에게도 그 변경이 정확히 무엇을 하는지 분명하게 전달하는 길이었습니다. 변경된 줄 하나하나가 "입력 검증을 더한다"는 목적으로 곧장 설명될 수 있어야 한다는 기준을 스스로 세워 두고 diff를 다시 살폈습니다.
작은 수정에서 배운 것
이 기여를 마치고 돌아보면, 바뀐 코드의 양은 많지 않습니다. 메서드 한 줄을 검증으로 감싸고, 잘못된 입력을 확인하는 단위 테스트 몇 개를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 코드의 일관성이 그 자체로 하나의 품질이라는 감각입니다. 같은 클래스의 두 메서드가, 또 형제 로더들이 입력을 대하는 태도가 서로 달랐던 것이 이번 어긋남의 본질이었습니다. 어느 한쪽만 틀렸다기보다, 약속이 한 곳에서만 지켜지지 않았던 것입니다. 이런 어긋남은 코드를 한 파일만 봐서는 잘 드러나지 않고, 닮은 코드들을 나란히 놓고 비교할 때 비로소 보입니다. 저장소를 넓게 읽어 두는 일이 왜 중요한지를, 이번 비교 과정에서 다시 배웠습니다.
또 하나는 빠른 실패의 가치입니다. 잘못된 입력을 가장 앞에서, 가장 가까운 자리에서 막으면, 실패가 더 일찍 일어나고 그 원인도 더 분명해집니다. 그리고 그 막는 동작은 테스트하기도 쉬워집니다. 검증을 어디에 두느냐 하는 작은 결정이, 코드를 쓰는 사람의 디버깅 경험과 코드를 지키는 테스트의 단순함을 동시에 좌우한다는 것을 이번에 분명히 느꼈습니다.
마지막으로, 버그를 고치는 일과 그 버그를 잡는 테스트를 더하는 일은 결이 다른 두 가지라는 것입니다. 검증을 더하는 것은 지금의 한 번을 바로잡는 일이고, 잘못된 입력에 예외가 나는지를 확인하는 테스트를 더하는 것은 앞으로 누군가 이 검증을 실수로 걷어내더라도 곧장 알아채도록 그물을 쳐 두는 일입니다. 두 가지가 함께 있어야 비로소 수정이 완성된다는 것을, 이 작은 기여가 일러 주었습니다.
여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 한 건의 작은 입력 검증으로 무언가를 다 안다고 말할 수는 없습니다. 다만 닮은 코드들을 비교하며 어긋난 곳을 찾고, 그것을 저장소가 이미 가진 패턴으로 되돌리고, 같은 실수가 반복되지 않도록 테스트까지 남겨 두는 이 과정 자체가, 기능 하나를 더 만드는 것만큼이나 중요한 일이라고 생각됩니다. 다음에 또 어떤 코드를 읽다가 어긋난 한 곳을 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5434
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Redis vs DB
- Java Performance
- 트랜잭션 관리
- TTL 설계
- 트래픽 처리
- Redis 성능 개선
- 캐시 장애
- DB 트랜잭션
- Enum 기반 싱글톤
- DB 인덱스 성능
- Cache Aside
- Eager Initialization
- 스레드 생명주기
- 백엔드 성능
- Cache Avalanche
- mybatis
- Spring Batch
- 백엔드 성능 설계
- 동시성처리
- Cache Penetration
- 캐시 성능 비교
- 캐시와 인덱스
- 백엔드 성능 튜닝
- 백엔드 아키텍처
- Hot Key 문제
- spring batch 5
- Double-Checked Locking
- Initialization-on-Demand Holder Idiom
- InterruptedException
- Redis 캐시 전략
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
