Base64 형식을 다루어야 하나요? 그러면 여러분에게 이 웹사이트가 딱 맞네요! 저희 웹사이트의 아주 편리한 온라인 도구를 사용하여 데이터를 인코딩하거나 디코딩해보세요.

Rust에서의 Base64 디코딩: 완전한 가이드

하나의 문자열이 당신의 Rust 프로그램에 내려앉습니다: 글자와 숫자, 간혹 보이는 더표와 슬래시, 그리고 끝자락에 매달려 있는 의심스러운 = 한두 개. 이것이 Base64이고, 이 가이드는 놀람 없이 원래 바이트를 되찾는 방법에 관한 것입니다. 이 사이트의 홈 페이지가 해당 형식을 끝까지 깊이 있게 다루므로, 여기서는 그 교환의 모양만 짚어 두겠습니다: 알파벳 문자 4개가 입력 바이트 3개를 나타내고, 뒤에 붙은 = 문자 한두 개가 진짜 데이터가 끝난 자리를 표시합니다. 디코딩은 그 교환을 거꾸로 실행하는 것이며, 아래 모든 내용은 그것을 의도적으로 해내는 데 대한 것입니다.

Rust를 대부분의 언어와 달리 만드는 유일한 반전은 이것입니다: 표준 라이브러리에 Base64가 아예 없다는 것. std에 숨어 있는 base64_decode()도 없고, 하나를 소환하는 use std::...도 없습니다. 생태계는 이름이 그저 base64인 크레이트 하나에 정착했고, 그 크레이트는 이제 구조를 떠받치는 기둥이 되었습니다: 0.23.1 버전은 2026년 8월 4일에 출시되었고, 2015년 12월 첫 출시 이후 45개 버전을 공개했으며, 다운로드 카운터는 15억에 근접해 있습니다. 당신은 이미 거의 확실하게 이 크레이트를 통해 Base64를 디코딩하고 있을 것입니다: 직접 쓰거나, jsonwebtoken, pem, serde_with 같은 것들에 의해 끌려 들어오는 형태로요, 이 셋은 모두 이 크레이트에 의존합니다.

그 하나의 크레이트와 그 원

Rust 자체가 아직 머신에 없다면, 운영체제가 그것을 함께 나눠 줍니다: Debian과 Ubuntu에는 rustc와 cargo, macOS와 Windows에는 패키지나 설치 프로그램, 그리고 rustup을 설정해 주는 공식 설치 프로그램:

# Debian / Ubuntu
sudo apt install rustc cargo
# 또는 rustup과 cargo를 설정해 주는 공식 설치 프로그램
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

그다음은 크레이트로, 어떤 cargo 프로젝트 안에서든 됩니다. 이 한 줄이 곧 설치 전부이며, 끌어오는 의존성은 정확히 0개입니다:

cargo new my-app
cd my-app
cargo add base64

빌드의 모양을 만드는 선택 피처가 세 개 있습니다. std는 기본으로 켜져 있으며, std::io 스트리밍 타입, 표준 Error 구현, 힙 할당을 활성화합니다. alloc는 완전한 표준 라이브러리 없이 임베디드 no_std 빌드를 위한 할당 API를 제공합니다. simd-unsafe는 기본으로 켜져 있으며, 조금 뒤 만나게 될 벡터화 엔진의 문을 여닫습니다. 최소 지원 Rust 버전은 1.71.0이므로, 최신이면 무엇이든 그것을 실행할 수 있습니다. 핵심 크레이트 주변에는 작은 전문가들의 원이 돌고 있습니다. 각자는 핵심이 의도적으로 당신에게 맡기는 경계를 하나씩 담당합니다:

크레이트 버전 (2026) 가져오는 것 언제 잡아야 할 때
base64ct 1.8 RustCrypto 프로젝트의 상수 시간 디코딩; 힙 API는 alloc 피처 뒤에 있습니다 디코딩하는 바이트가 키 자료처럼 타이밍을 통해 정보를 새길 수 있을 때
data-encoding 2.11 base32, hex 등 친구들과 함께 Base64, 관대한 MIME 변형과 슬라이스 수준의 인코딩/디코딩을 제공합니다 한 컴포넌트가 더럽고 줄 바꿈되어 있거나 여러 프로토콜이 섞인 입력을 파싱해야 할 때
base64-turbo 0.3 정점이 100 GiB/s를 넘는 더 새로운 코덱. AVX512, AVX2, NEON 커널과 안전한 스칼라 폴백을 갖추고 있습니다 처리량이 전부인 상황이고, 표준 엔진이 클록 사이클을 남길 때

이들 중 어느 것도 일상적인 일을 대신하지는 못합니다. Rust 프로그램의 압도적인 다수에 대해, base64 하나만으로 정확하고 완전한 답이 됩니다. 이 글의 나머지 부분은 디코딩 자체에는 그 한 개의 크레이트를 쓰고, 일이 base64보다 더 넓을 때만 원의 전문가들을 부릅니다.

당신의 바이트까지 3줄

디코딩 생활의 90%는 3줄 안에 들어갑니다. 정석 스모크 테스트는 유명한 TWFu 문자열을 씁니다:

