티스토리 뷰

자바에서 문자열을 더하는 일은 너무 익숙해서 위험을 잘 느끼지 못합니다. 두 문자열을 +로 이으면 그만이고, 숫자든 다른 객체든 옆에 붙이면 알아서 글자로 바뀝니다. 그런데 이 편리함에는 조용한 함정이 하나 있습니다. 비어 있는 값, 즉 null을 문자열에 더하면 오류가 나는 것이 아니라 "null"이라는 네 글자가 그대로 붙는다는 것입니다. 예외가 터지면 적어도 무언가 잘못됐다는 신호라도 받겠지만, 이 경우에는 아무 일도 없었다는 듯 멀쩡해 보이는 문자열이 만들어집니다. 그 문자열이 어딘가로 전송되기 전까지는 말입니다.

 

이번 글에서 다룰 LangChain4j의 HTTP 클라이언트 수정은 바로 그 함정에 관한 것이었습니다. 파일을 업로드할 때 만들어지는 본문 안에, 비어 있어야 할 자리에 null이라는 글자가 그대로 박혀 잘못된 형식의 데이터가 서버로 나가고 있었습니다. 결과만 보면 헤더를 만드는 코드 몇 줄을 손보고 테스트 세 개를 더한 작은 변경이지만, 이 버그가 정상적인 사용에서 어떻게 도달하는지, 그리고 같은 일을 하는 형제 구현들과 어떻게 어긋나 있었는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.


파일을 업로드할 때 만들어지는 본문

먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. 웹에서 파일을 업로드할 때는 멀티파트라는 형식을 씁니다. 한 요청 안에 여러 조각을 담을 수 있는 형식인데, 각 조각은 자기 자신을 설명하는 머리말과 실제 내용으로 이루어집니다. 머리말에는 이 조각이 어떤 이름의 항목인지, 파일이라면 파일 이름이 무엇인지, 그리고 그 내용이 어떤 종류의 데이터인지를 알려주는 정보가 들어갑니다. 마지막의 "어떤 종류인지"를 나타내는 것이 Content-Type입니다. 예를 들어 음성 파일이라면 Content-Type: audio/wav 같은 식입니다.

 

LangChain4j는 여러 가지 HTTP 클라이언트 구현을 함께 제공합니다. Apache 기반, JDK 기본 기능 기반, 그리고 OkHttp 기반의 세 가지입니다. 같은 일을 하는 세 가지 구현이 있는 셈인데, 이 가운데 Apache와 JDK 구현은 멀티파트 본문을 손수 글자로 조립합니다. 즉 머리말의 각 줄을 문자열로 직접 이어 붙여 만듭니다. 이번에 들여다본 것은 그중 Apache 구현에서 파일 조각을 만드는 addFile이라는 부분이었습니다.


비어 있는 값이 글자가 되어 박혔습니다

addFile이 머리말을 만드는 수정 전 코드는 다음과 같았습니다.

String header = "--" + BOUNDARY + CRLF + "Content-Disposition: form-data; name=\""
        + name + "\"; filename=\"" + file.fileName() + "\"" + CRLF + "Content-Type: "
        + file.contentType() + CRLF + CRLF;

 

여러 조각을 문자열로 이어 머리말 한 덩어리를 만드는 코드입니다. 여기서 눈여겨볼 부분은 마지막에 "Content-Type: " 뒤에 file.contentType()을 그대로 이어 붙이는 대목입니다. 파일의 종류 정보가 제대로 들어 있을 때는 아무 문제가 없습니다. Content-Type: audio/wav처럼 올바른 줄이 만들어집니다.

 

문제는 그 종류 정보가 비어 있을 때였습니다. 만약 그 값이 null이면, 문자열 연결은 오류를 내지 않고 Content-Type: null이라는 줄을 만듭니다. null이 네 글자짜리 단어가 되어 버린 것입니다. 값이 빈 문자열이면 Content-Type: 뒤에 아무것도 없는, 값이 비어 있는 머리말 줄이 만들어집니다. 두 경우 모두 멀티파트 형식에 어긋난 잘못된 머리말입니다. 그런데 코드 입장에서는 그저 문자열 몇 개를 이어 붙였을 뿐이라 아무런 이상 신호도 나지 않습니다. 잘못된 머리말이 그대로 본문에 담겨 서버로 전송됩니다.

 

