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

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

글자와 숫자가 길게 늘어진 문자열, 가끔 +나 /가 끼어들고, 꼬리에는 =가 한두 개 붙어 있는. 바로 이 문자열이 API 응답, 설정 파일, 이메일 첨부 파일, 혹은 URL 한복판 어디에서나 모습을 드러냅니다. 당신은 그걸 단번에 알아보고, 이번엔 C로 원본 바이트를 되찾아야 하죠. Base64 디코딩의 일 전체가 바로 이것입니다: 알파벳 문자 4개가 들어가고 원본 바이트 3개가 나옵니다, 또 4개가 들어가고 3개가 나옵니다. = 표시가 진짜 데이터가 어디서 끝났는지 알려줄 때까지, 이 과정은 계속됩니다. 이 사이트의 홈 페이지는 포맷을 단계별로 풀어 주므로, 이 기사는 진짜 힘이 드는 부분에 에너지를 씁니다: 버퍼, 라이브러리, 그리고 그 사이에 웅크리고 있는 함정들.

첫 malloc 전에 알아 둘 것, 두 가지입니다. 첫째, 디코딩은 수축하는 방향입니다: 출력은 입력의 4분의 3 크기이므로, 디코더는 이미 손에 쥔 페이로드보다 더 큰 메모리를 쓸 일이 결코 없습니다. 둘째 - 그리고 여기가 오늘의 헤드라인입니다 - C는 Base64 디코더를 싣고 오지 않습니다. 이 언어의 표준 라이브러리는 Base64가 태어나기 훨씬 전에 얼어붙었고, 그 이후의 어떤 표준도 그 공백을 메우지 못했습니다. 그래서 Base64를 디코딩하는 C 프로그램은 모두 라이브러리에 기대고, 실무에서 중요한 것은 OpenSSL, Mbed TLS, APR-Util, GLib 네 곳입니다. 각각 성격이 다릅니다: 무엇을 봐 주는지, 오류를 어떻게 알리는지, 그리고 당신의 출력을 소리 없이 어떻게 만지는지가요. 당신의 디코더 성격을 알아야, C에서의 Base64 디코딩은 미스터리 버그의 공급원이 아니라 누워서도 쓸 수 있을 만큼 손에 익은 루틴이 됩니다.

도구상자: 바이트를 되찾는 네 가지 길

전반상을 한눈에 정리하면 이렇습니다. 네 곳 모두 표준 알파벳을 다룹니다. 차이는 엣지에 있고, 버그는 바로 그 엣지에서 나옵니다.

라이브러리 헤더 오류 모델 꼭 기억할 출력의 특이점
OpenSSL (libcrypto) <openssl/evp.h> 잘못된 입력에 -1 반환 원샷 디코더가 꼬리를 제로 패딩한다
Mbed TLS <mbedtls/base64.h> 반환 코드(-0x002C, -0x002A) 네 곳 중 가장 엄격한 입력 규칙
APR-Util <apr-1.0/apr_base64.h> 없음: 이상한 문자 첫 곳에서 중단 int 길이 API이므로 2 GB가 상한
GLib <glib.h> 진짜 실패일 때만 NULL 반환 뒹구는 쓰레기를 조용히 무시

설치는 배포판마다 패키지 이름 하나면 됩니다. OpenSSL은: Debian과 Ubuntu에서 libssl-dev, Fedora와 RHEL에서 openssl-devel, Arch에서 openssl, macOS에서는 brew install openssl입니다. Mbed TLS는: libmbedtls-dev(혹은 mbedtls)입니다. APR-Util은: libaprutil1-dev와 libapr1-dev가 함께 필요합니다. GLib은 glib2.0-dev입니다. 그리고 각각 -lcrypto, -lmbedcrypto, -laprutil-1, -lglib-2.0로 링크하면 됩니다. 어디를 고를까요? 이미 TLS나 해시를 위해 OpenSSL을 링크하고 있다면(대부분의 서버가 그렇습니다), OpenSSL을 쓰세요. 임베디드나 자원 제약이 큰 빌드라면, Mbed TLS가 작고 엄격한 일원입니다. Apache 생태계 안에 있다면, APR-Util은 이미 거기에 있습니다. 코드베이스가 GNOME이나 GTK 기반이라면, GLib이 모든 것을 한 런타임 안에 모아 줍니다.

OpenSSL: 자로로 갭을 채우는 디코더

OpenSSL은 Base64를 두 가지 맛으로 싣고 있습니다. 원샷 함수가 대부분의 코드에서 주인공입니다:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char out[16];
  const char *payload = "TWFuZQ==";
  int n = EVP_DecodeBlock(out, (const unsigned char *)payload,
                          (int)strlen(payload));
  if (n < 0) {
    printf("not base64\n");
    return 1;
  }
  printf("%d bytes\n", n);
  return 0;
}

Base64 문자 버퍼와 길이를 주면, 디코딩된 바이트를 out에 쓰면서 몇 바이트인지를 돌려줍니다. 앞쪽 공백은 다듬어 주고, 뒤쪽 공백과 줄바꿈도 다듬어 주며, 다듬고 난 뒤 4문자의 배수가 아니거나 알파벳 밖의 문자가 들어 있는 입력은 거절합니다. 이 정도까진 완벽하게 합리적인 계약입니다. 다만 한 가지 디테일이 있고, 그것이 조용히 한두 건이 넘는 데이터베이스 임포트를 망가뜨려 왔습니다: 반환 값이 진짜 데이터 길이가 아닙니다.

그 프로그램을 돌리면 4 bytes가 나옵니다... 아니, 잠깐만. TWFuZQ==는 4문자 그룹 두 개이므로, 함수는 6을 돌려주고, 버퍼에는 4d 61 6e 65 00 00가 들어 있습니다: 단어 "Mane"에 제로 바이트 두 개를 더한 것. OpenSSL의 원샷 디코더는 고정 양자 단위로 일합니다: 입력 문자 4개가 언제나 정확히 출력 바이트 3개를 낳는 구조입니다. 마지막 그룹이 진짜 바이트를 하나만 실어 왔다면, 나머지 두 자리는 제로로 채워지죠. 매뉴얼은 이것을 아주 담담한 한 문장으로 언급합니다("필요할 경우 출력이 0비트로 패딩됩니다"). 이 함수의 man 페이지 전체에서 가장 중요한 문장이 바로 그 한 문장입니다.

진짜 길이는 패딩에서 되찾고, 계산은 2줄이면 됩니다:

size_t real_length(const char *b64) {
  size_t len = strlen(b64);
  while (len > 0 && b64[len - 1] == '=') len--;
  return len * 3 / 4;
}

