엊저녁에 이런 트윗을 봤다.
루비의 String 타입이 인코딩을 가변으로 지정할 수 있는 게 장점이라고 말씀하신 분도 있는데, 난 아무리 봐도 이건 장점은 커녕 심각한 API 설계 실수에 가깝다고 생각한다. 비슷한 생각의 글 https://raw.githubusercontent.com/candlerb/string19/47b0cba0a2047eca0612b4e24a540f011cf2cac3/soapbox.rb
유니코드가 지원하지 않는 특수한 인코딩이 필요하면 그걸 위한 타입을 정의하면 될 일이지, 모든 String 타입의 인코딩을 가변으로 만들 일이 아니라고 생각한다.
이게 무슨 얘기인가 살펴 봤더니, 꽤 격한 토론이 있었던 모양이다. 특히 강한 장점으로 제시한 근거는 다음과 같다.
미국 친구들은 대체로 (서양은 원래 문자가 몇 개 없으니) 유니코드면 충분하니 유니코드 방식으로 문자열을 구현하자고 당시 주장하던 편이고, 마츠는 시간이 걸리더라도 모든 인코딩을 지원하는 방식으로 하겠다고 했었습니다.
저는 당시에 양쪽 입장 모두 어느 정도 일리가 있다고 생각했습니다만, 이후에 확장 완성형 등으로 된 legacy 한국 텍스트 데이터들을 처리하는 과정에서 문자열이 여러 인코딩을 지원하는게 얼마나 편리한지 경험하게 돼서 입장이 이렇게 정리됐지요.
예전에 유니코드 지원에 따른 프로그래밍 언어의 분류라는 글을 쓴 적이 있기 때문에 루비의 상당히 특이한 선택은 익히 알고 있었다. 다만 그게 호환성적인 측면(옛날 루비에는 $KCODE
라는 것도 있었지 아마…) 때문에 불가피한 선택인 줄 알았지, 마츠가 의도적으로 설계한 것이라고는 꿈도 꾸지 못 했다.
사실 이건 확장성과 편의성 사이에서 얼마나 줄을 타느냐의 문제에 가까워서 정답이 명백하게 나오는 경우가 드물다. 유니코드가 사실상 모든 레거시 문자 집합을 쌈싸먹는 유일한 문자 집합으로 거듭난 상태에서는 그게 정답 아니냐는 지적이 가능하긴 하지만, 위에 언급된 대로 레거시를 정말로 다뤄야 할 경우에는 (그리고, 레거시는 바꿀 수 없는데도 써야 하니까 레거시이므로) 문제가 커진다. 한 가지 예를 들면 윈도 API에서 wchar_t
배열로 표현되는 문자열들은 한동안 UTF-16이 아니라 UCS-2, 즉 surrogate pair를 포함할 수 있었다. (지금은 아니지만…) 유닉스에서도 파일 이름 같은 거의 인코딩은 파일 시스템 설정에 따르는데, 당연히 경로는 문자열로 표현할 수 있겠지라는 생각을 하던 사람들을 가끔씩 놀라게 만드는 원인이 된다. 이걸 피하려면 경로를 문자열과 완전 다른 타입으로 만들거나, 경로를 고정된 인코딩으로 번역하되 왕복 변환(round trip)이 가능하도록 이상한 짓[1]을 해야 한다. 이런 레거시와의 접점이 많을 수록 그냥 문자열 자체가 인코딩을 들고 있는 게 맞지 않느냐는 생각을 하게 되는 건 어쩔 수 없다.
그래서 나는 문제 제기 자체에는 환영한다. 그러나 나는 옛날부터 (실은, 저 글을 썼을 시점부터) 지금까지 줄곧 인코딩을 넣는 것은 정말로 불가피한 이유가 아니면 나쁜 설계라고 생각해 왔는데, 이건 위의 불편함이 실은 존재하지 않거나 미미한 문제라고 주장함이 아니라, 좀 더 근본적으로 이런 설계를 표준 라이브러리에서 지양해야 할 이유가 있기 때문이다.
1 Chrono와 시간대
Chrono 얘기를 잠깐 해 보자. 이 라이브러리는 Rust에서 날짜/시간 관련 타입을 제공하는 라이브러리로, 오래 전부터 만들어 온 덕분에 어쩌다 보니 내가 그간 관리한 F/OSS 프로젝트 중에서 가장 흥하는 프로젝트가 되었다. (요즘 업데이트 못 해서 죄송합니다…)
날짜/시간이 뭐 그리 어려운 문제인가 생각할 수 있지만 실은 꽤 복잡한 문제이고 거의 10년 가까이 고민해 오던 문제였다. 일단 효율적으로 저장하고 연산하는 게 한 문제이고(여기에 대해서는 NaiveDate
소스 코드로 내 설명을 대신함), 입출력도 피곤한 문제이며(여기에 대해서는 parsed.rs
를…), 시간대 및 윤초와 연관된 거대한 설계 문제도 있다. 앞의 두 개는 그래도 정답이 나름 존재하는 문제라 쉬운(?) 편이었지만, 시간대 관련은 다른 날짜/시간 라이브러리에서도 중구난방이고 잘 구현된 걸 찾기가 어려워서 상당히 고생했다.
시간대 관련된 얘기를 하기 전에 일단 현재 Chrono의 설계를 살펴 보자.

