티스토리 뷰
[Open source contribution] langchain4j 오픈소스 기여 경험기 - 한글과 이모지가 깨지던 이유 — 마크다운 파서에 UTF-8을 못 박은 작은 기여 경험
ebson 2026. 6. 19. 10:38버그에는 시끄러운 것과 조용한 것이 있습니다. 시끄러운 버그는 예외를 던지며 멈추거나, 눈에 띄게 잘못된 결과를 내놓습니다. 적어도 무언가 잘못됐다는 신호는 줍니다. 반면 조용한 버그는 아무 소리 없이 잘못된 일을 합니다. 프로그램은 멈추지 않고, 로그에도 아무것도 남지 않으며, 결과는 그럴듯해 보입니다. 그래서 한참 뒤에야, 그것도 엉뚱한 곳에서 문제가 드러납니다.
이번 글에서 다룰 LangChain4j의 마크다운 파서 수정은 바로 그 조용한 종류의 버그였습니다. 비영어권 문자가 든 마크다운 문서를 읽으면, 어떤 환경에서는 글자가 깨진 채로 조용히 통과해 버렸습니다. 예외도 없고 경고도 없었습니다. 결과만 보면 코드 한 줄을 고치고 테스트 하나를 더한 작은 변경이지만, 이 버그가 왜 눈에 잘 띄지 않는지, 그리고 왜 그것을 테스트하기가 까다로운지를 따져 보는 과정이 저에게는 오래 남았습니다. 이 글은 그 한 건의 기여를 처음부터 끝까지 따라가며 정리한 기록입니다.
문서를 읽어 들이는 파서들
LangChain4j는 여러 형식의 문서를 읽어와 LLM이 다룰 수 있는 텍스트로 바꿔주는 라이브러리입니다. 그 묶음 안에는 형식마다 짝이 되는 파서들이 있습니다. 일반 텍스트를 읽는 파서, PDF를 읽는 파서, 워드 문서를 읽는 파서, 그리고 이번에 들여다본 마크다운을 읽는 파서까지 형식별로 나뉘어 있습니다.
이 파서들이 공통으로 하는 일의 첫 단계는 바이트를 글자로 바꾸는 것입니다. 파일이나 입력 스트림은 사실 0과 1의 연속, 즉 바이트의 나열일 뿐입니다. 이 바이트들을 사람이 읽을 수 있는 글자로 해석하려면 "어떤 규칙으로 바이트를 글자에 대응시킬 것인가"를 정해야 하는데, 그 규칙이 바로 문자셋(charset)입니다. 같은 바이트라도 어떤 문자셋으로 해석하느냐에 따라 전혀 다른 글자가 됩니다. 그래서 바이트를 글자로 바꾸는 이 첫 단추를 잘못 끼우면, 그 뒤의 모든 처리가 깨진 글자 위에서 이루어집니다.
마크다운 파서를 읽을 때, 저는 이 첫 단추가 어떻게 끼워져 있는지를 형제 파서들과 견주어 보았습니다. 그리고 마크다운 파서만 이 문자셋을 명시하지 않고 있다는 것을 발견했습니다.
문자셋을 정하지 않으면 환경이 대신 정합니다
수정 전 마크다운 파서의 핵심 코드는 다음과 같았습니다.
final Node node = parser.parseReader(new InputStreamReader(inputStream));
여기서 눈여겨볼 것은 InputStreamReader를 만드는 방식입니다. 이 클래스는 바이트 스트림을 글자 스트림으로 바꿔주는 다리 역할을 하는데, 위 코드처럼 문자셋 없이 입력 스트림만 넘기는 생성자를 쓰면 자바는 "JVM 플랫폼 기본 문자셋"을 사용합니다. 즉 어떤 문자셋으로 글자를 해석할지를 코드가 정하지 않고, 프로그램이 실행되는 환경에 맡기는 것입니다.
문제는 이 플랫폼 기본 문자셋이 환경마다 다르다는 데 있습니다. 많은 리눅스나 맥 환경에서는 기본값이 UTF-8이라 별 탈이 없지만, 어떤 윈도우 환경에서는 기본값이 UTF-8이 아닌 다른 문자셋일 수 있습니다. 같은 마크다운 파일을 같은 코드로 읽어도, 어디서 실행하느냐에 따라 결과가 달라진다는 뜻입니다. 코드가 환경에 의존해 다르게 동작하는 것은, 결과를 예측하기 어렵게 만드는 까다로운 성질입니다.
마크다운은 관례상 UTF-8로 작성됩니다. UTF-8로 저장된 마크다운에 한글이나 악센트가 붙은 알파벳, 이모지 같은 여러 바이트로 이루어진 문자가 들어 있다고 해 봅시다. 이 바이트들을 기본값이 UTF-8이 아닌 환경에서 읽으면, 자바는 그 바이트들을 엉뚱한 문자셋의 규칙으로 해석합니다. 결과는 깨진 글자, 흔히 말하는 모지바케입니다. 영어 알파벳처럼 한 바이트로 표현되는 문자는 대개 멀쩡하지만, 여러 바이트로 이루어진 비영어권 문자는 망가집니다.
이 버그가 조용한 이유
이 버그를 두고 한참 생각한 지점은, 왜 이것이 여태 눈에 띄지 않았을까 하는 점이었습니다. 따져 보니 이 버그가 조용한 데에는 분명한 이유가 있었습니다.
첫째로, 아무 예외도 던지지 않습니다. 바이트를 엉뚱한 문자셋으로 해석하는 것은 잘못된 해석일 뿐, 불법적인 연산은 아닙니다. 자바 입장에서는 그저 바이트를 글자로 바꿨을 뿐이고, 그 결과가 의미상 깨졌는지는 알 길이 없습니다. 그래서 프로그램은 멈추지 않고 깨진 글자를 그대로 다음 단계로 흘려보냅니다.
둘째로, 많은 개발 환경에서는 이 버그가 드러나지 않습니다. 기본 문자셋이 UTF-8인 환경에서 코드를 짜고 테스트하면, 문자셋을 명시하지 않은 코드도 마침 UTF-8로 동작하니 아무 문제가 없어 보입니다. 버그는 기본값이 다른 환경에서야 모습을 드러냅니다. 만드는 사람과 겪는 사람의 환경이 다를 수 있다는 점이, 이 버그를 더 늦게 발견되게 만듭니다.
셋째로, 영어만으로 테스트하면 보이지 않습니다. 한 바이트로 표현되는 영어 알파벳은 대부분의 문자셋에서 같은 글자로 해석되므로, 영어 마크다운만 다뤄서는 깨짐이 나타나지 않습니다. 비영어권 문자를 일부러 넣어 보아야 비로소 드러납니다.
이 세 가지가 겹치면, 버그는 "특정 환경에서 비영어권 문자가 든 문서를 읽을 때만" 조용히 나타납니다. 그 조건이 맞아떨어지기 전까지는 모두에게 멀쩡해 보입니다. 깨진 텍스트가 그대로 임베딩이나 검색의 입력으로 들어간다고 생각하면, 눈에 안 띄는 만큼 더 신경 쓰이는 종류의 문제였습니다.
형제 파서가 이미 답을 가지고 있었습니다
"내가 보기에 어색하다"는 느낌만으로 고치기에는 늘 조심스럽습니다. 그래서 이번에도 같은 묶음의 형제 파서들이 문자셋을 어떻게 다루는지 비교해 보았습니다. 가장 좋은 비교 대상은 일반 텍스트를 읽는 TextDocumentParser였습니다.
이 파서를 열어 보면, 문자셋을 받지 않는 기본 생성자가 다음과 같이 되어 있습니다.
public TextDocumentParser() {
this(UTF_8);
}
문자셋을 따로 지정하지 않고 만들면, 내부적으로 UTF-8을 기본값으로 못 박아 둔 생성자에게 일을 넘깁니다. 그리고 실제로 바이트를 글자로 바꿀 때도 그 문자셋을 명시적으로 사용합니다. 즉 텍스트 파서는 환경이 무엇이든 상관없이 항상 UTF-8로 글자를 해석합니다. 환경에 결정을 맡기지 않고, 코드가 스스로 결정을 내리고 있는 것입니다.
같은 라이브러리 안에서 텍스트를 다루는 정식 파서가 이렇게 UTF-8을 기본으로 보장하고 있다는 사실은, 이번 수정의 방향을 분명하게 해 주었습니다. 마크다운 파서만 이 약속에서 빠져 있었고, 고칠 방향은 형제 파서가 이미 보여 주고 있었습니다. 새로운 규칙을 만들 필요 없이, 저장소가 이미 따르고 있는 방식으로 마크다운 파서를 맞추면 되는 것이었습니다. 무엇을 고쳐야 하는지뿐 아니라 왜 그렇게 고치는 것이 자연스러운지를 같은 저장소 안의 선례로 설명할 수 있다는 점이, 기여를 준비하는 입장에서 큰 힘이 되었습니다.
고치는 일은 문자셋 하나를 명시하는 것이었습니다
방향이 분명해지니 수정 자체는 단출했습니다. 문자셋 없이 만들던 InputStreamReader에 UTF-8을 명시적으로 넘기는 것이 전부였습니다.
final Node node = parser.parseReader(new InputStreamReader(inputStream, UTF_8));
여기에 UTF-8 상수를 가져오는 import 한 줄을 더한 것이 변경의 전부였습니다. 이렇게 하면 마크다운 파서는 더 이상 환경의 기본 문자셋에 의존하지 않고, 어디서 실행되든 항상 UTF-8로 글자를 해석합니다. 텍스트 파서가 이미 하고 있던 일을 마크다운 파서에서도 똑같이 보장하게 된 셈입니다.
여기서 신경 쓴 점이 두 가지 있었습니다. 하나는 새 도구를 들여오지 않았다는 것입니다. UTF-8을 가리키는 상수는 자바 표준 라이브러리에 이미 들어 있는 것이라, 외부 라이브러리를 새로 추가할 필요가 없었습니다. 외부 의존성을 함부로 늘리지 않는 것은 이 저장소의 중요한 규칙인데, 이번 변경은 그 규칙과 부딪힐 일이 없었습니다.
다른 하나는, 이 변경이 기존 동작을 깨지 않는다는 점입니다. 기본 문자셋이 이미 UTF-8인 환경에서는 이번 수정이 사실상 아무것도 바꾸지 않습니다. 원래도 UTF-8로 읽고 있었으니까요. 달라지는 것은 기본값이 UTF-8이 아니던 환경뿐이고, 그곳에서는 깨지던 글자가 제대로 읽히게 됩니다. 잘 동작하던 경우는 그대로 두고, 잘못 동작하던 경우만 바로잡는 변경이라, 호환성을 걱정할 부분이 없었습니다. 변경의 폭도 코드 한 줄과 import 한 줄로 좁게 유지했고, 문자셋과 무관한 다른 줄은 일부러 건드리지 않았습니다.
버그의 성질이 테스트 방식을 정했습니다
코드를 고치는 것만큼 신경 쓴 것이 테스트였습니다. LangChain4j에는 "테스트가 없으면 리뷰하지 않는다"는 원칙이 있어, 변경을 증명하는 테스트가 반드시 필요했습니다. 그런데 이 버그는 테스트를 쓰기가 생각보다 까다로웠습니다.
까다로움의 원인은 버그의 성질 자체에 있었습니다. 이 버그는 "플랫폼 기본 문자셋이 UTF-8이 아닐 때" 나타나는데, 그 기본 문자셋이라는 값은 JVM이 시작하는 순간에 한 번 정해지고 그 뒤로는 고정됩니다. 테스트 하나만 따로 떼어 다른 문자셋 환경에서 돌리는 일이 간단하지 않다는 뜻입니다. 즉 "기본값이 UTF-8이 아닌 환경에서 글자가 깨진다"는 회귀 상황 자체를 평범한 단위 테스트로 재현하기는 어려웠습니다.
그래서 접근을 바꿨습니다. 회귀 상황을 직접 재현하는 대신, "UTF-8로 인코딩한 비영어권 문자가 파서를 거쳐 원래 글자 그대로 돌아오는가"를 확인하는 방향으로 테스트를 작성했습니다. 한글과 악센트가 붙은 알파벳, 이모지가 섞인 문자열을 UTF-8 바이트로 만들어 파서에 넣고, 그 결과 텍스트에 원래의 문자들이 그대로 들어 있는지를 검증하는 식입니다.
@Test
void should_decode_multibyte_content_as_utf8() {
String markdown = "café 한글 🚀";
DocumentParser parser = new MarkdownDocumentParser();
InputStream inputStream = new ByteArrayInputStream(markdown.getBytes(UTF_8));
Document document = parser.parse(inputStream);
assertThat(document.text()).contains("café").contains("한글").contains("🚀");
}
이 테스트는 여러 바이트로 이루어진 문자가 파서를 통과하면서 망가지지 않는다는 것을 확인합니다. 잘못된 환경 자체를 흉내 내지는 못하지만, 파서가 UTF-8로 동작한다는 사실을 분명히 보장하고, 누군가 나중에 문자셋 지정을 무심코 걷어내면 이 테스트가 신호를 줄 수 있습니다. 버그의 성질 때문에 검증할 수 있는 범위에 한계가 있다는 점은 솔직하게 인정하되, 그 안에서 가능한 한 의미 있는 보장을 남기는 쪽을 택했습니다. 무엇을 검증했고 무엇은 검증하기 어려웠는지를 분명히 적어 두는 것이, 검토하는 사람에게도 정직한 태도라고 생각했습니다.
저장소의 절차를 따라가며 배운 것
코드와 테스트를 마친 뒤에는 저장소가 요구하는 절차를 익히는 일이 남았습니다. LangChain4j는 버그 수정의 경우 곧장 PR을 올리기보다, 먼저 이슈를 등록해 문제를 공유하고 그 이슈 번호를 연결한 PR을 올리는 흐름을 따릅니다. PR 본문에는 어떤 이슈를 닫는 변경인지를 명시합니다. 작은 수정 하나에도 이슈를 먼저 만드는 일이 번거롭게 느껴질 수 있지만, 특히 이렇게 조용한 버그는 "어떤 조건에서 무엇이 어떻게 잘못되는가"를 글로 정리해 두는 것이 중요했습니다. 재현 조건과 증상을 먼저 설명해 두면, 검토하는 사람도 코드를 보기 전에 문제의 그림을 그릴 수 있기 때문입니다.
변경을 작게 유지하라는 원칙도 내내 의식했습니다. 코드를 읽다 보면 손대고 싶은 다른 부분이 눈에 띄지만, 이번 PR은 문자셋을 명시한다는 한 가지 목적에만 집중했습니다. 문자셋과 무관한 정리나 리팩터링을 같은 PR에 섞지 않는 것이, 이 변경이 정확히 무엇을 하는지를 분명하게 전달하는 길이었습니다. 바뀐 줄 하나하나가 "마크다운을 UTF-8로 읽는다"는 목적으로 곧장 설명될 수 있어야 한다는 기준을 세워 두고 diff를 다시 살폈습니다. 이 저장소는 여러 커밋을 하나로 합쳐 병합하기 때문에 PR 제목이 그대로 기록에 남는데, 그 제목 역시 무엇을 왜 고쳤는지가 한눈에 드러나도록 다듬었습니다.
작은 수정에서 배운 것
이 기여를 마치고 돌아보면, 바뀐 코드의 양은 정말 적습니다. 문자셋 하나를 명시하고 import 한 줄을 더하고, 비영어권 문자가 깨지지 않는지 확인하는 테스트 하나를 더한 것이 전부입니다. 하지만 그 적은 변경에 도달하기까지 거친 과정에서 얻은 것은 줄 수와 비례하지 않았습니다.
가장 크게 남은 것은, 환경에 결정을 맡기는 코드의 위험을 분명히 알게 됐다는 점입니다. 문자셋을 지정하지 않는 것은 당장은 편해 보이지만, 그 순간 코드의 동작은 실행되는 환경에 따라 달라지게 됩니다. 만드는 사람의 환경에서는 멀쩡하던 코드가 쓰는 사람의 환경에서는 글자를 깨뜨릴 수 있습니다. 바이트를 글자로 바꾸는 것처럼 해석의 규칙이 필요한 일에서는, 그 규칙을 코드가 분명히 정해 두는 것이 안전하다는 것을 이번에 배웠습니다.
또 하나는, 조용한 버그일수록 테스트로 붙잡아 두는 일이 중요하다는 감각입니다. 예외를 던지는 버그는 적어도 스스로 존재를 알리지만, 조용히 글자를 깨뜨리는 버그는 누군가 의식적으로 확인하지 않으면 계속 숨어 있습니다. 비영어권 문자가 든 입력으로 동작을 검증하는 테스트를 남겨 두는 것은, 같은 종류의 깨짐이 다시 들어왔을 때 그것을 드러내 줄 그물을 치는 일이었습니다. 동시에 그 그물에도 한계가 있다는 것, 즉 버그의 성질에 따라 테스트로 잡을 수 있는 범위가 달라진다는 것도 함께 배웠습니다.
마지막으로, 좋은 수정이란 새로운 무언가를 더하는 것이 아니라 이미 존재하는 일관성으로 어긋난 한 곳을 되돌리는 것일 때가 많다는 점입니다. 이번에도 답은 형제 텍스트 파서가 이미 따르고 있던 방식 안에 있었습니다. 저장소를 넓게 읽어 두면, 한 곳에서 빠진 약속이 자연스럽게 눈에 들어옵니다. 정답을 새로 발명하기보다 이미 있는 정답을 찾아 맞추는 쪽이, 검토하는 사람에게도 훨씬 설득력 있는 변경이 된다는 것을 다시 한번 느꼈습니다.
여전히 저는 오픈소스 기여를 익혀 가는 사람이고, 한 건의 작은 문자셋 수정으로 무언가를 다 안다고 말할 수는 없습니다. 다만 조용히 숨은 버그를 비교를 통해 찾아내고, 그것을 저장소가 이미 가진 방식으로 되돌리고, 검증할 수 있는 만큼의 보장을 테스트로 남겨 두는 이 과정 자체가, 기능 하나를 더 만드는 것보다 저를 더 단단하게 만들어 준다는 것은 분명히 느꼈습니다. 다음에 또 어떤 조용한 어긋남을 만나게 될지는 모르지만, 그때도 같은 방식으로 차분히 따라가 볼 생각입니다.
이 글에서 다룬 기여: langchain4j/langchain4j#5436
'OPEN SOURCE' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 백엔드 성능
- Spring Batch
- Double-Checked Locking
- TTL 설계
- 백엔드 아키텍처
- Redis vs DB
- Redis 캐시 전략
- Initialization-on-Demand Holder Idiom
- Java Performance
- Cache Aside
- 동시성처리
- Cache Penetration
- 캐시 성능 비교
- Cache Avalanche
- 캐시와 인덱스
- 스레드 생명주기
- DB 트랜잭션
- Redis 성능 개선
- InterruptedException
- 트랜잭션 관리
- Enum 기반 싱글톤
- Hot Key 문제
- DB 인덱스 성능
- 백엔드 성능 설계
- spring batch 5
- 백엔드 성능 튜닝
- 트래픽 처리
- mybatis
- 캐시 장애
- Eager Initialization
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