use base64::prelude::*;
fn main() {
  let packed = "TWFu";
  let bytes = BASE64_STANDARD.decode(packed).expect("valid base64");
  println!("{}", String::from_utf8(bytes).expect("valid utf-8"));
}

출력은 Man이며, 그 작은 의식에서 눈여겨둘 것이 세 가지 있습니다. 첫째, decode()는 항상 Vec<u8>를 건네고, 절대 문자열을 주지 않습니다. 이것은 사고가 아니라 기능입니다: Base64는 문장, JPEG, 인증서까지 실어 나를 수 있고, 손에 든 것이 무엇인지 알기 전에 그 어느 것도 다르게 대우받아서는 안 되니까요. 둘째, 바이트에서 텍스트로 건너가는 도약은 String::from_utf8()를 거치는 별개의, 의도된 단계이며, 그 단계에 문자 집합 결정이 자리하고 있습니다. 셋째, prelude 모듈은 조용히 두 가지를 한꺼번에 건네줍니다: BASE64_STANDARD 엔진과, 당신이 그 메서드를 호출하는 Engine 트레잇. 명시적인 임포트를 선호한다면, use base64::engine::general_purpose::STANDARD;와 use base64::Engine;를 함께 쓰는 것이 바로 바로판이 붙은 동일한 문입니다.

그리고 결국 당신이 직접 인코딩한 것을 디코딩하는 날은 반드시 오므로, 두 방향이 일치한다는 것을 증명하는 라운드 트립을 보여 드립니다. 인코딩은 자매 사이트에서 자기만의 완전한 가이드를 갖습니다; 여기서는 테스트 데이터를 만들기 위해만 등장합니다:

use base64::prelude::*;
fn main() {
  let packed = BASE64_STANDARD.encode("Hello, world!");
  println!("{packed}");                             // SGVsbG8sIHdvcmxkIQ==
  let back = BASE64_STANDARD.decode(packed).unwrap();
  println!("{}", String::from_utf8(back).unwrap()); // Hello, world!
}

쓰는 모든 디코딩 경로의 스모크 테스트로 TWFu를 뒷주머니에 넣어 두세요: 그것이 TWFu를 Man으로 바꿔 놓는다면, 그 머신은 정직합니다.

거절하는 네 가지 방식

이것이 새벽 2시에 당신을 구해 주는 섹션입니다. 운영 환경의 문자열이 터졌을 때, 그 크레이트가 정확히 무엇에 불만인지 알고 싶으니까요. 좋은 소식: 그것은 시끄럽고 정확하게 불평합니다. DecodeError는 정확히 네 가지 변종을 갖고 있으며, 전형적인 범인 일가에 대해 각각이 어떻게 들리는지 보시죠. 모든 입력을 엄격한 표준 엔진으로 통과시킨 것입니다:

입력 무엇이 문제인지 정확한 오류
"SGVs bG8s" 공백이 끼어들었습니다 Invalid symbol 32, offset 4.
"SGVs\nbG8s" 줄 바꿈이 끼어들었습니다 Invalid symbol 10, offset 4.
"SG=VsbG8="" 문자열 한가운데에 패딩이 있습니다 Invalid symbol 61, offset 2.
"SGVsbG8sIHdvcmxkIQ==xx" 패딩 뒤에 쓰레기가 따라옵니다 Invalid symbol 61, offset 18.
"S" 심볼 하나로 바이트를 이룰 수 없습니다 Invalid input length: 1
"SGV" 뒤따라야 할 패딩 없이 심볼 세 개 Invalid padding
"SGVs$bG8="" $는 알파벳에 없습니다 Invalid symbol 36, offset 4.

Invalid symbol 메시지가 문제의 바이트 값과 그 위치(offset)를 모두 알려주니, 범행 현장에 바로 점프할 수 있습니다. InvalidLength 변종이 바로 까다로운 놈입니다: 0.22.0 버전 이후로는 유효 심볼의 수가 불가능할 때, 다시 말해 4의 배수보다 한 개 긴 길이일 때만 특별히 발화하며, 다른 잘못된 길이는 패딩 오류로 드러납니다. 각 실패를 다르게 다뤄야 하는 날을 위해, 여기 네 가지를 모두 다루는 완전한 매치를 두고 가겠습니다:

use base64::DecodeError;
use base64::prelude::*;
fn triage(dirty: &str) {
  match BASE64_STANDARD.decode(dirty) {
    Ok(_) => println!("{dirty:?} sailed through"),
    Err(DecodeError::InvalidByte(offset, byte)) =>
      println!("{dirty:?}: symbol {byte} at {offset} is not in the alphabet"),
    Err(DecodeError::InvalidLength(symbols)) =>
      println!("{dirty:?}: {symbols} valid symbols is impossible"),
    Err(DecodeError::InvalidLastSymbol { offset, .. }) =>
      println!("{dirty:?}: trailing bits at {offset} suggest truncation"),
    Err(DecodeError::InvalidPadding) =>
      println!("{dirty:?}: padding is wrong or missing"),
  }
}

