i18n, L10n 그리고 유니코드 (3)

등록일: 2014. 10. 31

스케일러블 웹사이트 구축: 확장성 있는 웹사이트 만들기

  • 칼 헨더슨 지음
  • 김슬기 옮김
  • 424쪽
  • 25,000원
  • 2010년 11월 18일

UTF-8 인코딩

UTF-8은 대부분의 웹 애플리케이션 개발자들이 선호하는 인코딩이며 Unicode Transformation Format 8-bit의 약자이다. UTF-8은 가변폭 인코딩이며 라틴어 기반 문자를 효과적으로 저장하는 데 최적화되어 있다. 또한 UTF-16과 같은 좀 더 큰 고정폭 인코딩1을 사용할 때보다 해당 문자들을 저장하는 공간이 절약되며 매우 범위가 큰 코드 포인트의 인코딩을 지원한다. ISO 646 규격으로도 알려진 ASCII는 7비트를 사용하므로 0부터 127까지의 코드 포인트에 대한 인코딩만 정의하고 있기 때문에 UTF-8은 ASCII와 완전히 호환되어 ASCII의 모든 인코딩을 그대로 쓸 수 있을뿐만 아니라 더 큰 값의 코드 포인트를 표현하는 데 상위 비트들을 이용할 수 있다.

UTF-8은 코드 포인트의 바이트 길이를 첫 번째 바이트에 기록하고 뒷 바이트들과 함께 코드 포인트를 비트로 표현한다. UTF-8로 인코딩된 바이트들은 각각 0개에서 7개까지의 비트를 제공하여 최종 코드 포인트를 표현하며, 왼쪽에서부터 오른쪽으로 하나의 긴 이진수 숫자를 만든다. 각 바이트별로 실제 코드 포인트에 사용되는 비트들은 표 4.3에서 “b”로 표현된 비트 마스크(mask)에 위치한다.

표 4.3 UTF-8 바이트 구조

바이트 비트 표기
1 7 0bbbbbbb
2 11 110bbbbb 10bbbbbb
3 16 1110bbbb 10bbbbbb 10bbbbbb
4 21 11110bbb 10bbbbbb 10bbbbbb 10bbbbbb
5 26 111110bb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb
6 31 1111110b 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb
7 36 11111110 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb
8 42 11111111 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb 10bbbbbb

따라서 벵갈 모음 문자 RR을 표현하는 코드 포인트인 U+09E0은 12비트 (16진수 09E0은 이진수로 100111100000이므로) 데이터이므로 세 개의 바이트가 필요하다. 따라서 코드 포인트를 표현하는 비트를 비트 마스크에 놓게 되면 0xE0 0xA7 0xA0 또는 11100000 10100111 10100000으로 인코딩된다.

UTF-8가 지닌 디자인적 장점 중 하나는 WORD나 DWORD를 사용하여 코드 포인트를 표현하지 않고 바이트 배열로 인코딩함으로써 시스템의 엔디언 방식과 무관하다는 점이다. 즉, 리틀 엔디언 방식을 사용하는 장비에서 빅 엔디언 방식을 사용하는 장비 쪽으로 UTF-8 인코딩으로 스트림(stream)을 전송할 때는 바이트 순서를 변경하거나 BOM을 추가할 필요가 없다. 따라서 하위 아키텍처를 완전히 무시해도 된다.

UTF-8의 또 다른 유용한 장점은 코드 포인트의 비트 값을 왼쪽에서 오른쪽으로 저장한다는 점이다. 따라서 바이트 상태에서 이진 정렬을 할 수 있어서 문자열을 코드 포인트의 순서대로 정렬할 수 있다. 이 방법은 로캘 기반의 정렬 규칙보다 좋지 않을 수 있지만 매우 쉽고 빠르게 정렬할 수 있는 방법이다. 하위 시스템은 UTF-8을 몰라도 되고 다만 바이트를 정렬할 수만 있으면 된다.

UTF-8 웹 애플리케이션

애플리케이션이 UTF-8을 사용한다는 것은 무엇을 의미할까? 몇 가지가 될 텐데 모두 단순한 내용이지만 개발하는 내내 명심하고 있어야 하는 내용이기도 하다.

출력 관리

