(번역) 자바스크립트에서 base64 문자열 인코딩의 미묘한 차이

Chanhee Kim·2023년 11월 1일
27

FE 글 번역

목록 보기
18/23
post-thumbnail

원문: https://web.dev/articles/base64-encoding

base64 인코딩과 디코딩은 바이너리 콘텐츠를 웹에 안전한 텍스트로 표현하기 위해 변환하는 일반적인 형식입니다. 인라인 이미지와 같은 데이터 URL에 일반적으로 사용됩니다.

자바스크립트 문자열에 base64 인코딩 및 디코딩을 적용하면 어떤 일이 발생할까요? 이 글에서는 미묘한 차이와 피해야 할 일반적인 함정을 살펴봅니다.

btoa()와 atob()

자바스크립트에서 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 문서를 참조하세요.

유니코드의 문자와 관련 번호의 몇 가지 예입니다.

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764과 숨겨진 변형자(hidden modifier) 65039
  • ⛳ - 9971
  • 🧀 - 129472

각 문자를 나타내는 숫자를 "코드 포인트"라고 합니다. "코드 포인트"는 각 문자에 대한 주소라고 생각하시면 됩니다. 빨간색 하트 이모지에는 실제로 두 개의 코드 포인트가 있는데, 하나는 하트를 위한 것이고 다른 하나는 색상을 "변경"하여 항상 빨간색으로 만들기 위한 것입니다.

variant selectors에 대해 자세히 알아보세요.

유니코드에는 이런 코드 포인트를 통해 컴퓨터가 일관되게 해석할 수 있도록 바이트 시퀀스로 만드는 두 가지 일반적인 방법이 있습니다. UTF-8과 UTF-16입니다.

이를 아주 단순하게 설명하면 다음과 같습니다.

  • UTF-8에서 코드 포인트는 1바이트에서 4바이트(바이트당 8비트)를 사용할 수 있습니다.
  • UTF-16에서 코드 포인트는 항상 2바이트(16비트)입니다.

중요한 점은 자바스크립트가 문자열을 UTF-16으로 처리한다는 점입니다. 이로 인해 btoa()와 같은 함수가 작동하는 방식이 영향을 받습니다. 이러한 함수는 문자열 내의 각 문자가 실제로 하나의 바이트에 매핑된다는 가정으로 동작합니다. 이는 MDN에 명시적으로 기술되어 있습니다.

btoa() 메서드는 바이너리 문자열(즉, 문자열의 각 문자가 바이너리 데이터의 바이트 단위로 처리되는 문자열)에서 Base64로 인코딩된 ASCII 문자열을 생성합니다.

이제 자바스크립트에서 문자가 1바이트 이상을 필요로 하는 경우가 많다는 것을 알았으니, 다음 섹션에서는 base64 인코딩 및 디코딩에서 이 경우를 처리하는 방법을 보여드리겠습니다.

유니코드를 사용한 btoa() 및 atob()

