티스토리 뷰

지금까지 제가 다룬 기여 중에는 인자 순서가 바뀐 것도, 큰 정수가 좁혀진 것도, 목록에서 한 줄이 빠진 것도 있었습니다. 이번에 다룰 LangChain4j 수정은 그중에서도 가장 작습니다. 바뀐 것은 코드 한 곳의 글자 한 개입니다. 그런데 그 한 글자가, 흔히 쓰는 비디오 파일 하나가 제 짝을 못 찾고 엉뚱한 답을 받게 만들고 있었습니다.

 

이 글은 그 한 건의 기여를, 무엇이 어긋나 있었고 그것을 어떻게 알아챘으며 한 글자를 고치는 데에도 왜 신경 쓸 거리가 있었는지 처음부터 끝까지 따라가며 정리한 기록입니다. 결과만 보면 글자 하나를 바꾼 변경이지만, 그 글자가 틀렸다고 확신하기까지, 그리고 그것을 고친 흔적을 깔끔히 남기기까지 거친 과정이 저에게는 더 오래 남았습니다.


파일 확장자로 MIME 타입을 알아내는 작은 표

먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j는 이미지나 오디오, 비디오 같은 미디어 파일을 LLM에 넘길 때, 그 파일이 어떤 종류인지를 MIME 타입이라는 문자열로 함께 알려 줍니다. 예를 들어 mp4 비디오라면 video/mp4, png 이미지라면 image/png 같은 식입니다. 모델에게 "이건 이런 형식의 파일이다"라고 알려 주는 꼬리표인 셈입니다.

 

이 꼬리표를 붙여 주는 일을 CustomMimeTypesFileTypeDetector라는 클래스가 맡습니다. 이 클래스는 안에 작은 표를 하나 들고 있습니다. 파일 확장자를 열쇠로 삼고, 그에 대응하는 MIME 타입을 값으로 둔 표입니다. 파일 경로가 들어오면 확장자를 떼어내 이 표에서 찾아보고, 맞는 항목이 있으면 그 MIME 타입을 돌려줍니다. 표에 없으면 자바 표준 라이브러리가 제공하는 기본 판별 기능으로 넘겨 거기서 답을 얻습니다.

 

제가 들여다본 것은 이 표를 채우는 부분이었습니다. 표는 클래스가 처음 불릴 때 한 번에 만들어지는데, 비디오 형식들을 죽 등록하는 구역이 있었습니다. 그 구역 위에는 출처를 밝힌 주석도 달려 있었습니다. 구글의 Vertex AI Gemini가 받아들이는 비디오 형식 목록을 따랐다는 내용이었습니다.


값은 wmv를 가리키는데 열쇠는 다른 글자였습니다

비디오 등록 구역을 한 줄씩 읽어 내려가다 한 항목에서 멈칫했습니다. 이 표는 등록하는 코드가 열쇠와 값을 나란히 적는 형태라, 두 값을 한눈에 비교하기 좋았습니다. 대부분의 줄은 이런 모양이었습니다.

defaultMappings.put("mp4",    "video/mp4");
defaultMappings.put("mov",    "video/mov");
defaultMappings.put("avi",    "video/avi");
defaultMappings.put("flv",    "video/x-flv");
defaultMappings.put("webm",   "video/webm");

 

여기서 규칙이 분명히 보입니다. 열쇠 자리에는 파일 확장자가, 값 자리에는 그 확장자에 대응하는 MIME 타입이 들어갑니다. mp4라는 확장자에는 video/mp4가, mov에는 video/mov가 짝지어집니다. 비디오 구역에는 모두 열 개의 항목이 있었는데, 그중 아홉은 이 규칙, 즉 "열쇠는 곧 확장자"라는 패턴을 지키고 있었습니다.

그런데 나머지 한 줄만 결이 달랐습니다.

defaultMappings.put("mmv",    "video/wmv");

 

값은 video/wmv입니다. WMV라는 비디오 형식을 가리킵니다. WMV는 윈도우 환경에서 흔히 보이는 비디오 형식이고, 그 파일 확장자는 .wmv입니다. 그렇다면 이 줄의 열쇠는 당연히 wmv여야 했습니다. 그런데 열쇠 자리에는 mmv가 적혀 있었습니다. 첫 글자가 w가 아니라 m이었던 것입니다.

 