출력하는 모든 페이지가 UTF-8을 사용하게 만들려면 유니코드를 사용하고 인식할 수 있는 편집기를 사용해서 마크업 템플릿들을 생성해야 하며 파일을 저장할 때 UTF-8 인코딩으로 저장해야 한다. 대부분의 경우 기존에 (공식 명칭이 ISO-8859-1인) Latin-1을 사용하고 있었다면 바뀌는 부분은 거의 없을 것이다. 사실 몇몇 악센트 기호가 있는 문자를 제외하고는 하나도 바뀌는 것이 없을 것이다. 템플릿이 UTF-8로 인코딩된 후에는 페이지의 인코딩 방식을 브라우저에 알려주면 된다. 다음과 같이 컨텐츠 유형(content-type) 헤더(header)에서 charset 속성을 지정해줌으로써 브라우저가 인코딩 방식을 알 수 있다.

Content-Type: text/html; charset=utf-8

생각해보면 charset은 이러한 속성을 지정하기에는 조금 이상한 이름이다. 왜냐하면 이 속성은 문자 집합과 인코딩을 모두 표현하고 있으며 오히려 대개 인코딩을 나타내기 때문이다. 자, 그렇다면 이제 페이지에 헤더를 어떻게 출력하는지 알아보자. 여기에는 몇 가지 방법이 있으며 이들 중 몇 가지 방법 또는 모든 방법을 조합하여 사용해도 좋을 것이다.

보통 HTTP 헤더를 전송하기 위해 애플리케이션 코드나 웹 서버의 설정을 이용할 수 있다. 아파치를 사용하고 있다면 AddCharset 지시자를 httpd.conf 파일이나 .htaccess 파일에 추가하여 특정 확장자를 가진 모든 문서의 charset 헤더 값을 지정할 수 있다.

AddCharset UTF-8 .php

PHP에서는 HTTP 헤더를 간단한 header() 함수를 통해 넣을 수 있다. UTF-8 헤더를 넣기 위해서는 다음과 같은 코드를 사용하면 된다.

header(“Content-Type: text/html; charset=utf-8”);

이러한 방법의 작은 단점이라면 웹 서버가 브라우저 정보(user agent)에 따라 자동으로 Content-Type을 정하지 못하게 하며 명시적으로 (이 경우에는 text/html 유형으로) 선언해야 한다는 점이다. 이것은 특히 Content-Type을 text/html으로 보낼지 application/xhtml+xml으로 보낼지 결정해야 할 때 문제가 된다. 왜냐하면 후자의 Content-Type이 기술적으로는 맞지만 Netscape 4나 일부 버전의 Internet Explorer 6에서는 브라우저가 페이지를 다운로드하려고 하기 때문이다.

헤더를 일반적인 HTTP 요청의 일부로 보내는 것 외에도 meta 태그를 통해 HTML에 동일한 내용이 포함되게 할 수 있다. 다음과 같은 HTML을 템플릿의 head 태그 안에 넣어서 페이지에 반영할 수 있다.

<meta http-equiv=”Content-Type” content=”text/html; charset=UTF-8”>

일반적인 헤더에 비해 meta 태그가 가진 장점은 사용자가 페이지를 저장하면 페이지의 헤더 없이 바디(body)만이 저장되지만 인코딩에 대한 정보를 계속해서 갖고 있게 된다는 점이다. 그렇지만 meta 태그만 사용하지 않고 헤더를 함께 보내야 하는 데는 중요한 이유가 두 가지 있다. 첫째로 웹 서버가 이미 인코딩을 잘못 보내고 있는 경우에는 http-equiv의 내용은 무시되기 때문이다. 아예 잘못된 전송을 막거나 아니면 올바른 인코딩 정보를 싣도록 해야 한다. 둘째로 잘못된 인코딩 정보를 가지고 문서를 분석하고 있을 때 meta 태그를 만나게 되면 대부분의 브라우저는 문서를 처음부터 다시 분석하기 시작할 것이다. 이렇게 되면 페이지를 출력하는 시간이 오래 걸리게 되고 브라우저에 따라 내용이 아예 무시되어 버릴 수도 있다. 아마 말할 필요도 없겠지만 HTTP 헤더에 있는 인코딩 정보는 meta 태그에 있는 정보와 같아야 한다. 만약 두 정보가 서로 다르다면 최종 출력되는 페이지가 어떻게 될지 예측하기 어려울 것이다.

