티스토리 뷰

문서는 코드보다 오래 사는 것처럼 보이지만, 사실 코드만큼이나 빨리 낡습니다. 코드가 바뀌면 그에 딸린 설명과 예제도 함께 바뀌어야 하는데, 사람의 손이 닿는 일이다 보니 한두 군데가 빠지기 쉽습니다. 그렇게 빠진 자리는 한동안 아무 표시도 내지 않습니다. 문서는 컴파일되지 않으니, 그 안의 예제가 더 이상 동작하지 않게 되어도 빌드가 깨지며 알려 주지 않습니다. 누군가 그 예제를 믿고 그대로 따라 쓰기 전까지는 조용히 남아 있습니다.

 

이번 글에서 다룰 LangChain4j의 문서 수정은 바로 그런 종류의 문제였습니다. 한 애너테이션이 라이브러리에서 제거되었는데, 그것을 사용하는 예제가 여러 문서에 그대로 남아 있었습니다. 그 예제를 복사해 붙이면 더 이상 컴파일되지 않는 상태였습니다. 결과만 보면 문서 안의 예제 표기를 바꾼 작은 변경이지만, 사라진 표기가 어디어디에 남아 있는지를 빠짐없이 찾는 과정과 문서 전용 수정이 기여에서 어떤 위치를 갖는지를 따라가는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.


애너테이션으로 에이전트를 엮는 선언형 방식

먼저 이 코드가 무슨 맥락에 있는지부터 짚어 두겠습니다. LangChain4j에는 여러 동작 단위를 엮어 하나의 흐름을 구성하는 기능이 있습니다. 그리고 그 흐름을 코드로 일일이 조립하는 대신, 인터페이스의 메서드에 애너테이션을 붙여 선언적으로 나타내는 방식을 제공합니다. 예를 들어 "이 메서드는 여러 단위를 순서대로 실행한다"거나 "이 단위들을 동시에 실행한다"는 것을, 메서드 위에 붙인 애너테이션과 그 안에 나열한 하위 단위 목록으로 표현하는 식입니다.

 

이런 선언형 애너테이션들은 각자 클래스 설명 안에 사용 예제를 담고 있습니다. 이 기능을 처음 쓰는 사람이 설명을 읽고 곧바로 따라 할 수 있도록, 실제로 어떻게 작성하는지를 보여 주는 예제 코드를 함께 적어 두는 것입니다. 문서 안에 들어가는 예제이지만, 사용자가 그대로 복사해 출발점으로 삼는다는 점에서 이 예제들은 단순한 장식이 아니라 사실상 하나의 약속에 가깝습니다. 보여 준 대로 쓰면 동작해야 한다는 약속입니다.

 

문제는 이 약속을 떠받치던 표기 하나가 어느 시점에 사라졌다는 데 있었습니다.


하위 단위를 적는 방식이 바뀌었습니다

이전에 어떤 변경이 있었습니다. 하위 단위들을 나열할 때 쓰던 별도의 애너테이션이 제거되고, 더 간결한 방식으로 바뀐 것입니다. 예전에는 하위 단위 하나하나를 별도의 애너테이션으로 감싸 나열했습니다. 지금은 그냥 클래스 목록만 적으면 됩니다. 같은 내용을 적는 두 방식을 나란히 놓으면 차이가 분명합니다.

// 예전 방식 (지금은 사라진 표기)
@SequenceAgent(outputKey = "story", subAgents = {
        @SubAgent(type = CreativeWriter.class, outputKey = "story"),
        @SubAgent(type = AudienceEditor.class, outputKey = "story") })

// 지금 방식 (배열 형태)
@SequenceAgent(outputKey = "story",
        subAgents = { CreativeWriter.class, AudienceEditor.class })

 

예전 방식에서 하위 단위를 감싸던 그 애너테이션은 라이브러리에서 완전히 제거되었습니다. 더 이상 존재하지 않는 표기가 된 것입니다. 그 변경을 한 사람은 주요 애너테이션들의 예제를 새 방식으로 바꿔 두었습니다. 다만 그때 모든 예제가 함께 바뀌지는 못했습니다. 같은 패키지 안의 여러 보조 애너테이션들이, 자기 설명 속 예제에서 여전히 사라진 옛 표기를 쓰고 있었습니다.

 