이 의도적인 엄격함의 뿌리는 표준 자체에 있습니다. RFC 4648 제12절은 알파벳 밖의 문자가 밴드 아웃의 정보를 밀수하려는 은밀 채널로 악용되거나, 둔한 파서 속 버그를 찌르는 데 쓰일 수 있다고 경고하며, 디코더가 그것들을 거부하기를 권고합니다. MIME 사양이 유명한 예외로, 디코더가 떠돌아다니는 문자를 무시하도록 명시적으로 지시하는데, 아래 메일 섹션이 길들이는 방법을 보여 주는 입력의 모양이 바로 그것입니다.

패딩에 대한 세 가지 태도

세상 속의 모든 Base64 문자열은 패딩에 대해 침묵하는 약속을 합니다. 그리고 0.23 버전은 DecodePaddingMode 열거형을 통해 어떤 약속을 강제할지 당신이 고를 수 있게 해 줍니다. 모드는 세 가지이며, 동작의 차이는 외울 가치가 있습니다. Zm8의 점수판입니다. 이것은 =를 뺀 단어 fo입니다:

모드 "Zm8", 패딩 없음 "Zm8=", 패딩 있음 언제 쓸 때
RequireCanonical, 기본값 Err(Invalid padding) Ok([102, 111]) 당신이 직접 데이터를 만들고 소비할 때
Indifferent Ok([102, 111]) Ok([102, 111]) 혼합된 출처에서 데이터를 받을 때
RequireNone Ok([102, 111]) Err(Invalid padding) 패딩 없는 프로토콜을 돌릴 때
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD_PAD_INDIFFERENT};
use base64::engine::DecodePaddingMode;
use base64::prelude::*;
let strict = BASE64_STANDARD;  // RequireCanonical이 기본값입니다
let flexible = STANDARD_PAD_INDIFFERENT;
let bare = GeneralPurpose::new(
  &base64::alphabet::STANDARD,
  GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::RequireNone),
);
println!("{:?}", strict.decode("Zm8"));    // Err(Invalid padding)
println!("{:?}", flexible.decode("Zm8"));  // Ok([102, 111])
println!("{:?}", flexible.decode("Zm8=")); // Ok([102, 111])
println!("{:?}", bare.decode("Zm8="));     // Err(Invalid padding)

기본값은 표준을 따릅니다: RFC 4648 제3.2절은 주변 사양이 따로 말하지 않는 한, 구현은 인코딩된 데이터 끝에 적절한 패드 문자를 포함해야 한다고 하므로, 그대로 쓰이는 STANDARD 엔진은 그것들을 요구합니다. 그리고 이 선택은 문법 집착을 넘어 보안에도 중요합니다. 같은 데이터의 패딩 있는 표기와 패딩 없는 표기를 모두 받아들이면 Base64는 변형 가능해집니다: 같은 논리 페이로드가 두 가지 방식으로 쓸 수 있게 되고, 하나의 정준 표기를 전제로 하는 코드는 놀라게 될 수 있습니다. 2022년 논문 "실전에서의 Base64 가소성"(Chatzigiannis and Chalkias, ePrint 2022/361)는 실제 결과를 기록해 두고 있으며, 크레이트의 문서 자체가 그곳으로 링크합니다. 실용 규칙: 프로토콜마다 모드를 하나로 정하고, 생산자를 통제하지 못하는 모든 경계에서 엄격하세요.

마지막 심볼의 숨겨진 비트

여기는 모든 문자 검사를 통과해 살아남는 부패가 있습니다. 각 Base64 심볼은 6비트를 실고, 입력 바이트 3개(24비트)는 정확히 4개의 심볼이 됩니다. 입력이 1바이트나 2바이트뿐이면, 마지막 심볼에는 쓰이지 않는 비트가 남고, RFC는 분명히 말합니다: 부합하는 인코더는 그 유휴 비트를 0으로 설정해야 한다고. 버그가 있거나 악의적인 인코더는 대신 거기에 쓰레기를 남길 수 있고, 그 결과물은 알파벳 검사, 길이 검사, 패딩 검사를 모두 통과하면서도 조용히 부패한 꼬리를 싣고 있습니다. 엄격한 엔진은 당신을 지켜 줍니다. 의심스러운 비트까지 보여 주는 유독 자세한 오류와 함께:

use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
println!("{:?}", BASE64_STANDARD.decode("MT=="));
// Err(Invalid last symbol 0x54 ('T') at offset 1, decoded as 0b00010011.)
let lenient = GeneralPurpose::new(
  &base64::alphabet::STANDARD,
  GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
println!("{}", String::from_utf8_lossy(&lenient.decode("MT==").unwrap()));
// 1

오류 속의 그 0b00010011은 불법적인 상위 비트를 포함해 디코딩된 문제의 심볼 값이며, 0.23.0 버전이 바로 그 디테일은 그렇지 않으면 디버깅하기가 너무 어려워서 그것을 눈에 보이는 것으로 만들었습니다. 당신의 생산자들이 둔하다고 알면, with_decode_allow_trailing_bits(true)는 쓰레기를 거부하기 대신 삼켜 버립니다. 브라우저들은 반대 쪽에 베팅했습니다: JavaScript의 atob() 뒤에 있는 WHATWG의 forgiving-base64 알고리즘은 트레일링 비트에 대해 명시적으로 관대하고, Rust의 기본값은 감식관입니다. 당신은 어느 쪽의 식탁에 앉아 있는지 알아 두세요.

Base64url: 여행하는 알파벳

표준 Base64는 알파벳 슬롯의 마지막 두 자리를 +와 /에 씁니다. 바로 URL이 그것들을 원치 않는 자리입니다: 쿼리 문자열에서 더표는 공백을 뜻하고, 슬래시는 새로운 경로 세그먼트를 시작하며, 매달려 있는 =는 구분자로 읽힙니다. 그래서 RFC 4648 제5절은 URL과 파일명에 안전한 알파벳을 정의하는데, 두 트러블메이커를 -와 _로 바꾸고, 길이는 보통 복구 가능하므로 패딩도 보통 생략합니다. RFC는 심지어 이 인코딩을 표준 Base64와 동일하게 여겨서는 안 된다고 경고하며, 엔진 이름들도 이에 동의합니다:

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}");                            // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}");                         // [fb, ef, be]
// 표준 엔진은 같은 입력을 거부합니다.
// 대시가 그 알파벳에 아예 없기 때문이죠
println!("{:?}", base64::prelude::BASE64_STANDARD.decode("----"));
// Err(Invalid symbol 45, offset 0.)

이제 대부분의 개발자가 base64url을 만나게 되는 이유입니다: JSON Web Token. JWT는 점으로 이어진 base64url 세 부분이며, 그 안을 엿보는 일은 5줄짜리 일입니다:

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let parts: Vec<&str> = jwt.split('.').collect();
let header = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
println!("{header}");
println!("{payload}");
// {"alg":"HS256","typ":"JWT"}
// {"sub":"1234567890","name":"John Doe","iat":1516239022}

솔직한 면책 조항 두 가지. JWT를 디코딩하는 것은 엿보는 것이지 신뢰하는 것이 아닙니다: 세 번째 부분은 서명이고, 키와 대조해 확인할 때까지 아무것도 뜻하지 않으며, 그것을 확인하는 일은 jsonwebtoken 크레이트(2026년 기준 11 버전)의 일입니다. 11 버전에는 날카로운 모서리가 하나 있습니다: Cargo.toml에서 rust_crypto 또는 aws_lc_rs 피처 중 정확히 하나만 켜져 있지 않으면, 토큰에 서명하거나 검증하는 첫 번째 시도에서 panic하고, 그 Validation 빌더는 기본적으로 exp 클레임을 필수로 여깁니다. 그래서 다른 라이브러리를 위해 만든 토큰은 조정된 검증을 필요로 할 수 있습니다. 그리고 디코딩은 알파벳 선택이 실제로 물리는 곳입니다: URL-안전한 문자열을 BASE64_STANDARD에 주거나 그 반대라면 거절을 당합니다. -, _, 패딩 없음은 모두 다른 엔진에게는 무효이기 때문이죠. 매번, 엔진을 프로토콜과 일치시키세요.

바이트는 단어가 아니다

이 글의 모든 디코더는 의도적으로 바이트에서 멈춥니다. Rust에서는 대부분의 언어보다 이것이 더 쉽습니다. 잘못될 수 있는 숨겨진 문자 집합 단계가 없기 때문이죠. Base64는 바이트 형식입니다. 끝까지입니다. "그게 무슨 텍스트였지?"라는 질문은 당신이 답할 몫이며, 현대 웹의 기본 답은 UTF-8입니다. 표준 라이브러리 한 줄로 그것을 확인하면 됩니다:

use base64::prelude::*;
let packed = "Y2Fmw6k=";   // 악센트가 붙은 단어 cafe, 포장된 상태
let bytes = BASE64_STANDARD.decode(packed).unwrap();
match std::str::from_utf8(&bytes) {
  Ok(text) => println!("{text}"),
  Err(_) => eprintln!("not utf-8: {bytes:02x?}"),
}

멀티바이트의 평탄한 경로가 네트워크에서 만나게 될 모든 것을 커버합니다:

원문 텍스트 Base64 다시 디코딩됨
café Y2Fmw6k= 네
日本語 5pel5pys6Kqe 네
naïve résumé bmHDr3ZlIHLDqXN1bcOp 네
😀 8J+YgA== 네
π ≈ 3.14159 z4Ag4omIIDMuMTQxNTk= 네

페이로드가 아예 텍스트가 아니라면, 같은 코드가 단지 다른 결말을 갖게 됩니다. 여기 PNG 파일의 매직 넘버가 있습니다: 바이트 4개 89 50 4E 47와 그 뒤를 따르는 CRLF 쌍:

use base64::prelude::*;
let packed = "iVBORw0KGgo=";
let bytes = BASE64_STANDARD.decode(packed).unwrap();
println!("{bytes:02x?}");                          // [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
assert!(std::str::from_utf8(&bytes).is_err());
std::fs::write("sprite.png", &bytes).unwrap();     // 나가는 것은 바이트이지 텍스트가 아님