HTML이 아닌 다른 문서를 UTF-8로 제공할 때도 같은 규칙이 적용된다. XML 문서나 피드(feed)는 다른 Content-Type을 가진 HTTP 헤더를 사용할 수 있다.

header(“Content-Type: text/xml; charset=utf-8”);

HTML과는 달리 XML은 임의의 HTTP 헤더 값을 문서에 포함시킬 방법이 없다. 다행히도 XML은 XML 선언부에 직접 (이번에는 적절한 이름으로) 인코딩 정보를 설정하는 것을 지원한다. XML 문서를 UTF-8로 설정하려면 간단히 XML 선언부에 다음과 같이 표시하면 된다.

<?xml version=”1.0” encoding=”utf-8” ?>

입력 관리

폼(form)을 통해 애플리케이션으로 전송된 입력 값은 자동적으로 페이지의 인코딩과 문자 집합을 사용하게 된다. 다시 말하자면 모든 페이지가 UTF-8을 통해 인코딩된 경우 모든 입력 값 또한 UTF-8을 통해 인코딩 될 것이다. 아주 훌륭하고 적절하게 동작한다.

당연하게도 이러한 단일화된 입력 방식의 이상향을 이루기 위해서는 몇 가지 단서가 붙는다. 누군가 다른 사이트로부터 여러분이 만든 애플리케이션이 사용하는 URL로 데이터를 전송하는 폼을 만들었다면 이 폼에서 사용하는 문자 집합으로 입력 값이 인코딩될 것이다. 아주 오래된 브라우저는 요청된 인코딩에 관계없이 특정 인코딩만을 사용할 수도 있다. 사용자가 여러분이 만든 애플리케이션으로 데이터를 전송하는 애플리케이션을 만들었는데 실수로 잘못된 인코딩을 사용할 수도 있다. 일부 사용자는 고의적으로 예기치 않은 인코딩을 사용하여 데이터를 전송하는 애플리케이션을 만들 수도 있다.

이러한 입력 방식은 모두 같은 결과를 낳는다. 안전한 사용을 위해 모든 들어오는 데이터는 먼저 걸러야 한다. 이와 관련된 내용은 다음 장에서 아주 자세히 다루겠다.

PHP에서 UTF-8 사용

바이트 기반의 인코딩 방식인 UTF-8의 부작용 중 하나는 문자열의 내용을 건드릴 필요만 없다면 모든 이진(binary) 무결성 시스템에서 문자열을 여기저기 아무 곳에나 돌릴 수 있다는 점이다(여기서 이진 무결성이란 어떠한 바이트 값이라도 '문자열'에 저장할 수 있고 또한 언제든지 같은 바이트 값을 돌려 받을 수 있다는 의미다).

따라서 PHP 4와 PHP 5가 문자 집합이나 인코딩에 대한 지원을 언어 차원에서 하지 않더라도 유니코드 애플리케이션을 손쉽게 지원할 수 있다. 해야 할 일이 UTF-8로 인코딩된 데이터를 받아서 저장하고 그저 출력하는 것이라면 바이트를 복사하는 것 외에는 할 일이 없기 때문이다.

하지만 유니코드에 대한 지원 없이는 할 수 없는 작업도 있다. 예를 들어, 문자열을 추출하는 substr()과 같은 것이 있다. substr()은 바이트 단위로 작업을 하므로 UTF-8로 인코딩된 문자열을 임의의 바이트 길이로 추출한다면 안전하지 않을 수 있다. 일례로 UTF-8로 인코딩된 문자열에서 첫 세 개의 바이트를 잘라낸다면 한 문자를 이루는 바이트들의 중간에서 잘릴 수 있으며 따라서 불완전한 문자가 추출될 것이다.