이것이 이 버그의 첫 번째 핵심이었습니다. 비어 있는 값을 문자열에 더하는 순간, 그 비어 있음이 오류가 아니라 글자로 둔갑한다는 것입니다. 예외라면 어디선가 걸려 멈췄을 텐데, 글자로 바뀌어 버리니 아무 데도 걸리지 않고 끝까지 흘러갑니다.


이상한 입력이 아니라 정상적인 사용이었습니다

처음에는 이런 경우가 실제로 일어날까 의심했습니다. 누가 일부러 파일의 종류를 비워 두겠나 싶었던 것입니다. 그런데 코드를 따라가 보니, 이것은 억지로 만들어낸 상황이 아니라 평범한 사용에서 도달하는 경로였습니다.

 

LangChain4j에는 음성 데이터를 다루는 자료형이 있는데, 이 자료형은 종류 정보를 반드시 채우도록 강제하지 않습니다. 문서에도 그 값이 설정되지 않으면 비어 있을 수 있다고 분명히 적혀 있습니다. 즉 음성 데이터를 만들 때 종류를 따로 지정하지 않으면 그 값은 비어 있는 상태가 됩니다. 그리고 음성을 글자로 옮기는 기능을 호출하는 코드는, 이 비어 있을 수 있는 종류 값을 따로 확인하지 않고 그대로 멀티파트 본문 조립부에 넘깁니다. 결국 종류를 지정하지 않은 음성 데이터를 전사 기능에 넣으면, 본문에 Content-Type: null이라는 잘못된 줄이 박히게 되는 것입니다.

 

여기에 더해, 프로젝트 자신의 계약 테스트 중에도 종류 정보를 빈 문자열로 넘기는 경우가 있었습니다. 즉 빈 값은 외부의 이상한 입력이 아니라, 프로젝트 스스로가 정상으로 간주하고 쓰는 입력이었습니다. 정상 경로에서 도달하는 결함이라는 점이, 이 버그를 고칠 가치가 있는 것으로 만들었습니다.


형제 구현이 올바른 답을 보여 주었습니다

"내가 보기에 잘못됐다"는 판단만으로 고치기에는 늘 조심스럽습니다. 그래서 같은 일을 하는 세 구현이 이 상황에서 각각 어떻게 동작하는지를 나란히 비교해 보았습니다. 비교 대상으로 가장 좋은 것은 OkHttp 구현이었습니다.

 

Apache와 JDK 구현이 머리말을 손수 문자열로 조립하는 것과 달리, OkHttp 구현은 종류 정보를 OkHttp 라이브러리의 처리 함수에 맡깁니다. 그 함수는 빈 값이 들어오면 종류가 없는 것으로 판단하고, 결과적으로 그 조각에서 Content-Type 줄을 아예 빼 버립니다. 즉 OkHttp 구현은 종류가 비어 있으면 잘못된 줄을 만드는 대신 그 줄을 생략합니다. 같은 입력에 대해 세 구현이 서로 다른 바이트를 내보내고 있었고, 그중 OkHttp의 방식이 올바른 쪽이었습니다.

 

이 비교를 통해 고칠 방향이 분명해졌습니다. 새로운 규칙을 만들 필요 없이, OkHttp 형제가 이미 보여 주고 있는 방식, 곧 "종류가 비어 있으면 Content-Type 줄을 생략한다"는 동작으로 Apache 구현을 맞추면 되는 것이었습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 옳은지를, 같은 저장소 안의 형제 구현으로 설명할 수 있었습니다. 어긋난 구현을 이미 올바르게 동작하는 형제 쪽으로 수렴시키는 변경은, 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.


비어 있으면 줄을 빼도록 고쳤습니다

방향이 분명해지니 수정 자체는 단출했습니다. 머리말을 한 번에 이어 붙이는 대신, 종류 정보가 비어 있지 않을 때만 Content-Type 줄을 더하도록 나눴습니다. 수정 후 코드는 다음과 같습니다.

String header = "--" + BOUNDARY + CRLF + "Content-Disposition: form-data; name=\"" + name + "\"; filename=\""
        + file.fileName() + "\"" + CRLF;