준칙은 짧습니다: UTF-8이라고 가정하고, std::str::from_utf8()로 검증하고, 실패한 것은 무엇이든 fs::write, 데이터베이스 블롭, 아니면 그것이 왔던 어떤 싱크를 위한 바이트 페이로드로 대하세요. 진짜 문자 집합을 손볼 한 번의 경우는, 결코 이주하지 못한 레거시 데이터입니다. encoding_rs 크레이트(0.8 버전)는 옛 인코딩의 이름을 알고 변환해 줍니다:

use base64::prelude::*;
use encoding_rs::Encoding;
let packed = "Y2Fm6Q==";   // Latin-1 바이트에서 포장된, 악센트가 붙은 cafe
let bytes = BASE64_STANDARD.decode(packed).unwrap();
let (text, _, _) = Encoding::for_label(b"windows-1252").unwrap().decode(&bytes);
println!("{text}");   // UTF-8로 된, 악센트가 붙은 cafe

base64 크레이트 안에는 잘못 설정할 "Latin-1로 디코딩" 모드가 없습니다. 그것이 당신을 위해 추측을 하지 않으니까요. 당신이 지켜야 할 규율이 바로 그것입니다: 크레이트가 바이트를 주면, 그것이 무슨 뜻이든 당신이 정합니다.

메일을 살아남은 입력

메일 시스템을 살아남은 Base64는 줄 바꿈을 싣고 있습니다: MIME은 라인당 76자에서 줄을 감고(PEM 블록은 64자), MIME 사양은 준수하는 디코더에게 알파벳 밖의 문자를 무시하도록 명시적으로 지시하는데, 줄 바꿈도 포함됩니다. 우리의 엔진은 MIME 준수의 정반대입니다: 첫 줄 바꿈조차 거부하고, 스트리밍 리더는 같은 정확한 DecodeError를 감싼 I/O 오류로 그 거절을 보고합니다:

use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
let wrapped_in = "SGVs\nbG8s";
let mut reader = DecoderReader::new(wrapped_in.as_bytes(), &BASE64_STANDARD);
let mut out = Vec::new();
println!("{:?}", reader.read_to_end(&mut out).map(|_| out));
// Err(Custom { kind: InvalidData, error: Invalid symbol 10, offset 4. })

두 위치 모두 같은 RFC로 거슬러 올라갑니다. RFC는 그 선택을 주변 사양에 맡겨 두고, base64 크레이트는 엄격한 쪽을 고랐습니다. 그것이 마음 바꾼 것이 처음이 아닙니다: 0.5.0 버전은 내장된 MIME 줄 감기와 공백 처리를 실었고, 0.10.0 버전은 둘 다 제거했는데, 그 근거는 일반적인 라이브러리로서 줄 감기는 의견이 너무 강하고 no_std 이야기를 복잡하게 만든다는 것이었습니다. 그래서 현실의 줄이 감긴 입력에 대한 레시피는 크레이트 문서가 스스로 제안하는 것과 같습니다: 먼저 알파벳 밖의 문자를 제거하고, 그다음 디코딩합니다. 메모리 안의 문자열이라면 한 개의 필터입니다:

use base64::prelude::*;
fn strip_non_b64(input: &[u8]) -> Vec<u8> {
  input.iter().copied().filter(|b| !b" \n\r\t\x0b\x0c".contains(b)).collect()
}
fn main() {
  let wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
  let clean = strip_non_b64(wrapped.as_bytes());
  let bytes = BASE64_STANDARD.decode(clean).unwrap();
  println!("{}", String::from_utf8_lossy(&bytes));   // Hello, standard
}

줄 바꿈을 그냥 뚫고 보는 디코더를 원한다면, data-encoding 크레이트의 BASE64_MIME_PERMISSIVE 상수가 바로 그것입니다: 한 줄도 건드리지 않은 채 "SGVsbG8s\r\nd29ybGQh\r\n"를 Hello,world!로 디코딩합니다. 전체 입력을 한꺼번에 유지할 수 없는 스트림의 경우, 크레이트의 FAQ는 바이트 스트림을 필터링하기 위해 iter_read 크레이트를, 아니면 원하지 않는 바이트가 도착하는 즉시 버리는 조그만 Read 래퍼를 직접 쓰는 것을 가리킵니다. 손으로 "관대한 디코더"를 만들기 전에 경고 하나: 알파벳 밖의 문자를 조용히 무시하는 것은 RFC 4648 제12절이 은밀 채널로 지목하는 바로 그 동작입니다. 그래서 기대하는 공백만 제거하고, 그 외의 모든 것은 거부하세요.

전체 메시지가 손에 있을 때, 메일 파서가 base64를 당신을 위해 해 줍니다. mail-parser 크레이트(0.11 버전)는 파싱하는 동안 모든 Content-Transfer-Encoding: base64 부분을 디코딩하므로, 첨부 파일은 이미 감김이 풀리고 디코딩된 raw 바이트로 돌아옵니다:

use mail_parser::MessageParser;
let email = br#"From: art@vandelay.com
To: jane@example.com
Subject: gift
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="festivus"

--festivus
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: base64

SGVsbG8gZnJvbSBlbWFpbA==
--festivus
Content-Type: image/gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment; filename="tiny.gif"