만약 이러한 이유로 UCS2와 같은 고정폭 인코딩을 고려한다면 아무리 고정폭 인코딩을 사용하여 문자 폭에 맞춰 작업하더라도 맹목적으로 유니코드 문자열을 자를 수는 없다는 점을 명심하길 바란다. 왜냐하면 유니코드는 분음 표기 및 기타 기호들의 문자들을 더할 수 있게 허용하고 있기 때문에 두 코드 포인트 사이를 정확하게 잘라낸다고 해도 문자열의 맨 마지막 문자의 액센트 기호가 사라지거나, 문자열의 맨 앞에 액센트 부호만 남아 있거나 또는 여기서는 너무 복잡해서 다루기 어려운 결합 표시 전각 문자(double width combining mark)에서 오는 이상한 부작용들과 같은 결과를 초래하기 때문이다.

따라서 내부적으로 문자열을 추출하는 작업을 하는 모든 함수들은 안전하게 사용할 수 없게 된다. PHP에서는 이러한 예로 wordwrap()과 chunk_split() 같은 함수가 있다.

PHP에서는 유니코드 문자열 추출을 mbstring(다중 바이트 문자열) 확장을 통해 지원하며 이는 기본 PHP 패키지에 포함되어 있지 않다. 이 확장 패키지를 설치하면 mb_substr()이 substr()을 대신하게 되는 등 대체 문자열 조작 함수들을 제공하게 된다. 실은 mbstring 확장이 기존의 문자열 조작 함수에 대한 오버로딩(overloading)을 지원하기 때문에 기존 함수를 호출하면 실제로는 mb_로 시작하는 함수들을 자동으로 호출하게 된다. 하지만 이러한 오버로딩이 문제를 야기할 수도 있다는 점을 명심해야 한다. 기존에 문자열 조작 함수들을 (문자들을 이진 데이터로 취급하는 경우가 아니라) 진짜 바이너리 데이터를 다루기 위해 사용하고 있었다면 이렇게 오버로딩을 했을 때 기존 코드가 깨지게 된다. 따라서 가장 안전한 방법으로 필요한 곳에 명시적으로 다중 바이트 함수들을 사용하는 것이 좋다.

UTF-8로 인코딩된 문자열을 조작하는 것 외에도 언어 수준에서 필요한 기능은 데이터의 유효성을 검증하는 능력이다. 왜냐하면 모든 바이트 스트림이 유효한 UTF-8은 아니기 때문이다. 이 주제에 관해서는 5장에서 좀 더 심도있게 다룬다.

다른 언어에서 UTF-8 사용

앞서 논의한 PHP 기법들은 5.6 버전 이전의 펄 버전이나 다른 오래된 언어처럼 기본적으로 유니코드를 지원하지 않는 언어에도 동일하게 적용된다. 언어가 바이트 스트림을 투명하게 처리할 수만 있는 한 불투명한 이진 데이터로 문자열을 전달할 수 있다. 문자열 조작과 검증과 관련해서는 iconv나 ICU 같은 전용 라이브러리에 처리하라고 넘겨주면 된다.

근래에 출시되는 많은 언어들은 부분적으로나 완전히 유니코드를 지원한다. 펄 5.8 이상의 버전들은 유니코드 문자열을 투명하게 처리할 수 있으며 5.6.0 버전은 use utf8 pragma를 이용하여 제한적으로 지원하고 있다. 펄 6에서는 바이트, 코드 포인트, 그리고 문자소 단위의 문자열 조작을 할 수 있도록 매우 광범위한 유니코드 지원이 계획되어 있으며 언어 자체에 유니코드 지원을 내장하여 기존 코드를 손쉽게 옮길 수 있게 할 예정이다. 루비 1.8은 유니코드를 명시적으로 지원하지 않으며 PHP처럼 문자열을 일련의 8비트 바이트로 간주하여 처리한다. 루비 1.9/2.0에서는 일종의 유니코드 지원이 계획되어 있다.

Java와 .NET은 모두 완전한 유니코드 지원을 하며 따라서 이 장에서 다루는 귀찮고 우회적인 방법을 쓰지 않고도 직접적으로 문자열을 언어 차원에서 다룰 수 있다. 그러나 내부적으로 유니코드 문자열을 사용하더라도 외부에서 받는 데이터에 대해서는 사용하는 인코딩에 맞는지 유효성을 항상 검사해야 한다. 이 언어들은 잘못 인코딩된 문자열을 조작하려 하면 기본적으로 에러를 출력하기 때문에 입력을 받는 곳에서 값을 필터링하거나 아니면 애플리케이션 깊숙한 부분에서 나오는 예외(exception)를 잡을 준비가 되어 있어야 한다. 사용하는 언어에서 유니코드 문자열을 사용하는 것과 관련된 서적을 보면 많은 도움이 될 것이다.