여기서 제가 확신을 얻은 부분은, 값이 의도를 증언하고 있었다는 점입니다. 만약 값까지 애매했다면 이게 오타인지 아니면 제가 모르는 어떤 형식인지 헷갈렸을 겁니다. 하지만 값은 분명히 video/wmv였습니다. 이 줄을 쓴 사람의 의도가 WMV 비디오를 등록하려던 것이었음을, 값이 그대로 말해 주고 있었습니다. 게다가 주석이 출처로 가리킨 Vertex AI Gemini의 비디오 형식 목록에도 WMV는 있지만 .mmv라는 확장자는 없습니다. .mmv는 알려진 비디오 확장자가 아닙니다. 열쇠 자리의 mmv는 그저 잘못 친 글자였습니다.


한 글자 오타가 두 가지 문제를 동시에 만들고 있었습니다

이 오타가 흥미로웠던 것은, 한 글자가 어긋났을 뿐인데 그 결과가 두 방향으로 동시에 어긋나 있었다는 점입니다.

 

첫 번째는 정작 등록하려던 .wmv 파일이 이 표에서 빠져 버린 것입니다. 표를 찾을 때는 들어온 확장자와 열쇠가 정확히 일치해야 합니다. 그런데 .wmv 파일의 확장자는 wmv이고, 표에 등록된 열쇠는 mmv이니 둘은 만나지 못합니다. 그래서 .wmv 파일이 들어오면 이 표에서는 짝을 못 찾고, 앞서 말한 자바 표준 라이브러리의 기본 판별로 넘어갑니다. 그쪽이 돌려주는 값은 이 프로젝트의 표가 의도한 video/wmv와 같다는 보장이 없습니다. 실제로 표준 라이브러리는 같은 WMV를 두고 video/x-ms-wmv처럼 다른 문자열을 돌려주곤 합니다. 결국 .wmv 파일은 이 프로젝트가 정해 둔 꼬리표가 아니라 엉뚱한 곳에서 온 꼬리표를 달게 됩니다. 오류가 나며 멈추는 게 아니라, 그저 다른 값이 조용히 붙는 종류의 어긋남이었습니다.

 

두 번째는 잘못 등록된 mmv라는 열쇠가 영영 쓸모없는 항목이 되어 버린 것입니다. .mmv는 실제로 존재하는 파일 확장자가 아니니, 어떤 파일도 이 열쇠와 짝지어지지 않습니다. 표 안에 자리는 차지하고 있지만 어느 입력으로도 닿을 수 없는, 말하자면 죽은 항목이었습니다. 누군가 이 표를 읽다가 mmv가 무슨 형식인지 찾아보려 한다면 헛수고를 하게 될 자리이기도 했습니다.

요약하면, 있어야 할 .wmv 매핑은 없고, 없어도 되는 .mmv 매핑만 덩그러니 남아 있는 상태였습니다. 한 글자가 두 가지를 한꺼번에 어긋나게 한 셈입니다.


이 표가 실제로 쓰이는 길을 확인했습니다

오타라는 확신은 섰지만, 이 매핑이 실제로 누군가에게 영향을 주는지도 따져 보고 싶었습니다. 표가 잘못되어 있어도 아무도 그 길을 지나가지 않는다면 영향은 없을 테니까요.

 

이 표를 가진 판별기가 어디에서 불리는지 따라가 보니, Gemini나 Vertex AI 같은 구글 계열 모델로 미디어를 넘기는 변환 코드들이 이걸 쓰고 있었습니다. 사용자가 미디어의 MIME 타입을 직접 지정하지 않으면, 이 변환 코드가 판별기를 불러 확장자로부터 MIME 타입을 채워 넣습니다. 그러니 사용자가 .wmv 비디오를 MIME 타입 없이 이런 모델의 입력으로 넘기면, 방금 본 어긋난 경로를 그대로 지나가게 됩니다. 흔히 쓰는 비디오 형식 하나가 일관되지 않은 꼬리표를 받는 상황이, 일부러 만들어야 닿는 특수한 경우가 아니라 평범한 사용에서 일어날 수 있다는 뜻이었습니다.