알파벳 문자를 세고, 뒤의 패드를 빼고, 3을 곱하고, 4로 나눕니다. TQ==(문자 M을 인코딩한 것)의 경우 (2 * 3) / 4 = 1, 즉 진짜 바이트 1개가 나옵니다 - 반면 EVP_DecodeBlock는 3을 보고하죠. (포인터, 길이) 쌍은 언제나 함께 다니다니, 디코딩된 데이터에 strlen을 쓰지 마세요. 되돌려 받은 바이트가 JPEG일 수 있고, 그 첫 바이트가 NUL일 수도 있거든요.

스트리밍 디코더: 멈출 때를 아는 디코더

그 외 모든 일에는 OpenSSL이 스트리밍 쌍을 제공합니다: EVP_DecodeUpdate에 EVP_DecodeFinal를 더한 것이죠. 호출 사이로 상태를 옮겨 주는 것은 컨텍스트 오브젝트입니다. 끝내지 못한 그룹의 1~3문자를 붙들고 있게 해주므로, 페이로드를 청크 단위로 밀어 넣을 수 있습니다. 중요한 행동은 이렇습니다: 공백(스페이스, 탭, 캐리지 리턴, 라인 피드)은 스트림 어디에 있어도 건너뜁니다. 알파벳 밖의 다른 어떤 문자거나, 데이터 한복판의 =가 있다면 즉시 -1을 돌려줍니다. 업데이트에서 0이 돌아오면 "패딩을 봤고, 더 이상의 것은 기대하지 않는다"는 뜻입니다. 그리고 부분 그룹이 아직 남아 있으면 EVP_DecodeFinal는 -1로 거절합니다. 공백을 빼고도 4의 배수가 아닌 길이는 유효한 페이로드가 아니기 때문이죠.

코드 앞에 버전 관련 주의 하나. 오래된 튜토리얼에 걸려 넘어지기 쉬우니까요: OpenSSL 3.x에서 컨텍스트 타입 EVP_ENCODE_CTX는 불투명이므로, 인터넷 코드에 많이 나도는 스택 패턴 EVP_ENCODE_CTX ctx;는 더 이상 컴파일되지 않습니다. 명시적으로 할당하고 해제하세요:

static int decode_b64(const unsigned char *in, int in_len,
                      unsigned char *out, int *out_len) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return -1;
  }
  *out_len = 0;
  EVP_DecodeInit(ctx);
  int r = EVP_DecodeUpdate(ctx, out, out_len, in, in_len);
  if (r < 0) {
    EVP_ENCODE_CTX_free(ctx);
    return -1;
  }
  int tail = 0;
  r = EVP_DecodeFinal(ctx, out + *out_len, &tail);
  EVP_ENCODE_CTX_free(ctx);
  if (r < 0) {
    return -1;
  }
  *out_len += tail;
  return 0;
}

출력 버퍼를 in_len * 3 / 4 + 3으로 크기만 잡으면 어떤 입력에든 안전한 호출이 됩니다. 줄바꿈이 그룹 한가운데 떨어지는 MIME 래핑 페이로드를 어떻게 처리하는지 지켜보세요:

const char *wrapped = "TWFu\nZQ==";
unsigned char out[16];
int out_len = 0;
if (decode_b64((const unsigned char *)wrapped,
    (int)strlen(wrapped), out, &out_len) != 0) {
  printf("invalid base64\n");
  return 1;
}
printf("%.*s\n", out_len, out); /* Mane */

줄바꿈은 사라지고, 네 바이트가 나옵니다. 아무도 입력을 미리 청소할 필요 없었죠. 원샷 함수와 비교해 보너스 차이 하나가 더 있습니다: 스트리밍 경로는 바이트를 정직하게 세는 것입니다. TQ==를 주면 정확히 한 바이트(4d)를 돌려줍니다. 제로 패딩도 없습니다. 패드 두 개는 출력 슬롯 3개 중 두 개가 처음부터 채워지지 않았다는 걸 알기 때문이죠. OpenSSL에서 믿을 만한 길이가 필요할 때가 언제든, 쓸 것은 바로 이 경로입니다.

Mbed TLS: 엄격함 그 자체

Mbed TLS(처음에는 PolarSSL이라는 이름으로 태어나, 지금은 ARM의 임베디드 스택 안에 싣고 다니는 암호 라이브러리)는 아주 깨끗한 계약의 두 함수를 줍니다:

int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen,
                          const unsigned char *src, size_t slen);
int mbedtls_base64_decode(unsigned char *dst, size_t dlen, size_t *olen,
                          const unsigned char *src, size_t slen);

신중하게 일하는 사람처럼 디코딩하세요. dst를 NULL(혹은 dlen을 0)로 두고 호출하면, 아무 일도 하지 않고 필요한 크기를 *olen에 알려 줍니다. 진짜로 호출하면 성공할 때 0이, 입력 어딘가가 잘못되면 MBEDTLS_ERR_BASE64_INVALID_CHARACTER(이는 -0x002C)가, 목적지가 너무 작으면 MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL(이는 -0x002A)가 돌아옵니다. 디코딩된 길이는 *olen에 들어오며, OpenSSL의 원샷 함수와 달리 언제나 정직한 숫자입니다: TQ==를 디코딩하면 한 바이트, 4d가 나옵니다. 그 이상도 그 이하도 아니죠.

입력 규칙은 네 라이브러리 중 가장 엄격한 것으로, Mbed TLS에서 "유효"가 무엇을 뜻하는지 정의하기 때문에 외워 두기 가치가 있습니다:

  • CRLF와 LF 줄바꿈은 그룹 사이 어디에나 올 수 있습니다: 이메일 페이로드는 그대로 동작합니다.
  • 스페이스는 줄바꿈 직전과 버퍼의 맨 끝에만 허용되며, 줄바꿈 직후나 그룹 한가운데의 스페이스는 오류입니다.
  • = 문자는 최대 두 개, 그리고 끝에만; 패드 뒤에 오는 데이터는 무조건 오류입니다.
  • 127을 넘는 바이트(센스, UTF-8 조각, 바이너리 쓰레기)는 전부 오류입니다.

가장 물리는 규칙이 바로 그 마지막입니다: 문자 인코딩을 망가뜨린 출처에서 페이로드가 도착하면, Mbed TLS는 더 느슨한 디코더가 어깨를 으쓱하며 받아 넘길 그것을 거절합니다. 신뢰할 수 없는 입력을 만지는 어떤 코드든, 엄격함은 기능입니다. 완전한 디코딩은 이렇게 생겼습니다:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
  const char *payload = "TWFuZQ==";
  size_t need = 0;
  int rc = mbedtls_base64_decode(NULL, 0, &need,
      (const unsigned char *)payload,
      strlen(payload));
  if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
    printf("size query failed: %d\n", rc);
    return 1;
  }
  unsigned char *out = malloc(need);
  size_t olen = 0;
  rc = mbedtls_base64_decode(out, need, &olen,
      (const unsigned char *)payload,
      strlen(payload));
  if (rc != 0) {
    printf("decode failed: %d\n", rc);
    free(out);
    return 1;
  }
  printf("%.*s\n", (int)olen, out);
  free(out);
  return 0;
}