MySQL에서 UTF-8 사용

PHP와 마찬가지로 사용하는 매개체가 바이트 스트림을 지원한다면 곧 UTF-8을 지원하는 것을 의미한다. MySQL은 바이트 스트림을 지원하므로 ASCII 문서를 저장하는 것과 마찬가지로 UTF-8로 인코딩된 문자열을 저장하고 가져올 수 있다.

데이터를 읽고 쓸 수 있다면 남은 일은 무엇이 있을까? PHP의 경우처럼 두 가지 중요한 문제가 있다. 대개 코드보다 데이터베이스에서 처리하고자 하는 정렬과 같은 작업은 유니코드 데이터를 처리할 수 있어야 한다. 이미 언급한 것처럼 UTF-8은 다행히 이진수로 정렬될 수 있고, 따라서 코드 포인트 값대로 정렬된다. 그러므로 MySQL은 CHAR와 VARCHAR 칼럼(column)에서 BINARY 속성을 지정하고 TEXT 타입이 아닌 BLOB 타입을 사용한다면 UTF-8로 인코딩된 데이터를 원활하게 정렬할 수 있다.

PHP와 마찬가지로 주의해야 할 것은 문자열 조작과 관련된 작업이다. 대부분의 문자열 조작 관련 작업을 SQL이 아닌 코드에서 처리하면 대부분 이 문제를 비켜갈 수 있다. 다음과 같은 SQL 문을 쓰는 것을 피해라.

SELECT SUBSTRING(name, 0,1) FROM UserNames;

대신에 관련 작업을 비지니스 로직 계층으로 옮긴다.

<?php
    $rows = db_fetch_all(“SELECT name FROM UserNames;”);

    foreach($rows as $k => $v){
        $rows[$k][‘name’] = mb_substr($v[‘name’], 0, 1);
    }
?>

어떤 경우에는 이런 방법이 문제가 될 수도 있다. 예컨대 SQL에서 문자열을 추출하여 SELECT이나 JOIN에 사용하고 있었다면 더 이상 그러한 작업을 할 수 없게 된다. 대안으로는 데이터베이스에서 문자 집합이 지원되게 하거나 (이 내용은 잠시후 더 설명할 기회가 있을 것이다) 또는 쿼리가 간단해지도록 데이터 설계를 하는 것이다. 예를 들어, 그룹 레코드에서 특정 필드의 첫 문자에 대한 문자열 추출 작업을 하고 있었다면 (정규화된 코드 포인트 집합으로서) 그러한 첫 문자들을 별도의 필드에 저장하고 사용하는 식으로 데이터베이스에서 문자열 작업을 하는 것을 피할 수 있다.

MySQL은 또한 별도의 문자열 조작 함수들을 백그라운드에서 사용하고 있으며 여기서 발생하는 문제들은 놓치기 쉽다. MySQL에서 FULLTEXT 인덱스를 만들려면 입력 문자열을 단어별로 잘라내어 각각 인덱스를 만들어야 한다. UTF-8에 대한 지원이 없으면 유니코드 문자열이 잘못 분할되어 인덱스를 할당받게 되고 따라서 기이하고 예기치 않은 결과를 낳을 수 있다.

명시적인 문자열 조작 함수와 달리 문자 인덱스 기능을 아예 다시 만들지 않는 한 문자를 인덱스하는 로직(logic)을 코드 계층으로 옮길 방법은 없다. 문자 인덱스 기능은 복잡하고 정교한 코드로 만들어지며 이미 MySQL FULLTEXT 인덱스의 형식으로 만들어진 코드가 있으므로 자체적으로 이런 기능을 구현하는 것은 시간 낭비다.