값은 그대로 두고 열쇠 한 글자만 고쳤습니다

고치는 방법은 분명했습니다. 열쇠를 mmv에서 wmv로 바로잡으면 됩니다.

defaultMappings.put("wmv", "video/wmv");

 

여기서 한 가지 의식적으로 정한 것은 값을 건드리지 않는다는 점이었습니다. 값 video/wmv는 표준으로 통용되는 MIME 타입 이름과는 조금 다릅니다. 앞서 본 것처럼 자바 표준 라이브러리는 같은 형식을 video/x-ms-wmv로 부르기도 합니다. 그렇다고 값을 더 "표준적인" 쪽으로 바꾸고 싶은 마음이 들 수도 있지만, 같은 표의 다른 항목들도 video/mov나 video/avi처럼 출처인 Vertex 문서의 표기를 그대로 따르고 있었습니다. 이 표는 그 나름의 일관된 표기 규칙을 가지고 있었던 것입니다. 제가 고쳐야 할 것은 열쇠의 오타이지 값의 표기 방식이 아니었습니다. 값까지 손대면 동작이 달라지고 변경의 범위가 넓어집니다. 틀린 한 곳만 정확히 고치고 나머지는 그대로 두는 것이, 변경을 작게 유지하면서 의도를 분명히 하는 길이었습니다.

 

이 변경에는 예상치 못한 잔주름이 하나 있었습니다. 이 표를 채우는 줄들은 보다시피 열쇠와 값 사이에 공백을 여러 칸 넣어 세로로 줄을 맞춰 둔 형태였습니다. 그런데 이 프로젝트의 코드 형식 검사 도구는 직전 기준과 달라진 줄만 골라 형식을 다시 맞춥니다. 그래서 제가 고친 그 한 줄만 도구가 단일 공백 형태로 정리하고, 손대지 않은 형제 줄들은 원래의 세로 정렬을 그대로 유지하게 됩니다. 결과적으로 제가 고친 줄만 정렬이 어긋난 것처럼 보입니다. 처음에는 이게 잘못된 것 같아 형제 줄까지 맞춰 정렬을 다시 할까 고민했지만, 그렇게 하면 제 변경과 무관한 줄들까지 diff에 끌려 들어옵니다. 형식 도구가 그 한 줄만 그렇게 만든다면 그것이 도구가 강제하는 올바른 상태이고, 보기에 조금 어긋나더라도 제 변경은 정확히 한 줄에만 머무는 것이 맞았습니다. 기여 규칙에서 "기존 코드를 함께 재포맷하지 말라"고 한 이유를, 이 작은 잔주름에서 체감했습니다.


한 줄짜리 검증을 더했습니다

코드를 한 글자 고친 만큼, 이것이 정말 맞는지를 보여 주는 테스트를 더했습니다. 이 저장소는 테스트가 없는 변경은 검토하지 않는다는 규칙을 분명히 두고 있고, 매핑 표의 동작은 환경에 좌우되지 않고 늘 같은 결과를 내므로 단위 테스트로 깔끔하게 확인할 수 있었습니다.

다행히 이 클래스의 테스트에는 기본 매핑을 확인하는 단언들이 이미 여럿 있었습니다. 특정 확장자를 넣었을 때 기대하는 MIME 타입이 나오는지를 보는 형태였습니다. 그 패턴을 그대로 따라, .wmv 파일을 판별기에 넣었을 때 video/wmv가 나오는지를 확인하는 단언을 더했습니다.

@Test
void should_return_a_mime_type_for_wmv_from_default_mapping() {
    // given
    CustomMimeTypesFileTypeDetector detector = new CustomMimeTypesFileTypeDetector();

    // when
    String mimeType = detector.probeContentType("video.wmv");

    // then
    assertThat(mimeType).isEqualTo("video/wmv");
}

 