R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--festivus--
"#;
let message = MessageParser::default().parse(email).unwrap();
for part in message.attachments() {
  let name = part
    .headers()
    .iter()
    .find(|h| h.name().eq_ignore_ascii_case("content-disposition"))
    .and_then(|h| h.value().clone().unwrap_content_type()
      .attribute("filename").map(|n| n.to_string()));
  let bytes = part.contents().to_vec();
  println!("{name:?}: {} bytes", bytes.len());
}
// Some("tiny.gif"): 42 bytes

그 GIF 첨부 파일은 바이트 42개로 디코딩되며, 처음 네 바이트는 47 49 46 38, ASCII 글자 GIF8입니다. 당신은 Base64 코드를 한 줄도 쓰지 않았고, 그것이 바로 파서를 쓰는 요점입니다: 인코딩의 세부 사항은 라이브러리의 문제입니다.

큰 페이로드

문자열은 쉽습니다. 파일은 Base64가 자기 값을 찾는 곳이며, 크레이트는 Rust 나머지 io와 같은 스트리밍 철학으로 답합니다. read::DecoderReader는 어떤 리더든 감싸고, 당신이 읽는 동안 디코딩된 바이트를 투명하게 건네 줍니다. 그래서 기가바이트 규모의 인코딩 파일은 절대 메모리에 들어맞을 필요가 없습니다. 아래 예제를 위해, 문자열 dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==을 fox.b64라는 이름의 일반 텍스트 파일로 저장하세요:

use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn main() {
  let packed = std::fs::read("fox.b64").unwrap();
  let mut decoder = DecoderReader::new(&packed[..], &BASE64_STANDARD);
  let mut plain = Vec::new();
  decoder.read_to_end(&mut plain).unwrap();
  println!("{}", String::from_utf8_lossy(&plain));
  // the quick brown fox jumps over the lazy dog
}

같은 아이디어는 io::copy로 한 줄로 줄어듭니다: 파일 주위에 DecoderReader를 만들고 아무 대상에도 복사하면, 디코딩은 그 길 위에서 일어납니다. 공식 문서의 멋진 트릭도 하나 있습니다. 페이로드를 상수 공간에서 검증하는 것인데, 정적으로 크기가 정해진 버퍼를 쓰고 디코딩된 데이터의 할당은 전혀 하지 않습니다:

use std::io::Cursor;
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn is_valid_base64(input: &str) -> bool {
  let mut cursor = Cursor::new(input.as_bytes());
  let mut decoder = DecoderReader::new(&mut cursor, &BASE64_STANDARD);
  let mut buf = [0u8; 128];
  loop {
    match decoder.read(&mut buf) {
      Ok(0) => return true,   // 오류 없이 끝까지 읽음
      Ok(_) => continue,
      Err(_) => return false, // base64가 아닌 것이 있었습니다
    }
  }
}
fn main() {
  println!("{}", is_valid_base64("SGVsbG8sIHdvcmxkIQ=="));  // true
  println!("{}", is_valid_base64("dt=="));                  // false
}

크지만 여전히 당신이 관리하는 버퍼에 들어맞는 페이로드의 경우, 슬라이스 API가 놀람 제로 옵션입니다: base64::decoded_len_estimate(len)은 len개의 심볼에 대해 보수적인 최대 디코딩 크기를 주며, decode_slice()는 당신이 미리 할당해 둔 버퍼에 바로 써서, 정확히 몇 바이트를 썼는지 돌려줍니다:

use base64::prelude::*;
let packed = "SGVsbG8sIHdvcmxkIQ==";
let cap = base64::decoded_len_estimate(packed.len());  // 15, 보수적 최대값
let mut buf = vec![0u8; cap];
let written = BASE64_STANDARD.decode_slice(packed, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap());    // Hello, world!

버퍼가 너무 작으면 panic 대신 깔끔한 DecodeSliceError::OutputSliceTooSmall를 얻고, 설계대로 panic하는 decode_slice_unchecked() 변종도 있습니다. "너무 작음"이 프로그래머 오류인 장소, 즉 프로그램이 무너져 버리는 편이 어정쩡하게 어기적거리며 처리하는 편보다 나은 장소를 위해서요. 0.22.0 이후 슬라이스 검사는 당신에 유리하게 보수적입니다: 출력이 정말로 들어맞지 않을 때만 실패하므로, 크기가 정확히 맞은 버퍼가 작동합니다.

디코딩된 바이트가 가는 곳

Base64는 "우연한 문자열"이라는 표현이 시사하는 것보다 Rust 프로젝트에서 훨씬 더 자주 등장합니다:

  • API 응답과 웹훅: 페이로드 안에 이진 데이터나 중첩 JSON을 Base64 텍스트로 넣는 것, 고전적인 JSON으로의 파일 업로드 패턴.
  • JWT 검사: 헤더와 페이로드를 디코딩해 클레임을 엿보고, 실제로 의미를 가진 부분인 서명을 jsonwebtoken에게 맡기는 곳.
  • 메일 모양의 데이터: MIME 첨부 파일과 메일 시스템을 통과한 모든 것. 그래서 공백 섹션이 아예 존재하는 겁니다.
  • 인증서와 키의 PEM 블록, 모든 TLS 스택이 씹어 먹는 -----BEGIN CERTIFICATE----- 섹션; pem 크레이트가 당신을 위해 그것들을 파싱하고, 알맞게도 내부적으로는 base64 크레이트 위에 지어졌습니다.
  • 스크래핑하거나 렌더링 중인 HTML과 CSS 안에 숨은 Data URI, data:image/png;base64,... 종류.
  • HTTP Basic 인증 헤더, Basic TWFuOnBhc3M=가 바로 위장을 한 Man:pass인 곳.
  • 데이터베이스와 설정 파일, 누군가가 텍스트 열이나 환경 변수 안에 이진 데이터를 넣고 싶었던 곳.
  • 언어 간 인계: Python 서비스가 블롭을 포장하고, Rust가 그것을 풀어내며, 정의상 양쪽은 같은 알파벳을 말합니다.

