티스토리 뷰

서비스를 만들다 보면 "정상적인 입력"을 가정하고 짠 코드가 의외로 많습니다. 사용자가 빈 값을 보내지 않을 것이고, 필수 항목은 채워서 보낼 것이라는 전제 위에 코드가 서 있습니다. 그런데 그 입력이 사람이 아니라 언어 모델에서 온다면 이야기가 달라집니다. 언어 모델의 출력은 대체로 형식을 잘 지키지만, 늘 그런 것은 아닙니다. 가끔은 비워 두어야 할 자리를 채우거나, 채워야 할 자리를 비운 채로 내놓습니다. 그래서 모델의 출력을 받아 실제 동작으로 옮기는 코드는, 그 입력이 어긋날 수 있다는 것을 전제로 단단하게 짜여 있어야 합니다.

 

이번 글에서 다룰 LangChain4j의 셸 도구 수정은 바로 그 지점에 관한 것이었습니다. 모델이 만들어낸 도구 인자 중 하나가 비어 있을 때, 코드가 의도한 방식으로 부드럽게 거절하지 못하고 가공되지 않은 오류를 그대로 토해 냈습니다. 결과만 보면 한 메서드를 손보고 테스트 두 개를 더한 작은 변경이지만, "키는 있는데 값이 없다"는 미묘한 상황과, 그 오류가 하필 잘못된 자리에서 새어 나가던 구조를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.

 


모델이 만든 인자로 셸 명령을 실행하는 도구

먼저 이 코드가 무슨 일을 하는지부터 짚어 두겠습니다. LangChain4j에는 언어 모델이 직접 도구를 호출하도록 연결해 주는 구조가 있습니다. 모델이 "이 도구를 이런 인자로 실행해 줘"라고 요청하면, 그 요청을 받아 실제 동작을 수행하는 식입니다. 이번에 들여다본 것은 그중 셸 명령을 실행하는 도구였습니다. 이 도구를 다루는 RunShellCommandToolExecutor라는 클래스는, 모델이 보낸 요청에서 실행할 명령을 꺼내 셸에서 돌리고 그 결과를 돌려줍니다.

 

여기서 중요한 점은 모델이 보내는 인자가 글자 그대로의 텍스트, 즉 JSON 형태로 온다는 것입니다. 예를 들어 모델이 셸 명령을 실행하고 싶으면 {"command": "echo hello"} 같은 JSON을 만들어 보냅니다. 도구를 다루는 코드는 이 JSON을 읽어 키와 값의 묶음으로 바꾼 다음, 그 안에서 command라는 필수 인자를 꺼내 씁니다. 필수 인자를 꺼내는 일은 getRequiredArgument라는 메서드가 맡고 있었습니다. 이번 버그는 바로 이 메서드 안에 있었습니다.


키가 있는지만 확인하고 값은 확인하지 않았습니다

getRequiredArgument 메서드의 수정 전 모습은 다음과 같았습니다.

private String getRequiredArgument(String argumentName, Map<String, Object> arguments) {
    if (isNullOrEmpty(arguments) || !arguments.containsKey(argumentName)) {
        throwException("Missing required tool argument '%s'".formatted(argumentName));
    }
    return arguments.get(argumentName).toString();
}

 

이 메서드는 먼저 인자 묶음이 비어 있는지, 그리고 찾는 이름의 키가 들어 있는지를 확인합니다. 둘 중 하나라도 어긋나면 "필수 인자가 빠졌다"는 메시지와 함께 의도된 예외를 던집니다. 여기까지는 괜찮습니다. 문제는 그 확인을 통과한 다음 줄에 있었습니다. 키가 있다고 판단하고 나면, 그 키에 해당하는 값을 꺼내 곧바로 텍스트로 바꿉니다.

 

여기서 놓친 경우가 "키는 있는데 값이 비어 있는" 상황입니다. 키가 들어 있는지를 확인하는 검사는, 그 키에 딸린 값이 무엇인지까지는 보지 않습니다. 키가 존재하기만 하면, 그 값이 비어 있어도 검사를 통과합니다. 즉 모델이 {"command": null}처럼 명령 자리를 비운 채로 보내면, JSON을 읽어 만든 묶음에는 command라는 키가 분명히 들어 있고 그 값만 비어 있는 상태가 됩니다. 키 존재 검사는 이를 통과시키고, 다음 줄에서 비어 있는 값을 텍스트로 바꾸려다 가공되지 않은 오류, 즉 흔히 말하는 널 포인터 예외가 터집니다.

 