(크기 조회가 "너무 작음" 코드를 돌려주는 것은 의도된 설계입니다: 함수가 실제로 쓸 내용을 보고하는 방식이 바로 이것이기 때문이죠. 위 두 반환 코드는 모두 <mbedtls/base64.h>에서 옵니다: 함수가 선언된 바로 그 헤더 말입니다.)

APR-Util과 GLib: 두 의자 더

APR-Util - Apache Portable Runtime의 유틸리티 라이브러리, Apache HTTP Server가 세워진 바로 그 기반 - 서버가 Basic 인증 헤더를 디코딩해야 할 필요가 있던 동안만큼 Base64를 싣고 왔습니다. API는 int 기반 함수들의 작은 가족입니다:

#include <apr-1.0/apr_base64.h>
int apr_base64_encode_len(int len);
int apr_base64_encode(char *coded_dst, const char *plain_src,
                      int len_plain_src);
int apr_base64_decode_len(const char *coded_src);
int apr_base64_decode(char *plain_dst, const char *coded_src);

손을 뻗기 전에 알아 둘 것 두 가지입니다. 첫째, 길이는 int입니다: 32비트가므로, 호출당 실질 상한은 2 GB입니다. 헤더나 설정 값에는 충분하지만, 4 GB 파일 디코딩에는 부족하죠. 둘째 - 그리고 이것이 큰 문제입니다 - 디코딩 함수에는 오류 반환이 아예 없습니다. 이 행동은 헤더가 아니라 구현에서만 보입니다: 디코더는 어떤 잘못된 문자든 - 공백과 NUL을 포함해 - 종료 신호로 삼습니다. 알아보는 첫 곳에서 멈추고, 어디까지 왔는지만 돌려주고, 아무 말도 하지 않습니다. 잘린 페이로드, 꼬리에 주석이 붙은 붙여넣기, 한가운데 깨진 바이트 - 이 모든 것이 조용히 짧은 출력을 낳습니다. APR의 디코더를 쓴다면, 돌려받은 길이를 페이로드가 약속한 길이와 직접 비교해야 합니다. 함수는 대신 해 주지 않습니다. 풀 할당 래퍼도 없습니다 - 목적지 버퍼는 당신이 제공해야 하므로, 풀 기반 코드에서는 plain_dst를 직접 풀에서 할당합니다. 이 기사 어디에서도 찾을 수 없는 EBCDIC 관점도 있습니다: EBCDIC 머신에서는 이 함수들이 인코딩 전에 입력을 ASCII로, 디코딩 후에 다시 원래 코드로 바꿔 주므로, 같은 코드가 아직 httpd를 돌리는 메인프레임에서도 동작합니다.

GTK와 대부분의 GNOME 앱 뒤의 런타임인 GLib은 정반대의 성격을 냅니다. 그 디코더는 문자열을 받아 언제나 새로 할당된 버퍼를 돌려줍니다(NULL 포인터를 넘길 때만 NULL이죠). 디코딩할 수 있는 것은 전부 디코딩하고, 나머지는 조용히 건너뜁니다:

#include <glib.h>
gsize out_len = 0;
guchar *bytes = g_base64_decode(payload, &out_len);
if (bytes == NULL) {
  printf("not base64\n");
} else {
  printf("%u bytes\n", (unsigned)out_len);
  g_free(bytes);
}

함정은 바로 "언제나"라는 말 안에 있습니다. GLib의 디코더는 관대주의 학교 소속입니다: 알파벳 밖의 문자는 치명적이지 않고 그냥 건너뜁니다. TWFuZ@==를 주면 "Man"의 세 바이트를 손도 안 움직이고 돌려줍니다. 편리한 인플레이스 변형 g_base64_decode_inplace()도 있습니다: 입력 버퍼 위에 덮어쓰듯 디코딩합니다(출력이 입력보다 짧으므로 안전합니다). 같은 포인터를 돌려주니, 결과는 버퍼의 맨 처음에서 시작합니다 - 메모리가 아픈 코드에 좋은 트릭이고, CRLF 래핑 입력도 기꺼이 먹습니다. C 개발자를 위한 교훈: 데이터가 신뢰할 수 없다면, GLib은 깨진 페이로드에서 당신을 구해 주지 않습니다. 단계적 디코딩이 필요하면 _step 변형(상태 정수를 다루는 g_base64_decode_step)이 준비되어 있고, 짝을 이룬 g_base64_encode_step/g_base64_encode_close 쌍은 인코딩 쪽에 있습니다.

URL-Safe Base64: 다른 알파벳

표준 알파벳과 당신의 URL 사이 어딘가에서, 누군가는 상처를 받았습니다. 표준 Base64는 +와 /를 두 개의 최고 심볼로 쓰는데, 둘 다 URL에서는 문제입니다: 쿼리 문자열의 +는 서버가 보기 전에 이미 스페이스로 해석되는 일이 일상이고, /는 경로 구분자입니다. RFC 4648 5절은 base64url이라 불리는 해결책을 정의합니다: 같은 인코딩에서 +를 -로, /를 _로 바꾼 뒤, 길이를 다른 방식으로 알 수 있다면 꼬리의 = 패딩을 빼버리는 것입니다. JSON Web Token, OAuth state 파라미터, 그리고 수많은 API 세션 ID가 이 변형 위에 살고 있습니다.

네 C 라이브러리 모두 base64url을 네이티브로 디코딩하지 못하므로, 이 변환은 한 번 써서 계속 재사용할 작은 헬퍼입니다: 두 특수 문자를 원래대로 되돌리고, 빠진 패딩을 다시 붙인 뒤, 결과를 표준 디코더에 넘기면 됩니다. 길이 검사부터 합니다: 4의 배수보다 1 많은 길이는 어떤 Base64 변형에서도 불가능하기 때문이죠:

int base64url_decode(const char *url_safe, unsigned char *out,
    size_t out_cap, size_t *out_len) {
  size_t len = strlen(url_safe);
  if (len % 4 == 1) {
    return -1;
  }
  size_t needed = (len * 3) / 4;
  if (needed > out_cap) {
    return -2;
  }
  char *std = malloc(len + 4);
  if (std == NULL) {
    return -3;
  }
  for (size_t i = 0; i < len; i++) {
    char c = url_safe[i];
    if (c == '-') c = '+';
    if (c == '_') c = '/';
    std[i] = c;
  }
  size_t pad = (4 - len % 4) % 4;
  for (size_t i = 0; i < pad; i++) {
    std[len + i] = '=';
  }
  int n = EVP_DecodeBlock(out, (const unsigned char *)std,
                          (int)(len + pad));
  free(std);
  if (n < 0) {
    return -1;
  }
  *out_len = needed;
  return 0;
}