JSON의 경우, 알아둘 가치가 있는 단축키가 있습니다: serde_with 크레이트(3 버전)는 struct 필드에 주석을 달아 serde가 양방향으로 Base64를 처리하게 할 수 있습니다. 나가는 길에 Vec<u8> 필드를 텍스트로 인코딩하고, 들어오는 길에 다시 디코딩하며, URL-안전한 변종은 매개변수 하나 거리에 있습니다:

use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Config {
  #[serde_as(as = "serde_with::base64::Base64")]
  blob: Vec<u8>,
}
let cfg = Config { blob: b"stored in a database".to_vec() };
let json = serde_json::to_string(&cfg).unwrap();
// {"blob":"c3RvcmVkIGluIGEgZGF0YWJhc2U="}
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);

Data URI는 두 단계의 문자열 조작 뒤에 평범한 디코딩이 따라옵니다: 쉼표를 찾고, 그 뒤에 있는 것을 유지하고, 메타데이터가 단어 base64로 끝나는지 확인합니다:

use base64::prelude::*;
let uri = "data:image/png;base64,iVBORw0KGgo=";
let comma = uri.find(',').unwrap();
let meta = &uri[..comma];
let payload = &uri[comma + 1..];
let is_b64 = meta.rsplit(';').next().unwrap() == "base64";
let bytes = BASE64_STANDARD.decode(payload).unwrap();
println!("{is_b64}: {} bytes from {meta}", bytes.len());
// true: 8 bytes from data:image/png;base64

그리고 모든 것을 지배하는 단 하나의 규칙, 여전히 사람들을 그 행위에다 붙잡기 때문에 반복할 가치가 있는 규칙: Base64는 포장용 테이프이지 자물쇠가 아닙니다. 그것은 암호화가 아니고 압축도 아닙니다 - 압축의 반대입니다 - 그리고 이 글을 가진 누구든 그것이 한 모든 일을 거꾸로 돌릴 수 있습니다. 자유롭게 디코딩하고, 선택적으로만 신뢰하세요.

조심스러운 걸음을 걷은 10년

크레이트의 역사 자체는 나사가 천천히 죄여 들어가는 것처럼 읽힙니다. 2015년 12월에 crates.io에서 처음 모습을 드러냈고, 0.5.0 버전은 자부심 어린 MIME 지원을 더했습니다. 설정 가능한 줄 끝과 줄 감기와 함께요. 그리고 2018년의 0.10.0 버전은 줄 감기와 공백 처리를 제거했는데, 범용 크레이트는 디코딩을 하고 시는 애플리케이션 계층에 맡겨야 한다는 판단에서였습니다; 같은 릴리스는 스트리밍 인코더와 무효한 트레일링 심볼의 탐지를 더했습니다. 2022년의 0.20.0 버전은 엔진 추상화를 도입하고 패딩 기본값을 뒤집어, 그대로 쓰이는 엔진이 정준 패딩을 요구하게 했습니다; 0.21.0 버전은 base64::decode() 같은 옛 자유 함수를 비추천 처리하고 엔진 메서드를 선호했는데, 컴파일러 노트는 "Engine::decode를 사용하세요"였습니다 (여전히 작동합니다. 그래서 많은 레거시 코드가 쾌활하게 컴파일되는 겁니다). 2024년, 0.22.0 버전은 오류 의미를 또렷이 하고, InvalidLength가 무엇을 뜻하는지 다듬고, 디코딩을 5에서 10퍼센트 빠르게 했습니다. 그리고 2026년 7월, 0.23.0 버전은 SIMD 엔진, 커스텀 패딩 심볼, 더 선명한 InvalidLastSymbol 메시지, MSRV의 1.71 상향과 함께 도착했고, 8월 4일의 0.23.1 패치는 non-SIMD 아키텍처의 테스트 스위트를 고쳤습니다. 작고 신중한 걸음의 10년, "이건 그냥 base64다. 더 뭘 원할 수 있다는 건가?"로 출발한 크레이트가 이제 벡터화 커널을 실어 보내고 있습니다.

