이모지 하나가 문서 562개를 깨뜨렸다
증상
학습 문서를 검색 가능한 형태로 바꾸는 작업을 하고 있었다. 문서를 일정 길이의 조각으로 쪼개고, 각 조각을 임베딩해서 저장하는 흔한 파이프라인이다.
743개 문서를 넣고 돌렸더니 중간에 죽었다. 예외는 MalformedInputException. 입력 바이트열이 지정한 문자 인코딩으로 해석되지 않는다는 뜻이다.
어디서 터졌나가 단서였다
처음 의심은 당연히 읽기였다. 어떤 문서가 UTF-8이 아닌 인코딩으로 저장돼 있고, 그걸 UTF-8로 읽으려다 실패했겠거니 했다.
그런데 스택을 보니 예외가 난 지점은 읽기가 아니라 쓰기였다. 이미 메모리에 문자열로 올라와 있는 것을 다시 바이트로 내보내는 단계였다.
이건 이상하다. 정상적으로 읽힌 문자열이라면 다시 인코딩하지 못할 이유가 없다. 원본 파일이 문제였다면 읽는 순간에 터졌어야 한다. 읽기와 쓰기 사이에서 문자열에 무슨 일이 벌어진 것이다.
그 사이에 있는 것은 청킹, 즉 자르는 코드뿐이었다.
원인
자바를 비롯한 여러 언어에서 문자열은 UTF-16 코드유닛의 배열이다. 대부분의 문자는 코드유닛 하나에 들어가지만, 이모지처럼 기본 다국어 평면 밖에 있는 문자는 코드유닛 두 개로 표현된다. 이 쌍을 서로게이트 쌍이라고 부르고, 둘은 반드시 붙어 다녀야 의미가 있다.
청커는 문자열 길이를 코드유닛 개수로 재고, 그 인덱스로 잘랐다. 자르는 경계가 우연히 서로게이트 쌍 한가운데에 떨어지면, 앞 조각의 끝과 뒤 조각의 시작에 짝을 잃은 반쪽이 하나씩 남는다.
짝 잃은 서로게이트는 메모리 안에서는 그냥 값으로 존재한다. 아무도 뭐라 하지 않는다. 그런데 그것을 UTF-8 바이트로 내보내려는 순간, 대응하는 코드포인트가 없으므로 인코더가 거부한다. 그래서 예외가 읽기가 아니라 쓰기에서 났다.
실측
「이모지 쓴 문서가 몇 개나 되겠나」 싶었다. 세어 봤다.
743개 문서 중 562개에 기본 다국어 평면 밖 문자가 있었고, 합쳐서 5,246자였다. 대부분은 문서 제목 옆의 체크 표시나 경고 아이콘 같은 것들이다. 4분의 3이 넘는 문서가 잠재적으로 같은 지점을 밟을 수 있었다는 뜻이다.
같은 작업에서 626개 문서가 CRLF 줄바꿈을 쓰고 있다는 것도 함께 확인했다. 텍스트 전처리에서 「설마 그런 게 있겠어」로 넘긴 가정은 세어 보면 대체로 뒤집힌다.
고치는 방향을 잘못 잡을 뻔했다
가장 빨리 예외를 없애는 방법은 인코더를 관대하게 만드는 것이다. 대체 문자로 바꾸고 계속 가라고 설정하면 예외는 사라진다.
그러면 어떻게 되는가. 깨진 자리에 물음표 비슷한 대체 문자가 들어가고, 파이프라인은 끝까지 잘 돌고, 로그에는 아무 문제도 남지 않는다. 그리고 저장된 본문 562군데가 조용히 손상된다.
시끄러운 실패를 조용한 손상으로 바꾸는 거래다. 나쁜 거래다.
고쳐야 할 곳은 인코더가 아니라 경계 계산이다. 자를 위치가 서로게이트 쌍의 한가운데면 한 칸 밀어서 쌍을 온전히 보내면 된다. 고친 뒤에는 743개 문서가 전부 통과했다.
남은 것
이 사건의 교훈은 「이모지를 조심하라」가 아니다. 예외가 난 지점과 원인이 있는 지점이 다를 수 있다는 것, 그리고 예외를 없애는 가장 쉬운 방법이 대체로 문제를 없애는 방법이 아니라는 것이다.
읽기에서 터졌다고 믿고 원본 파일들의 인코딩을 뒤졌다면 아무것도 못 찾았을 것이다. 파일에는 아무 문제도 없었으니까.