이것이 문제의 핵심이었습니다. 사라진 표기를 쓰는 예제는, 그것을 복사해 자기 코드에 붙이는 순간 컴파일되지 않습니다. 존재하지 않는 애너테이션을 참조하기 때문입니다. 문서는 컴파일 대상이 아니라서 빌드가 이를 잡아내지 못하고, 그래서 이 어긋남은 조용히 남아 있었습니다. 라이브러리를 처음 익히려는 사람이 하필 이 예제들 중 하나를 출발점으로 삼으면, 설명을 정확히 따랐는데도 코드가 컴파일되지 않는 당황스러운 상황을 만나게 되는 것입니다.


빠짐없이 찾는 일이 먼저였습니다

이런 종류의 문제는 한 군데를 고치는 것보다, 같은 문제가 어디어디에 흩어져 있는지를 빠짐없이 찾는 일이 먼저입니다. 한두 개만 고치고 나머지를 남겨 두면, 다음 사람이 또 같은 함정에 빠지기 때문입니다. 그래서 사라진 표기가 들어 있는 파일을 패키지 전체에서 검색했습니다. 단순한 텍스트 검색만으로도 충분했습니다. 그 사라진 표기 문자열을 포함한 파일을 모두 찾으니 아홉 개가 나왔습니다.

 

확인을 위해 한 가지를 더 살폈습니다. 그 표기가 정말로 제거되었는지, 즉 라이브러리 어딘가에 아직 남아 있는 것은 아닌지를 변경 이력에서 직접 확인한 것입니다. 그 표기를 정의하던 파일이 과거의 한 변경에서 삭제되었고, 저장소 어디에도 그 정의가 남아 있지 않다는 것을 확인했습니다. 예제 속 표기가 단순히 모양만 옛것인 게 아니라, 실제로 존재하지 않는 것을 가리키는 죽은 참조라는 점이 분명해졌습니다.

 

찾아낸 아홉 개의 파일은 저마다 다른 보조 애너테이션이었지만, 안고 있는 문제는 똑같았습니다. 설명 속 예제가 사라진 옛 표기로 하위 단위를 나열하고 있었습니다. 고칠 방향은 이미 주요 애너테이션들이 보여 주고 있었습니다. 그것들이 새 방식으로 바뀐 예제를 가지고 있었으니, 아홉 개의 예제도 그와 똑같은 형태로 맞추면 되는 것이었습니다. 무엇으로 바꿔야 하는지를 같은 저장소 안의 이미 고쳐진 예제로 확인할 수 있어, 새로 판단할 여지가 거의 없었습니다.

 

왜 이런 빠짐이 생기는지도 짚어 둘 만합니다. 한 가지 기능을 바꿀 때, 그 기능을 언급하는 자리가 코드 곳곳에 흩어져 있으면 사람의 눈은 그중 일부를 놓치기 쉽습니다. 특히 이번처럼 같은 사용 예제가 여러 애너테이션의 설명에 비슷하게 반복되어 들어가 있는 경우, 주요한 몇 곳을 고치고 나면 나머지가 다 처리된 것 같은 착각이 들기 쉽습니다. 그런데 그 나머지는 컴파일되지 않는 문서 안에 있어, 빌드도 테스트도 그것이 남아 있다고 알려 주지 못합니다. 결국 사람이 의식적으로 검색해 훑지 않으면 드러나지 않습니다. 그래서 이런 종류의 정리는 "내 기억으로 어디어디였더라"가 아니라, 사라진 표기 그 자체를 검색어로 삼아 기계적으로 훑는 편이 안전합니다. 기억은 빠뜨리지만 검색은 빠뜨리지 않기 때문입니다.

 

또 하나 마음에 걸렸던 것은, 이 예제들이 단순한 참고용이 아니라 이 기능을 처음 익히는 사람이 가장 먼저 마주치는 출발점이라는 점이었습니다. 새로운 라이브러리를 배울 때, 사람들은 대개 설명을 꼼꼼히 읽기보다 예제를 먼저 복사해 돌려 보며 감을 잡습니다. 그 첫걸음에서 곧바로 컴파일 오류를 만나면, 자기 코드가 잘못된 것인지 라이브러리가 잘못된 것인지조차 헷갈려 한참을 헤매게 됩니다. 가장 친절해야 할 자리가 오히려 가장 먼저 발을 거는 자리가 되는 셈입니다. 그래서 이 수정은 작은 문서 손질처럼 보여도, 이 기능을 새로 접하는 사람의 첫인상을 바로잡는 일이라는 생각이 들었습니다.