이 형식은 웹보다 오래되었고, 그래서 그 엄격함이 개인적으로 느껴집니다. 1987년, Privacy-Enhanced Mail 프로토콜(RFC 989)은 7비트 메일 채널로 이진 데이터를 실어야 했으며, 64자 라인의 인코딩으로 이것을 표준화했습니다; 당신의 TLS 스택이 신뢰해 온 모든 -----BEGIN CERTIFICATE----- 블록은 그 결정의 직접적인 후손입니다. 1996년 MIME 사양(RFC 2045)은 그 방식을 받아들여, 64자 알파벳에서 따서 "base64"라고 이름 붙이고, 오늘도 당신의 메일 첨부 파일을 줄 감는 76자 라인 길이를 정했습니다. 2006년, RFC 4648은 모두가 인용하는 표준이 되었습니다: 알파벳 표, 제5절의 base64url 변종, 그리고 이 크레이트가 그런 뚜렷한 즐거움으로 구현하는 엄격함 규칙들.

재미있는 사실들

완전한 가이드는 미소로 끝맺어야 하니까요:

  • 단어 "base64"는 YmFzZTY0로 인코딩됩니다. 자기 자신을 설명하는 형식은, 기술적으로 모스 부호로 말하는 거울과 같은 것입니다.
  • 빈 문자열은 0바이트로 디코딩되지만, "AA=="는 하나의 바이트, NUL로 디코딩됩니다. Base64에서 "아무것도 아닌 것"과 "영"은 다른 존재입니다.
  • 당신이 본 모든 Base64 인코딩 PNG는 iVBORw0K로 시작합니다. 그것은 위장한 PNG 매직 넘버이며, 인터넷에서 가장 알아볼 수 있는 접두사 중 하나입니다.
  • YouTube 영상 ID는 패딩 없는 base64url입니다: 8바이트 값은 Base64 문자 12개를 주고, 끝 패딩을 떼면 URL 어디에나 붙여 넣을 수 있는 익숙한 11자 ID가 남습니다. 인터넷 전체에서 패딩 없음 모드의 가장 눈에 띄는 용도 중 하나입니다.
  • Bash는 수년째 64진수로 세어 왔습니다: $((64#...)) 산술 리터럴은 숫자를 0-9, a-z, A-Z 순서로 받고, 값 62와 63에는 마침내 @와 _를 쓰므로, 당신의 쉘은 64자 알파벳을 노골적인 채로 싣고 다닙니다.
  • 옛 crypt(3) 비밀번호 해시는 알파벳이 ./로 시작하는 Base64 변종을 썼고, 사랑스러운 성질이 하나 있습니다: 인코딩된 문자열을 정렬하면 원래 바이트를 정렬한 것과 같은 순서가 나옵니다. 계보 파일은 임베드 멀티미디어에 같은 알파벳을 썼고(GEDCOM 5.5; 5.5.1 개정판은 그것을 제거했으며), base64 크레이트는 그것을 alphabet::CRYPT로 싣고 있습니다.
  • MIME 수학, 옛 준칙이 여전히 계산하는 그대로: 줄이 감긴 메일 페이로드는 원래 크기의 약 1.37배를 먹으며, 헤더로 몇백 바이트 남짓이 더 붙습니다. 1990년대 메일 인프라는 정말로 모든 첨부 파일에 그 통행료를 받은 겁니다.
  • 크레이트 전체가 #![forbid(unsafe_code)]인데, 기본 켜져 있는 simd-unsafe 피처만 예외입니다. 그것에 들어가기를 고르는 것이 아니라 빠져나가기로 고르는 피처죠. "unsafe"라는 단어 하나, 그것이자 피처 플래그의 이름입니다.
  • Base64는 보안 연구자들이 움찔하게 할 정도로 변형되기 쉽습니다: 같은 바이트가 패딩이 있거나 없거나, 트레일링 비트에 쓰레기가 있거나로 쓸 수 있고, 관대한 디코더는 그것을 알아채지 못합니다. 2022년 논문이 현실의 결과를 증명했는데, 그래서 이 크레이트의 엄격한 기본값은 신중하게 느껴집니다.
  • Base64는 암호화가 아닙니다. 그랬다면 이 글의 어떤 예제 출력도 읽을 수 없었을 겁니다. 그것은 창가 좌석이지 금고가 아닙니다.

마무리

가까이 지내는 상대를 기준으로 엔진을 고르세요: 당신이 만들고 통제하는 모든 것에는 BASE64_STANDARD, 혼합 출처의 경계에는 STANDARD_PAD_INDIFFERENT, 토큰과 URL에는 URL_SAFE_NO_PAD, 프로토콜이 자기만의 규칙을 요구할 때는 손으로 만든 GeneralPurpose를 쓰세요. 네 가지 DecodeError 변종에게 정확한 불평을 하게 두고, 바이트를 텍스트라고 부르기로 전에 std::str::from_utf8()를 지나가게 하고, 큰 것들은 DecoderReader로 스트리밍하고, 기대하는 공백만 제거하고, 타이밍이 위협이 될 때는 base64ct를 잡으세요. 모든 것을 디코딩하되, 검증되는 것만 신뢰하세요. 그리고 어느 날 다른 방향으로 가야 한다면, 풀어내기 대신 바이트를 길에 쓸 문자열로 포장해야 한다면, 자매 글이 Rust에서의 인코딩을 다루며, 크기 계산부터 스트리밍의 마무리까지입니다.

마지막 업데이트: 2026-09-08

관련 문서: Rust에서의 Base64 인코딩: 완전한 가이드