이 테스트는 고치기 전 코드에서는 실패합니다. .wmv가 표를 비껴가 표준 라이브러리의 다른 값을 받기 때문입니다. 그리고 열쇠를 바로잡은 뒤에는 통과합니다. 버그를 재현하는 테스트를 먼저 두고 그것을 통과시키는 방식으로, 같은 오타가 다시 들어오면 곧장 걸리도록 그물을 만들어 둔 것입니다. 이번 변경은 매핑의 열쇠를 바로잡는 일이라, 잘못된 입력을 막는 음성 테스트는 따로 의미가 없었습니다. 그래서 PR 본문에 이 테스트가 양성 케이스만 가지는 이유를 솔직히 적어 두었습니다.

 

그 밖의 기여 절차도 차례로 밟았습니다. 버그를 먼저 이슈로 공유하고, 그 이슈 번호를 Closes #이슈번호로 연결한 PR을 올렸습니다. squash merge 정책에 맞춰 제목은 영어 명령형으로, 어떤 클래스에서 무엇을 고치는지가 한 줄에 드러나도록 다듬었습니다. PR 본문에는 형식 검사 도구가 고친 줄의 정렬을 어떻게 바꾸는지, 그리고 모듈과 핵심 모듈의 테스트를 돌려 모두 통과했다는 점을 함께 적었습니다. 변경 자체는 한 글자였지만, 그 한 글자가 왜 맞는지와 그 흔적이 어떻게 남는지를 정직하게 설명하는 일에 더 많은 손이 갔습니다.


한 글자에서 배운 것

이 기여를 마치고 돌아보면, 바뀐 것은 글자 하나입니다. 거기에 그 글자가 맞는지 확인하는 테스트 한 덩어리가 붙은 정도입니다. 그런데 그 한 글자에 도달하기까지의 과정에서 얻은 것은 분량과 비례하지 않았습니다.

 

가장 크게 남은 것은, 짝지어진 데이터에서는 한쪽이 다른 쪽의 의도를 증언해 준다는 감각입니다. 이번 오타를 확신할 수 있었던 것은 열쇠와 값이 함께 있었기 때문입니다. 값이 video/wmv라고 분명히 말하고 있었기에, 열쇠의 mmv가 틀렸다고 단언할 수 있었습니다. 만약 둘 중 하나만 있었다면 이것이 오타인지 의도인지 가리기 어려웠을 겁니다. 짝지어진 두 값이 서로 어긋날 때, 둘 중 무엇이 옳은지를 나머지 형제 항목들과 비교해 가려내는 일이 이런 종류의 버그를 다루는 방법이라는 것을 배웠습니다.

 

또 하나는, 작은 오타가 만드는 결과가 의외로 여러 방향으로 번질 수 있다는 점입니다. 한 글자가 틀렸을 뿐인데, 있어야 할 매핑은 사라지고 없어도 될 죽은 항목은 남았으며, 그 영향은 흔히 쓰는 비디오 형식의 꼬리표가 어긋나는 데까지 이어졌습니다. 사소해 보이는 곳일수록 그것이 닿는 경로를 한 번 따라가 보는 일이 필요하다는 것을 느꼈습니다.

 

마지막으로, 한 줄을 고칠 때에도 그 흔적을 어떻게 남길지가 중요하다는 점입니다. 형식 검사 도구가 고친 줄의 정렬을 바꾸더라도, 제 변경과 무관한 줄까지 끌어들이지 않는 것이 옳았습니다. 보기에 조금 어긋나는 것을 감수하더라도, diff가 정확히 제가 의도한 곳에만 머물게 하는 편이 검토하는 사람에게 더 정직한 변경이 됩니다. 작은 수정일수록 그 경계를 분명히 지키는 일이 오히려 더 까다로울 수 있다는 것을, 이번에 배웠습니다.

 

여전히 저는 오픈소스 기여를 막 익혀 가는 사람이고, 글자 하나를 고친 것으로 무언가를 다 안다고 말할 수는 없습니다. 다만 표를 천천히 읽고, 형제 항목과 비교해 무엇이 옳은지를 가리고, 영향이 닿는 경로를 확인하고, 변경을 꼭 필요한 자리에만 한정하는 이 과정 자체가, 더 큰 변경을 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 표에서 어긋난 한 글자를 발견하게 될지는 모르지만, 그때도 같은 방식으로 차분히 짚어 볼 생각입니다.


이 글에서 다룬 기여: langchain4j/langchain4j#5511