"키가 있다"와 "값이 있다"가 다른 이야기라는 것이 이 버그의 첫 번째 핵심이었습니다. 사람이 직접 입력을 채운다면 키만 만들고 값을 비워 두는 일이 드물지도 모릅니다. 하지만 모델이 만든 JSON에서는 이런 형태가 충분히 나올 수 있었습니다. 빈자리를 명시적으로 "비어 있음"으로 채워 보내는 것은 모델 입장에서 자연스러운 출력 중 하나이기 때문입니다.


오류가 새어 나간 자리가 문제를 키웠습니다

이 버그가 단순한 빈 값 처리 누락에 그치지 않았던 이유는, 오류가 터지는 위치에 있었습니다. 이 셸 도구에는 인자 오류를 다루는 나름의 규칙이 있었습니다. 무언가 잘못되면 가공되지 않은 오류를 그대로 내보내는 것이 아니라, 상황에 맞는 타입 있는 예외로 감싸서 던지도록 되어 있었습니다. 설정에 따라 어떤 경우에는 한 종류의 예외를, 다른 경우에는 또 다른 종류의 예외를 골라 던지는 방식이었습니다. 도구를 쓰는 쪽이 그 예외의 종류를 보고 적절히 대응할 수 있도록 한 배려였습니다.

 

그런데 이 예외를 감싸 주는 안전망은 명령을 실제로 실행하는 구간을 감싸고 있었지, 인자를 꺼내는 구간까지는 덮고 있지 않았습니다. 필수 인자를 꺼내는 getRequiredArgument 호출은 그 안전망보다 앞쪽에서 일어났습니다. 그래서 비어 있는 값 때문에 터진 가공되지 않은 오류는, 타입 있는 예외로 감싸지는 과정을 거치지 못한 채 그대로 바깥으로 새어 나갔습니다.

 

결과적으로 두 가지가 한꺼번에 어그러졌습니다. 하나는 도구를 쓰는 쪽이 받게 되는 오류가, 의도된 타입 있는 예외가 아니라 맥락 없는 가공되지 않은 오류였다는 것입니다. 다른 하나는, 어떤 예외를 던질지 고르도록 마련해 둔 설정이 이 경우에는 아무 의미가 없어졌다는 것입니다. 어떤 설정이든 결국 같은 오류가 나왔기 때문입니다. 빈 값 하나가, 공들여 만들어 둔 예외 처리 정책을 통째로 비껴간 셈이었습니다.


같은 메서드가 이미 답을 가지고 있었습니다

흥미로웠던 점은, 이 문제를 어떻게 다뤄야 하는지를 바로 그 메서드 자신이 이미 보여 주고 있었다는 것입니다. getRequiredArgument는 키가 아예 없는 경우에는 가공되지 않은 오류를 내지 않고, "필수 인자가 빠졌다"는 메시지와 함께 의도된 예외를 던지는 처리를 이미 갖추고 있었습니다. 같은 클래스의 다른 부분에서도 인자와 관련된 오류는 한결같이 이 의도된 방식으로 처리하고 있었습니다. 즉 이 클래스에는 "인자에 문제가 있으면 정해진 방식으로 예외를 던진다"는 일관된 규약이 자리 잡고 있었고, 오직 "키는 있지만 값이 비어 있는" 경우만 그 규약에서 빠져 가공되지 않은 오류로 새어 나가고 있었습니다.

 

그러니 이번 수정은 새로운 규칙을 만드는 일이 아니라, 이미 그 메서드가 따르고 있던 방식으로 빠진 한 경우를 마저 맞추는 일이었습니다. 비어 있는 값을 키가 없는 경우와 똑같이 취급해, 같은 메시지와 함께 같은 의도된 예외로 보내면 되는 것이었습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 자연스러운지를, 같은 메서드 안의 기존 처리로 설명할 수 있었습니다. 이런 종류의 수정은 검토하는 사람에게도 받아들이기 쉽다는 점을 이번에도 느꼈습니다.


값을 한 번만 꺼내 확인하도록 고쳤습니다