이 길에는 함정이 두 개 서 있습니다. 첫 번째는 방향입니다: 문자 교환 없이 URL-safe 페이로드를 표준 디코더에 넣으면, OpenSSL과 Mbed TLS는 거절합니다(그 문자들은 그쪽 알파벳에 없으니까). GLib은 -와 _를 조용히 건너뛴 뒤, 당초보다 짧은 문자열을 돌려주면서 - 오류도 없이 말이죠. 언제나 헬퍼를 거치세요. 두 번째는 RFC의 자체 경고로, 진지하게 받아들이기 가치가 있습니다: base64url은 "base64 인코딩과 동일한 것으로 봐서는 안 된다"고 합니다. 만약 페이로드에 우연히 -나 _가 하나도 없다면, 두 변형은 그 데이터에 대해 바이트 단위로 동일해지고, 혼동을 눈으로 발견할 수 없습니다. 그리고 바로 그 때문에, 혼동은 그런 문자를 포함하는 페이로드를 만나기까지 살아남는 것입니다.

파일: 원본 복원하기

파일 모양의 일 중 가장 흔한 것은, 어떤 내보내기 루틴이 한 일의 역행입니다: .b64 텍스트 파일이 도착했고, 원본 파일을 되찾아야 합니다. 텍스트 전체를 읽고, 디코딩한 뒤, 어떤 라벨도 믿기 전에 바이트 스스로가 어떤 것인지 말하게 하세요. C에는 finfo가 없으므로, 현실적인 검사는 처음 몇 바이트에 대한 매직 넘버 스니핑입니다:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("upload.b64", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  char *text = malloc((size_t)size + 1);
  size_t got = fread(text, 1, (size_t)size, f);
  fclose(f);
  text[got] = '\0';
  unsigned char *out = malloc((got * 3) / 4 + 3);
  int out_len = 0;
  if (decode_b64((const unsigned char *)text, (int)got,
      out, &out_len) != 0) {
    printf("not valid base64\n");
    free(text);
    free(out);
    return 1;
  }
  free(text);
  const char *kind = "unknown binary";
  if (out_len >= 4 && memcmp(out, "\x89PNG", 4) == 0) kind = "png";
  else if (out_len >= 5 && memcmp(out, "%PDF-", 5) == 0) kind = "pdf";
  else if (out_len >= 4 && memcmp(out, "PK\x03\x04", 4) == 0) kind = "zip";
  else if (out_len >= 3 && memcmp(out, "\xff\xd8\xff", 3) == 0) kind = "jpeg";
  printf("looks like a %s, %d real bytes\n", kind, out_len);
  free(out);
  return 0;
}

엣지에 대한 노트: 텍스트 쪽이라 해도 파일을 바이너리 모드(rb/wb)로 여세요. 어떤 플랫폼에서는 텍스트 모드가 줄바꿈을 바꿔 버려 문자 수가 엉망이 되니까요. 그리고 "무엇인지 보고 싶다"고 해서 디코딩된 버퍼에 printf("%s")를 쓰는 일은 절대 하지 마세요. 그 질문에 정직하게 답하는 방법은 매직 스니핑입니다. 나중에 복원한 파일을 브라우저에 서빙한다면, Content-Type은 파일 이름이 아니라 같은 스니핑에서 나와야 합니다.

Data URI: URL 안에 숨겨진 이미지

웹 세계로부터의 인기 있는 도착자 하나: 누군가 폼에 이미지를 붙여 넣고, 프론트 엔드가 당신의 서버에게 data:image/png;base64,iVBORw0KGgo... 같은 완전한 data URI를 건네는 일이죠. RFC 2397이 모양을 정의합니다: data:, 선택적 미디어 타입, 선택적 ;base64 플래그, 쉼표, 그리고 페이로드. 플래그가 있으면 페이로드는 Base64이고, 없으면 percent-인코딩된 평문입니다 - 흔하지는 않지만 합법적이죠. 미디어 타입이 생략되면 기본값은 text/plain;charset=US-ASCII입니다. C에서 이를 파싱하는 일은 쉼표를 찾아 그 바로 앞에 무엇이 앉아 있는지를 보는 일입니다:

int split_data_uri(const char *uri, char *mime, size_t mime_cap,
    int *is_b64, const char **payload) {
  if (strncmp(uri, "data:", 5) != 0) {
    return -1;
  }
  const char *comma = strchr(uri, ',');
  if (comma == NULL) {
    return -1;
  }
  *is_b64 = 0;
  const char *meta = uri + 5;
  size_t meta_len = (size_t)(comma - meta);
  if (meta_len >= 7 && strncmp(comma - 7, ";base64", 7) == 0) {
    *is_b64 = 1;
    meta_len -= 7;
  }
  if (meta_len == 0) {
    snprintf(mime, mime_cap, "text/plain;charset=US-ASCII");
  } else {
    snprintf(mime, mime_cap, "%.*s", (int)meta_len, meta);
  }
  *payload = comma + 1;
  return 0;
}

호출 쪽은 문장처럼 읽힙니다:

char mime[256];
int is_b64 = 0;
const char *payload = NULL;
const char *uri = "data:image/png;base64,iVBORw0KGgo...";
if (split_data_uri(uri, mime, sizeof(mime), &is_b64, &payload) == 0) {
  printf("mime=%s base64=%d\n", mime, is_b64);
  /* 이제 원하는 라이브러리로 payload를 디코딩하세요 */
}

이 포맷에는 함정이 셋 살고 있습니다. 첫 번째는 빠진 ;base64 플래그입니다: 이 플래그 없이도 합법인 data URI는 percent-인코딩된 페이로드를 실고 있으며, 그것을 Base64 디코더에 넣으면 쓰레기가 나옵니다 - 플래그를 먼저 확인하고, 그때 디코더를 고르세요. 두 번째는 주장된 미디어 타입입니다: 이것은 발신자의 힌트일 뿐 사실이 아닙니다. 파일 섹션의 매직 넘버 스니핑이 당신의 사실입니다. 세 번째는 크기입니다: RFC의 자체 조언은 data URI가 짧은 값을 위한 것이라는데, 그래서 URL 안에 타고 있는 여러 메가바이트짜리 이미지는 축하할 패턴이 아니라 당신의 아키텍처에 핀 냄새입니다.

