Dart에서의 Base64 디코딩: 완전한 가이드
API 응답 안에서, URL 한가운데서, 혹은 서포트 티켓에 붙여넣긴 채로 마주치게 됩니다. 알파벳과 숫자로 길게 이어진 문자열인데, 그 사이사이에 드문드문 +, /, -, _가 섞여 있고, 끝에는 = 기호가 하나둘 매달려 있죠. 누군가는 이를 Base64라고 부릅니다. 여러분은 그 안에 무엇이 들어 있는지 알아야 합니다. 이 가이드는 그것을 되찾아내는 Dart 레시피입니다. 홈 페이지에서 포맷을 깊이 있게 다루므로 간단히 방향만 잡아두면 이렇습니다. Base64는 입력 바이트 3개를 64글자 알파벳에서 고른 문자 4개로 다시 씁니다. 마지막 청크가 짧으면 끝에는 = 패드 하나둘을 붙입니다. 디코딩은 이 교환에서 줄어드는 방향입니다. 문자 4개가 들어가면 바이트 3개가 나오므로, 결과는 언제나 입력보다 약 4분의 1 작은 공간을 차지합니다.
좋은 소식부터: 설치할 것이 아무것도 없습니다. Base64는 2015년 Dart 1.13부터 dart:convert 라이브러리에 함께 들어 있고, API는 2018년 Dart 2.0부터 안정적이었습니다. import 한 줄이면 표준 알파벳과 URL 안전 알파벳을 모두 읽는 빠르고 엄격한 디코더를 손에 쥡니다.
솔직한 경계 하나: 이것은 이야기의 디코더 쪽입니다. 디코더가 무엇을 받아들이고 무엇을 거부하는지, 패딩이 어떻게 작동하는지, 바이트를 모지바케 없이 다시 텍스트로 되돌리는 법, 그리고 JWT, data URI, 파일, 스트림, 이메일, 설정, 명령줄에서 Base64를 만나게 되는 방법을 알게 될 것입니다. 반대 방향, 즉 바이트를 문자열로 압축하는 일은 이 글의 끝에 링크된 별도의 가이드가 담당합니다.
엄격한 기계 하나로 통하는 네 개의 문
여기서 여러분이 쓸 공개된 표면을 전부 보여드립니다. 모두 dart:convert 안에 있습니다:
| 항목 | 무엇인가 | 이런 때에 쓰면 좋은가 |
|---|---|---|
base64Decode(source) |
탑레벨 함수, Uint8List로 디코딩 |
일상 디코딩, 거의 언제나 이것 |
base64.decode(source) |
코덱의 디코딩 메서드, 동작은 동일 | fuse나 스트림 변환에 코덱이 필요할 때 |
base64Url.decode(source) |
URL 안전 코덱의 디코딩 메서드 | 입력이 URL 안전으로 문서화되어 있을 때 (기계는 동일) |
base64Url.normalize(source) |
문자열을 검증하고 수리해 패딩된 채로 반환 | 입력이 패딩을 잃거나, 알파벳이 섞이거나, 퍼센트 이스케이프를 쓰는 경우 |
둘만 눈여겨볼 일입니다. 첫째, 네 갈래 길 모두 같은 디코더로 이어집니다. 룩업 테이블 하나를 가진 엄격한 상태 머신 한 대입니다. 둘째, 마지막 행은 애초에 디코더가 아닙니다. 그것은 수리 공방이며, 패딩이 벗겨진 JWT나 반쯤 정리된 설정 값이 처음 등장하는 순간 그 가치를 톡톡히 하게 될 것입니다.
첫 디코딩
디코딩 일상의 9할은 다섯 줄이면 됩니다. 작업 전체의 모양을 보여주는 가장 작은 예제입니다:
import 'dart:convert';
void main() {
final bytes = base64Decode('TWFu');
final text = utf8.decode(bytes);
print(text); // Man
}
방금 일어난 일에 대해 세 문장으로 정리합니다. 첫째, 진입점이 건네주는 것은 텍스트가 아니라 바이트입니다. base64Decode는 Uint8List를 반환하는데, 이는 의도적인 선택입니다. 페이로드가 문장일 수도, JPEG일 수도, 해시일 수도 있고, 손에 무엇을 가졌는지 알기 전까지 그중 어느 하나도 같은 방식으로 다뤄서 안 되기 때문입니다. 둘째, 바이트에서 텍스트로 건너가는 것은 별도의 명시적 단계이며, 인코딩도 명시적으로 지정합니다. 이 단계가 대충 다루면 "café"가 모지바케로 변하는 자리입니다. 셋째, 빈 문자열은 일급 값입니다. base64Decode('')는 예외도 소동도 없이 길이 0의 리스트를 줍니다.
디코더가 받아들이는 것과 거부하는 것
Dart의 디코더는 설계부터 엄격합니다. RFC 4648은 알파벳 밖의 문자가 담긴 입력을 구현이 거부해야 한다고 말하고, Dart는 그 해석을 글자 그대로 따릅니다. 공백은 건너뛰지 않고, 줄바꿈은 무시하지 않으며, 두 번째 기회도 없습니다. 입력이 잘못되면 FormatException이 입력을 보여주며 정확히 그 문자를 가리킵니다. 단골 문제아들에 대한 동작은 이렇습니다:
| 입력 | 무엇이 잘못되었는가 | 정확한 오류 |
|---|---|---|
'SGVs bG8s' |
공백이 끼어 들어감 | FormatException: Invalid character (at character 5) |
'SGVs\nbG8s' |
줄바꿈이 끼어 들어감 | FormatException: Invalid character (at character 5) |
'SGVs$bG8s' |
달러 기호는 알파벳에 없음 | FormatException: Invalid character (at character 5) |
'Zm8' |
패딩이 아예 없음 | FormatException: Invalid length, must be multiple of four (at character 4) |
'Zm8==' |
패딩 하나 자리인데 패딩이 두 개 | FormatException: Invalid padding character (at character 5) |
'Zm=8' |
데이터 한가운데에 패딩 | FormatException: Invalid encoding before padding (at character 3) |
'Zm8=xx' |
패드 뒤에 쓰레기 | FormatException: Invalid padding character (at character 5) |
'Zé' |
비 ASCII 문자 | FormatException: Invalid character (at character 2) |
메시지 속 위치는 1부터 세는 문자 인덱스이고, 입력이 캐럿 바로 아래에 인쇄되므로 손상된 페이로드를 이분 탐색하는 일은 금방입니다. 엄격함 속에 숨은 기분 좋은 놀라움 하나: 디코더는 양쪽 알파벳을 모두 받아들입니다. 표준 문자열 한가운데의 -나 _는 문제가 없고, URL 안전 문자열 안의 +나 /도 문제 없습니다. 알파벳 선택은 텍스트를 읽을 때가 아니라 여러분이 그 텍스트를 만들 때에만 중요해집니다.
패딩: 타협 불가
대부분의 사람을 놀라게 하는 규칙이 있습니다. Dart 디코더는 올바른 패딩을 요구합니다. 입력의 문자 수는 4의 배수여야 하고, 끝의 = 기호는 정확히 필요한 만큼만 존재해야 합니다. 관대 모드는 없고, 느슨하게 만들 플래그도 없고, 바꿀 설정도 없습니다. 이유는 정당합니다. 패딩 없는 디코딩은 모서리 사례에서 모호해지고, RFC는 자유로운 디코딩이 은밀한 채널을 열 수 있다고 경고하므로, 엄격한 해석이 안전한 쪽입니다. 실전에서 이것이 의미하는 바는:
| 입력 | 결과 |
|---|---|
'' |
빈 Uint8List, 오류 없음 |
'QQ==' |
1바이트: A |
'QUI=' |
2바이트: AB |
'QUJD' |
3바이트: ABC |
'Zm8' |
FormatException: 잘못된 길이 |
'Zm8==' |
FormatException: 잘못된 패딩 문자 |
패딩을 제거하는 시스템에서 입력이 올 때, 그리고 JWT에 패딩 없는 값이 가득할 때, 수리 단계는 normalize 한 번 호출입니다. 문자열을 검증하고, URL 안전 문자를 표준 알파벳으로 변환하며, 빠진 패드를 채워 줍니다:
import 'dart:convert';
void main() {
final stripped = '-__--Q';
final repaired = base64Url.normalize(stripped);
print(repaired); // +//++Q==
final bytes = base64Decode(repaired);
print('decoded ${bytes.length} bytes'); // decoded 4 bytes
}
퍼센트 기호의 놀라움
이것은 Dart 고유의 특징입니다. Base64가 data URI 안에 등장할 때, 어떤 도구는 패딩을 퍼센트 인코딩해 %3D를 =의 자리에 씁니다. 그냥 =는 URL 문법에서 "파라미터 구분자"가 될 수 있기 때문이죠. 대부분의 언어라면 먼저 이스케이프를 풀어라 할 것입니다. Dart의 디코더는 그렇지 않습니다. 그 룩업 테이블은 %3D를 패딩 문자의 네이티브 표기로 다루므로, 생 페이로드를 그대로 건네도 됩니다:
import 'dart:convert';
void main() {
final fromDataUri = 'SGVsbG8%3D';
final bytes = base64Decode(fromDataUri);
print(utf8.decode(bytes)); // Hello
}
이스케이프는 패딩이 합법인 자리, 즉 끝 부분에 정확히 받아들여집니다. %3D를 =가 거부될 자리에 놓으면 같은 방식으로 거부되고, %25는 대신 패딩 검사에서 실패합니다 - %는 Dart의 네이티브 패딩 이스케이프 문자이므로, 디코더는 그것을 이스케이프된 =로 읽고 2를 Invalid padding character로 거부합니다. 실전에서는 브라우저 개발자 도구에서 바로 복사한 ;base64, 페이로드를 전처리 없이 디코딩할 수 있다는 뜻으로, 작지만 진정 편리한 트릭입니다.
URL 안전 Base64
RFC 4648은 한 가지 이유로 두 번째 알파벳을 정의합니다. 표준 알파벳에는 URL 문법과 충돌하는 문자 3개, +, /, =가 있기 때문입니다. RFC에서 base64url이라 불리는 URL 안전 알파벳은 +를 -로, /를 _로 바꿉니다. 패딩도 자주 뺍니다. JWT, 객체 ID, 공유 링크, 그리고 URL이나 파일 이름 안에 사는 모든 것의 알파벳입니다.
디코딩 쪽에서 Dart는 하나의 답을 줍니다. 두 알파벳을 읽는 기계는 하나입니다. base64Decode와 base64Url.decode는 같은 디코더의 두 이름이므로, 진정한 일은 오직 패딩뿐입니다. URL 안전 쪽의 생산자들은 패딩 없이 내보내는 경우가 매우 많기 때문이죠. normalize는 정확히 그것을 위해 존재합니다:
import 'dart:convert';
void main() {
final bytes = [0xfb, 0xff, 0xfe, 0xf9];
final urlSafe = base64UrlEncode(bytes);
print(urlSafe); // -__--Q==
final repaired = base64Url.normalize(urlSafe.replaceAll('=', ''));
print(repaired); // +//++Q==
print(base64Decode(repaired).length); // 4
}
떠나기 전에 남길 함정 두 가지입니다. 디코딩 전에 -를 +로 바꾸는 걸 손수 만드지 마세요. 불필요하고, normalize는 이미 필요할 때 알파벳 변환을 해 줍니다. 또, URL 안전 문자열이 패딩 없이 도착한다고 가정하지 마세요. 어떤 생산자는 패드를 유지하고, 패딩이 정확하기만 하면 디코더는 양쪽 모두를 받아들입니다.
바이트에서 텍스트로: 문자 집합 결정
Base64 디코딩은 여러분에게 바이트를 건넵니다. 그 바이트가 텍스트라면, 그것을 다시 String으로 바꿔 줄 인코딩을 골라야 하고, 그 선택은 여러분이 명시적으로 할 일입니다. 최신 시스템의 기본 가정은 UTF-8이며, utf8.decode가 그 주역입니다:
import 'dart:convert';
void main() {
final payload = base64Encode(utf8.encode('Héllo Wörld'));
final bytes = base64Decode(payload);
print(utf8.decode(bytes)); // Héllo Wörld
final legacy = base64Encode(latin1.encode('Héllo'));
print(latin1.decode(base64Decode(legacy))); // Héllo
}
바이트가 유효한 UTF-8이 아니면 utf8.decode는 FormatException을 던지는데, 이것이 옳은 동작입니다. 조용한 모지바케보다 훨씬 낫죠. 데이터가 레거시 싱글바이트 텍스트라고 안다면, 맞는 인코딩을 쓰세요:
| 인코딩 | 무엇에 쓸 때 | 무엇으로 디코딩 |
|---|---|---|
utf8 |
모던 텍스트, JSON, 웹의 모든 것 | utf8.decode(bytes) |
latin1 |
레거시 서양 싱글바이트 데이터 | latin1.decode(bytes) |
ascii |
단순 7비트 텍스트 | ascii.decode(bytes) |
경고 한 줄을 마땅히 받을 함정이 하나 있습니다: String.fromCharCodes는 문자 집합이 아닙니다. 바이트를 UTF-16 코드 유니트로 읽으므로, Héllo의 UTF-8 바이트를 넣으면 Héllo를 어색함 없이 출력합니다. 출력에서 그 모지바케 패턴을 본다면, 해결책은 거의 언제나 utf8.decode입니다.
JWT: 토큰 읽기
JSON Web Token은 점으로 이어진 base64url 부분 3개, 헤더, 페이로드, 서명입니다. 여기서 Base64가 쓰이는 이유는 비밀이 아니라 간결함과 URL 안전성입니다. 토큰을 가진 누구도 헤더와 페이로드를 읽을 수 있고, 이것은 설계 그대로입니다. 여러분이 검증하는 것은 서명이며, 공유 비밀키 또는 발급자의 공개키로 합니다. Dart에서 읽을 수 있는 부분을 디코딩하는 것은 몇 줄이면 됩니다:
import 'dart:convert';
Map<String, dynamic> readJwtPayload(String token) {
final parts = token.split('.');
if (parts.length != 3) {
throw FormatException('Not a compact JWT');
}
final padded = base64Url.normalize(parts[1]);
final bytes = base64Decode(padded);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
void main() {
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
'.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRhcnQgRGV2IiwiaWF0IjoxNTE2MjM5MDIyfQ'
'.c2lnbmF0dXJl';
print(readJwtPayload(token)['name']); // Dart Dev
}
패딩 댄스에 눈여겨볼 일입니다. JWT는 패딩 없이 만들어지므로, 어느 부분의 길이가 4의 배수가 아니면 직접 base64Decode하면 실패합니다. (위 예제에서 헤더는 마침 36자라 바로 디코딩되고, 페이로드는 74자라 그러지 못합니다.) normalize 호출은 길이에 상관없이 수리를 일정하게 만들어 줍니다. 경고 두 가지 더 드립니다. 디코딩은 검증이 아닙니다. 서명과 exp 클레임을 확인하는 것은 별도의 필수 단계이며, HMAC 알고리즘의 경우 보통 crypto 패키지로 합니다. 또, alg: none이라고 주장하는 토큰에는 의심의 눈길을 보내세요. 그것들을 받아들이는 파서는 기능이 아니라 취약점입니다.
Data URI: URL 옷을 입은 파일
RFC 2397에서 정의한 data URI는 페이로드가 데이터 자체인 URL입니다. data:image/png;base64, 뒤에 인코딩된 바이트가 따라오는 형태죠. HTML 속성, CSS 규칙, JSON 문서 같은 텍스트 전용 채널이 별도의 파일 없이 바이너리를 실을 수 있게 존재합니다. Base64가 페이로드 포맷으로 뽑힌 이유는, 대안인 퍼센트 인코딩이 바이너리 데이터에 대해 훨씬 더 길기 때문입니다.
그리고 Dart는 그것들을 네이티브로 파싱할 수 있습니다. data URI 지원은 2016년부터 dart:core에 들어 있어, URI 라이브러리가 필요하지 않습니다:
import 'dart:convert';
void main() {
final uri = Uri.parse('data:image/png;base64,iVBORw0KGgo=');
final data = uri.data!;
print(data.mimeType); // image/png
print(data.isBase64); // true
print('decoded ${data.contentAsBytes().length} bytes');
final textUri = Uri.parse('data:text/plain;base64,SGVsbG8sIERhcnQh');
print(textUri.data!.contentAsString()); // Hello, Dart!
}
UriData 객체는 MIME 타입, isBase64 플래그, 생 페이로드 텍스트, 그리고 문자열이나 바이트로 디코딩된 콘텐츠를 줍니다. 함정 두 가지: 선언된 MIME 타입은 거짓말을 할 수 있으니, 보안이 중요한 코드에서는 실제 마법 바이트를 확인하세요. 또, data URI는 작은 에셋을 위한 것입니다. 페이로드 전체가 그것을 참조하는 문서 안에 함께 실려 다니기 때문이죠.
파일: 디스크 위의 Base64
Base64 파일은 내보내기 포맷, 프로비저닝 번들, 그리고 바이너리를 실어야 하는 텍스트 전용 전송 어디서나 등장합니다. 레시피는 이렇습니다. 텍스트를 읽고, 편평하게 만들고, 디코딩하고, 바이트를 씁니다:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final encoded = await File('image.b64').readAsString();
final flat = encoded.replaceAll(RegExp(r'\s+'), '');
final bytes = base64Decode(flat);
await File('image.png').writeAsBytes(bytes);
print('wrote ${bytes.length} bytes');
}
그 replaceAll는 진짜 일을 합니다. 텍스트 파일은 줄바꿈으로 가득 차 있고, 보통 76자 MIME 래핑이며, 엄격한 디코더는 그것들을 거부하므로 먼저 편평하게 만드세요. 이 정규식은 모든 공백 문자를 제거하는데, 순수한 base64 파일에는 정확히 원하는 것이지요. 파일에 PEM 헤더 같은 다른 주석이 들어 있을 수 있다면, 디코딩 전에 그것들을 명시적으로 제거하고, 진짜로 손상된 것은 디코더의 오류가 잡게 두세요.
HTTP와 API
HTTP 안의 Base64는 두 벌의 옷을 입습니다. 첫째, API 응답: 바이너리를 문자열로 실은 JSON 필드입니다. 둘째, Authorization: Basic 헤더이며, 여기서는 자격 증명이 표준 알파벳과 패딩으로 base64 인코딩됩니다:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
final response = await http.get(
Uri.parse('https://httpbin.org/get?attachment=TWFuIGlzIGhlcmU%3D&name=man.txt'),
);
final payload = jsonDecode(response.body) as Map<String, dynamic>;
final args = payload['args'] as Map<String, dynamic>;
final bytes = base64Decode(args['attachment'] as String);
print('got ${bytes.length} bytes');
final credentials = utf8.decode(base64Decode('b2N0b2NhdDpzZWNyZXQ='));
print(credentials.split(':').first); // octocat
}
http 패키지는 표준 클라이언트로, dart pub add http 한 번이면 됩니다. Basic 인증에서는 Basic 접두사 뒤의 부분을 디코딩합니다. 함정 두 가지: 어떤 API는 문서가 base64라고 쓰는 곳에 URL 안전이거나 패딩 없는 값을 보내므로, 직접 디코딩이 예외를 던지면 그 값을 먼저 base64Url.normalize를 통과시키세요. 그리고 Basic 인증은 보호가 아니라 은폐라는 점도 기억하세요. 그래서 그것은 TLS 연결에만 어울립니다.
이메일과 MIME: 줄바꿈 문제
이메일은 가장 오래된 base64 고객입니다. MIME은 base64 줄을 76자에서 감습니다 - 76자에 CRLF를 더해도 80열 디스플레이에 여유 있게 들어맞으니까요 - 그리고 RFC 2045는 디코더가 줄바꿈을 무시하라고 말합니다. Dart의 디코더는 의도적으로 그렇지 않습니다. 그것들을 거부하죠. 해결책은 디코딩 전에 편평하게 만드는 것입니다:
import 'dart:convert';
List<int> decodeMimeBody(String wrapped) {
final flat = wrapped.replaceAll(RegExp(r'\s+'), '');
return base64Decode(flat);
}
void main() {
const wrapped =
'SGVsbG8gZnJvbSBhbiBlbWFpbCBhdHRhY2htZW50LCB3cmFwcGVkIGF0IDc2IGNoYXJhY3RlcnMg'
'\r\n'
'dGhlIHdheSBNSU1FIHdhbnRzIGl0IHRvIGJlLCB3aXRoIENSTEYgYmV0d2VlbiB0aGUgbGluZXMu';
print(utf8.decode(decodeMimeBody(wrapped)));
}
규칙은 간단합니다. 공백을 제거하고, 그뿐입니다. 친절하려다 다른 문자를 제거하지 마세요. 디코더가 검증자입니다. 진짜 손상에 대해 불평하게 하고 싶은 것이죠. 대량으로 이메일을 처리한다면, 편평화 단계는 저렴합니다. 정규식 한 번 지나기일 뿐인데, 파이프라인의 나머지를 정직하게 유지해 줍니다.
설정과 환경 변수
텍스트 기반 설정에 사는 토큰과 자격 증명은 한 줄에 두고 토큰처럼 보이게 하려고 때때로 base64 인코딩됩니다. 솔직한 틀부터: base64는 암호화가 아니라 은폐이므로, 이 패턴은 깔끔함을 위한 것이지, 결코 비밀을 위한 것이 아닙니다. 패턴 자체는 자명합니다:
import 'dart:convert';
import 'package:dotenv/dotenv.dart';
Future<void> main() async {
final env = DotEnv()..load();
final encoded = env['API_TOKEN_B64'];
if (encoded == null) {
return;
}
final token = utf8.decode(base64Decode(encoded));
print('loaded a ${token.length}-char token');
}
dotenv 패키지로, 값은 .env 파일에 API_TOKEN_B64=c2stbGl2ZS1hYmMxMjM=로 앉아 있고, 디코딩 이후 평문으로 돌아옵니다. 같은 모양은 String.fromEnvironment로 컴파일 시 dart-define 값에도 작동하지만, 경고가 하나 있습니다. dart-define 값은 컴파일된 바이너리에 구워 들어가므로, 비밀스러운 것은 런타임 설정이나 시크릿 매니저에 속할 뿐, 그곳에는 속하지 않습니다.
스트림: 한 청크씩
인코딩된 텍스트가 조각으로 도착할 때 - 네트워크 스트림, 블록으로 읽는 큰 파일 - 디코더는 잘 해냅니다. 그 상태 머신은 부분 그룹을 청크 경계를 넘어 싣고 가므로, 청크는 4자 경계에 맞출 필요가 없습니다:
import 'dart:convert';
Future<void> main() async {
final incoming = Stream.fromIterable(['TWF', 'uaGVsbG8=']);
final text = await incoming
.transform(base64.decoder)
.map(utf8.decode)
.join();
print(text); // Manhello
}
transform 호출은 디코더를 스트림 변환기로 씁니다. 3자인 첫 청크는 그 비트들을 디코더의 상태에 잠시 주차하고, 두 번째 청크가 그룹을 완성합니다. 오류는 같은 FormatException 상세 내용과 함께 스트림 오류로 나타나고, 빈 스트림은 그저 출력을 생산하지 않습니다. 싱크를 선호한다면, base64.decoder.startChunkedConversion은 같은 상태 머신에 연결된 StringConversionSink를 줍니다.
빅데이터: 계산과 메모리
디코딩은 줄어듭니다. 문자 4개가 바이트 3개가 되므로, 출력은 언제나 입력 길이의 3분의 4보다 약간 짧습니다. 이것은 디코딩 전에 출력 크기를 알 수 있다는 뜻이고, 그래서 메모리가 예측 가능해집니다. 작은 헬퍼가 문자열만으로 그것을 계산합니다:
import 'dart:convert';
int decodedLength(String encoded) {
var padding = 0;
for (var i = encoded.length - 1; i >= 0 && padding < 2; i--) {
if (encoded.codeUnitAt(i) == 0x3d) {
padding++;
} else {
break;
}
}
return (encoded.length ~/ 4) * 3 - padding;
}
void main() {
print(decodedLength('QQ==')); // 1
print(decodedLength('QUI=')); // 2
print(decodedLength('QUJD')); // 3
}
내장 디코더는 빠릅니다. 문자당 문자열 할당 없이 룩업 테이블을 한 번 지나가므로, 수메가바이트짜리 문자열도 일상입니다. base64가 비용을 청구하는 곳은 입력 쪽입니다. 인코딩된 텍스트는 데이터보다 약 33퍼센트 크고, 그것 또한 문자열이라서 VM 위에서는 UTF-16 코드 유니트로 살아갑니다. 인코딩된 문자들의 바이트 길이의 약 두 배죠. 크게 자라날 수 있는 페이로드는, 한 개의 큰 문자열로 합치기보다 디코딩을 스트림하세요.
명령줄에서
Dart의 VM은 디코더로 깔끔한 CLI를 만들어 줍니다. 이 작은 도구는 파일 인자나 표준 입력을 읽고, 공백을 편평하게 만들고, 생바이트를 표준 출력에 씁니다:
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
String encoded;
if (args.isNotEmpty) {
encoded = await File(args[0]).readAsString();
} else {
encoded = await stdin
.transform(utf8.decoder)
.join();
}
final flat = encoded.replaceAll(RegExp(r'\s+'), '');
stdout.add(base64Decode(flat));
await stdout.flush();
}
bin/decode.dart로 저장해 dart run bin/decode.dart image.b64 > image.png로 실행하거나, 파이프하세요: cat token.b64 | dart run bin/decode.dart. stdout.add 호출은 Uint8List를 직접 받아들여 중간 문자열이 없는데, 이것이 바로 바이너리가 파이프라인을 통해 이동해야 하는 방식입니다.
Dart 개발자를 무는 함정들
- 패딩의 벽. JWT 스타일과 URL 도구의 입력은
=기호 없이 오는 경우가 많고, 디코더는Invalid length, must be multiple of four로 그것을 거부합니다. 신뢰할 수 없는 입력은 먼저base64Url.normalize를 통과시키세요. - 공백의 함정. 텍스트 파일, 이메일, 복사-붙여넣기 모두 줄바꿈을 들여보내며, 디코더는 그것들을 절대 건너뛰지 않습니다. 디코딩 전에
replaceAll(RegExp(r'\s+'), '')로 편평하게 만드세요. - 알파벳에 대한 확신. 두 알파벳이 어디서나 디코딩되므로, 어떤 디코더가 문자열을 만들었는지에 의존하는 로직을 만들지 마세요. 계약은 문자열이지, 생산자의 설정이 아닙니다.
- String.fromCharCodes는 문자 집합이 아니다. UTF-16 코드 유니트를 읽으므로, UTF-8 텍스트를 모지바케로 만듭니다.
utf8.decode나 명시적인 인코딩을 사용하세요. - 다른 두 오류 유형. 디코딩 문제는
FormatException이며, 인코더는 0에서 255 범위를 벗어난 값에 대해ArgumentError를 던집니다. 경계를 지으려면 그것들을 따로 잡으세요. - 결과는 고정 길이.
Uint8List는 자라지 않으므로,bytes.add(1)은UnsupportedError를 던집니다. 자라날 수 있는 리스트가 필요하면List<int>.from(bytes)로 복사하세요. - %3D를 손수 이스케이프 해제하지 마세요. 디코더는 퍼센트 이스케이프된 패딩을 네이티브로 읽습니다. 성급한
replaceAll('%3D', '=')는 여러분의 코드를 SDK가 이미 관리하는 세부 사항에 묶어 둡니다. - JWT 페이로드를 디코딩하는 것은 그것을 검증하는 것이 아니다. 클레임을 읽고 그걸 신뢰하는 것은, 결심한 사용자를 기다리는 보안 버그입니다.
모범 사례, 짧은 목록
base64Decode를 기본으로 쓰세요.normalize는 입력이 신뢰할 수 없는 경계에서만 사용하세요.- UTF-8이라고 가정하더라도,
utf8.decode(bytes)로 문자 집합을 명시하세요. - 바이트가 무엇인지 알기 전까지 바이트로 두세요.
Uint8List는File.writeAsBytes와 그 친구들에게 깨끗이 이동합니다. - 신뢰 경계에서는
FormatException을 잡아, 메시지가 알려주는 입력 위치를 기록하세요. - 수메가바이트를 넘어설 수 있는 것은 전부 스트림하세요.
- base64를 보호가 아니라 포맷으로 대하세요. 그것이 base64인 것을 아는 누구에게도, 그것은 아무것도 숨기지 않습니다.
Dart에서 Base64, 짧은 역사
방금 만난 디코더는 Dart 3, 널 세이프티, 그리고 Flutter 시대의 등장보다 오래되었습니다. 짧은 버전:
- 2015년 11월 18일, Dart 1.13: Base64가
dart:convert에BASE64상수와Base64Codec,Base64Encoder,Base64Decoder클래스를 몰고 옵니다. 이 릴리스 이전, SDK에는 base64가 아예 없었습니다. - 2016년 1월 28일, Dart 1.14:
Base64Decoder.convert가start와end범위 매개변수를 얻고, 같은 릴리스가dart:core에 data URI 지원을 더합니다. 이 글이 의지하는Uri.parse경로입니다. - 2016년 4월 26일, Dart 1.16: URL 안전 알파벳이
BASE64URL과Base64Codec.urlSafe생성자를 달고 합류합니다. - 2018년 8월 7일, Dart 2.0: 상수들이 소문자
base64와base64Url으로 이름이 바뀌고, 탑레벨base64Decode와 친구들이 도착하며, 디코딩은Uint8List를 반환하게 되어 더 이상 자라날 수 있는List<int>는 아니게 되며,Base64Codec.normalize가 가문에 합류해 검증과 수리를 한 번 호출 단계로 만들어 줍니다. - 2021, Dart 2.12: 널 세이프티가 출고되고,
dart:convert이야기 전체, base64를 포함해, 널 세이프가 됩니다. - 오늘, Dart 3.13: 클래스들은
final로 표시되고, 위에서 만난 동작은 2015년부터 가동되어 온 그 엄격한, 두 알파벳을 읽는, 퍼센트를 아는 기계 그대로입니다.
엄격함은 구현의 우연이 아닙니다. 그것은 RFC 4648의 지시, 즉 알파벳 밖의 문자를 구현이 거부해야 한다는 것을 디코더가 따르는 것입니다. MIME 스타일의 관대함은 그것을 필요로 하는 응용 프로그램에 맡겨져 있고, Dart에서는 그것은 디코딩 전의 편평화 단계를 의미합니다.
재미있는 사실들
- 디코더는
%3D를 네이티브 패딩으로 읽습니다. 이스케이프를 포함해 data URI의 생 페이로드를 그대로 건네면, 디코딩됩니다. 전처리 단계 없이 그렇게 하는 언어 런타임은 매우 적습니다. base64.decoder와base64Url.decoder는 글자 그대로 같은 객체입니다. 둘 다 정규화된const Base64Decoder()인스턴스입니다. "URL 안전 디코더"는 다른 옷을 입은 표준 디코더입니다.- 디코더 전체는 128개 항목의 룩업 테이블 하나에 들어갑니다.
Int8List는 인터프리터와 AOT 컴파일된 코드 사이에 공유되며,+와-는 모두 알파벳 슬롯 62를,/와_는 모두 63을 가리킵니다. - Dart의 base64와 그 data URI 지원은 1.13과 1.14, 릴리스 두 개 차이로 들어왔고, 분명히 한 쌍으로 계획되었습니다. 하나는 포맷을 읽고, 하나는 URL에서 바로 그것을 읽기 위해서요.
- 빈 문자열은 오류 없이 빈
Uint8List로 디코딩되고, 빈 문자열은 빈 문자열로 인코딩됩니다. base64는 데이터의 부재를 완전히 유효한 메시지로 대합니다. - 2018년, Dart 2.0이 상수들을 이름 바꿀 때,
BASE64는base64가 되었습니다. SDK 전체의 소문자 상수 이름 이동의 일부로,ascii,json,utf8를 여러분에게 준 그 물결과 같은 것이죠.
이제 여러분은 온전한 디코더를 손에 넣었습니다. 무엇을 받아들이고, 무엇을 거부하는지, 손상된 입력을 어떻게 수리하는지, 그리고 JWT, data URI, 파일, 스트림, 이메일, 셸에서 그것과 어떻게 마주치는지. 그 교환의 반대 방향, 패딩 결정과 크기 계산까지 포함해 바이트를 받아 두 알파벳 중 하나를 만들어내는 일은 Base64 인코딩 가이드에서 자세히 다뤄지며, 이 가이드는 이 페이지의 끝에 링크되어 있습니다.
마지막 업데이트: 2026-09-08
관련 문서: Dart에서의 Base64 인코딩: 완전한 가이드