다행히도 MySQL 버전 4.1은 UTF-8과 같은 다중 바이트 문자 집합 및 검증을 지원하고 있으며, 따라서 앞서 설명한 작업을 할 필요가 없다. 테이블을 만들 때 칼럼별로 문자 집합을 지정하거나 매번 칼럼을 만들 때마다 문자 집합을 지정할 필요 없이 서버, 데이터베이스, 또는 테이블별로 기본값을 지정할 수도 있다. 각 칼럼에 저장되는 데이터는 지정된 문자 집합 형식으로 저장되며 일반적인 문자열 조작 함수를 사용할 수 있고 FULLTEXT 인덱스도 올바르게 작동하게 된다.

또한 칼럼의 길이를 바이트 단위가 아닌 문자 단위로 지정하도록 만드는 장점도 있다. 4.1 버전 이전에는 MySQL에서 CHAR(10) 칼럼 유형은 10바이트 길이를 의미했으며 따라서 2개와 10개 사이의 UTF-8 문자를 저장할 수 있었다. 버전 4.1에서는 CHAR(10)은 10개의 문자를 저장할 수 있음을 의미하므로 10개 또는 그 이상의 바이트를 사용할 수 있다. 저장 공간이 걱정된다면 CHAR 타입보다 VARCHAR 타입을 사용하는 편이 낫다. 왜냐하면 CHAR(10) 칼럼은 실제로 30바이트를 필요로 하는데 10개의 문자가 각자 세 개의 바이트까지 사용할 수 있기 때문이다.

MySQL은 현재 하나의 UTF-8 문자당 3바이트로 제한하고 있으며, 따라서 U+FFFF 이상의 코드 포인트를 저장하지 못한다. 대개 이러한 제한이 문제가 되지 않을 텐데 그 이상의 코드 값은 음악 기호, 고대 페르시아 문자, 에게(Aegean) 숫자들을 포함한 잘 쓰이지 않는 문자를 나타내기 때문이다. 하지만 일부 코드 포인트들을 저장할 수 없다는 사실을 기억하는 편이 좋고 데이터를 필터링하는 코드에서 참고해야 한다.

이메일에서 UTF-8 사용

애플리케이션에서 이메일을 전송해야 한다면 이메일은 애플리케이션이 사용하는 문자 집합이나 인코딩을 지원해야 한다. 그렇지 않으면 키릴 문자를 이용해 이름을 등록한 사용자에게 올바른 이름을 표기한 이메일을 보낼 수 없는 상황에 놓일 것이다.

보내는 이메일에 쓸 문자 집합과 인코딩을 지정하는 것은 웹 페이지에 문자 집합과 인코딩을 지정하는 것과 매우 비슷하다. 모든 이메일은 HTTP 헤더와 비슷한 형태의 하나 또는 그 이상의 헤더 요소를 가지고 있으며 받는이, 시간, 제목 등 메일의 다양한 부분들을 기술하고 있다. 문자 집합과 인코딩은 HTTP와 마찬가지로 컨텐츠 유형 헤더를 통해 지정한다.

Content-Type: text/plain; charset=utf-8

컨텐츠 유형 헤더의 문제점은 이메일의 바디(body)에 대해서만 정의한다는 점이다. HTTP와 마찬가지로 이메일 헤더는 ASCII만으로 작성해야 한다. 많은 메일 서버들은 8비트에 대해 안정적으로 동작하지 않기 때문에 ASCII 범위를 벗어나는 문자는 제거해 버린다. 제목이나 보낸이 이름과 같이 헤더에 넣고 싶은 문자열은 꼭 ASCII를 사용해야 한다.

분명히 이것은 말도 안되는 일이다. 꼭 사용하고 싶은 UTF-8 데이터가 있고 그것을 이메일 제목에 쓰고 싶을 것이다. 다행히도 꽤 간단한 해법이 있다. 헤더는 RFC 1342에 ('인터넷 메시지 헤더에서 ASCII가 아닌 문서의 표현') 정의된 인코딩된 단어(encoded word)라는 것을 포함할 수 있다. 인코딩된 단어는 다음과 같은 형태를 띤다.

=?utf-8?Q?hello_=E2=98=BA?=
=?charset?encoding?encoded-text?=

여기서 charset은 문자 집합의 이름이며 인코딩 값으로는 “B”나 “Q”를 가질 수 있다. 인코딩된 문서는 해당 문자 집합을 사용한 문자열이며 특정 방법으로 인코딩되어 있다.