아홉 곳의 예제를 같은 형태로 맞췄습니다

수정은 단순하지만 손이 가는 일이었습니다. 아홉 개의 파일을 하나씩 열어, 설명 속 예제에서 사라진 옛 표기를 새 배열 형태로 바꿨습니다. 하위 단위를 별도 애너테이션으로 감싸 나열하던 부분을, 클래스 목록만 적는 형태로 고친 것입니다. 동작을 동시에 실행하는 예제든, 순서대로 실행하는 예제든, 반복하는 예제든, 조건에 따라 갈라지는 예제든, 모두 같은 원칙으로 바꿨습니다.

@ConditionalAgent(outputKey = "response",
        subAgents = { MedicalExpert.class, TechnicalExpert.class, LegalExpert.class })

 

여기서 분명히 해 둘 점은, 이 변경이 오직 설명 안의 예제만 건드린다는 것입니다. 실제 동작하는 코드는 한 줄도 바뀌지 않았습니다. 애너테이션의 정의도, 그것이 받는 값도, 라이브러리가 실제로 동작하는 방식도 전혀 달라지지 않았습니다. 바뀐 것은 사람이 읽는 예제의 글자뿐이고, 그 예제가 이제 실제로 컴파일되는 올바른 코드를 보여 준다는 점만 달라졌습니다. 라이브러리를 쓰는 누구의 코드에도 영향을 주지 않는, 순수한 문서 수정이었습니다.

 

다만 아홉 개 중 두 개의 파일은, 예제를 고치느라 파일을 건드린 김에 코드 정리 도구가 사소한 형식까지 함께 손보았습니다. 가져오는 항목의 순서나 빈 본문의 표기 같은 부분인데, 동작과는 무관한 형식상의 변화였습니다. 이런 형식 정리는 파일을 수정하면 자동으로 적용되도록 되어 있어, 빌드를 통과시키기 위해 받아들여야 하는 부분이었습니다. 정작 봐야 할 변경은 어디까지나 예제 표기를 새 방식으로 바꾼 부분이라는 점은 스스로 분명히 해 두었습니다.


테스트가 없는 것이 맞는 경우

이 기여에서 흥미로웠던 점은, 테스트를 더하지 않은 것이 오히려 올바른 선택이었다는 것입니다. LangChain4j에는 "테스트가 없으면 리뷰하지 않는다"는 강한 원칙이 있습니다. 그래서 변경을 할 때마다 그에 맞는 테스트를 함께 작성하는 것이 기본입니다. 그런데 이번 변경은 동작하는 코드를 전혀 건드리지 않았습니다. 바뀐 것은 사람이 읽는 설명뿐입니다.

 

테스트는 코드의 동작을 확인하는 장치입니다. 그런데 이번 수정에는 확인할 새로운 동작이 없습니다. 예제의 글자가 달라졌을 뿐, 라이브러리가 하는 일은 전과 똑같기 때문입니다. 이런 순수한 문서 수정에 굳이 테스트를 붙이려 하면, 오히려 의미 없는 테스트를 억지로 만들게 됩니다. "테스트가 없으면 리뷰하지 않는다"는 원칙은 동작을 바꾸는 변경을 향한 것이지, 모든 변경에 무조건 테스트를 요구하는 것이 아니라는 점을 이번에 분명히 이해했습니다.

 

대신 이런 변경에는 다른 종류의 확인이 어울립니다. 변경의 성격을 정직하게 밝히는 것입니다. 이 수정이 문서 전용이고 동작이나 외부에서 보이는 부분을 바꾸지 않는다는 점, 그리고 문서를 갱신했다는 점을 분명히 표시했습니다. 변경의 종류에 따라 그에 맞는 증명 방식이 다르다는 것, 그리고 어떤 변경에는 "테스트가 없다"는 사실 자체가 올바른 상태라는 것을 배웠습니다.


저장소의 절차를 따라가며 배운 것

문서 수정이라고 해서 절차가 가벼워지지는 않았습니다. LangChain4j는 변경을 올리기 전에 먼저 문제를 이슈로 등록해 공유하는 흐름을 따릅니다. 이번에도 사라진 표기가 어느 파일들의 예제에 남아 있는지, 그리고 그것이 왜 문제인지를 이슈로 먼저 정리했습니다. 어떤 변경으로 그 표기가 제거되었는지, 지금 어떤 파일들이 여전히 옛 표기를 쓰는지를 구체적으로 적어 두니, 검토하는 사람이 문제의 범위를 한눈에 파악할 수 있었습니다. 문서 문제임을 알리는 표시도 함께 달았습니다.

 