이제 아시다시피, 오류가 발생하는 이유는 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}]`);

다음은 이 코드가 문자열을 인코딩하는 방법을 단계별로 설명합니다.

  1. TextEncoder 인터페이스를 사용하여 UTF-16으로 인코딩된 자바스크립트 문자열을 가져온 뒤 [TextEncoder.encode()](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)를 사용해 UTF-8로 인코딩된 바이트 스트림으로 변환합니다.
  2. 이 함수는 자바스크립트에서 일반적으로 자주 사용되지 않는 데이터 유형이며 TypedArray의 서브클래스인 Uint8Array를 반환합니다.
  3. Uint8ArraybytesToBase64() 함수에 제공하면, 이 함수는 String.fromCodePoint()를 사용하여 Uint8Array의 각 바이트를 코드 포인트로 처리하고 이로부터 문자열을 생성하여 모두 단일 바이트로 표현할 수 있는 코드 포인트 문자열을 생성합니다.
  4. 이 문자열을 가져와서 btoa()를 사용하여 base64 인코딩합니다.

디코딩 프로세스도 반대로 처리할 뿐 동일합니다.

이는 Uint8Array와 문자열 사이의 단계가 자바스크립트의 문자열이 UTF-16, 2바이트 인코딩으로 표시되는 동안 각 2바이트가 나타내는 코드 포인트가 항상 128보다 작도록 보장하기 때문에 작동합니다.

이 코드는 대부분의 상황에서 잘 작동하지만 다른 상황에서는 조용히 실패합니다.

역주: 조용히라는 것은 오류 등을 통해 실패를 알리지 않는 다는 것을 뜻합니다. 이 경우 실패했음을 인지하기 어렵게됩니다.

조용히 실패하는 경우

동일한 코드를 사용하되 다른 문자열을 사용합니다.

// 출처: 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비트 코드 단위입니다.
// '❤️'는 두 개의 16비트 코드 단위인 U+2764와 U+FE0F(하트 및 변형)입니다.
// '🧀'는 32비트 코드 포인트(U+1F9C0) 또는 두 16비트 코드 단위 '\ud83e\uddc0'의 서로게이트 쌍으로 표현할 수 있습니다.
// '\uDE75'는 서로게이트 쌍(surrogate pair)의 절반인 코드 단위입니다.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// 실행 결과는 다음과 같습니다.
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// 실행 결과는 다음과 같습니다.
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

디코딩 후 마지막 문자(�)를 가져와서 16진수 값을 확인하면 원래 \uDE75가 아닌 \uFFFD라는 것을 알 수 있습니다. 실패하거나 오류가 발생하지는 않지만 입력 및 출력 데이터가 조용히 변경되었습니다. 왜 그럴까요?

문자열은 자바스크립트 API마다 다르게 처리됩니다

앞서 설명한 것처럼 자바스크립트는 문자열을 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 중 하나입니다. 텍스트 처리를 수행하기 전에 문자열이 제대로 형성되었는지 확인하는 것이 좋습니다.

올바른 형식의 문자열 확인

최신 브라우저에는 이제 올바른 형식의 문자열을 확인하기 위한 함수인 [isWellFormed()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/isWellFormed)가 있습니다.

브라우저 지원 (출처)

  • Chrome 111
  • Edge 111
  • Firefox 119
  • Safari 16.4

문자열에 론 서로게이트가 포함된 경우 URIError 오류를 throw하는 encodeURIComponent()를 사용하여 비슷한 결과를 얻을 수 있습니다.

다음 함수는 사용할 수 있는 경우 [isWellFormed()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/isWellFormed)를 사용하고 사용할 수 없는 경우 [encodeURIComponent()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)를 사용합니다. 아래와 같은 코드를 사용하여 isWellFormed()에 대한 폴리필을 작성할 수 있습니다.

// isWellFormed()를 지원하지 않는 구형 브라우저를 위한 간단한 폴리필입니다.
// encodeURIComponent()는 론 서로게이트에 대해 오류를 던지지만 본질적으로 동일합니다.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // 새로운 isWellFormed() 기능을 사용합니다.
    return str.isWellFormed();
  } else {
    // 오래된 encodeURIComponent()를 사용합니다.
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

하나로 통합하기

이제 유니코드와 론 서로게이트를 모두 처리하는 방법을 알았으니, 모든 것을 통합해 조용한 텍스트 대체 없이 모든 경우를 처리하는 코드를 작성할 수 있습니다.

// 출처: 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);
}

// isWellFormed()를 지원하지 않는 구형 브라우저를 위한 간단한 폴리필입니다.
// encodeURIComponent()는 론 서로게이트에 대해 오류를 던지지만 본질적으로 동일합니다.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // 새로운 isWellFormed() 기능을 사용합니다.
    return str.isWellFormed();
  } else {
    // 오래된 encodeURIComponent()를 사용합니다.
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // 실행 결과는 다음과 같습니다.
  // 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}]`);
} else {
  // 이 예에서는 도달 하지 못합니다.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // 이 예에서는 도달 하지 못합니다.
} else {
  // 이 문자열은 올바른 형식의 문자열이 아니므로 이 분기에서 처리합니다.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

이 코드에는 폴리필로 일반화하거나, 론 서로게이트를 조용히 대체하는 대신 에러를 던지도록 TextDecoder 매개변수를 변경하는 등 많은 최적화를 수행할 수 있습니다.

이러한 지식과 코드를 통해 데이터를 거부하거나 명시적으로 데이터 대체를 활성화하거나 나중에 분석할 수 있도록 오류를 던지는 등 잘못된 문자열을 처리하는 방법에 대한 명시적인 결정을 내릴 수도 있습니다.

이 글은 base64 인코딩 및 디코딩에 대한 유용한 예시일 뿐만 아니라, 특히 텍스트 데이터가 사용자 생성 또는 외부 소스에서 제공되는 경우 신중한 텍스트 처리가 특히 중요한 이유에 대한 예시를 제공합니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

4개의 댓글

comment-user-thumbnail
2023년 11월 2일

좋은 글 잘 읽었습니다~

답글 달기
comment-user-thumbnail
2023년 11월 2일

잘 읽었습니다 :)

답글 달기
comment-user-thumbnail
2023년 12월 12일

Different, fresh and modern is what this game attracts millions of plays every day, so do not hesitate to ask your friends to join now !!!! https://shell-shockers.co

답글 달기
comment-user-thumbnail
2023년 12월 21일

Food Lion offers an online survey called the Customer Satisfaction Survey at https://talktofoodlion-com.store. It enables clients to share their experiences with the business with the organization. These could be poor or good. The business utilizes this data to fulfill your requests. Take it whenever you have time because it is an online completion.

답글 달기