if (!isNullOrBlank(file.contentType())) {
    header += "Content-Type: " + file.contentType() + CRLF;
}
header += CRLF;

 

먼저 조각의 이름과 파일 이름까지를 담은 머리말의 앞부분을 만듭니다. 그다음 종류 정보가 비어 있지 않은 경우에만 Content-Type 줄을 덧붙입니다. 마지막으로 머리말과 내용을 가르는 빈 줄을 더합니다. 이렇게 하면 종류가 제대로 있을 때는 전과 똑같이 Content-Type 줄이 들어가고, 종류가 비어 있을 때는 그 줄이 통째로 빠집니다. null이라는 글자가 박히던 자리도, 값이 비어 있던 머리말 줄도 더 이상 만들어지지 않습니다.

 

여기서 비어 있는지를 판단하는 데 쓴 도구는 LangChain4j가 이미 가지고 있던 검사 함수였습니다. 값이 null이거나 공백뿐인지를 한 번에 확인해 주는 것으로, 저장소의 핵심 모듈에 들어 있어 새로 끌어올 것이 없었습니다. 외부 라이브러리를 추가하지 않는 것은 이 저장소의 중요한 규칙인데, 이번 수정은 그 규칙과 부딪힐 일이 없었습니다.

 

이 변경이 기존 동작을 깨지 않는다는 점도 분명히 해 두었습니다. 종류 정보가 제대로 들어 있던 기존 요청은 전과 똑같은 본문을 만듭니다. 달라지는 것은 종류가 비어 있던 경우뿐이고, 그 경우는 잘못된 줄 대신 올바른 생략으로 바뀝니다. 잘 동작하던 경우는 건드리지 않고 어긋나던 경우만 바로잡는 변경이라, 호환성을 걱정할 부분이 없었습니다.


만들어진 본문을 직접 확인하는 테스트

코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 "테스트가 없으면 리뷰하지 않는다"는 원칙이 있고, 이번처럼 만들어지는 결과물이 정확히 어떤 형태여야 하는지가 분명한 경우에는 그 결과물을 직접 확인하는 테스트가 적절했습니다. 다행히 이 멀티파트 조립부에는 외부 연결 없이 순수하게 동작을 확인할 수 있는 단위 테스트가 이미 있었습니다. 실제 네트워크나 인증 키 없이도, 만들어진 본문 문자열을 그대로 들여다볼 수 있었습니다.

 

그래서 세 가지 경우를 확인하는 테스트를 더했습니다. 종류가 비어 있는 경우, 종류가 빈 문자열인 경우, 그리고 종류가 제대로 들어 있는 경우입니다. 앞의 두 경우에서는 만들어진 본문에 Content-Type 줄이 들어 있지 않은지를 확인합니다.

@Test
void should_omit_content_type_header_when_content_type_is_null() {
    MultipartBodyPublisher publisher = new MultipartBodyPublisher();
    FormDataFile file = new FormDataFile("audio.wav", null, "hello".getBytes(UTF_8));

    publisher.addFile("file", file);
    publisher.build();

    String body = bodyAsString(publisher.parts());
    assertThat(body).doesNotContain("Content-Type:");
}

 

세 번째 경우에서는 반대로, 종류가 제대로 있을 때는 Content-Type: audio/wav 줄이 그대로 들어가는지를 확인합니다. 잘못된 경우를 막는 것만큼이나, 올바른 경우의 동작이 그대로 유지되는지를 확인하는 것도 중요했습니다. 비어 있는 입력에 대한 부정적인 검증과 정상 입력에 대한 긍정적인 검증을 함께 갖춰, 이 수정이 한쪽만 고치고 다른 쪽을 망가뜨리지 않았음을 분명히 했습니다. 만들어진 본문을 글자 단위로 비교하니, 머리말에서 어떤 줄이 빠지고 어떤 줄이 남는지가 테스트만 봐도 한눈에 드러났습니다.


한 모듈만 먼저 고치는 절제