“B”는 단순히 RFC 3548에 정의된 base64 인코딩을 뜻하며 “Q”는 quoted-printable의 한 형태 이며 다음과 같은 규칙을 있다.

  • 모든 바이트는 “=” 기호로 시작되어 두 자리의 16진수 값으로 표현된다. 예를 들어, 0x8A는 =8A와 같이 표현된다.
  • 공백은 (바이트로 0x20) “_”로 (바이트로 0x5F) 바꿔 표현한다.
  • ASCII 영문자와 숫자는 그대로 사용한다.

대개는 Quoted-printable 방식의 “Q” 인코딩 방법을 선호하는데 간단한 ASCII 문자열은 그대로 볼 수 있기 때문이다. 이는 디버깅할 때 많은 도움이 되며 ASCII를 사용하는 터미널에서 미가공(raw) 메일 헤더를 쉽게 읽고 이해할 수 있다.

이 인코딩은 다음과 같은 간단한 PHP 함수로 구현할 수 있다.

function email_escape($text){
    $text = preg_replace('/([^a-z ])/ie', 'sprintf("=%02x",
        ord(StripSlashes("\\1")))', $text);$text = str_replace(' ',
        '_', $text);
    return "=?utf-8?Q?$text?=";
}

이 함수를 조금 더 개선하자면 기본적인 문자 외의 것을 포함하고 있을 때만 인코딩해서 용량을 줄이고 소스의 가독성을 높일 수 있다.

function email_escape($text){
    if (preg_match('/[^a-z ]/i', $text)){
        $text = preg_replace('/([^a-z ])/ie', 'sprintf("=%02x",
            ord(StripSlashes("\\1")))', $text);
        $text = str_replace(' ', '_', $text);
        return "=?utf-8?Q?$text?=";
    }
    return $text;
}

RFC 1342에서는 각 인코딩 부분의 길이가 75자를 넘기지 못하게 정하고 있으며 위의 함수가 이러한 규칙을 준수하게 만들려면 조금 더 수정해야 한다. 현재 각 인코딩된 부분이 이미 기본적으로 12개의 문자를 포함하기 때문에 각각 최대 63개의 문자만을 가지도록 나눠야 하며 각각 접두사(“=?”)와 접미사(“?=”)를 붙이고 줄 바꿈 문자를 넣어줘야 한다. 그리고 당연히 인코딩된 문자를 중간에 자르는 일은 없어야 할 것이다. 실제 구현은 연습 문제로 남겨두겠다.

자, 여기까지 메일 바디와 헤더의 인코딩에 대해 설명했다. 이제 남은 것은 지금까지 설명한 내용을 모두 반영해서 다음과 같이 안전하게 UTF-8로 작성된 이메일을 보내는 함수를 작성하는 것이다.

function email_send($to_name, $to_email, $subject, $message, $from_name, $from_email){
    $from_name = email_escape($from_name);
    $to_name = email_escape($to_name);

    $headers = "To: \"$to_name\" <$to_email>\r\n";
    $headers .= "From: \"$from_name\" <$from_email>\r\n";
    $headers .= "Reply-To: $from_email\r\n";
    $headers .= "Content-Type: text/plain; charset=utf-8";

    $subject = email_escape($subject);

    mail($to_email, $subject, $message, $headers);
}

자바스크립트에서 UTF-8 사용

근래의 브라우저들은 자바스크립트에서 유니코드를 지원하고 있다. 기본적으로 String 클래스는 바이트 단위가 아닌 코드 포인트를 기준으로 문자열을 저장하고 있기 때문에 문자열 조작 함수에서도 올바르게 작동한다. 자바스크립트를 사용하여 폼의 데이터를 넣거나 가져올 때마다 (페이지의 인코딩 타입을 UTF-8로 해놓았다면) 최종적으로 전송되는 데이터는 UTF-8로 인코딩된다.

한 가지 주의해야 할 점은 유니코드 문자를 지원하지 않는 escape() 내장 함수인데, 이 함수는 URL에 포함되는 문자열을 변환하는 데 사용된다. 다시 말해 GET 요청 방식과 같이 사용자가 입력한 문자를 가지고 URL을 만든다면 escape() 함수를 사용할 수 없다.