JWT: 비밀 아닌 부분 읽기

웹에서 가장 유명한 Base64 페이로드는 JSON Web Token이며, 모양을 알면 가장 무섭지 않은 것이기도 합니다. RFC 7519에 따르면, 컴팩트 JWT는 마침표로 이어진 base64url 세 부분입니다: 헤더, 페이로드, 서명 - 각각 패딩 없이, 줄바꿈 없이 인코딩됩니다. 앞의 두 부분은 그냥 JSON입니다. 그래서 누구나 읽을 수 있고, 그래서 누구나 토큰에 손을 대기 전에 먼저 읽어 두어야 하죠.

앞의 두 부분 읽기는 아까의 base64url 헬퍼 몇 줄이면 되고, 이것이 바로 토큰의 신비를 푸는 가장 빠른 길입니다:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int base64url_decode(const char *url_safe, unsigned char *out,
    size_t out_cap, size_t *out_len);
int main(void) {
  const char *token =
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
    "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
    "TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
  const char *dot1 = strchr(token, '.');
  if (dot1 == NULL) {
    return 1;
  }
  const char *part2 = dot1 + 1;
  const char *dot2 = strchr(part2, '.');
  if (dot2 == NULL) {
    return 1;
  }
  const char *part3 = dot2 + 1;
  char seg[512];
  char buf[1024];
  size_t n = 0;
  size_t hlen = (size_t)(dot1 - token);
  memcpy(seg, token, hlen);
  seg[hlen] = '\0';
  if (base64url_decode(seg, (unsigned char *)buf,
      sizeof(buf), &n) == 0) {
    printf("header:  %.*s\n", (int)n, buf);
  }
  size_t plen = (size_t)(dot2 - part2);
  memcpy(seg, part2, plen);
  seg[plen] = '\0';
  if (base64url_decode(seg, (unsigned char *)buf,
      sizeof(buf), &n) == 0) {
    printf("payload: %.*s\n", (int)n, buf);
  }
  printf("signature: %s (encoded, verify before trusting!)\n", part3);
  return 0;
}

찍어 보면, 헤더는 {"alg":"HS256","typ":"JWT"}, 페이로드는 {"sub":"1234567890","name":"John Doe"}입니다. 자, 중요한 부분입니다: 세 번째 부분은 서명이고, 당신이 방금 디코딩한 두 부분은 비밀도 아니고 인증된 것도 아닙니다. 패킷 캡처를 가진 누구나 그것을 읽고, 텍스트 에디터를 가진 누구나 다시 쓸 수 있습니다. C에서 서명을 검증하기 전에 JWT 페이로드를 믿는 것은 고전적인 인증 버그이고, Base64는 그것을 눈치채지 못하게 만듭니다 - 토큰은 깨지지 않는 덩어리처럼 보이면서 실상은 엽서니까요. HS256 토큰을 검증하려면, <openssl/hmac.h>의 HMAC()로 당신의 비밀열쇠를 써서 header.part에 대한 HMAC-SHA256을 다시 계산하고, CRYPTO_memcmp()로 상수 시간에 비교하세요. 다이제스트가 안 맞으면, 그 주장이 무엇이든 토큰은 거절됩니다. C에는 사실상 표준인 JWT 라이브러리가 없으므로, 프로덕션에서는 그 작은 검증 단계를 직접 짓거나 커뮤니티 라이브러리 중 하나를 채택해야 합니다 - 하지만 이 일의 Base64 쪽은 위 분할-디코딩 안무 그대로이고, 당신은 그것의 전부를 이해해야 합니다.

Basic 인증: 프라이버시를 배우지 못한 헤더

웹에서 가장 오래된 인증 헤더는 아직 Base64를 타고 다닙니다: Authorization: Basic 뒤에 username:password의 표준 알파벳 인코딩이 따라옵니다 (Basic 스킴에 대해 RFC 9110이 참고로 삼는 RFC 7617). RFC는 이것이 보호가 아니라 인코딩이라고 분명히 말합니다 - 패킷 캡처를 가진 누구나 한 명령으로 양쪽 절반 모두 디코딩할 수 있으니까요 - 그러므로 C에서 디코딩 쪽의 일은 헤더를 파싱하고, 엄격히 디코딩하고, 첫 번째 콜론에서 자르고(패스웨드에 합법적으로 콜론이 들어 있을 수 있습니다), 타이밍-안전 함수로 비교하는 것입니다:

#include <string.h>
#include <openssl/evp.h>
#include <openssl/crypto.h>
static size_t real_length(const char *b64);
int basic_auth_ok(const char *header, const char *expected_user,
                  const char *expected_pass) {
  if (strncmp(header, "Basic ", 6) != 0) {
    return 0;
  }
  const char *b64 = header + 6;
  unsigned char out[256];
  int n = EVP_DecodeBlock(out, (const unsigned char *)b64,
                          (int)strlen(b64));
  if (n < 0) {
    return 0;
  }
  size_t real = real_length(b64);
  size_t u_len = strlen(expected_user);
  size_t p_len = strlen(expected_pass);
  if (real != u_len + 1 + p_len) {
    return 0;
  }
  if (memcmp(out, expected_user, u_len) != 0) {
    return 0;
  }
  if (out[u_len] != ':') {
    return 0;
  }
  return CRYPTO_memcmp(out + u_len + 1, expected_pass, p_len) == 0;
}

길이 검사는 진짜 일을 합니다: 꼬리 쓰레기를 달고 "alice:secret"로 디코딩되는 페이로드, 또는 잘려서 "alice:secre"가 된 페이로드가 매칭되는 것을 막아 주죠. 그리고 CRYPTO_memcmp(타이밍 함의를 이해하고 있을 때만 memcmp)가 공격자로 하여금 타이밍으로 당신 사용자 목록을 헤집어 다니는 것을 막아 줍니다. 이 헤더는 HTTPS로 서빙하거나, 아니면 아예 서빙하지 마세요 - 평문 연결 위에서 Base64 레이어는 창문 장식일 뿐이니까요.

이메일과 PEM: 원래의 집

Base64는 아주 구체적인 문제 때문에 태어났습니다: 메일 운송은 7비트 ASCII만 실었고, 사람들은 바이너리를 그것으로 보내고 싶었으니까요. MIME (RFC 2045)은 Base64를 표준 전송 인코딩 중 하나로 만들면서 집 규칙 두 가지를 추가했습니다: 인코딩된 줄은 76문자를 넘어서는 안 되며, 디코딩 소프트웨어는 알파벳 밖의 문자를 무시해야 한다는 것 - 줄바꿈도 포함해서입니다. 바로 그 두 번째 규칙 때문에, 위 스트리밍 디코더들은 아무 사전 처리 없이 래핑된 첨부 파일을 씹어 삼킵니다. 그리고 76문자 습관이 지구상의 모든 메일 라이브러리에 아직 배어 있는 이유이기도 하죠. 조상은 64문자 줄을 쓰던 PEM(Privacy Enhanced Mail, RFC 1421)입니다 - 도구에서 보는 64/76 갈라짐은 바로 그 역사이며, 두 상한 모두 결국 SMTP가 내린 것입니다.