고치는 방향은 분명했지만, 구현 방식에는 작은 선택지가 있었습니다. 단순하게는 기존 검사에 "값이 비어 있는 경우"를 한 가지 더 붙이는 방법이 있었습니다. 다만 그렇게 하면 값을 묶음에서 꺼내는 동작이 검사할 때 한 번, 실제로 쓸 때 또 한 번, 모두 두 번 일어납니다. 그래서 값을 한 번만 꺼내 변수에 담아 두고, 그 변수가 비어 있는지를 확인하는 형태로 정리했습니다. 수정 후 코드는 다음과 같습니다.

private String getRequiredArgument(String argumentName, Map<String, Object> arguments) {
    Object value = isNullOrEmpty(arguments) ? null : arguments.get(argumentName);
    if (value == null) {
        throwException("Missing required tool argument '%s'".formatted(argumentName));
    }
    return value.toString();
}

먼저 인자 묶음이 비어 있으면 값을 비어 있는 것으로 두고, 그렇지 않으면 묶음에서 값을 꺼내 변수에 담습니다. 이렇게 하면 키가 아예 없어 꺼낼 값이 없는 경우든, 키는 있지만 값이 비어 있는 경우든, 모두 변수가 비어 있는 상태로 모입니다. 그다음 그 변수가 비어 있으면 기존과 똑같은 메시지로 의도된 예외를 던집니다. 비어 있지 않을 때만 그 값을 텍스트로 바꿔 돌려줍니다. 키 존재만 보던 검사를 값의 유무를 보는 검사로 바꾼 것이고, 그 과정에서 두 경우를 하나의 흐름으로 합친 것입니다.

 

여기서 마음을 놓을 수 있었던 점은, 이 변경이 기존 동작을 깨지 않는다는 것입니다. 정상적인 명령을 담아 보내던 기존 요청은 전과 똑같이 동작합니다. 키가 없던 경우에 나오던 메시지도 그대로입니다. 달라지는 것은 키는 있지만 값이 비어 있던 경우 하나뿐이고, 그 경우는 원래 가공되지 않은 오류로 새어 나가던 것이 이제 같은 메시지의 의도된 예외로 바뀝니다. 잘 동작하던 경우는 건드리지 않고 어긋나던 경우만 바로잡는 변경이라, 호환성을 걱정할 부분이 없었습니다. 메서드의 바깥 모습, 즉 이름이나 받는 값과 돌려주는 값의 형태도 전혀 바뀌지 않았습니다.


비어 있는 입력을 다루는 부정 케이스 테스트

코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 "테스트가 없으면 리뷰하지 않는다"는 원칙이 있고, 특히 이런 버그는 잘못된 입력에 대해 정말로 의도된 예외가 나오는지를 테스트로 못 박아 두는 것이 핵심이었습니다. 정상 입력이 잘 처리되는지는 기존 테스트들이 이미 확인하고 있었으니, 이번에 더해야 할 것은 비어 있는 입력을 다루는 부정 케이스였습니다.

 

그래서 모델이 명령 자리를 비운 채 보낸 상황을 그대로 재현하는 테스트 두 개를 더했습니다. 하나는 기본 설정에서 그 입력을 넣으면 한 종류의 타입 있는 예외가 나오는지를 확인하고, 다른 하나는 다른 종류의 예외를 던지도록 설정을 바꾼 상태에서 그에 맞는 예외가 나오는지를 확인합니다.

@Test
void should_throw_ToolExecutionException_when_command_argument_value_is_null() {
    RunShellCommandToolExecutor executor = executor(false);

    assertThatThrownBy(() -> executor.executeWithContext(requestWithRawArguments("{\"command\": null}"), null))
            .isInstanceOf(ToolExecutionException.class)
            .hasMessageContaining("Missing required tool argument");
}

 

이 두 테스트는 세 가지를 한꺼번에 못 박습니다. 비어 있는 값에 대해 가공되지 않은 오류가 아니라 의도된 예외가 나온다는 것, 설정에 따라 올바른 종류의 예외가 선택된다는 것, 그리고 그 메시지가 키가 없던 경우와 똑같다는 것입니다. 마지막 확인이 특히 의미가 있었습니다. 비어 있는 값을 키가 없는 경우와 같은 메시지로 다룬다는 약속을 테스트가 직접 지켜 주기 때문입니다. 모델이 어긋난 출력을 보내더라도 도구가 그것을 정해진 방식으로 거절한다는 것을, 이 테스트들이 앞으로도 보장해 줄 것이었습니다.


