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

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

JavaScript에서 문자열에 base64 인코딩 및 디코딩을 적용하면 어떻게 되나요? 이 게시물에서는 미묘한 차이와 피해야 할 일반적인 함정에 대해 살펴봅니다.

btoa() 및 atob()

JavaScript에서 base64로 인코딩 및 디코딩하는 핵심 함수는 btoa()atob()입니다. btoa()는 문자열에서 base64로 인코딩된 문자열로 변환되고 atob()은 다시 디코딩됩니다.

다음은 간단한 예시입니다.

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

안타깝게도 MDN 문서에 설명된 대로 ASCII 문자가 포함된 문자열이나 단일 바이트로 표시할 수 있는 문자에서만 작동합니다. 즉, 유니코드에서 작동하지 않습니다.

어떤 일이 발생하는지 확인하려면 다음 코드를 사용해 보세요.

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// 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 도움말을 참고하세요.

유니코드 문자 및 관련 숫자의 예는 다음과 같습니다.

  • 시간 - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 숨겨진 수정자 번호가 65039인 2764
  • 📊 - 9971
  • 🌎 - 129472

각 문자를 나타내는 숫자를 '코드 포인트'라고 합니다. '코드 포인트'를 각 문자의 주소로 생각할 수 있습니다. 빨간색 하트 그림 이모티콘에는 실제로 두 개의 코드 포인트가 있습니다. 하나는 하트용이고 다른 하나는 색상을 '바꾸어' 항상 빨간색으로 만드는 코드입니다.

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

간단히 설명하면 다음과 같습니다.

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

중요한 점은 JavaScript가 문자열을 UTF-16으로 처리한다는 것입니다. 이 경우 문자열의 각 문자가 단일 바이트에 매핑된다는 가정하에 사실상 작동하는 btoa()와 같은 함수가 중단됩니다. 이는 MDN에 명시적으로 명시되어 있습니다.

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

JavaScript의 문자에는 1바이트 이상이 필요한 경우가 많으므로 다음 섹션에서는 base64 인코딩 및 디코딩에서 이러한 사례를 처리하는 방법을 보여줍니다.

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

아시다시피 발생하는 오류는 UTF-16에서 단일 바이트 외부에 있는 문자가 포함된 문자열 때문에 발생합니다.

다행히 base64에 관한 MDN 문서에는 이 '유니코드 문제'를 해결하는 데 유용한 샘플 코드가 포함되어 있습니다. 이 코드를 수정하여 이전 예시와 함께 작업할 수 있습니다.

// From 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));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

다음 단계에서는 이 코드가 문자열을 인코딩하는 작업을 설명합니다.

  1. TextEncoder 인터페이스를 사용하여 UTF-16으로 인코딩된 자바스크립트 문자열을 가져와 TextEncoder.encode()를 사용하여 UTF-8로 인코딩된 바이트 스트림으로 변환합니다.
  2. 그러면 자바스크립트에서 덜 사용되는 데이터 유형이며 TypedArray의 서브클래스인 Uint8Array가 반환됩니다.
  3. Uint8Array을 가져와 bytesToBase64() 함수에 제공합니다. 이 함수는 String.fromCodePoint()를 사용하여 Uint8Array의 각 바이트를 코드 포인트로 처리하고 거기에서 문자열을 만듭니다. 그러면 모두 단일 바이트로 표시될 수 있는 코드 포인트 문자열이 생성됩니다.
  4. 이 문자열을 가져와서 btoa()를 사용하여 base64로 인코딩합니다.

디코딩 프로세스는 동일하지만, 반대입니다.

Uint8Array와 문자열 사이의 단계는 JavaScript의 문자열이 UTF-16, 2바이트 인코딩으로 표시되지만 각 2바이트가 나타내는 코드 포인트는 항상 128 미만임을 보장하기 때문입니다.

이 코드는 대부분의 경우 잘 작동하지만 다른 상황에서는 자동으로 실패합니다.

무음 실패 사례

동일한 코드를 다른 문자열 사용:

// From 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));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

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

문자열은 JavaScript API에 따라 다름

앞에서 설명한 것처럼 JavaScript는 문자열을 UTF-16으로 처리합니다. 하지만 UTF-16 문자열에는 고유한 속성이 있습니다.

치즈 이모티콘을 예로 들어보겠습니다. 그림 이모티콘 (🌎)에는 129472의 유니코드 코드 포인트가 있습니다. 안타깝게도 16비트 숫자의 최댓값은 65535입니다! 그렇다면 UTF-16은 어떻게 이보다 높은 수치를 나타낼까요?

UTF-16에는 서로게이트 쌍이라는 개념이 있습니다. 다음과 같이 생각할 수 있습니다.

  • 쌍의 첫 번째 숫자는 검색할 '도서'를 지정합니다. 이를 '서로게이트'라고 합니다.
  • 쌍의 두 번째 숫자는 'book'의 항목입니다.

상상할 수 있듯이, 도서를 나타내는 숫자만 있고 도서의 실제 항목이 아닌 경우 문제가 발생할 수 있습니다. UTF-16에서는 론 서로게이트라고 합니다.

이는 JavaScript에서 특히 까다롭습니다. 일부 API는 단일 서로게이트가 있어도 작동하는 반면 다른 API는 실패하기 때문입니다.

이 경우 base64에서 디코딩할 때 TextDecoder를 사용합니다. 특히 TextDecoder의 기본값은 다음을 지정합니다.

기본값은 false입니다. 즉, 디코더가 잘못된 형식의 데이터를 대체 문자로 대체합니다.

앞에서 관찰한 문자는 대체 문자이며 16진수로 \uFFFD로 표시됩니다. UTF-16에서 론 서로게이트가 있는 문자열은 '잘못된 형식' 또는 '올바르지 않은' 문자열로 간주됩니다.

잘못된 형식의 문자열이 API 동작에 영향을 미치는 경우를 정확하게 지정하는 다양한 웹 표준 (예 1, 2, 3, 4)이 있지만 특히 TextDecoder가 이러한 API 중 하나입니다. 텍스트를 처리하기 전에 문자열이 올바른지 확인하는 것이 좋습니다.

문자열의 형식이 올바른지 확인

최신 브라우저에는 이제 이러한 용도의 함수(isWellFormed())가 있습니다.

브라우저 지원

  • 111
  • 111
  • 119
  • 16.4

소스

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

다음 함수는 사용 가능한 경우 isWellFormed()를 사용하고 사용할 수 없으면 encodeURIComponent()를 사용합니다. 유사한 코드를 사용하여 isWellFormed()의 polyfill을 만들 수 있습니다.

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

종합해보기

유니코드와 고립된 서로게이트를 모두 처리하는 방법을 알았으므로 모든 것을 결합하여 자동 텍스트 교체 없이 모든 사례를 처리하는 코드를 만들 수 있습니다.

// From 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));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

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

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

이 코드에는 폴리필로 일반화, 론 서로게이트를 자동으로 대체하는 대신 발생할 수 있는 TextDecoder 매개변수 변경 등 다양한 최적화를 실행할 수 있습니다.

이러한 지식과 코드를 통해 데이터 거부, 명시적으로 데이터 교체 사용 설정, 추후 분석을 위한 오류 발생 등 잘못된 형식의 문자열을 처리하는 방법에 대해 명시적인 결정을 내릴 수도 있습니다.

이 게시물에서는 base64 인코딩 및 디코딩의 중요한 예일 뿐만 아니라 특히 텍스트 데이터가 사용자 생성 또는 외부 소스에서 발생할 때 신중한 텍스트 처리가 특히 중요한 이유에 관한 예를 제공합니다.