단순 날짜/시간은 큰 문제가 없는데 시간대가 들어간 순간 매우, 매우 복잡해지는 걸 볼 수 있다. 이 설계는 기본적으로 시간대에 대한 모든 정보를 담고 있는 TimeZone
트레이트와, 실제로 DateTime
타입에 포함되어 오프셋 정보를 제공하는 Offset
트레이트로 나눌 수 있다. 어떤 타입은 TimeZone
과 Offset
을 둘 다 구현하기도 하는데 이 경우에는 오프셋 정보 자체가 없어도 되거나, 오프셋만 있으면 변환이 가능한 경우(FixedOffset
)에 해당한다. 그렇지 않은 경우에는 둘이 나뉘어 있을 수 있다.
이 설계를 자세히 살펴 보면 뭔가 이상한 점을 발견할 수 있다. 어차피 DateTime
의 출력에는 현재 오프셋(…과 어쩌면 시간대 이름)만 중요한 건데 굳이 Offset
을 트레이트로 빼낼 필요가 있는가? 맞다. 현재 오프셋 저장하는 데 4바이트 밖에 안 드니까 Tz::Offset
을 저장하는 대신 Tz
만 저장하고[2] Offset
의 개념을 완전히 제거해 버릴 수도 있다. 그럼에도 불구하고 Offset
이 남아 있는 이유는 DateTime<utc>
만큼은 추가 저장 공간을 지불하지 않게 하겠다는 목표가 있었기 때문이다. 겉보기에는 별 차이가 없고, 어쩌면 이 확장성을 필요로 하는 사용자가 있을 지도 모를 일이었다. 확장성은 그만한 비용을 수반하지만 이 경우 추가 비용이 별로 들지 않는다고 본 것이다.
다른 설계가 있을까? 시간대 타입 인자를 아예 DateTime
에서 뺄 수도 있다. 이 경우 DateTime
은 출력은 가능하지만 연산을 하려면 항상 구체적인 시간대 타입을 한 번씩 거쳐야 한다는 단점이 생긴다(이를테면 lhs.add(rhs, &tz)
같이). 이 설계는 다분히 자바스크립트 Date
타입과 유사성이 있는데, 한 가지 차이는 자바스크립트에서는 Date
타입에 시간대 오프셋을 주지 않으면 현재 시간대의 오프셋을 가정한다는 점이다. 처음부터 날짜/시간 사이의 연산은 그렇게 수요가 크다고 보지 않았기 때문에 이 설계도 한동안 진지하게 고민했는데, 결국 Local
과 UTC
시간대의 존재와, “자연스러운” 연산의 욕구, 그리고 연산을 외부 타입으로 아웃소싱해도 결국 외부 타입과의 인터페이스를 미리 고민해야 한다는 점[3]과 Joda-Time의 선례 등을 이유로 DateTime
에 타입 인자를 남기기로 했다.
2 Chrono와 표준 라이브러리
앞에서 Chrono가 Rust에서 잘 쓰이는 날짜/시간 라이브러리라는 얘기를 했는데, 실은 그보다 더 잘 쓰이는 라이브러리가 있으니 libtime이다. 이 라이브러리는 Rust 초기 표준 라이브러리에 들어 있다가 표준화 과정에서 덜 여물었다고 판단하고 가지쳐서 나온 라이브러리인데, 가지친 뒤에 변경이 거의 없이 방치(…)되다가 URL을 보면 알 수 있듯 “이건 안 되겠네”하고 폐기 수순으로 들어간 상태이다. 그럼 현재의 표준 라이브러리에 뭐가 들어 있느냐 하면… std::time
이라는 모듈이 있긴 한데 날짜/시간이 아닌 타임스탬프 관련된 타입만 좀 있다.
처음에 Chrono를 만들 때만 해도 libtime이 사라질 줄은 몰랐다. 물론 Chrono가 많이 쓰이기 시작하면서 아예 libtime도 먹어 버릴까 하는 생각이 들지 않은 건 아니지만, 시간을 (거의) 선형 스케일로 다루는 타임스탬프와 시간을 년월일시분초 같은 단위로 나누는 날짜/시간은 다른 종류의 타입이고, libtime은 타임스탬프를 중심으로 형성된 라이브러리에 시스템 의존적인 날짜/시간 변환이 포함되어 있는 구조이기 때문에, 둘 다 쉬운 대체가 불가능해서 어떤 형태로든 std::time
에 들어갈 거라고 생각하고 있었다. 근데 들어간 게 타임스탬프 타입 뿐이라서 의아해했는데, 나중에 Rust 라이브러리 팀 쪽에서 libtime의 날짜/시간을 안 쓰고 Chrono를 쓸 걸 고려하고 있다는 얘기를 듣고 나서야 상황을 깨달았다. 아이고.
여차저차 해서 Rust에서 날짜/시간 타입을 다루기 위한 가장 유력한 라이브러리는 Chrono가 되어 버렸다. 어쩌면 잠재적으로 표준 라이브러리가 될 가능성이 있는 rust-lang-nursery에 포함될 수도 있다(내가 바빠서 전혀 진행을 못 하고 있다). 그리고 이 가능성을 놓고 보니, 기존의 Chrono 설계가 표준 라이브러리화되었을 때 상당히 문제가 있음을 깨닫게 되었다.
표준 라이브러리 하면 무엇이 생각나는가? 사용한 언어에 따라서 느낌은 다르겠지만, 표준 라이브러리 하면 잘 바뀌지 않고, (언어마다 기준은 다르지만) 적잖게 쓰이고, 검증된 인터페이스가 떠오를 것이다. 이미 널리 쓰이던 서드파티 라이브러리가 표준 라이브러리로 흡수될 경우 바뀌는 것은 보통 “잘 바뀌지 않게 된다”라는 꼭지 하나 뿐인데, 복잡한 라이브러리일 수록 이 꼭지가 상상 외로 심각한 문제가 된다. 파이썬의 httplib
/urllib
/urllib2
로 이어지는 이상한 HTTP 클라이언트가 대표적인 예제라 할 수 있다(결국 urllib3
을 거쳐서 requests
가 주류가 되고 만다). Rust도 이런 가능성을 염려해서 어지간히 안정화될 게 아니면 일단 rust-lang-nursery에 놓고 보는 정책을 쓰고 있으나, 그럼에도 불구하고 일단 표준 라이브러리가 될 가능성이 있는 이상 “기존보다 바뀌는 속도가 낮아져야 한다”는 것은 달라지지 않는다.
Chrono의 경우, 표준 라이브러리화된다는 것은 자체 시간대 구현을 거의 포기해야 한다는 것과 비슷한 이야기가 된다. 왜냐하면 세계의 시간대 정보는 계속 바뀌는데, 운영체제의 시간대 정보를 믿을 수 없는 경우가 제법 있어서 데이터베이스를 직접 가져다 쓰는 경우가 많기 때문이다(pytz가 바로 이 접근을 취하고 있어서 한 해에 여러 번 업데이트된다). 표준 라이브러리에 들어가면 당연히 업데이트를 할 수 없고, 그럼 해당 부분이 빠져야 한다. 근데 그렇게 되면 순식간에 표준 라이브러리의 DateTime
과 서드파티 라이브러리의 Offset
/TimeZone
구현들 사이의 복잡한 인터페이스가 그대로 노출되어서 표준화되어야 한다. 안 그래도 복잡해서 몇 번 실수한 적이 있음에도 구현이 얼마 없을 것으로 예상해서 복잡도를 낮추지 않았던/못했던 바로 그 인터페이스가 표준화되어야 하는 것이다(…).
타입 인자를 안 쓰고 시간대 타입을 완전히 나눠 버리면 이런 문제가 크게 줄어들게 된다. 표준 라이브러리는 시스템 시간대와 UTC, 그리고 어쩌면 강제 지정한 시간대 오프셋만을 인지하고, 모든 연산을 타임스탬프를 통하도록 강제해서 시간대 관련 결정을 모두 사용자한테 일임한다. 서드파티 시간대 구현이 연산을 직접 구현하겠다면 알아서 자체 함수를 만들면 된다. 어느 경우든 표준 라이브러리에서 자기가 모르는 시간대 변환에 대해서 신경쓸 필요가 없고, 거의 모든 경우에서 잘 작동한다. “덜” 예쁘지만 표준 라이브러리 설계로서는 훨씬 간명하다.
3 문자 인코딩과 표준 라이브러리
지금까지 본 Chrono의 설계 문제는 인코딩이 붙어 있는 문자열에도 비슷하게 적용할 수 있다. 서로 호환되지 않는 인코딩들끼리의 연산이 타입으로 드러나지 않는다는 등의 문제는 어떤 식으로든 회피할 수 있다. 하지만 서드파티 문자열 인코딩을 지원하기 위한 자명하지 않은 인터페이스는 어떤 식으로든 존재해야 한다. 그리고 당연하게도 인터페이스가 부족한 것으로 판명나면, 표준 라이브러리를 고쳐야 한다.
파이썬 얘기가 나왔으니 여기서도 예제로 좀 써 먹어 보자. 파이썬은 상당히 옛날부터 어지간한 레거시 인코딩을 모두 지원해 왔으니까 말이다(이게 다 CJKCodecs의 원죄공덕이다…). 인코딩 인터페이스는 codecs
모듈로 정의되어 있는데, 유심히 보면 CodecInfo
에 인코더/디코더 함수가 두 쌍이 아니라 세 쌍이 있는 걸 볼 수 있다. 기술적으로는 상태 있는 인코더/디코더 한 쌍만 만들면 되고, 성능이나 편의성을 위해서라면 한 방에 인코딩/디코딩하는 한 쌍이 더 있으면 될 것 같아 보이는데 말이다. 진실은, stream
으로 시작하는 것들이 먼저 만들어졌는데 얘네들은 입출력이 파일 오브젝트였고, 이게 너무 느려서 incremental
로 시작하는 문자열만 받는 인터페이스가 추가된 것이다. 당연히 incremental
계열 인터페이스만 사용해서 모든 걸 구현할 수 있음에도 불구하고, 기존에 있던 stream
계열 인터페이스는 더 이상 지울 수 없다.
만에 하나 처음부터 설계를 잘 해서 더 이상 인터페이스가 바뀔 일이 없다고 하자. 그럼 문자 인코딩 매핑은 안 바뀌느냐? 그럴리가. 파이썬 얘기를 더 하면 CJKCodecs는 실 사용자들(특히 일본 사용자들)의 피드백으로 몇 차례 매핑이 바뀐 적이 있다. 물론 유니코드 웹사이트에 있는 벤더 매핑이랑 정확히 일치하지도 않고, 오류 처리 방법도 계속 바뀐다. 지금이라면 생각 안 하고 인코딩 알고리즘을 하나 하나 명세해 놓은 WHATWG Encoding 표준을 쓰면 되지 않겠냐 싶지만, 거기 없는 인코딩은 어쩔 거냐는 당연한 문제를 제외하고라도 얘도 브라우저들의 총의에 따라 계속 바뀐다. 게다가 인코딩 수를 계속 줄이려고 하고 있다. 원래 브라우저 구현자를 위한 명세니 구멍이 날 곳을 최대한 줄이려는 의도. 표준 라이브러리에 이런 변화무쌍한 걸 넣는다는 얘기는, 한 번 넣고 나서 신경을 안 쓰겠다는 얘기거나 호환성을 깨더라도 계속 업데이트하겠다는 얘기다. 어느 쪽이든 표준 라이브러리에 기대되는 안정성과는 동떨어져 있는 얘기다.
이 모든 문제들을 극복했다고 치자. 일단 파이썬은 다 극복해 냈으니까 한 번 더 하는 것 정도야 할 수도 있겠지! 이제 마지막 숙제가 남아 있다. 변환을 넘어선 문자열 연산 인터페이스는 어쩌자는 것인가…? 예를 들어 독일어의 에스체트(ß) 문자는 일부 CJK 인코딩에도 들어 있는데, 이걸 대문자로 바꾸면 ẞ(U+1E9E)가 되어야 하지만 이걸 지원하는 레거시 인코딩은 내가 알기로 존재하지 않는다(유니코드에도 나중에 추가되었다). 즉 에스체트를 대문자로 바꾸면 뭐가 나오느냐 하는 정보가 인코딩마다 다를 수도 있고, 설령 같다 하더라도 그 정보가 분산 저장되어야 하는 것이다. 사실 이 글을 쓰면서 그래도 설마 이걸 쓰고 있진 않겠지 하면서 루비 소스 코드를 봤는데, 역시나 이미 사용하던 오니구루마(鬼車)라는 정규식 라이브러리에 있는 문자 인코딩 인터페이스를 그대로 쓰고 있었다. 예상 가능하듯 이 인터페이스는 굉장히 복잡한 편으로, 각 문자 인코딩마다 대소문자 정규화(case folding)나 문자 분류 등을 수동으로 지정해 줘야 하는 아주 끔찍한 구조이다(Shift_JIS의 예제를 보자). 그마저도 일부 인코딩, 이를테면 Big5-HKSCS 같은 데에는 테이블이 구현이 되어 있지 않은데[4] 근본적으로 해당 인코딩으로 정규식을 만드는 것 자체를 불가능하게 만드는 것으로 문제를 회피하고 있다. 거 참 창의적일세.
너무 길게 썼으니 정리하자. 표준 라이브러리는 (현실적으로도) 잘 움직이지 않으며 (이상적으로도) 잘 움직여서는 안 된다. 어떤 종류의 인터페이스는 너무 잘 움직이거나, 잘 움직이지 않게 만들려면 노출되는 부분이 너무 많아져 불안정해진다. 다른 구현체들의 경험으로 볼 때 문자열 인코딩은 확실히 이 케이스에 속하며, 이 문제를 감수하고 구현을 한 루비조차 아직 만족스러운 상태라고 할 수는 없다. 레거시 인코딩의 필요성은 부정할 수 없지만 그것이 표준 라이브러리에 이 정도 수준으로 노출되는 것은 바람직하지 못 하다. 오늘의 뻘글 끗.
1
파이썬이 대표적으로, 파이썬 문자열은 UCS-2/4이므로 surrogate pair를 포함할 수 있다. 따라서 기본 인코딩(보통 UTF-8)을 쓰되 디코딩에 실패한 바이트를 surrogate pair로 우겨 넣어서(…) 문자열로 만든다. (PEP 383) 일반적인 인코딩 모드에서는 이 왕복 변환이 꺼져 있기 때문에 오류 처리되므로 아주 큰 문제는 되지 않지만, 경로를 출력하다가 변환 오류가 날 수 있다는 사실을 많은 사람이 인식하고 있을지는 모르겠다. ▲
2
여기에서 Rust의 소유권 규칙이 좀 귀찮게 작용하기 때문에 Tz
는 값으로 저장되어야 한다. 뭐, 복사가 싫다면 Rc<t>
/Arc<t>
/&T
를 대신 쓰던지 해야 할 것이다. 그림의 밑줄 친 Ptr
이 이러한 “스마트 포인터”를 나타내는 타입이다. ▲
3
선술한 lhs.add(rhs, &tz)
같은 코드가 가능하려면 tz
에 대응되는 트레이트가 존재해서 add
연산을 그 트레이트의 메소드로 재지정해 줘야 한다(일종의 dependency injection). 결국 이 상황에서는 트레이트를 피할 수가 없다. ▲
4
예를 들어 Á
(0x8857)/á
(0x8868)은 짝이지만 인터페이스에서는 ASCII 제외한 모든 코드의 case folding을 포기하는 코드로 연결되어 있다. ▲