다행히도 자바스크립트는 코드 포인트를 내부적으로 지원하며 String.getCodeAt() 함수를 사용하여 쿼리할 수 있기 때문에 UTF-8을 위한 변환 함수를 쉽게 만들 수 있다.

function escape_utf8(data) {
    if (data == '' || data == null){
        return '';
    }
    data = data.toString( );
    var buffer = '';
    for(var i=0; i<data.length; i++){
        var c = data.charCodeAt(i);
        var bs = new Array( );

        if (c > 0x10000){
            // 4 bytes
            bs[0] = 0xF0 | ((c & 0x1C0000) >>> 18);
            bs[1] = 0x80 | ((c & 0x3F000) >>> 12);
            bs[2] = 0x80 | ((c & 0xFC0) >>> 6);
            bs[3] = 0x80 | (c & 0x3F);
        }else if (c > 0x800){
            // 3 bytes
            bs[0] = 0xE0 | ((c & 0xF000) >>> 12);
            bs[1] = 0x80 | ((c & 0xFC0) >>> 6);
            bs[2] = 0x80 | (c & 0x3F);
        }else if (c > 0x80){
            // 2 bytes
            bs[0] = 0xC0 | ((c & 0x7C0) >>> 6);
            bs[1] = 0x80 | (c & 0x3F);
        }else{
            // 1 byte
            bs[0] = c;
        }

        for(var j=0; j<bs.length; j++){
            var b = bs[j];
            var hex = nibble_to_hex((b & 0xF0) >>> 4)
                + nibble_to_hex(b &0x0F);buffer += '%'+hex;
        }
    }

    return buffer;
}

function nibble_to_hex(nibble){
    var chars = '0123456789ABCDEF';
    return chars.charAt(nibble);
}

escape_utf8() 함수는 문자열에서 코드 포인트를 하나씩 가져와 UTF-8 바이트 스트림을 만들어 낸다. 그 후 스트림의 바이트를 하나씩 %XX 형식으로 만들어 URL에서 사용할 수 있도록 변환한다. 여기서 더 향상시킬 수 있는 방법은 영문자와 숫자들은 변환하지 않게 해서 반환된 값의 가독성을 높이는 것이다.

API에서 UTF-8 사용

API는 입력과 출력에 있어서 문자 집합과 인코딩을 강화해야 한다(이 책을 통틀어 API라는 용어는 별도의 언급이 없다면 외부 웹서비스의 API를 의미하며 프로그래밍 언어나 클래스를 말하는 것이 아니다).

이미 출력에 대해서는 아마도 잘 준비가 되어 있을 것이다. API 응답이 XML 기반이라면 앞서 설명한 HTTP와 XML 헤더를 사용하면 되고 HTML 기반이라면 HTTP 헤더와 태그를 조합해서 사용하면 된다.

그 외 사용자 정의 출력 형식에서는 스트림의 앞부분을 지정할 방법이 있다면 BOM을 사용하는 것이 좋고 BOM을 사용할 수 없거나 사용하기 싫다면 전송하는 내용에 대해 문서화하는 것이 가장 좋다. 출력 문자 집합과 인코딩을 초기부터 명시적으로 정해두면 애플리케이션을 만들 때 처음에는 잘 동작하는 것처럼 보이지만 낯선 문자를 만났을 때 멈춰버리는 현상을 방지할 수 있다.

API로 입력하는 것은 좀 더 힘든 문제가 될 수 있다. 컴퓨터의 영리함은 이를 사용하는 사용자의 지적 능력만큼 제한된다는 말이 있듯이 애플리케이션에 대한 API를 공개하면 전송되어 오는 내용들이 올바른 문자 집합을 사용한다는 보장은 없을 것이다. 모든 입력이 그렇듯 입력값이 유효하고 올바른지 검증하는 것은 매우 중요하다. 이와 관련한 주제는 다음 장에서 좀 더 자세히 살펴보겠다.


  1. UTF-16은 실질적으로 항상 2byte 또는 4byte로 인코딩한다. 가변폭이기는 하지만 거의 고정폭에 가깝게 행동한다. UTF-8/16/32 모두 표현하는 문자 집합은 동일하다.