이번 기여에서 특히 의식한 것은 변경의 범위를 어디까지로 둘 것인가 하는 문제였습니다. 코드를 비교하면서, 손수 멀티파트를 조립하는 JDK 구현이 Apache 구현과 사실상 똑같은 방식으로 짜여 있다는 것을 알게 됐습니다. 즉 JDK 구현에도 정확히 같은 버그가 있었습니다. 두 곳을 한 번에 고치고 싶은 마음이 드는 것이 자연스러웠습니다. 같은 버그이니 같은 PR에서 함께 처리하면 효율적으로 보였습니다.

 

그렇지만 한 번에 여러 모듈을 건드리면 변경이 커지고, 검토하는 사람이 살펴야 할 범위도 넓어집니다. 그래서 이번에는 Apache 구현 한 곳만 먼저 고치기로 했습니다. 같은 버그를 가진 JDK 구현은 이번 수정이 받아들여지는 것을 확인한 뒤에 따로 다루기로 미뤄 두었습니다. 한 곳을 먼저 올려 그 방향이 유지보수하는 사람들에게 받아들여지는지를 확인하고, 그 신호를 본 다음에 같은 패치를 다른 곳으로 넓히는 방식입니다. 만약 첫 수정이 다른 방향으로 바뀌어야 한다면, 한 곳만 고쳐 둔 편이 되돌리기도 쉽습니다.

 

이 절제는 "변경은 작게, 한 번에 한 가지에 집중하라"는 저장소의 원칙과도 맞닿아 있었습니다. 같은 버그가 여러 곳에 있다는 사실은 이슈와 PR 본문에 분명히 적어 두되, 실제 변경은 한 곳으로 좁혔습니다. 고쳐야 할 것을 다 알면서도 한 번에 다 손대지 않는 것이, 처음에는 답답하게 느껴졌지만 협업하는 저장소에서는 오히려 신중한 태도라는 것을 이번에 배웠습니다. 물론 버그 수정인 만큼, PR을 올리기 전에 먼저 이슈를 등록해 문제를 공유하고 그 번호를 PR에 연결하는 절차도 그대로 따랐습니다.


작은 수정에서 배운 것

이 기여를 마치고 돌아보면, 바뀐 코드의 양은 많지 않습니다. 머리말을 만드는 방식을 조금 나누고, 만들어진 본문을 확인하는 테스트 세 개를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.

 

가장 크게 남은 것은, 문자열 연결처럼 익숙한 동작에도 함정이 숨어 있다는 감각입니다. 비어 있는 값을 문자열에 더하면 오류가 아니라 글자가 됩니다. 예외라면 어디선가 걸렸을 잘못이, 글자로 둔갑하는 순간 아무 데도 걸리지 않고 끝까지 흘러갑니다. 값이 비어 있을 가능성이 있는 자리를 문자열에 직접 이어 붙일 때는, 그 비어 있음을 먼저 다루어야 한다는 것을 이번에 분명히 배웠습니다.

 

또 하나는 같은 일을 하는 구현이 여럿일 때, 그것들이 같은 입력에 같은 결과를 내는지를 살피는 일의 가치입니다. 세 클라이언트가 같은 요청에 서로 다른 바이트를 내보내고 있었고, 그 차이가 곧 버그의 단서였습니다. 형제 구현 중 올바르게 동작하는 쪽이 있다면, 그것이 곧 고칠 방향이자 그 방향이 옳다는 근거가 됩니다. 여러 구현을 나란히 놓고 비교하는 습관이 어긋남을 드러내 준다는 것을 다시 느꼈습니다.

 

마지막으로, 고쳐야 할 것을 다 알면서도 범위를 절제하는 일의 의미입니다. 같은 버그가 두 곳에 있어도, 한 곳만 먼저 고치고 다른 곳은 그다음을 기약하는 편이 협업에서는 더 신중한 선택일 수 있습니다. 변경을 작게 유지하는 것은 단지 코드의 양을 줄이는 일이 아니라, 검토하는 사람과 호흡을 맞추고 위험을 나눠 다루는 일이라는 것을 이번에 배웠습니다.

 

여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 헤더 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 익숙한 동작에 숨은 함정을 알아채고, 형제 구현과 비교해 올바른 방향을 찾고, 만들어지는 결과물을 직접 확인하는 테스트를 남기며, 변경의 범위를 절제하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 어긋난 출력을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.


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