저장소의 절차와 범위를 지키며 배운 것

코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. 특히 이번처럼 "어떤 입력이, 왜, 어떤 오류로 이어지는가"가 미묘한 버그는 재현 조건과 원인을 글로 먼저 정리해 두는 것이 중요했습니다. 검토하는 사람이 코드를 보기 전에 문제의 그림을 그릴 수 있어야 하기 때문입니다.

 

이번에 특히 의식한 것은 변경의 범위를 좁게 지키는 일이었습니다. 코드를 살펴보니, 같은 방식으로 인자를 꺼내는 비슷한 코드가 다른 곳에도 있어 같은 빈 값 문제를 안고 있을 가능성이 보였습니다. 한 번에 그 모든 곳을 함께 고치고 싶은 마음도 들었지만, 그렇게 하면 변경이 여러 모듈로 번지고 PR이 커집니다. 그래서 이번 PR은 셸 도구 한 모듈로만 범위를 한정하고, 비슷한 다른 곳은 이번 수정이 받아들여진 뒤에 따로 다루기로 미뤄 두었습니다. 변경을 작게 유지하고, 리팩터링과 기능 변경을 한 PR에 섞지 않으며, 한 번에 한 가지 문제에 집중하라는 저장소의 원칙을 따른 것입니다. 바뀐 줄 하나하나가 "비어 있는 필수 인자를 의도된 예외로 거절한다"는 목적으로 곧장 설명될 수 있어야 한다는 기준을 세워 두고 변경 내용을 다시 살폈습니다.


작은 수정에서 배운 것

이 기여를 마치고 돌아보면, 바뀐 코드의 양은 많지 않습니다. 한 메서드의 검사 방식을 다듬고, 비어 있는 입력을 다루는 테스트 두 개를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.

 

가장 크게 남은 것은, 신뢰할 수 없는 입력을 다루는 코드는 그 어긋남까지 미리 생각해 두어야 한다는 감각입니다. 사람이 채우는 입력이라면 잘 일어나지 않을 형태라도, 모델이 만든 입력에서는 충분히 나올 수 있습니다. 키가 있는지만 보고 값까지는 보지 않는 검사처럼, 평소에는 별 탈 없던 가정이 신뢰할 수 없는 입력 앞에서는 구멍이 됩니다. 입력의 출처가 무엇인지에 따라 검사의 촘촘함도 달라져야 한다는 것을 이번에 분명히 배웠습니다.

 

또 하나는 오류가 "어디에서" 발생하는지가 중요하다는 점입니다. 같은 오류라도 안전망 안에서 터지면 의도된 형태로 감싸지지만, 안전망 밖에서 터지면 가공되지 않은 채 그대로 새어 나갑니다. 이번 버그는 빈 값 자체보다도, 그 빈 값을 다루는 코드가 예외를 감싸 주는 구간 밖에 있었다는 점이 문제를 키웠습니다. 오류를 다루는 정책을 마련했다면, 오류가 날 수 있는 모든 자리가 그 정책의 우산 안에 들어와 있는지를 함께 살펴야 한다는 것을 배웠습니다.

 

마지막으로, 좋은 수정이란 이미 존재하는 일관성으로 어긋난 한 곳을 되돌리는 것일 때가 많다는 점입니다. 이번에도 답은 같은 메서드가 키 없는 경우에 이미 하고 있던 처리 안에 있었습니다. 새로운 방식을 만들기보다 이미 자리 잡은 규약에 빠진 경우를 맞추는 쪽이, 코드의 결을 흩뜨리지 않으면서 문제를 푸는 길이었습니다. 그리고 그 수정을 셸 도구 한 곳으로 좁게 지키며, 비슷한 다른 곳은 다음을 기약한 것도 이번에 의식적으로 연습한 부분이었습니다.

 

여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 예외 처리 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 신뢰할 수 없는 입력의 어긋남을 미리 떠올려 보고, 오류가 새어 나가던 자리를 따라가고, 이미 있는 규약으로 그것을 되돌리며, 변경의 범위를 절제하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 어긋난 입력을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.


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