PEM 아머 - 키와 인증서가 이동하는 포맷 - 은 그냥 라벨을 단 Base64입니다: -----BEGIN ... ----- 줄, 64문자 줄로 된 본문, 그리고 매칭되는 END 줄. C에서 아머를 벗기는 것은 줄 스캔이고, 그다음은 디코더가 나머지를 합니다:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("server.key", "r");
  if (f == NULL) {
    return 1;
  }
  char line[256];
  char b64[8192];
  size_t pos = 0;
  int in_body = 0;
  while (fgets(line, sizeof(line), f) != NULL) {
    if (strncmp(line, "-----BEGIN", 10) == 0) {
      in_body = 1;
      continue;
    }
    if (strncmp(line, "-----END", 8) == 0) {
      in_body = 0;
      break;
    }
    if (in_body) {
      size_t l = strlen(line);
      while (l > 0 && (line[l - 1] == '\n' || line[l - 1] == '\r')) {
        l--;
      }
      memcpy(b64 + pos, line, l);
      pos += l;
    }
  }
  fclose(f);
  unsigned char der[8192];
  int out_len = 0;
  if (decode_b64((const unsigned char *)b64, (int)pos,
      der, &out_len) != 0) {
    printf("armor contained no valid base64\n");
    return 1;
  }
  printf("DER payload decoded\n");
  return 0;
}

디코딩된 바이트는 DER, 즉 컴팩트한 바이너리 직렬화이며, OpenSSL의 인증서와 키 함수가 결국 먹는 것이 바로 그것입니다. 노트 두 가지: 본문은 줄바꿈 없이 모아야(루프가 하는 것처럼) 길이가 4의 배수가 됩니다. 그리고 파일이 여러 블록을 실고 있다면, 연 BEGIN 라벨에 END 라벨이 매칭되게 하세요 - 여기처럼 첫 블록만 원한다면 단순한 플래그면 충분합니다.

시크릿, 설정, 데이터베이스 컬럼

Base64는 텍스트 컨테이너입니다. 그래서 당신이 예상하지 못한 곳에서 계속 모습을 드러내죠. 설정 파일과 환경 변수에서는, 그렇지 않으면 포맷을 깨뜨릴 값을 밀반입하는 트릭입니다: 세미콜론을 둔 데이터베이스 DSN, 따옴표를 둔 패스워드, 줄바꿈을 둔 값. 데이터베이스에서는 바이너리 블롭이 Base64로 텍스트 컬럼에 살면서, 텍스트를 전제하는 모든 도구를 무사히 통과할 수 있습니다 - 다만 크기가 3분의 1쯤 더 커지는 대가로, 그래서 컬럼 크기를 그에 맞게 잡으세요 (아니면 왜 그 값이 아예 BLOB 컬럼에 없는지 물어보세요).

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
static size_t real_length(const char *b64) {
  size_t len = strlen(b64);
  while (len > 0 && b64[len - 1] == '=') len--;
  return len * 3 / 4;
}
int main(void) {
  const char *b64 = getenv("API_KEY_B64");
  if (b64 == NULL) {
    printf("API_KEY_B64 is not set\n");
    return 1;
  }
  size_t cap = strlen(b64);
  unsigned char *out = malloc(cap);
  int n = EVP_DecodeBlock(out, (const unsigned char *)b64, (int)cap);
  if (n < 0) {
    printf("API_KEY_B64 is not valid base64\n");
    free(out);
    return 1;
  }
  size_t real = real_length(b64);
  printf("key is %zu bytes\n", real);
  free(out);
  return 0;
}

주의는 두 번 적용됩니다. 첫째, 이것은 포맷 안전일 뿐 비밀이 아닙니다: 개발자가 설정 파일을 읽을 수 있는 순간, 한 호출로 값을 디코딩할 수 있습니다. RFC의 보안 섹션은, 프로토콜 교환을 서포트에 보고하다 "우연히 비밀번호를 공개했다"는 실제 사건들을 기록합니다 - Base64는 계산적으로 보호하는 것이 아니라 시각적으로 위장하는 것이니까요. 시크릿을 Base64로 저장해 놓고 이를 암호화라 부르는 일은 절대 하지 마세요. 둘째, 시작 시점에 검증하세요: 반쯤 붙여 넣은 환경 변수 값은 엄격한 호출에서 -1이 되고, 한 줄짜리 검사는 3시간 뒤의 알 수 없는 실패를 부팅 시점에 행동 가능한 메시지로 바꿔 줍니다.

셸에서 디코딩

디코딩의 전부가 당신의 프로그램 안에서 일어나는 것은 아닙니다. CLI 스크립트, cron 잡, 원라이너는 끊임없이 Base64를 디코딩하며, C 개발자는 이미 모든 Linux 머신에 있는 두 도구를 알아야 합니다. coreutils 도구가 범용입니다: base64 -d가 디코딩하고, -i는 실패하는 대신 쓰레기 문자를 무시하게 하고, -w는 래핑 열을 설정합니다 (인코딩에만 영향, 디코딩에는 없음):

base64 -d < blob.b64 > blob.bin
base64 -d -i < messy.b64 > blob.bin

OpenSSL도 자체 도구를 싣고 있습니다: openssl base64로 도달할 수 있고 (openssl enc -base64의 더 친근한 별칭입니다):

openssl base64 -d < blob.b64 > blob.bin
openssl base64 -d -A < blob.b64 > blob.bin

-A 플래그는 "한 줄"을 뜻합니다: 64문자 래핑 없이 인코딩하고, 입력도 한 줄일 것으로 기대합니다. 그리고 읽지 않으면 저녁 하나를 잃게 할 CLI 함정이 여기 있습니다: OpenSSL의 base64 디코딩은 줄 지향적이며, 줄바꿈이 아예 없이 도착한 페이로드는 아무것도 아닌 것으로, 그것도 조용히 디코딩됩니다:

printf 'TQ=='  | openssl base64 -d | wc -c   # 0
printf 'TQ==\n' | openssl base64 -d | wc -c  # 1

coreutils 디코더에게는 그런 기대가 없습니다. 이것이 바로 glue work에서 더 안전한 기본값인 이유 중 하나입니다. 변형 관련 노트 하나 더: BSD 계통 시스템(특히 오래된 macOS)에서는 역사적으로 디코딩 플래그를 -D로 적었습니다; 최근 릴리스는 GNU 관례인 -d를 따르므로, 실제로 일하고 있는 머신의 man 페이지를 확인하세요.

