티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - Azure Blob 문서 로더의 소스 URL이 어긋난 이유
ebson 2026. 6. 19. 10:34이 글은 LangChain4j의 Azure Blob Storage 문서 로더 수정의 기여를 처음부터 끝까지 따라가며, 무엇이 문제였고 그것을 어떻게 확인했으며 저장소의 기여 규칙을 익히면서 어떤 점에 부딪혔는지 정리한 기록입니다. 결과만 보면 코드 한 줄을 바꾼 작은 수정이지만, 그 한 줄에 도달하기까지 거쳐야 했던 확인 과정을 기록해두려고 합니다.
문서의 출처를 알려주는 한 줄에서 시작했습니다
LangChain4j는 여러 저장소에서 문서를 읽어와 LLM이 다룰 수 있는 형태로 바꿔주는 라이브러리입니다. 그중 document-loaders 묶음에는 Amazon S3, Google Cloud Storage, Azure Blob Storage, Tencent COS 같은 클라우드 저장소에서 파일을 불러오는 로더들이 모여 있습니다. 제가 들여다본 것은 Azure Blob Storage 로더였습니다.
이 로더들은 파일 내용만 읽어오는 게 아니라, 그 문서가 "어디에서 왔는지"를 함께 기록합니다. LangChain4j에서는 이런 부가 정보를 메타데이터라고 부르고, 문서의 출처는 그중 source라는 키에 담깁니다. RAG처럼 여러 문서를 검색해 답변의 근거로 쓰는 상황에서는 이 출처 정보가 꽤 중요합니다. 사용자가 "이 답변의 근거가 된 원본 파일을 보고 싶다"고 할 때, source에 적힌 주소가 정확해야 원래 파일로 되돌아갈 수 있기 때문입니다.
Azure Blob Storage의 주소 체계를 먼저 짚어두면 이후 이야기가 분명해집니다. Azure에서 Blob 하나의 정식 주소는 https://<계정명>.blob.core.windows.net/<컨테이너명>/<파일명> 형태입니다. 여기서 계정명(account name)은 스토리지 계정을 가리키는 최상위 이름이고, 컨테이너명(container name)은 그 계정 안에서 파일들을 묶는 폴더 같은 단위입니다. 즉 계정명은 도메인의 서브도메인 위치에, 컨테이너명은 경로의 첫 부분에 들어갑니다. 이 두 자리는 절대 서로 바뀌어선 안 됩니다. 바뀌면 그 주소로는 어떤 파일도 찾아갈 수 없습니다.
그런데 Azure 로더가 만들어내는 source 값을 따라가 보니, 바로 이 두 자리가 서로 어긋나 있었습니다.
출처 URL을 조립하는 코드는 멀쩡했습니다
처음에는 URL을 조립하는 코드 자체를 의심했습니다. 출처 문자열을 실제로 만들어내는 곳은 AzureBlobStorageSource라는 클래스입니다. 이 클래스의 metadata() 메서드를 열어 보면 다음과 같이 주소를 조립합니다.
metadata.put(SOURCE, format("https://%s.blob.core.windows.net/%s/%s", accountName, containerName, blobName));
이 줄만 보면 흠잡을 데가 없습니다. 첫 번째 자리에 accountName, 두 번째 자리에 containerName, 세 번째 자리에 blobName이 들어가니 Azure의 정식 주소 형식과 정확히 맞습니다. 필드도 accountName, containerName으로 제대로 나뉘어 있고요. 여기까지만 보면 버그가 있을 이유가 없어 보였습니다.
문제는 이 필드에 값이 채워지는 시점, 즉 생성자에 있었습니다. AzureBlobStorageSource의 생성자 시그니처는 이렇게 생겼습니다.
public AzureBlobStorageSource(
InputStream inputStream, String containerName, String accountName, String blobName, BlobProperties properties) {
this.inputStream = ensureNotNull(inputStream, "inputStream");
this.accountName = ensureNotBlank(accountName, "accountName");
this.containerName = ensureNotBlank(containerName, "containerName");
this.blobName = ensureNotBlank(blobName, "blobName");
this.properties = ensureNotNull(properties, "properties");
}
여기서 눈여겨볼 부분은 파라미터의 순서입니다. InputStream 다음에 컨테이너명이 먼저, 그다음에 계정명이 옵니다. 메서드 안에서는 두 번째 파라미터(containerName)를 this.containerName에, 세 번째 파라미터(accountName)를 this.accountName에 정직하게 대입합니다. 그러니 이 생성자도 그 자체로는 올바릅니다. 생성자에 값을 "선언된 순서대로" 넘기기만 하면 아무 문제가 없습니다.
버그는 이 생성자를 호출하는 쪽에 있었습니다.
호출하는 쪽에서 두 인자가 자리를 바꿔 들어갔습니다
실제로 파일을 읽어 source를 만들어내는 곳은 AzureBlobStorageDocumentLoader의 loadDocument() 메서드입니다. 수정 전 코드는 다음과 같았습니다.
public Document loadDocument(String containerName, String blobName, DocumentParser parser) {
BlobClient blobClient = blobServiceClient.getBlobContainerClient(containerName).getBlobClient(blobName);
BlobProperties properties = blobClient.getProperties();
BlobInputStream blobInputStream = blobClient.openInputStream();
AzureBlobStorageSource source = new AzureBlobStorageSource(
blobInputStream, blobClient.getAccountName(), containerName, blobName, properties);
return DocumentLoader.load(source, parser);
}
생성자 호출 부분만 떼어 놓고 보겠습니다.
new AzureBlobStorageSource(blobInputStream, blobClient.getAccountName(), containerName, blobName, properties);
생성자가 기대하는 순서는 (InputStream, 컨테이너명, 계정명, 파일명, properties)입니다. 그런데 이 호출은 InputStream 다음 자리, 그러니까 컨테이너명이 들어가야 할 두 번째 자리에 blobClient.getAccountName()(계정명 값)을 넣었습니다. 그리고 계정명이 들어가야 할 세 번째 자리에 containerName을 넣었습니다. 두 값이 정확히 자리를 맞바꿔 들어간 것입니다.
그 결과 생성자 내부에서는 계정명 값이 this.containerName 필드로, 컨테이너명 값이 this.accountName 필드로 저장됩니다. 그리고 이 뒤바뀐 필드들이 그대로 metadata()의 URL 조립에 쓰입니다. 최종적으로 만들어지는 source 주소는 https://<컨테이너명>.blob.core.windows.net/<계정명>/<파일명> 꼴이 됩니다. 정식 형식과 비교하면 서브도메인과 경로의 첫 부분이 통째로 뒤집힌, 어디로도 닿지 못하는 주소입니다.
컴파일러도, 검증 코드도, 기존 테스트도 잡지 못했습니다
이 버그를 두고 한참 생각한 지점이 있습니다. 이렇게 명백히 잘못된 호출이 어떻게 여태 살아남았을까 하는 점입니다. 따져 보니 이 버그를 걸러줄 만한 세 겹의 그물이 모두 이걸 통과시키고 있었습니다.
첫째는 컴파일러입니다. 컨테이너명과 계정명은 둘 다 String 타입입니다. 자바 컴파일러는 타입이 맞으면 통과시키므로, 같은 String 두 개의 순서가 바뀐 것은 전혀 잡아주지 못합니다. 만약 둘 중 하나가 다른 타입이었다면 컴파일 단계에서 곧장 걸렸겠지만, 같은 타입이 연달아 놓이는 순간 컴파일러의 도움은 사라집니다.
둘째는 생성자 안의 값 검증입니다. 생성자는 ensureNotBlank로 계정명과 컨테이너명이 비어 있지 않은지를 확인합니다. 그런데 자리를 바꿔 들어온 두 값은 어차피 둘 다 비어 있지 않은 정상 문자열입니다. 그래서 "비었는지"만 보는 검증은 두 값이 엉뚱한 필드에 들어왔다는 사실을 알아챌 방법이 없습니다. 값의 형식이 아니라 값이 놓인 자리가 문제였기 때문입니다.
셋째는 테스트입니다. 이 모듈에는 Azurite라는 Azure 스토리지 에뮬레이터를 도커 컨테이너로 띄워 실제 동작을 확인하는 통합 테스트가 있었습니다. 출처를 검증하는 부분도 있긴 했습니다. 다만 그 단언이 이런 모양이었습니다.
assertThat(document.metadata().getString("source")).endsWith("/test-file.txt");
endsWith는 주소가 특정 파일명으로 끝나는지만 확인합니다. 서브도메인에 계정명이 들어갔든 컨테이너명이 들어갔든, 끝부분의 파일명만 맞으면 이 단언은 통과합니다. 출처 URL에서 정작 중요한 앞부분, 즉 계정명과 컨테이너명의 위치는 검증 범위 밖에 있었던 셈입니다. 테스트가 있긴 했지만 이 버그가 숨을 수 있는 사각지대를 정확히 비워 두고 있었습니다.
세 겹의 그물이 모두 같은 종류의 빈틈을 가지고 있었다는 점이 인상적이었습니다. 타입만 보는 컴파일러, 빈 값만 보는 검증, 끝부분만 보는 테스트. 어느 것도 "값이 제 자리에 있는가"를 묻지 않았습니다.
형제 로더와 비교하며 확신을 얻었습니다
코드를 읽다 보면 "내가 뭔가 잘못 이해한 것 아닐까" 하는 의심이 들 때가 많습니다. 특히 오래 유지된 코드일수록 그렇습니다. 그래서 제가 자주 쓰는 방법은, 같은 일을 하는 형제 코드와 나란히 놓고 비교하는 것입니다. Azure 로더에는 비교하기 좋은 이웃들이 있었습니다. 같은 document-loaders 묶음 안의 Amazon S3 로더와 Tencent COS 로더입니다.
Amazon S3 쪽을 보면, AmazonS3Source의 생성자가 (InputStream, String bucket, String key) 순서로 선언되어 있고, 이를 호출하는 AmazonS3DocumentLoader도 new AmazonS3Source(inputStream, bucket, key)로 선언 순서와 똑같이 값을 넘깁니다. Tencent COS 로더도 마찬가지로 생성자의 파라미터 순서와 호출 순서가 일치합니다. 두 형제 로더는 모두 일관되게 "선언된 순서대로 넘긴다"는 규칙을 지키고 있었습니다.
오직 Azure 로더만 그 규칙에서 벗어나 있었습니다. 형제들과 나란히 놓고 보니, 이것이 제 오해가 아니라 Azure 로더 쪽의 실수라는 확신이 섰습니다. 동시에 수정의 방향도 분명해졌습니다. 무언가 새로운 설계를 도입할 필요 없이, 형제들이 이미 따르고 있는 일관된 패턴으로 Azure 로더를 되돌리면 되는 것이었습니다. 기여를 할 때 "이 저장소가 이미 가진 패턴으로 수렴시키는 변경"은 검토하는 입장에서도 받아들이기 쉽다는 점을, 이 비교 과정에서 체감했습니다.
고치는 일보다 규칙을 익히는 일이 더 컸습니다
정작 코드 수정 자체는 간단했습니다. 호출부에서 두 인자의 순서를 생성자 선언에 맞게 바로잡는 것이 전부였습니다.
AzureBlobStorageSource source = new AzureBlobStorageSource(
blobInputStream, containerName, blobClient.getAccountName(), blobName, properties);
컨테이너명을 두 번째 자리로, 계정명(blobClient.getAccountName())을 세 번째 자리로 옮겼습니다. 이렇게 하면 생성자가 기대하는 (InputStream, 컨테이너명, 계정명, 파일명, properties) 순서와 정확히 맞아떨어지고, 필드에도 올바른 값이 들어가, 최종 source 주소가 https://<계정명>.blob.core.windows.net/<컨테이너명>/<파일명>이라는 정식 형식으로 만들어집니다. 여기서 한 가지 신경 쓴 점은, AzureBlobStorageSource의 생성자 시그니처는 건드리지 않았다는 것입니다. 생성자는 처음부터 올바르게 선언되어 있었고, 잘못은 호출하는 쪽에 있었기 때문입니다. 문제가 없는 코드까지 손대지 않고, 정확히 틀린 한 곳만 고치는 것이 변경을 작게 유지하는 길이었습니다.
오히려 시간이 더 든 쪽은 코드가 아니라 저장소의 기여 규칙을 익히는 일이었습니다. LangChain4j의 CONTRIBUTING.md를 읽으면서 처음 알게 된 것이 몇 가지 있었습니다.
가장 먼저 마주친 규칙은 "no tests, no review"였습니다. 테스트가 없는 변경은 아예 리뷰 대상으로 보지 않는다는 것입니다. 처음에는 이미 코드를 고쳤으니 동작은 맞을 텐데 왜 테스트까지 필요한가 싶었지만, 이번 버그가 바로 "테스트가 사각지대를 비워 둔 탓에 오래 살아남은" 사례였다는 걸 떠올리니 납득이 됐습니다. 고치는 것만으로는 부족하고, 같은 버그가 다시 들어오면 반드시 걸리도록 그물을 촘촘히 다시 짜는 것까지가 기여의 일부였습니다.
또 하나는 절차의 순서였습니다. 이 저장소는 버그 수정의 경우 곧장 PR을 올리는 게 아니라, 먼저 이슈를 등록해 문제를 공유하고, 그다음에 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. PR 본문에는 Closes #이슈번호 형태로 어떤 이슈를 닫는 변경인지 명시해야 했습니다. 처음에는 작은 수정 하나에 이슈까지 따로 만드는 게 번거롭게 느껴졌지만, 변경의 맥락을 글로 먼저 정리해 두면 검토하는 사람도 "무엇을, 왜" 고치는지를 코드보다 먼저 이해할 수 있다는 점에서 이 순서에 수긍하게 됐습니다.
PR 제목을 쓰는 방식도 새로 배운 부분이었습니다. 이 저장소는 squash merge 정책을 쓰기 때문에, PR 제목이 그대로 main 브랜치의 커밋 메시지가 됩니다. 그래서 제목을 영어 명령형으로, 무엇을 고쳤는지가 한눈에 드러나도록 써야 했습니다. 단순히 "버그 수정" 같은 막연한 제목이 아니라, 어떤 증상을 어디서 고쳤는지가 제목만 봐도 전달되어야 했습니다. 커밋 로그에 영구히 남는 한 줄이라고 생각하니 제목 한 문장을 다듬는 데에도 손이 갔습니다.
테스트를 다시 쓰는 일이 수정의 절반이었습니다
코드를 한 줄 고친 뒤, 같은 비중으로 신경 쓴 것이 테스트였습니다. 기존 통합 테스트가 endsWith("/test-file.txt")로 끝부분만 확인하고 있었으니, 이 단언을 그대로 두면 제가 고친 부분이 정말 맞는지도 증명되지 않고, 나중에 누군가 다시 순서를 뒤집어도 테스트는 여전히 초록불일 것이었습니다.
그래서 단언을 출처 주소 전체를 확인하는 방식으로 바꿨습니다. 이 모듈의 테스트는 Azurite 에뮬레이터를 쓰는데, Azurite의 기본 계정명은 devstoreaccount1로 고정되어 있고 테스트에서 쓰는 컨테이너명은 test-container였습니다. 이 값들을 그대로 이용해, 만들어진 출처가 정확히 어떤 주소여야 하는지를 통째로 단언했습니다.
assertThat(document.metadata().getString("source"))
.isEqualTo("https://" + TEST_ACCOUNT + ".blob.core.windows.net/" + TEST_CONTAINER + "/" + TEST_BLOB);
endsWith에서 isEqualTo로 바꾼 것은 작은 변화처럼 보이지만, 검증의 성격이 완전히 달라집니다. 이제는 파일명뿐 아니라 서브도메인 자리에 계정명이, 경로 첫 부분에 컨테이너명이 정확히 들어갔는지까지 확인합니다. 만약 누군가 다시 두 인자의 순서를 바꿔 넣는다면, 서브도메인이 devstoreaccount1이 아니라 test-container가 되어 단언이 곧바로 실패합니다. 단일 문서를 읽는 경우와 여러 문서를 읽는 경우 양쪽 모두 이렇게 전체 주소를 검증하도록 단언을 강화했습니다. 이번 버그를 처음 살려 둔 사각지대를, 이번 기회에 메워 두고 싶었습니다.
수정과 테스트를 마치고 나서, 저장소가 안내하는 코드 포맷 검사 도구(spotless)가 형식까지 손봐 주었습니다. 들여쓰기나 import 정렬 같은 부분이 저장소의 일관된 스타일에 맞춰 정리되었는데, 이런 형식 규칙을 도구가 자동으로 맞춰 준다는 점도 큰 프로젝트에 기여하면서 처음 경험한 부분이었습니다. 다만 코드 동작과 무관한 형식 변경은 본질이 아니므로, 정작 봐야 할 변경은 어디까지나 인자 순서를 바로잡은 한 줄과 테스트 단언을 강화한 몇 줄이라는 점은 스스로 분명히 해 두었습니다.
작은 수정에서 배운 것
이 기여를 마치고 돌아보면, 바뀐 코드의 양은 정말 적습니다. 호출부 한 줄과 테스트의 몇 줄이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 코드 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 같은 타입의 인자가 연달아 놓일 때 생기는 위험을 몸으로 느낀 경험입니다. 타입이 같으면 컴파일러가 순서를 지켜 주지 못하고, 값이 비어 있지 않으면 일반적인 입력 검증도 통과시키며, 테스트가 핵심 부분을 비워 두면 그 사각지대로 버그가 조용히 숨어듭니다. 이런 코드를 다룰 때는 "각 인자가 정말 제 자리에 있는가"를 사람이 한 번 더 눈으로 확인하는 수밖에 없다는 것을, 이 버그가 분명하게 알려 주었습니다. 이후로 저는 인자가 여럿인 생성자나 메서드를 호출하는 코드를 읽을 때, 타입이 같은 인자들이 줄지어 있으면 한 번 더 멈춰서 순서를 맞춰 보는 습관이 생겼습니다.
또 하나는, 좋은 수정이란 새로운 무언가를 더하는 것이 아니라 이미 존재하는 일관성으로 되돌리는 것일 때가 많다는 점입니다. 이번에도 답은 형제 로더들이 이미 따르고 있던 패턴 안에 있었습니다. 저장소를 넓게 읽어 두면, 한 곳에서 어긋난 부분이 자연스럽게 눈에 들어옵니다. 정답을 새로 발명하기보다 이미 있는 정답을 찾아 맞추는 쪽이, 검토하는 사람에게도 훨씬 설득력 있는 변경이 된다는 것을 배웠습니다.
마지막으로, 테스트를 고치는 일이 코드를 고치는 일만큼 중요하다는 감각을 얻었습니다. 버그를 고치는 것은 현재의 한 번을 바로잡는 일이고, 그 버그를 잡아내는 테스트를 더하는 것은 앞으로의 모든 변경에서 같은 실수를 막아 두는 일입니다. endsWith를 isEqualTo로 바꾼 한 번의 결정이, 앞으로 이 코드를 만질 누군가를 같은 함정에서 지켜 줄 수 있다고 생각하면, 작은 수정이라는 말이 조금 다르게 들립니다.
여전히 저는 오픈소스 기여를 막 시작한 사람이고, 한 건의 작은 버그 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 코드를 천천히 읽고, 형제 코드와 비교하고, 저장소의 규칙을 존중하며, 같은 실수가 반복되지 않도록 테스트까지 다듬는 이 과정 자체가, 기능 하나를 더 만드는 것만큼이나 중요한 것이라는 것은 분명히 느꼈습니다. 다음에 또 어떤 코드를 읽다가 멈칫하게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5430
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Double-Checked Locking
- Hot Key 문제
- mybatis
- 트랜잭션 관리
- 트래픽 처리
- 캐시 장애
- 캐시와 인덱스
- Java Performance
- InterruptedException
- TTL 설계
- Redis 캐시 전략
- Initialization-on-Demand Holder Idiom
- Spring Batch
- Enum 기반 싱글톤
- DB 인덱스 성능
- DB 트랜잭션
- 캐시 성능 비교
- Eager Initialization
- 백엔드 성능
- 백엔드 성능 설계
- Cache Aside
- spring batch 5
- 백엔드 아키텍처
- Cache Avalanche
- Redis 성능 개선
- Cache Penetration
- 동시성처리
- 스레드 생명주기
- Redis vs 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 |