변경을 작게, 한 가지 목적에 집중해 유지하라는 원칙도 지켰습니다. 아홉 개의 파일을 건드렸지만, 모든 변경은 "사라진 표기를 현재의 배열 형태로 바꾼다"는 단 하나의 목적으로 묶여 있었습니다. 예제와 무관한 다른 손질을 끼워 넣지 않았고, 바뀐 줄 하나하나가 그 목적으로 곧장 설명될 수 있었습니다. 파일 수가 여럿이라고 해서 변경이 산만해지는 것은 아니며, 하나의 분명한 목적으로 묶여 있으면 그것이 곧 작고 초점 있는 변경이라는 것을 이번에 느꼈습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하기 때문에 PR 제목이 그대로 기록에 남는데, 그 제목 역시 무엇을 왜 바꿨는지가 한눈에 드러나도록 다듬었습니다.


작은 수정에서 배운 것

이 기여를 마치고 돌아보면, 바뀐 것은 사람이 읽는 예제의 글자들뿐입니다. 동작하는 코드는 한 줄도 달라지지 않았습니다. 하지만 그 작은 변경에 도달하기까지 거친 과정에서 얻은 것은 적지 않았습니다.

 

가장 크게 남은 것은, 문서도 코드처럼 낡으며 그래서 코드만큼 관리되어야 한다는 감각입니다. 코드가 바뀌면 그에 딸린 설명과 예제도 함께 바뀌어야 합니다. 그런데 문서는 컴파일되지 않으니, 안의 예제가 더 이상 동작하지 않게 되어도 빌드가 깨지며 알려 주지 않습니다. 그래서 문서 속 예제는 빌드라는 안전망 밖에 있고, 사람이 의식적으로 챙기지 않으면 조용히 낡아 갑니다. 특히 사용자가 복사해 쓰는 예제는 사실상 약속에 가까우므로, 그 약속이 깨지지 않도록 살펴야 한다는 것을 배웠습니다.

 

또 하나는, 무언가를 제거할 때는 그것을 가리키는 모든 자리를 함께 정리해야 한다는 점입니다. 한 표기가 사라졌는데 그것을 쓰던 예제가 흩어진 채 남으면, 그 예제들은 존재하지 않는 것을 가리키는 죽은 참조가 됩니다. 무언가를 없앨 때는 그 흔적이 어디에 남아 있는지를 검색으로 빠짐없이 훑어, 한 번에 함께 정리하는 것이 안전하다는 것을 이번에 다시 느꼈습니다. 빠짐없이 찾는 데에는 거창한 도구가 필요하지 않았습니다. 사라진 표기를 텍스트로 검색하는 것만으로 아홉 곳을 모두 찾을 수 있었습니다. 그리고 아홉 곳을 모두 새 형태로 바꾼 뒤, 같은 검색을 한 번 더 돌려 사라진 옛 표기가 한 건도 남지 않았음을 직접 확인했습니다. 찾을 때 쓴 그 검색을 고친 뒤에도 다시 써서 빠뜨린 자리가 없음을 같은 방법으로 매듭짓는 것이, 기억에 기대 "다 고쳤겠지" 하고 넘어가는 것보다 훨씬 마음이 놓였습니다.

 

마지막으로, 변경의 종류에 따라 그에 맞는 증명 방식이 다르다는 점입니다. 동작을 바꾸는 변경에는 테스트가 필요하지만, 순수한 문서 수정에는 그 변경이 문서 전용이며 동작을 건드리지 않는다는 사실을 정직하게 밝히는 것이 더 어울립니다. 규칙을 기계적으로 적용하기보다, 그 규칙이 무엇을 위한 것인지를 이해하고 변경의 성격에 맞게 적용하는 것이 중요하다는 것을 배웠습니다.

 

여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 문서 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 코드처럼 낡은 문서를 알아채고, 사라진 흔적이 어디에 남았는지를 빠짐없이 찾고, 그것을 이미 올바른 예제와 같은 형태로 맞추며, 변경의 성격에 맞는 방식으로 마무리하는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 낡은 흔적을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.


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