큰 페이로드, 작은 메모리

디코딩은 당신에게 유리한 방향입니다: 출력은 입력의 4분의 3 크기이므로, Base64 때문에 오는 메모리 압박은 드뭅니다. 그래도 수백 메가바이트짜리 .b64 파일이 디스크에 떨어지면, 아까의 스트리밍 경로가 당신의 도구입니다. 그리고 그것은 보이기보다 단순합니다. 인코딩된 파일을 청크 단위로 읽고, 각 청크를 EVP_DecodeUpdate에 넣고, 디코딩된 바이트가 도착하는 대로 쓰세요. 컨텍스트는 호출 사이에 끝나지 않은 그룹의 1~3문자를 붙들고 있으므로, 청크 경계는 어디에 떨어져도 됩니다 - 정렬할 필요가 없으니까요:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  FILE *in = fopen("huge.b64", "rb");
  FILE *outf = fopen("huge.bin", "wb");
  char inbuf[65536];
  unsigned char outbuf[49152 + 4];
  size_t got;
  int ok = 1;
  while (ok && (got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
    int outl = 0;
    int r = EVP_DecodeUpdate(ctx, outbuf, &outl,
        (const unsigned char *)inbuf, (int)got);
    if (r < 0) {
      ok = 0;
    } else if (outl > 0) {
      fwrite(outbuf, 1, (size_t)outl, outf);
    }
  }
  int tail = 0;
  if (ok && EVP_DecodeFinal(ctx, outbuf, &tail) == 1 && tail > 0) {
    fwrite(outbuf, 1, (size_t)tail, outf);
  }
  EVP_ENCODE_CTX_free(ctx);
  fclose(in);
  fclose(outf);
  return ok ? 0 : 1;
}

피크 메모리는 파일 크기와 무관하게 수십 KB급 버퍼 두 개입니다. 깨진 파일은 빨리 실패합니다 - 손상이 있는 청크에서 EVP_DecodeUpdate가 -1을 돌려주므로, 어깨를 으쓱하는 대신 오프셋을 보고할 수 있습니다. 이 경로에 대한 라이브러리 주의 하나: APR-Util의 디코더는 int 크기 집계로 NUL 종료 문자열 위에서 일합니다(반환 값이 int이고, 입력은 내부 상수로 3 GB 조금 아래에서 상한이 정해져 있습니다). 그래서 수 GB 파일에서는 후보에서 벗어납니다. 진도 보고가 필요하면 쓴 바이트 수를 세세요 - 그것이 출력에서의 당신의 위치이고, 입력 위치는 그것의 약 4/3배입니다.

함정들, 전부 C 특화

C로 이것을 할 때 특정한 함정들을 한곳에 모으면:

  • 제로 패딩된 원샷. EVP_DecodeBlock는 데이터 길이가 아니라 양자 길이를 돌려줍니다. TQ==는 세 바이트를 보고하지만 실제로는 하나를 싣습니다. 진짜 길이를 항상 뒤의 패드로부터 다시 계산하거나, 스트리밍 쌍을 쓰세요.
  • 디코딩된 바이트는 문자열이 아닙니다. 결과는 NUL 바이트를 포함할 수 있고, UTF-8이 아닐 수도 있습니다. strlen도, printf("%s")도, 텍스트를 전제하는 함수에 넘기는 것도 금지입니다. (포인터, 길이)를 어디에든 함께 다닙니다.
  • 버퍼 크기는 당신의 일입니다. C도 당신의 출력 버퍼를 키워 주지 않고, 디코더도 그렇지 않습니다: OpenSSL의 update는 디코딩한 것을 당신이 준 공간에 그냥 씁니다. in_len * 3 / 4 + 3으로 크기 잡으세요(입력이 래핑되어 있고 패드를 제거하지 않는 헬퍼로 디코딩한다면 래핑 오버헤드를 더해야 합니다). 그리고 모든 래퍼에 상한 검사를 유지하세요.
  • 부호 있는 char 조회. 언젠가 스스로 디코더를 쓰게 된다면, 고전적인 버그는 char가 부호 있는 플랫폼에서 그저 char로 입력 바이트를 256개 항목 테이블의 인덱스로 쓰는 것입니다: 바이트 0xFF가 -1이 되고, 당신은 메모리를 거꾸로 인덱싱하게 됩니다. 항상 unsigned char나 unsigned 값으로 인덱싱하세요.
  • 조용한 쪽이 위험한 쪽입니다. APR-Util은 첫 잘못된 문자에서 멈추고 아무 말도 하지 않습니다. GLib은 쓰레기를 건너뛰고 아무 말도 하지 않습니다. OpenSSL과 Mbed TLS는 소리를 지르며 실패합니다. 입력이 신뢰할 수 없다면, 라이브러리의 침묵은 라이브러리가 아니라 당신의 프로그램의 버그입니다.
  • 명령줄은 줄바꿈을 먹습니다. 입력에 줄바꿈이 없으면 openssl base64 -d는 0바이트를 디코딩합니다. 꼬리 줄바꿈을 제거하는 쉘 파이프라인(tr -d '\n', xargs, 마지막 줄바꿈 없이 저장하는 에디터)은 오류도 없이 빈 출력을 낳습니다.
  • int 대 size_t. OpenSSL의 원샷 API는 int 길이를 받고, APR-Util은 전체가 int를 쓰며, Mbed TLS와 GLib API는 size_t를 씁니다. 이들 사이의 길이 혼합 산술이 바로 부호/무부호 경고 뒤에 진짜 버그가 숨는 곳입니다 - 그리고 APR의 2 GB 상한이 사는 곳이죠.
  • 공백은 균일하지 않습니다. OpenSSL은 어디에서든 모든 공백을 건너뜁니다. Mbed TLS는 그룹 사이의 CRLF/LF와 줄바꿈 직전의 스페이스를 허용하지만, 줄바꿈 직후나 줄 한가운데는 허용하지 않습니다. CLI 도구는 제각각입니다. 한 디코더에 유효한 페이로드는 다른 디코더에 무효할 수 있고, "내 머신에서는 잘 돌아"는 보통 "내 디코더가 더 느슨했다"는 뜻입니다.

좋은 습관, 모아서 보기

