base64 인코딩과 디코딩은 바이너리 콘텐츠를 웹에 안전한 텍스트로 표현하기 위해 변환하는 일반적인 형식입니다. 인라인 이미지와 같은 데이터 URL에 일반적으로 사용됩니다.
자바스크립트 문자열에 base64 인코딩 및 디코딩을 적용하면 어떤 일이 발생할까요? 이 글에서는 미묘한 차이와 피해야 할 일반적인 함정을 살펴봅니다.
자바스크립트에서 base64 인코딩 및 디코딩을 위한 핵심 함수는 btoa()
와 atob()
입니다.
btoa()
는 문자열을 base64로 인코딩된 문자열로 변환하고, atob()
는 반대로 base64로 인코딩 된 문자열을 일반 문자열로 디코딩합니다.
다음은 간단한 예입니다.
// 128 미만의 코드 포인트만 있는 매우 평범한 문자열입니다.
const asciiString = 'hello';
// 실행 결과는 다음과 같습니다.
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);
// 실행 결과는 다음과 같습니다.
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);
안타깝게도 MDN 문서에 명시된 바와 같이 이 기능은 ASCII 문자 또는 단일 바이트로 표현할 수 있는 문자가 포함된 문자열에서만 작동합니다. 즉, 유니코드에서는 작동하지 않습니다.
어떤 일이 일어나는지 확인하기 위해 아래 코드를 살펴보시죠.
// 작은, 중간, 큰 코드 포인트의 조합을 나타내는 샘플 문자열입니다.
// 샘플 문자열은 유효한 UTF-16입니다.
// 'hello'의 코드 포인트는 각각 128 미만입니다.
// '⛳'는 단일 16비트 코드 단위입니다.
// '❤️'는 U+2764와 U+FE0F(하트 및 변형)의 두 가지 16비트 코드 단위입니다.
// '🧀' 는 32비트 코드 포인트(U+1F9C0), 또는 두 개의 16비트 코드 단위 '\ud83e\uddc0'의 서로게이트(surrogate) 쌍으로 표현할 수 있습니다.
const validUTF16String = 'hello⛳❤️🧀';
// 작동하지 않고, 다음과 같은 에러가 출력됩니다.
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
const validUTF16StringEncoded = btoa(validUTF16String);
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
console.log(error);
}
문자열에 포함된 이모지 중 하나라도 오류가 발생하면 오류가 발생합니다. 어떤 이유로 유니코드를 사용하면 문제가 발생할까요?
이를 이해하기 위해 한 걸음 물러서서 컴퓨터 과학과 자바스크립트 관점에서 문자열을 이해해 봅시다.
유니코드는 현재 문자 인코딩의 글로벌 표준으로, 컴퓨터 시스템에서 사용할 수 있도록 특정 문자에 숫자를 할당하는 방식입니다. 유니코드에 대해 자세히 알아보려면 W3C 문서를 참조하세요.
유니코드의 문자와 관련 번호의 몇 가지 예입니다.
각 문자를 나타내는 숫자를 "코드 포인트"라고 합니다. "코드 포인트"는 각 문자에 대한 주소라고 생각하시면 됩니다. 빨간색 하트 이모지에는 실제로 두 개의 코드 포인트가 있는데, 하나는 하트를 위한 것이고 다른 하나는 색상을 "변경"하여 항상 빨간색으로 만들기 위한 것입니다.
variant selectors에 대해 자세히 알아보세요.
유니코드에는 이런 코드 포인트를 통해 컴퓨터가 일관되게 해석할 수 있도록 바이트 시퀀스로 만드는 두 가지 일반적인 방법이 있습니다. UTF-8과 UTF-16입니다.
이를 아주 단순하게 설명하면 다음과 같습니다.
중요한 점은 자바스크립트가 문자열을 UTF-16으로 처리한다는 점입니다. 이로 인해 btoa()
와 같은 함수가 작동하는 방식이 영향을 받습니다. 이러한 함수는 문자열 내의 각 문자가 실제로 하나의 바이트에 매핑된다는 가정으로 동작합니다. 이는 MDN에 명시적으로 기술되어 있습니다.
btoa()
메서드는 바이너리 문자열(즉, 문자열의 각 문자가 바이너리 데이터의 바이트 단위로 처리되는 문자열)에서 Base64로 인코딩된 ASCII 문자열을 생성합니다.
이제 자바스크립트에서 문자가 1바이트 이상을 필요로 하는 경우가 많다는 것을 알았으니, 다음 섹션에서는 base64 인코딩 및 디코딩에서 이 경우를 처리하는 방법을 보여드리겠습니다.
이제 아시다시피, 오류가 발생하는 이유는 UTF-16의 단일 바이트를 넘어서는 문자가 포함된 문자열 때문입니다.
다행히도 base64에 대한 MDN 문서에 이 "유니코드 문제"를 해결하는 데 유용한 샘플 코드가 포함되어 있습니다. 이 코드를 수정하여 앞의 예제와 함께 사용할 수 있습니다.
// 출처: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
// 출처: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
// 작은, 중간, 큰 코드 포인트의 조합을 나타내는 샘플 문자열입니다.
// 이 샘플 문자열은 유효한 UTF-16입니다.
// 'hello'의 코드 포인트는 각각 128 미만입니다.
// '⛳'는 단일 16비트 코드 단위입니다.
// '❤️'는 U+2764와 U+FE0F(하트 및 변형)의 두 가지 16비트 코드 단위입니다.
// '🧀'는 32비트 코드 포인트(U+1F9C0)로, 또는 두 개의 16비트 코드 단위 '\ud83e\uddc0'의 서로게이트 쌍으로 표현할 수 있습니다.
const validUTF16String = 'hello⛳❤️🧀';
// 실행 결과는 다음과 같습니다.
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// 실행 결과는 다음과 같습니다.
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
다음은 이 코드가 문자열을 인코딩하는 방법을 단계별로 설명합니다.
TextEncoder
인터페이스를 사용하여 UTF-16으로 인코딩된 자바스크립트 문자열을 가져온 뒤 TextEncoder.encode()를 사용해 UTF-8로 인코딩된 바이트 스트림으로 변환합니다.
이 함수는 자바스크립트에서 일반적으로 자주 사용되지 않는 데이터 유형이며 TypedArray
의 서브클래스인 Uint8Array
를 반환합니다.
이 Uint8Array
를 bytesToBase64()
함수에 제공하면, 이 함수는 String.fromCodePoint()
를 사용하여 Uint8Array
의 각 바이트를 코드 포인트로 처리하고 이로부터 문자열을 생성하여 모두 단일 바이트로 표현할 수 있는 코드 포인트 문자열을 생성합니다.
이 문자열을 가져와서 btoa()
를 사용하여 base64 인코딩합니다.
디코딩 프로세스도 반대로 처리할 뿐 동일합니다.
이는 Uint8Array
와 문자열 사이의 단계가 자바스크립트의 문자열이 UTF-16, 2바이트 인코딩으로 표시되는 동안 각 2바이트가 나타내는 코드 포인트가 항상 128보다 작도록 보장하기 때문에 작동합니다.
앞서 설명한 것처럼 자바스크립트는 문자열을 UTF-16으로 처리합니다. 하지만 UTF-16 문자열에는 고유한 속성이 있습니다.
치즈 이모지를 예로 들어보겠습니다. 이모지(🧀)의 유니코드 코드 포인트는 129472
입니다. 안타깝게도 16비트 숫자의 최대값은 65535입니다! 그렇다면 UTF-16은 어떻게 이보다 훨씬 높은 숫자를 표현할 수 있을까요?
UTF-16에는 서로게이트 쌍이라는 개념이 있습니다. 다음과 같이 생각할 수 있습니다.
여러분이 상상할 수 있듯 책을 나타내는 숫자만 있고 해당 책의 실제 항목이 없는 경우 문제가 될 수 있습니다. UTF-16에서는 이를 론 서로게이트(lone surrogate)라고 합니다.
자바스크립트에서는 특히 이 문제가 어려운데, 일부 API는 론 서로게이트가 있음에도 불구하고 작동하는 반면 다른 API는 실패하기 때문입니다.
이 경우 base64에서 다시 디코딩할 때 TextDecoder
를 사용하고 있습니다. 특히 TextDecoder
의 기본 설정값은 다음과 같습니다.
기본값은 false이며, 이는 디코더가 잘못된 데이터를 대체 문자로 대체한다는 의미입니다.
앞서 관찰한 � 문자(16진수로 \uFFFD
로 표시됨)가 바로 이 대체 문자입니다. UTF-16에서 론 서로게이트 문자가 있는 문자열은 "변형됨(malformed)" 또는 "잘못된 형식(not well formed)"으로 간주됩니다.
잘못된 문자열이 API 동작에 영향을 미치는 시점을 정확히 지정하는 다양한 웹 표준(예시 1, 2, 3, 4)이 있고, 특히 TextDecoder
는 이를 반영한 API 중 하나입니다. 텍스트 처리를 수행하기 전에 문자열이 제대로 형성되었는지 확인하는 것이 좋습니다.
이 글은 base64 인코딩 및 디코딩에 대한 유용한 예시일 뿐만 아니라, 특히 텍스트 데이터가 사용자 생성 또는 외부 소스에서 제공되는 경우 신중한 텍스트 처리가 특히 중요한 이유에 대한 예시를 제공합니다.