믿기 전에 검증하세요: 모양 검사(알파벳 문자, 꼬리 패드 최대 두 개)는 어떤 디코딩도 하기 전에 뻔한 쓰레기를 잡아 냅니다. 하지만 Base64 의미를 이해하는 것은 진짜 디코딩뿐이므로, 최종 판정을 엄격한 디코더가 내립니다. 정직한 길이나 청크 입력이 필요하면 OpenSSL의 스트리밍 쌍을, 페이로드가 작고 길이를 즉시 교정할 수 있으면 원샷을 쓰세요. (포인터, 길이) 쌍을 함께 다니다니, 디코딩된 버퍼가 문자열 함수를 만나게 하지 마세요. 인증 자료는 CRYPTO_memcmp로 비교하세요. 파일 이름이나 주장된 MIME 타입을 믿기 전에 매직 바이트를 스니핑하세요. 그리고 Base64를 그것이 있는 그대로 대하세요 - 포장 포맷, 바이트를 위한 작은 상자 - 자물쇠가 아니라요: 이 64문자 중 당신의 데이터를 비공개로 만드는 것은 아무것도 없습니다.

C에서 Base64의 짧은 역사

이야기는 메일에서 시작됩니다. 1990년과 1991년, 암호학자 몇 명이 서명되고 암호화된 이메일을 위한 시스템인 Privacy Enhanced Mail을 그려 냈고, 7비트 네트워크를 통해 바이너리를 실어 나르는 방법이 필요했습니다. 그들의 답은 1993년에 RFC 1421로 표준화되었는데, 문자당 6비트 - "base 64" - 를 64문자 줄로 인코딩한 것이었고, 구현은 물론 C였습니다. 얼마 지나지 않아 웹이 자기만의 MIME을 갖고 왔습니다: RFC 1521 (1993), 그 뒤 RFC 2045 (1996). 같은 알파벳을 유지하되 줄 길이를 76으로 늘렸고, Base64를 어린 인터넷의 첨부 파일 포맷으로 만들었죠.

C의 표준 라이브러리는 배를 통째로 놓쳤습니다. C89 표준은 1990년, MIME보다 3년 전에 발표되었고, 그 뒤 이 언어의 위원회는 Base64 함수를 한 번도 추가하지 않았습니다: C99에도, C11에도, C23(2024 개정)에도 없죠. 그래서 생태계는 라이브러리들을 중심으로 자랐습니다: OpenSSL은 사람들이 TLS를 위해 OpenSSL을 링크해 온 동안만큼 libcrypto에서 EVP 인코딩/디코딩 루틴을 싣고 왔고, Mbed TLS(2015년 PolarSSL에서 개명)는 임베디드 시스템을 위한 작고 엄격한 쌍을 간직했고, APR-Util은 서버가 자기 인증 헤더를 디코딩해야 할 때 Apache와 함께 실렸고, GLib은 데스크톱을 위해 자기 삼총사를 추가했습니다. 표준은 구현을 쫓아갔습니다: 2003년의 RFC 3548은 옛 정의들을 정리했고, 2006년의 RFC 4648 (Base-N Encodings)은 알파벳, URL-safe 변형, 그리고 이 기사가 기대고 있는 보안 규칙들을 정식화했습니다. 알맞게도, 그 RFC의 11절은 ISO C99 참조 구현을 가리킵니다 - 표준의 모범 디코더조차 C로 쓰여 있는데, 이 포맷이 어디에 사는지를 보여주는 것이 바로 그것입니다.

재미있는 사실들, C 에디션

알아두면 그냥 재미있는 C 풍미의 사실 몇 가지:

  • 이름은 수학일 뿐 마케팅이 아닙니다: 출력 문자마다 정확히 6비트를 싣고, 2의 6제곱은 64입니다. "Base64"는 밑수를 소리 내어 읽은 것입니다.
  • 알파벳은 64가 아니라 65문자입니다: 64개의 심볼에 =를 더한 것인데, RFC 4648은 이것을 특수 처리 기능에 쓰이는 "추가 65번째 문자"라 부릅니다. 패드는 일꾼이지 알파벳의 글자가 아닙니다.
  • OpenSSL은 인코딩 출력을 64문자에서 래핑하고(PEM 습관), coreutils는 76에서 래핑합니다(MIME 습관). 12문자 차이는 같은 머신에서 두 명령의 출력으로 볼 수 있는 20년치 메일 역사입니다.
  • GNU coreutils base64 명령의 저자는 Simon Josefsson입니다 - RFC 4648을 쓴 바로 그 사람입니다. 표준과 가장 많이 쓰이는 구현 하나가 저자를 공유하는데, 그래서 두 쪽은 모든 엣지 케이스에서 의견이 일치하게 된 것입니다.
  • Mbed TLS는 상수 시간 헬퍼(mbedtls_ct_base64_*)를 통해 테이블 조회를 하므로, 디코딩 속도가 어떤 문자를 봤는지 새어나가지 않습니다. 당신이 절대 눈치채지 못하면서, 존재해서 다행인 디테일이죠.
  • TQ==는 가장 작은 비자명한 페이로드입니다: 진짜 바이트 하나, 패드 두 개. 완벽한 테스트 벡터입니다: OpenSSL의 원샷 디코더는 세 바이트를 돌려주고, 스트리밍 디코더는 하나를, Mbed TLS는 하나를, GLib도 하나를 돌려줍니다. 네 라이브러리, 두 답, 그리고 그 차이는 제로 패딩입니다.
  • APR의 base64 함수는 이 기사에서 EBCDIC을 신경 쓰는 유일한 것입니다: httpd가 아직 글자가 ASCII가 아닌 머신에서 돌거든요. C 표준 라이브러리는 메인프레임을 한 번도 만나지 못했습니다. APR은 만났죠.
  • 빈 페이로드는 범용 항등원입니다: 모든 라이브러리가 길이가 0인 입력을 오류 없이 길이가 0인 출력으로 인코딩하고 디코딩합니다. 디코더가 빈 문자열에 걸리면, 그것은 포맷의 문제가 아니라 당신의 버그입니다.

인코더 쪽으로 뒤집기

이것이 디코더 쪽이며, 고통의 대부분이 사는 곳입니다. 디코딩은 남의 데이터를 만나는 곳이니까요: 그들의 패딩 선택, 그들의 줄바꿈, 그들의 깨진 바이트, 그들의 토큰. 반대 방향 - 바이트를 Base64 문자열로 바꾸는 일 - 은 더 잔잔한 동물이며, 자기만의 함정 배우진이 있습니다: 정밀한 버퍼 계산, 줄 래핑이라는 질문, 그리고 모든 발신자에게 떨어지는 크기 청구서. C에서의 Base64 인코딩은 이 페이지에서 링크된 관련 기사에서 심층으로 다루며, 디코더가 인코더와 짝을 이루듯 이 기사와 짝을 이룹니다: 둘 다 읽으면 어느 방향에서도 더 이상 놀라지 않게 될 것입니다.

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

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