C++ (Cpp)에서의 Base64 디코딩: 완전한 가이드
문자열을 손에 넣었습니다. 글자와 숫자가 길게 늘어진 띠, 가끔 끼어드는 +나 /, 끝에 붙어 있는 = 한두 개, 그리고 티켓이나 계약서, 데이터베이스 컬럼 어딘가에 "이건 Base64다"라는 약속. 이제 C++에서 원본 바이트를 되찾아야 하고, 그것도 정확히. 이 사이트의 홈 페이지는 포맷을 깊이 파고들므로, 여기서는 줄인 버전만 짚습니다: 알파벳 문자 4개가 바이트 3개를 싣고, 1~2자의 = 꼬리가 진짜 데이터가 어디서 멈췄는지 표시하며, 인코딩된 형태는 원본보다 약 33퍼센트 더 큽니다. 디코딩은 수축하는 방향이므로, 디코더는 이미 손에 쥔 페이로드보다 큰 메모리를 필요로 할 일이 결코 없습니다. 참으로 기분 좋은 성질이고, 이 방향에서 일할 때 주어지는 고요한 즐거움 중 하나입니다.
더 큰 헤드라인은, C++ 자체는 단 하나의 문자도 당신을 대신해 디코딩해 주지 않는다는 것입니다. 표준 라이브러리는 base64 함수를 기를 시간을 30년이나 가졌는데 그 시간을 온전히 다른 데 썼으므로, 모든 C++ 프로그램은 성격이 아주 다른 디코더 셋이 대기하는 벤치에서 하나를 데려오거나, 40줄가량을 직접 쓰는 옵션을 택합니다. 한 마리는 1990년대부터 인터넷을 질주해 온 든든한 일꾼이고, 한 마리는 문장 한복판에서 멈추고도 그 어떤 말도 하지 않는 침묵형이며, 또 한 마리는 흘러들어온 공백 하나에 예외를 던지는 꼼꼼쟁이입니다. 각 디코더가 무엇을 봐 주는지, 무엇을 거부하는지, 당신의 뒤에 숨어서 조용히 무엇을 치는지 알게 되면, 디코딩은 더 이상 미스터리 버그의 공급원이 아닙니다. 자, 몇 가지 패키지를 열어 볼까요.
도구상자: 디코더 네 개, 성질 네 개
전반상을 한눈에 정리하면 이렇습니다. 넷 모두 표준 알파벳을 다룹니다. 차이는 엣지에 있고, 버그는 바로 그 엣지에서 나옵니다.
| 디코더 | 출처 | 오류 모델 | 꼭 기억할 특이점 |
|---|---|---|---|
| OpenSSL EVP | <openssl/evp.h>, 링크는 -lcrypto |
깨진 입력에 -1 반환 |
원샷 버전은 꼬리를 제로로 채운다 |
| Boost.Beast | <boost/beast/core/detail/base64.hpp>, 헤더 전용 |
없음: 그냥 멈춘다 | 오류 채널이 어떤 식으로든 없다 |
| Boost.Serialization 반복자 | <boost/archive/iterators/binary_from_base64.hpp>, 헤더 전용 |
dataflow_exception을 던진다 |
=를 진짜 제로 값으로 다룬다 |
| 당신의 40줄 | 어디에도 없다: 네 것이니까 | 바이트 위치 단위까지 네 선택 | 모든 엣지 케이스를 영원히 네가 책임진다 |
설치는 배포판마다 패키지 이름 하나입니다. OpenSSL은: Debian과 Ubuntu에서 libssl-dev, Fedora와 RHEL에서 openssl-devel, Arch에서 openssl, macOS에서 brew install openssl입니다. 1998년부터 라이브러리를 만들어 온 프로젝트의 현재 릴리스가 2026년 8월의 1.92.0인 Boost는: libboost-dev 또는 boost-devel입니다. 아래 나올 Boost 디코더 둘 다 헤더 전용이므로, 링크할 것이 전혀 없습니다. 프로젝트가 CMake 기반이라면, 전체 설정은 3줄입니다:
find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)
코드 앞에 버전 주의 하나. 디코더가 돌려주는 값을 바꾸는 사안이라서요. 2025년 4월에 장기 지원 라인으로 나온 OpenSSL 3.5는 스트리밍 디코더의 진짜 버그를 수정했고(잠시 후 자세히), 2026년 4월의 더 새로운 4.0 기능 릴리스는 그 수정을 상속했습니다. 빌드가 오래된 3.0이나 3.3에 고정되어 있다면, 꼬리 길이를 신뢰하기 전에 아래 OpenSSL 섹션의 버전 단락을 먼저 읽으세요.
OpenSSL: 아마 이미 링크하고 있을 디코더
C++ 프로그램이 TLS나 해시, 인증서를 건드린다면, OpenSSL은 이미 바이너리 안에 있습니다. 그리고 그 EVP base64 루틴은 이 업계에서 가장 많이 검증된 디코더입니다. 원샷 함수는 한 번의 호출입니다:
int EVP_DecodeBlock(unsigned char *t, const unsigned char *f, int n);
base64 문자 버퍼와 길이를 주면, 디코딩된 바이트를 t에 씁니다. 앞쪽 공백을 다듬고, 뒤쪽 공백, 줄바꿈, 캐리지 리턴을 다듬으며, 그런 다음 타협 없는 규칙을 적용합니다: 내부 공백은 허용하지 않고, 다듬은 길이는 4의 배수여야 합니다. 입력 문자 4개가 언제나 정확히 출력 바이트 3개를 만들고, 여기서 사람들을 놀라게 하는 부분이 있습니다: 패딩 문자는 제로 비트 6개로 디코딩되고, man 페이지는 담담한 어조로 끝에 남은 패딩을 감안할 책임은 호출자에게 있다고 적어 둡니다. 말하자면, 함수는 계산을 대신해 주면서 마지막에 제로 바이트를 최대 두 개까지 몰래 얹어 주는 것입니다. 관용적인 C++ 래퍼는 버퍼 계산을 std::string 뒤에 숨깁니다:
#include <cstddef>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>
std::string openssl_decode(const std::string &b64) {
std::vector<unsigned char> out(b64.size() * 3 / 4 + 4);
int n = EVP_DecodeBlock(out.data(),
reinterpret_cast<const unsigned char *>(b64.data()),
static_cast<int>(b64.size()));
if (n < 0) return {};
size_t pads = 0;
for (size_t i = b64.size(); i > 0 && b64[i - 1] == '='; --i) ++pads;
return std::string(reinterpret_cast<const char *>(out.data()),
static_cast<size_t>(n) - pads);
}
int main() {
std::string one = openssl_decode("TQ==");
std::printf("%zu bytes: %02x\n", one.size(),
static_cast<unsigned char>(one[0]));
std::string word = openssl_decode("TWFuZQ==");
std::printf("%zu bytes: %s\n", word.size(), word.c_str());
}
실행하면 제로 필이 문서가 약속한 바로 그 자리에 나타납니다: 4문자 문자열 TQ==를 디코딩하면 3바이트, 글자 M에 제로 두 개가 래퍼가 그들을 잘라내기 전에 나옵니다. TWFuZQ==를 넣어 주면 "Mane"의 깨끗한 4바이트가 나옵니다. 알파벳 밖의 문자를 넣어 주면 빈 문자열이 돌아옵니다. 함수가 -1로 답했기 때문이죠. 이 래퍼에서 std::string가 조용히 하는 일을 주목하세요: 자기 길이를 추적하고 제로 바이트를 기꺼이 품어 주므로, 디코딩한 JPEG도 텍스트 타입 안에 살아 있으면서 비교되고, 해시되고, 전달될 수 있습니다. C였다면 길이 변수 하나에 감사했을 텐데, 여기서는 문자열이 그냥 작동합니다.
조각조각 도착하는 데이터에는 OpenSSL이 청크를 밀어 넣고 결과를 뽑아내는 컨텍스트를 건네 주며, 세 함수는 간결한 반환 값 어휘를 가집니다:
| 호출 | 반환 | 뜻 |
|---|---|---|
EVP_DecodeUpdate |
-1 |
잘못된 문자, 혹은 데이터 한복판의 패드 문자 |
EVP_DecodeUpdate |
1 |
더 많은 입력이 기대됨 |
EVP_DecodeUpdate |
0 |
데이터 끝: 마지막 그룹이 패딩을 싣거나, 소프트 입력 끝 마커가 나타남 |
EVP_DecodeFinal |
1 / -1 |
스트림이 깨끗이 종료 / 남은 문자가 4의 배수가 아님 |
두 가지 행동이 스트리밍 디코더를 도구상자 안에서 가장 관대하게 만듭니다. 스페이스, 탭, 캐리지 리턴, 줄바꿈을 스트림 어디에 있어도 건너뛰므로, 76자 CRLF 줄의 MIME 이메일 블록도 한 줄짜리 문자열과 똑같이 흘러가고, 진짜 바이트 수를 보고합니다: 원샷 함수를 속인 바로 그 TQ==가 여기서는 아무 계산 없이 정확히 1바이트를 돌려줍니다. 입력은 base64 문자 64개까지의 청크로 씹어 먹고, 80바이트 내부 버퍼에서 일하며, 4개 그룹에 들어맞지 않는 것은 버퍼링합니다. 그래서 아무 크기로나 조각을 던져 줘도 됩니다. 래퍼는 이렇습니다:
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>
std::string openssl_decode_stream(const std::string &b64) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
std::string out;
std::vector<unsigned char> chunk(1024);
int outl = 0;
for (size_t pos = 0; pos < b64.size(); pos += chunk.size()) {
size_t take = std::min(chunk.size(), b64.size() - pos);
int ret = EVP_DecodeUpdate(ctx, chunk.data(), &outl,
reinterpret_cast<const unsigned char *>(b64.data()) + pos,
static_cast<int>(take));
if (ret < 0) {
EVP_ENCODE_CTX_free(ctx);
return {};
}
out.append(reinterpret_cast<const char *>(chunk.data()), outl);
if (ret == 0) break;
}
unsigned char tail[3];
int tail_l = 0;
int fin = EVP_DecodeFinal(ctx, tail, &tail_l);
EVP_ENCODE_CTX_free(ctx);
if (fin != 1) return {};
out.append(reinterpret_cast<const char *>(tail), tail_l);
return out;
}
이제 도구상자 표의 버전 각주를 다룹니다. "해결된" 티켓을 다시 열어 놓는 바로 그런 사안이라서요: 3.5 이전의 모든 OpenSSL 릴리스에서, 스트리밍 경로는 블록 디코더와 똑같은 제로 필 습관이 있었습니다. 2025년 2월에 issue 26677로 보고되었고, 수정 커밋은 2025년 2월 27일에 master에 들어갔으며, 관련 pull request는 같은 날 닫혔습니다. 공식 man 페이지는 이제 히스토리 섹션에 그것을 기록합니다: OpenSSL 3.5부터 EVP_DecodeUpdate는 문서가 언제나 주장해 온 바이트 수를 만들어 내며, 더 이상 패딩을 제로 비트로 디코딩하지 않습니다. 코드베이스가 오래된 OpenSSL에 고정되어 있고 꼬리 길이가 1~2바이트 어긋나는 것처럼 보인다면, 이것이 가장 먼저 확인할 것입니다. 그리고 PEM 시대로부터 이어 온 이색적인 습관이 하나 더 있습니다: 하이픈 -은 아예 알파벳 문자가 아니라, 소프트 입력 끝 마커입니다. 4의 배수인 유효 문자 뒤에 당신의 스트림에 그것이 들어 있으면, 디코더는 0을 돌려주며 멈추라고 말합니다. 그래서 - 문자가 진짜로 필요한 base64url 문자열은 그냥 디코딩되지 않습니다 - 아래 섹션에서 먼저 트랜스코드를 거치세요, 그러면 시간 여행자가 사라집니다.
Boost.Beast: 불평하지 않는 빠른 디코더
프로젝트에 이미 Boost가 있다면, 그 HTTP 라이브러리는 boost/beast/core/detail/base64.hpp라는 뜻밖의 주소에 base64 코덱을 싣고 있습니다. detail:: 네임스페이스는 "이건 우리의 내무 문제"라는 Boost의 표현 방식이고, 유지보수자들은 이를 공개 API로 승격하는 것을 거절해 왔습니다. 그래도 모두가 씁니다. 작고, 빠르고, 헤더 전용이라서요: include 전에 BOOST_BEAST_HEADER_ONLY를 정의하면 링크할 것이 아무것도 없습니다. 참고로, Boost.Beast의 WebSocket 핸드셰이크가 Sec-WebSocket-Accept 계산에 쓰는 코덱도 바로 이것이라, 실 트래픽을 몇 년째 씹어 먹고 있습니다.
디코딩 함수의 성질이 바로 놀라움입니다. 출력 버퍼, 입력, 그 길이를 받고 한 쌍을 돌려줍니다: 쓴 옥텟 수와 읽은 문자 수. 첫 =에서 멈추고, 첫 잘못된 문자에서도 멈춥니다 - 두 경우 모두 당신에게 알리지 않은 채요. 오류 코도 없고, 예외도 없고, 상태 플래그도 없습니다. 깨진 페이로드, 줄바꿈된 페이로드, 잘린 꼬리, 모두 성공적인 부분 결과를 만들어 냅니다:
| 입력 | 디코딩된 바이트 | 읽은 문자 수 | 멈춘 이유 |
|---|---|---|---|
"TWFuZQ==" |
4바이트 "Mane" |
6 | 첫 =에서 멈춤 |
"TWF!ZQ==" |
2바이트 "Ma" |
3 | !에서 멈춤 |
"TWFu\nZQ==" |
3바이트 "Man" |
4 | \n에서 멈춤 |
"TWFuZQ" |
4바이트 "Mane" |
6 | 꼬리가 잘린 것 |
"TQ==" |
1바이트 "M" |
2 | 패딩은 문제없이 처리 |
그 표를 두 번째로 읽어 보세요. 불평하지 않는 디코더의 전체 위협 모델이 바로 그것이기 때문입니다: 최선을 다했고, 멈춘 자리에 멈췄고, 알아채는 것은 당신 몫입니다. 검사에는 하나 꼬임이 있습니다: 패딩된 입력에서는 "읽은 문자 수"가 첫 =에서 멈추므로, 비교 전에 패드를 더해서 되돌려야 하고, 전체가 4의 배수이도록 요구해야 합니다. 진짜 페이로드가 가질 수 있는 모양은 그것뿐이니까요:
#define BOOST_BEAST_HEADER_ONLY
#include <boost/beast/core/detail/base64.hpp>
#include <cstddef>
#include <string>
namespace b64 = boost::beast::detail::base64;
std::string beast_decode(const std::string &in) {
std::string out;
out.resize(in.size() / 4 * 3 + 3); /* 홀수 길이를 위한 여유 공간 */
auto result = b64::decode(out.data(), in.data(), in.size());
out.resize(result.first);
size_t pads = 0;
for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
if (result.second + pads != in.size() || in.size() % 4 != 0)
return {}; /* 일찍 멈췄거나, 꼬리 부분이 불가능했던 것 */
return out;
}
아카이브해 둘 디테일 두 가지. 첫째, 헤더가 가리켜 주는 decoded_size(n) 헬퍼는 n이 4의 배수일 때만 유효한 상한입니다 - 함수의 주석 자체가 그렇게 말합니다 - 그래서 위 래퍼는 임의의 길이에 그것을 믿지 않고, 여유를 몇 바이트 더 얹어 둔 것입니다. 둘째, 계보: 소스 파일에는 Vinnie Falco의 2016-2019 저작권 표시가 있고, 푸터의 일부는 Rene Nyffenegger의 2004-2008 스니펫에 기원을 돌립니다. 그 스니펫은 영미권 인터넷에서 20년째 복사 붙여넣기를 해 온 base64 쌍이고, 이제는 Boost 안에, 당신의 바이너리 안에 실려, 웹 전체를 대신해 HTTP Basic 인증을 하고 있습니다.
Boost.Serialization: 흘러간 공백 하나에 예외를 던지는 디코더
Boost의 직렬화 라이브러리는 C++ 생태계에서 가장 오래된 base64를 싣고 있습니다: 2002년, Robert Ramey가 쓴 구성 가능한 반복자 어댑터 집합으로, "받으려면 관대하라"는 말을 개인적 모욕으로 여깁니다. 디코딩 방향은 binary_from_base64.hpp 안에 있습니다(네, 이름은 출력 쪽 관점에서 붙은 것이요), 6비트 값을 8비트 바이트로 다시 싸는 폭 변환기와 짝을 이룹니다:
#include <boost/archive/iterators/binary_from_base64.hpp>
#include <boost/archive/iterators/transform_width.hpp>
#include <cstddef>
#include <string>
namespace it = boost::archive::iterators;
std::string boost_decode(const std::string &in) {
using dec =
it::transform_width<it::binary_from_base64<const char *>, 8, 6>;
std::string out(dec(in.data()), dec(in.data() + in.size()));
size_t pads = 0;
for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
out.resize(out.size() - pads);
return out;
}
안쪽 반복자는 각 base64 문자를 6비트 값으로 바꾸고, 바깥쪽 반복자는 그 값을 다시 묶어 바이트로 만듭니다. 그 엄격함은 완전합니다: 알파벳 밖의 어떤 문자 - 스페이스 하나 포함 - 도 반복자로 boost::archive::iterators::dataflow_exception을 던지게 하며, 메시지는 "attempt to decode a value not in base64 char set"입니다. 바로 RFC들이 나중에 명시한 "알려 주지 않는 한 거부한다"는 행동입니다. 2002년에 구현된 것으로, RFC 3548이 같은 규칙을 법제화한 것보다 한 해 앞서 있습니다. 실질적 결과: MIME 줄바꿈된 입력은 이 반복자를 만지기 전에 줄바꿈을 벗겨야 합니다. 두 번째 특이점은 더 미묘합니다: 탐색 테이블에서 패딩 문자 =는 건너뛰지 않습니다 - 값 제로로 렌더링됩니다. 그래서 TWFuZQ==를 디코딩하면 6바이트 - 4d 61 6e 65 00 00 - 가 나옵니다. 패드 문자 두 개가 모두 실제(제로) 데이터를 공헌했기 때문이죠. 스니펫의 resize(size - pads) 행은 장식이 아니라 핵심입니다. TQ==를 디코딩하면 3바이트가 나와 글자 M 하나까지 다듬어지고, 정확히 당신이 닿고 싶은 곳에 놓입니다.
40줄, 당신의 소유
Base64는 작아서, 올바른 디코더 하나를 직접 소유하는 것은 충분히 그럴듯한 일입니다. 그리고 C++에서는 그 대가가 어떤 언어보다 좋습니다: std::string이 버퍼 관리를 편하게 해 주고, 손수 만든 디코더는 위의 어떤 라이브러리 버전도 할 수 없는 일을 할 수 있습니다. 바로 어떤 바이트가 상처를 준 것인지, 그 정확한 바이트를 가리켜 주는 것. 이 버전은 RFC 4648의 엄격한 해석을 따릅니다: 양쪽 끝을 다듬고, 내부 공백을 거부하고, 한복판의 패딩을 거부하고, 길이 규칙을 강제하며, 심지어 RFC가 표준 준수 인코더는 제로로 채워야 한다고 말하는 패드 비트까지 검사합니다:
#include <cstddef>
#include <cstring>
#include <string>
std::string strict_decode(const std::string &in, size_t *error_pos = nullptr) {
static const char *table =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
auto fail = [&](size_t pos) {
if (error_pos) *error_pos = pos;
return std::string();
};
size_t start = 0;
size_t end = in.size();
while (start < end && (in[start] == ' ' || in[start] == '\t' ||
in[start] == '\r' || in[start] == '\n'))
++start;
while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\t' ||
in[end - 1] == '\r' || in[end - 1] == '\n'))
--end;
size_t pads = 0;
while (end > start && in[end - 1] == '=') {
--end;
++pads;
}
size_t body = end - start;
if (pads > 2 || (pads == 1 && body % 4 != 3) ||
(pads == 2 && body % 4 != 2) || (pads == 0 && body % 4 == 1))
return fail(in.size());
if (body >= 2 && body % 4 != 0) {
int leftover = static_cast<int>((body % 4) * 6 % 8);
int last = static_cast<int>(std::strchr(table, in[end - 1]) - table);
if ((last & ((1 << leftover) - 1)) != 0)
return fail(end - 1); /* 정형이 아닌 패딩 비트 */
}
int value = 0;
int bits = -8;
std::string out;
out.reserve(body / 4 * 3);
for (size_t i = start; i < end; ++i) {
const char *p = std::strchr(table, in[i]);
if (!p)
return fail(i);
value = (value << 6) + static_cast<int>(p - table);
bits += 6;
if (bits >= 0) {
out.push_back(static_cast<char>((value >> bits) & 0xFF));
bits -= 8;
}
}
return out;
}
무엇을 강제하는지 하나씩 걸어 지나가 보겠습니다. 앞뒤 공백은 다듬어집니다. 이메일 헤더에서 복사한 페이로드는 그것을 걸고 도착할 가능성이 크니까요. 내부 공백은 거부됩니다. RFC 4648은 주변 규정이 달리 명시하지 않는 한 디코더는 알파벳 아닌 문자를 MUST 거부해야 한다고 말하고, 보안 경계에서는 엄격한 해석을 원하니까요. 데이터 한복판의 패드 문자도 거부되고, 진짜 페이로드와 대응할 수 없는 어떤 길이도 거부됩니다: 그룹에서 1문자 모자라는 것은 불가능하고, 패드 1개는 바디 문자 3개 뒤에서만 합법입니다. 마지막의 정형 검사는 대부분 구현이 건너뛰는 것입니다: 마지막 그룹에 패드 문자가 하나나 둘 있었다면, 마지막 알파벳 문자의 쓰이지 않은 저위 비트는 제로여야 합니다. 그렇지 않으면 같은 바이트가 겉보기에 다른 두 문자열로 적힐 수 있거든요. 이 가소성이 바로 이 검사가 존재하는 이유이고, 그 대가는 4줄입니다. 마지막으로, 이 디코더는 패딩 없는 입력을 받아들입니다. JWT 세그먼트가 정확히 그런 것이죠. 그리고 실패하면 위치를 건네 줍니다: TWF!ZQ==를 넣으면 오류는 인덱스 3, 느낌표 위에 놓입니다. 버그 리포트와 수정 사이의 차이입니다.
Base64url: 토큰들이 쓰는 알파벳
표준 알파벳에는 URL을 살아남지 못하는 두 문자가 있습니다: 쿼리 문자열에서 +는 공백을 뜻하고, 경로에서 /는 디렉터리를 뜻합니다. RFC 4648 5절은 두 문자 교환으로 이 문제를 고칩니다 - +가 -가 되고 /가 _가 되며 - 결과를 딱 끊어 말합니다: 이 인코딩은 "base64 인코딩과 동일하게 여겨져서는 안 된다". 이것은 JWT, OAuth PKCE 코드 챌린지, YouTube 동영상 식별자, 그리고 대부분의 API 토큰의 알파벳입니다. 토큰에서는 길이가 암묵적으로 알려져 있고 패딩은 그냥 일어나기를 기다리는 퍼센트 이스케이프에 불과하기 때문에, = 패딩도 루틴하게 떨어뜨립니다. 위의 C++ 디코더는 아무도 이것을 네이티브로 쓰지 못합니다 - OpenSSL은 -를 그 소프트 입력 끝 마커로까지 취급하니까요 - 그래서 해법은 디코딩 전에 작은 트랜스코드입니다. 머릿속에 넣어 둘 만큼 짧습니다:
#include <string>
std::string url_to_standard(std::string in) {
for (char &c : in) {
if (c == '-') c = '+';
else if (c == '_') c = '/';
}
switch (in.size() % 4) {
case 2: in += "=="; break; /* 빠진 패딩을 복원 */
case 3: in += '='; break;
default: break;
}
return in;
}
진짜 JWT에 적용해 보면, 첫 두 세그먼트는 벌어지기를 기다리는 순전한 JSON입니다. 고전적인 예시 토큰은 {"alg":"HS256","typ":"JWT"} 헤더와, 주제, 이름, 발급 시각 타임스탬프를 담은 클레임 집합으로 디코딩됩니다. 세 번째 세그먼트도 같은 방식으로 디코딩되어 원본 서명 바이트를 줍니다 - 텍스트도 아니고, 아무 것의 증거도 아니죠. 토큰을 디코딩하면 그것이 주장하는 것이 알려질 뿐, 서명을 검증하면 그것을 믿어야 하는지가 알려집니다. 그리고 그것은 어떤 base64 라이브러리도 당신을 대신해 해 주지 않는 암호학 업무입니다. Windows에서는 상황이 같은 방향으로 한쪽 편입니다: CryptBinaryToStringA는 인코딩 쪽에 CRYPT_STRING_BASE64URI 플래그를 갖고 있지만, 디코딩 방향에는 URL 안전 플래그가 아예 없으므로, 위의 트랜스코드는 모든 플랫폼에서 당신의 근 기억 안 자리를 정당하게 벌어 드립니다.
바이트에서 다시 텍스트로
C++ 디코더에게 "방금 무슨 문자셋을 디코딩했지?"라고 물으면, 이 언어가 줄 수 있는 가장 정직한 답을 듣게 됩니다: 없어요. 디코더는 끝에서 끝까지 바이트 지향입니다. 텍스트를 보는 것이 아니라 바이트를 보고, 싸 넣어진 바로 그 바이트를 돌려줄 뿐입니다. 원본이 UTF-8였다면, 이제 당신이 쥐고 있는 것은 UTF-8이고, 그 이상 필요한 것은 없습니다. 좋은 C++ 비틀림은, std::string 자체가 길이 멤버를 가진 바이트 컨테이너라는 점입니다. 그래서 클래식한 C 실패 모드 - 첫 NUL에서 멈추는 문자열 함수 - 는 대부분 증발합니다. 디코딩된 파일, 디코딩된 인증서, 디코딩된 이미지: 전부 string 안에 살아 있으면서 ==로 비교되고, 해시되고, 값으로 전달될 수 있으며, 안쪽의 제로 바이트는 그저 바이트일 뿐입니다. C 문자열로 바꾼 다음 strlen으로 재기만 하지 마세요. size()를 쓰세요.
오래된 데이터베이스, 내보내기, 손으로 만든 도구들 사이에서 아직도 기웃거리는 레거시 인코딩 - ISO-8859-1, Windows-1252, 그리고 그들의 친척들 - 에는 표준 도구가 있습니다: glibc와 함께 들어 있는 POSIX iconv입니다. 먼저 바이트로 디코딩한 다음, 소스와 맞는 코덱으로 그 바이트를 UTF-8로 변환하세요:
#include <iconv.h>
#include <cstddef>
#include <string>
#include <vector>
std::string to_utf8(const std::vector<unsigned char> &raw,
const char *source_charset) {
iconv_t cd = iconv_open("UTF-8", source_charset);
if (cd == (iconv_t)-1) return {};
char *inptr = reinterpret_cast<char *>(const_cast<unsigned char *>(raw.data()));
size_t inleft = raw.size();
std::vector<char> utf8buf(raw.size() * 4 + 8);
char *outptr = utf8buf.data();
size_t outleft = utf8buf.size();
if (iconv(cd, &inptr, &inleft, &outptr, &outleft) == (size_t)-1) {
iconv_close(cd);
return {};
}
iconv_close(cd);
return std::string(utf8buf.data(),
static_cast<size_t>(outptr - utf8buf.data()));
}
왕복은 양쪽 방향 모두 손실 없습니다: 문자열을 ISO-8859-1로 싸서 base64로 바꾸고, 보내고, 디코딩하고, 변환하면, 악센트 문자가 온전한 채 시작할 때의 것과 정확히 같은 것이 돌아옵니다. 그리고 바이너리 데이터에는 문자셋 자체가 없습니다 - PNG는 당신이 좋아하든 싫어하든 PNG입니다. 이것이 이 글 전체에서 가장 해방감 있는 답입니다.
파일 디코딩
작은 파일은 4단계 춤입니다: 바이너리로 열고, vector로 읽어 넣고, 디코딩하고, 결과를 바이너리로 다시 씁니다. 매번, 모든 플랫폼에서 바이너리 모드입니다 - Windows에서 텍스트 모드로 읽으면 CRLF 쌍이 단일 줄바꿈으로 번역되어, 디코더가 보기 전에 당신의 데이터가 조용히 바뀌어버리니까요:
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
std::vector<unsigned char> read_file(const std::string &path) {
std::ifstream in(path, std::ios::binary);
return {std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>()};
}
위 코드의 중괄호에 주목하세요. 단일 괄호라면, std::vector<unsigned char> bytes(istreambuf_iterator<char>(file), istreambuf_iterator<char>()) 모양의 한 줄은 악명 높은 "가장 골치 아픈 파싱(most vexing parse)"입니다: 컴파일러는 vector를 반환하는 함수 선언으로 읽고, 그렇게 읽는 것은 전적으로 정당합니다. 위와 같은 중괄호 초기자 형태는 문법을 통째로 우회합니다. 바이트를 손에 넣으면, 이 글의 어떤 디코더를 통과시켜도 좋고, 결과를 std::ofstream과 std::ios::binary로 쓰되, 스트림 연산자 대신 write(data.data(), data.size())를 쓰면, 내장된 제로 바이트가 디스크까지의 여정을 살아남습니다. 그러면 .b64 파일과 그 디코딩된 쌍은, 들어갈 때 낸 33퍼센트 세금만큼 정확히 달라집니다. 만족스러운 체크섬 순간입니다.
큰 파일: 양쪽 방향을 스트리밍으로
메모리에 담기엔 너무 큰 파일에는 OpenSSL 섹션의 스트리밍 디코더가 전체 일을 해냅니다: 청크를 읽고, 컨텍스트를 통과시키고, 나온 것을 쓰고, 반복. RAM에는 어느 순간에도 작은 버퍼 하나만 있으므로, 10 GB base64 파일도 10 KB 파일과 같은 코드로 디코딩됩니다. MIME 방식 줄바꿈은 통과 중에 전처리가 필요 없습니다. 스트리밍 디코더는 줄바꿈에 어깨를 으쓱하거든요:
#include <fstream>
#include <string>
#include <vector>
#include <openssl/evp.h>
bool decode_stream_to_file(const std::string &in_path,
const std::string &out_path) {
std::ifstream in(in_path, std::ios::binary);
std::ofstream out(out_path, std::ios::binary);
if (!in || !out) return false;
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
std::string chunk(65536, '\0');
std::vector<unsigned char> decoded(49152);
bool ok = true;
for (;;) {
std::streamsize got = in.read(chunk.data(), chunk.size()).gcount();
if (got < 0) { ok = false; break; }
if (got == 0) break;
int outl = 0;
int ret = EVP_DecodeUpdate(ctx, decoded.data(), &outl,
reinterpret_cast<const unsigned char *>(chunk.data()),
static_cast<int>(got));
if (ret < 0) { ok = false; break; }
out.write(reinterpret_cast<const char *>(decoded.data()), outl);
if (ret == 0) break;
}
unsigned char tail[3];
int tail_l = 0;
if (ok && EVP_DecodeFinal(ctx, tail, &tail_l) != 1)
ok = false;
if (ok)
out.write(reinterpret_cast<const char *>(tail), tail_l);
EVP_ENCODE_CTX_free(ctx);
return ok;
}
버퍼 크기는 임의가 아닙니다: EVP 함수의 길이 매개변수는 int이므로, 단일 호출은 2 GB까지 안전하고, 위 숫자들은 각 청크를 입력 64 KB, 출력 버퍼 48 KB로 유지합니다. 정확히 4분의 3이죠. 그 int 상한이 바로 스트리밍 경로가 존재하는 전부의 이유이고, 플랫폼 버그로 발견하기보다 확실한 사실로 아는 가치가 있습니다. 입력이 손상된 것으로 판명되면, 함수는 디코딩할 수 없는 첫 청크에서 false를 돌려주며, 출력 파일에는 그 직전까지 유효했던 것이 그대로 남습니다 - 파이프라인에 따라서는, 바로 당신이 원하던 부분 결과일 수도 있죠.
HTTP, API, 그리고 바이트를 숨긴 JSON 필드
Base64는 HTTP에서 두 가지 모습으로 등장합니다. 첫째는 데이터: "certificate"나 "avatar" 필드에 base64가 가득한 JSON 응답, 텍스트가 안전한 컬럼으로 바이트를 받아 주는 업로드 엔드포인트, .b64 파일을 건네 주는 다운로드 엔드포인트. 패턴은 언제나 동일합니다 - JSON을 파싱하고, 문자열을 빼내고, 디코딩하고, 결과를 바이트로 대한다 - 그리고 이 글의 디코딩 쪽이 바로 전체 구현입니다. 둘째는 자격 증명: Authorization: Basic 헤더는 user:password의 base64이며, 30년 동안 표준의 단 하나의 base64 용도였습니다. 파싱은 2단계이고, 첫 단계에서 사람들은 바이너리에 가까운 데이터 한가운데서 NUL 종료 C 문자열을 꺼내 든 다음, 왜 그렇게 됐는지 궁금해합니다:
#include <optional>
#include <string>
/* "40줄, 당신의 소유" 절에서 온 strict_decode */
std::optional<std::pair<std::string, std::string>> parse_basic_auth(
const std::string &b64) {
std::string raw = strict_decode(b64);
size_t colon = raw.find(':');
if (colon == std::string::npos)
return std::nullopt;
return std::make_pair(raw.substr(0, colon), raw.substr(colon + 1));
}
Basic 접두어 뒤의 헤더 값을 넘기면, 사용자와 비밀번호를 제대로 길이 추적된 문자열로 돌려줍니다. 페이로드가 user:pass 쌍이 아니라면, 아무것도요. C++ 주제는 아니지만 보안 주의도 여기에 속합니다: Basic 인증은 은폐이지, 보호가 아닙니다. 헤더는 네트워크를 읽을 수 있는 누구에게든 평문으로 타기 때문에 TLS 뒤에서만 허용할 수 있고, 그래도 기계 대 기계 호출용이지, 사람을 위한 선택지는 아닙니다.
JWT: 토큰이 주장하는 것을 읽는 일
JSON Web Token은 점으로 붙여진 base64url 세그먼트 3개입니다: 헤더, 클레임, 서명. 첫 둘은 JSON 오브젝트이고, 셋째는 header.claims 문자열 위에, 헤더에 명시된 알고리즘으로 계산된 암호학적 서명입니다. C++에는 내장 JWT 타입이 없지만, 토큰을 읽는 데 필요한 것은 base64url 섹션의 트랜스코드와 디코더뿐입니다. 흥미로운 부분이 바로 읽는 일이기 때문이죠:
#include <cstddef>
#include <cstdio>
#include <string>
/* 앞선 절들에서 온 url_to_standard와 strict_decode */
int main() {
const std::string token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ."
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
size_t dot1 = token.find('.');
size_t dot2 = token.find('.', dot1 + 1);
std::string header = strict_decode(
url_to_standard(token.substr(0, dot1)));
std::string claims = strict_decode(
url_to_standard(token.substr(dot1 + 1, dot2 - dot1 - 1)));
std::printf("header: %s\n", header.c_str());
std::printf("claims: %s\n", claims.c_str());
}
헤더는 {"alg":"HS256","typ":"JWT"}로, 클레임은 {"sub":"1234567890","name":"John Doe","iat":1516239022}로 돌아옵니다 - 주제, 이름, 그리고 발급 시각 타임스탬프. 이것이 읽기 쪽의 전부이고, 정말로 유용합니다: 토큰이 무엇을 주장하는지 로그로 남기고, 만료 필드를 보고 401을 디버깅하고, 어떤 클레임을 신뢰할지 결정하는 것까지, 전부 디코딩 한 번 거리에 있습니다. 이것이 아닌 것은 검증입니다. 서명 세그먼트도 base64url이고, 디코딩하면 자기만으로 아무것도 증명하지 않는 원시 바이트 32개나 64개가 나옵니다. 서명은 공유 시크릿 또는 공개 키로 header.claims의 해시를 다시 계산해서 비교할 때만 의미를 가집니다. 디코딩한 JWT를 편지처럼 대하세요: 그것은 자신이 말하는 바를 말할 뿐이고, 봉인을 검증하는 것은 별개의, 암호학적인 일입니다.
Data URI: 페이지에 스스로를 붙여 넣은 파일
data URI는 페이로드가 바로 주소 안에 있는 URL입니다: data: 뒤에 선택적 미디어 타입, 선택적 ;base64 마커, 쉼표, 그리고 데이터 자체 - RFC 2397의 전체 스키마죠. 브라우저는 추가 요청 없이 이미지, 폰트, 작은 스크립트를 HTML과 CSS에 직접 넣어 두기 위해 이것을 사용하며, 네트워크를 끈 채에도 작동하는 페이지를 본 적이 있다면, data URI는 유력한 용의자입니다. C++ 쪽에서 디코딩 업무는 URI를 자르고, 그 다음 페이로드를 평소 디코더를 통과시키는 것입니다. ;base64 마커가 있으면 페이로드는 순수한 표준 base64이니까요 - 보통 패딩되어 있고, 보통 한 줄입니다:
#include <cstddef>
#include <string>
std::string data_uri_payload(const std::string &uri, bool *is_base64) {
const std::string prefix = "data:";
if (uri.rfind(prefix, 0) != 0)
return {};
size_t comma = uri.find(',');
if (comma == std::string::npos)
return {};
std::string meta = uri.substr(prefix.size(), comma - prefix.size());
*is_base64 = meta.size() >= 7 &&
meta.compare(meta.size() - 7, 7, ";base64") == 0;
return uri.substr(comma + 1);
}
data:image/png;base64,iVBORw0KGgo=...로 호출하면, 페이로드와 어느 디코딩 경로를 따를지 알려 주는 플래그가 돌아옵니다. 함정이 둘입니다. 첫째, 마커가 없으면 페이로드는 URL 인코딩 텍스트이지 base64가 아니므로, 플래그는 형식이 아닙니다 - base64처럼 보이지만 퍼센트 인코딩 텍스트로 생성된 URI는 쓰레기로 디코딩됩니다. 둘째, 어떤 생성기는 긴 data URI를 MIME처럼 줄바꿈으로 감쌉니다. 당신의 엄격한 디코더는 그것을 거부하므로, 소스가 당신의 통제를 벗어나 있다면 디코딩 전에 줄바꿈을 벗기세요. data:image/png URI의 페이로드를 디코딩하면 헤더를 포함한 정확한 PNG 바이트가 나옵니다. 이것이 이 연습 전체의 고요한 만족감입니다.
이메일, MIME, 그리고 76자의 습관
이메일이 base64가 줄을 감는 법을 가르쳐 준 이유입니다. SMTP의 원형은 7비트 ASCII를 실어 나르도록 지어졌으므로, 바이너리인 것은 무엇이든 여행 전에 출력 가능한 텍스트로 다시 씌어져야 했습니다. 프라이버시 강화 메일이 1987년에 64자 줄로 그것을 했고, 1993년에 이메일 인코딩을 표준화한 MIME은 한도를 76자로 완화하고, 표준 준수 디코더는 줄바꿈을 그저 무시해야 한다는 규칙을 추가했습니다. 습관은 살아남았습니다: 이메일 첨부 파일은 오늘도 여전히 base64이며 76자로 감겨 있고, 정확한 계산은 4/3 곱하기 78/76, 즉 원래 크기의 약 137퍼센트, 그리고 헤더 몇백 바이트가 더해집니다. 당신의 C++ 디코더는 그것을 100퍼센트로 통째로 줄여 줍니다. 이것이 이 포맷 전체의 목적이죠.
C++의 꼬임은, 이 글의 디코더들이 줄바꿈에 대해 서로 의견을 달리한다는 점이고, 각각에는 이유가 있습니다. OpenSSL 스트리밍 디코더는 스트림 어디에 있는 줄바꿈이든 건너뜁니다 - 바로 MIME의 규칙이죠. OpenSSL 원샷 함수는 페이로드 안의 어떤 공백도 거부합니다. Boost.Beast는 아무 말 없이 첫 줄바꿈에서 멈춥니다. Boost 반복자는 스페이스 하나에 예외를 던집니다. 그래서 페이로드가 이메일에서 왔다면, 첫 결정은 어느 디코더를 쓸 것인가입니다. 아니면 당신이 줄바꿈을 스스로 벗기는 거예요 - \r와 \n에 대한 1줄 erase-remove 패스 - 그리고 원하시는 어떤 디코더든 진짜 일하게 만드세요. 먼저 벗기는 것은 지루하고 신뢰할 수 있는 선택이며, 당신의 디코더 선택이 페이로드의 역사와 독립적으로 유지되게 해 주는 선택입니다.
데이터베이스, 설정 파일, 환경 변수
base64의 세 번째 집은 저장 레이어입니다: 문서가 "base64"라고만 쓰고 아무것도 더 쓰지 않는 레거시 데이터베이스의 컬럼, JSON 파일 안의 설정 덩어리, 셸을 살아남게 하려고 어떤 서비스가 base64로 바꾼 환경 변수 안의 페이로드. 디코딩 패턴은 어딜 가나 같습니다 - 문자열을 읽고, 디코딩하고, 바이트로 대한다 - 하지만 라벨 없는 입력에는 특설 단락이 필요합니다. 어떤 알파벳이 쓰였는지 정말로 알 수 없을 때가 있으니까요. 알 수는 없지만, 테스트는 할 수 있습니다. 네 문자가 대부분의 일을 하니까요:
+또는/를 포함 - 표준 알파벳만이 맞을 수 있습니다.-또는_를 포함 - URL 안전 알파벳만이 맞을 수 있습니다.- 둘 다 아니고,
=로 끝남 - 패딩된 표준, 또는 교환된 두 문자가 페이로드에 필요 없었던 패딩된 URL 안전 문자열. - 둘 다 아니고, 패딩도 없음 - 둘 중 어느 쪽일 수 있음; 웹에서 흔한 것은 원본 URL 안전 형태이므로, URL이나 토큰에서 태어난 데이터라면 그것을 먼저 시도하세요.
구분용 네 문자를 하나도 쓰지 않는 문자열은 두 알파벳 아래에서 동일하게 디코딩되므로, 그런 문자열의 경우 시도 순서는 데이터가 어디에서 왔는가의 문제입니다: 이메일에서 태어난 것은 표준 알파벳을 원하고, URL에서 태어난 것은 URL 쪽을 원합니다. 그리고 같은 문자열의 패딩된 읽기와 패딩 없는 읽기도 시도해 보세요 - 빠진 = 하나는 "거부"와 "해결"의 차이입니다. 그래서 위의 엄격한 디코더는 둘 다 받아들입니다.
명령줄에는 base64가 두 개 있다
일회성 작업에는, Linux 박스에는 보통 base64 디코더가 두 개 있고, 사람들이 물리는 바로 그 방식대로 행동이 다릅니다. 첫 번째는 GNU coreutils의 base64입니다 (새로운 몇몇 배포판은 대신 uutils 재구현을 싣고, 둘은 같은 플래그를 씁니다 - base64 --version으로 확인하세요). RFC 4648을 준수하며, 인코딩 시 76자에서 줄바꿈합니다(-w 0로 그걸 꺼낼 수 있고), 디코딩 시에는 줄바꿈을 어디에나 기꺼이 받아들입니다; -i 플래그는 쓰레기 허용을 우연이 아닌 명시로 만듭니다. 두 번째는 OpenSSL의 것이고, 꼬임이 여기에 있습니다: openssl base64는 애초에 자기만의 앱이 아닙니다. 1.1.0 계열(2016)부터 enc 프로그램은 자기 자신의 호출 이름을 검사하고, "base64"로 불리면 스스로를 base64 모드로 전환합니다 - argv[0]에 대한 문자열 비교, 이것이 C식 별칭 싣는 방식이죠. -A 없이, 입력의 첫 1024바이트 어딘가에 줄바꿈을 기대하므로, 긴 한 줄 문자열은 종료 코드 0과 함께 빈 결과로 돌아옵니다. -A가 있으면 한 줄을 읽고, enc 명령의 문서화된 버그 목록은 2개 항목짜리 박물관입니다: -A 옵션은 큰 파일에서 제대로 동작하지 않고, -A 없이 첫 1024바이트에 줄바꿈이 없다면 입력의 첫 두 줄이 무시됩니다. 파이프라인에서, 조용한 빈 파일은 빈 페이로드의 성공적인 디코딩과 똑같이 보입니다.
# 정직한 원라인러
base64 -d < payload.b64 > payload.bin
openssl base64 -d -A < payload.b64 > payload.bin
둘 다 base64url을 네이티브로 쓰지 못합니다. 트랜스코드 스니펫이 당신의 근 기억에 속하는 또 하나의 이유가죠. 무엇이든 소중하다면, 당신의 프로그램 안에서 디코딩하세요. 오류가 검사할 수 있는 숫자로 돌아오고, 조용한 도구의 종료 코드가 유일한 신호가 되지 않는 곳에서요.
C++에서 특히 물리는 함정들
- 제로 필.
EVP_DecodeBlock은TQ==에 대해 3바이트를 돌려줍니다: 글자 M에 제로 두 개. 진짜 길이는 패딩에서 되찾거나, 수에 대해 정직한 스트리밍 API를 쓰세요. - 3.5 이전의 스트리밍 특이점. 3.5.0(2025년 4월) 이전의 OpenSSL 릴리스에서
EVP_DecodeUpdate는 같은 제로 필 습관이 있었습니다. 3.0 또는 3.3 고정으로 쓴 코드는 꼬리 길이에 대해 당신에게 거짓말을 하고 있을 수 있습니다. 수정은 man 페이지의 히스토리 섹션에 기록되어 있습니다. - 침묵한 정지. Boost.Beast의
decode는 오류 채널이 없습니다: 어떤 잘못된 문자, 어떤 줄바꿈, 불가능한 어떤 꼬리 길이에서도 멈추고, 담담한 얼굴로 부분 결과를 돌려줍니다.consumed + pads == input.size()이고 전체가 4의 배수임을 검사하지 않으면, 그것이 디코딩하기로 마음먹은 것을 디코딩하는 것입니다. - decoded_size 함정.
b64::decoded_size(n)은n이 4로 나누어 떨어진다고 가정합니다. 입력 2문자에서 1바이트가 나올 수 있는 반면decoded_size(2)는 제로라고 말합니다 - 홀수 길이에 여유를 더하세요. - 제로 바이트 패드. Boost 반복자는
=를 값 제로로 디코딩하므로,TWFuZQ==는 뒤의 제로 두 개를 포함한 6바이트가 됩니다. 패드 개수를 빼거나, 유령들을 즐기세요. - 공백, 네 가지 방식. OpenSSL 스트리밍은 건너뛰고, OpenSSL 원샷은 내부에서 거부하며, archive 반복자는 예외를 던지고, Beast는 거기서 멈춥니다. 붙여 넣은 문자열은 공백을 걸고 다니는 것을 좋아하고, 각 디코더는 그것에 대해 자기만의 의견을 가집니다.
- signed char 인덱싱. 문자로 인덱싱된 자기만의 디코딩 테이블을 만든다면,
unsigned char로 인덱싱하세요.char가 signed인 플랫폼에서, 127을 넘는 바이트는 음수 인덱스가 되고, 그것은 실험복을 입은 미정의 동작입니다. - 마이너스 기호는 시간 여행자. OpenSSL에서
-는 알파벳 문자가 아니라 PEM 시대의 소프트 입력 끝 마커입니다. 디코딩 전에 base64url을 트랜스코드하세요. - int, size_t가 아니라. EVP의 길이 매개변수는
int입니다. 2 GB를 넘으면 청크 스트리밍 경로만 안전합니다. 그것이 존재하는 이유죠. - Windows의 텍스트 모드. 파일을 텍스트로 열면 CRLF가 LF로 번역되어, 디코딩 전에 입력이 손상됩니다.
std::ios::binary, 매번, 모든 플랫폼에서. - 가장 골치 아픈 파싱.
std::vector<char> v(istreambuf_iterator<char>(f), istreambuf_iterator<char>())는 함수 선언입니다. 중괄호 초기화 또는 포인터 쌍을 사용하세요. - 비정형 패드 비트. 관대한 디코더는 쓰이지 않은 패드 비트가 제로가 아닌 문자열을 받아들일 수 있으므로, 겉보기에 다른 두 문자열이 같은 바이트로 디코딩될 수 있습니다(base64 가소성). 보안 경계에서는 필요 없는 것을 거부하세요 - RFC 4648은 디코더가 정확히 그렇게 해도 된다고(may) 말합니다.
- 명령줄은 조용히 실패한다.
-A없는openssl base64 -d는 한 줄 입력을 삼킵니다(빈 출력, 종료 코드 0). 문서화된 버그는 큰 파일과 줄바꿈 없는 입력을 양쪽 방향으로 다룹니다. 파이프라인에서 출력을 확인하세요. - 바이너리 위의 strlen.
std::string은 제로 바이트를 행복하게 해 주지만, C 문자열을 레거시 API에 넘기는 순간strlen은 첫 NUL에서 멈춥니다. 길이와 포인터를 넘기세요, 맨땅 포인터는 금지입니다.
C++에서 Base64의 짧은 역사
이 포맷은 이 언어의 현대 시대보다 오래되었습니다. 지금 MIME base64라고 부르는 인코딩의 첫 표준화된 사용은, 1987년에 64자 줄과 끝에 붙여진 RSA-MD2/MD5 메시지 무결성 체크로 제안된 프라이버시 강화 메일 프로토콜이었습니다. "base64"라는 이름 자체는 1993년에야 왔고, MIME 표준이 이름을 붙여 주었을 때의 일입니다. C++은 1998년에 C++98로 무대에 등장했습니다 - MIME보다 5년 늦게 - 그리고 이 언어의 개발자들이 가장 먼저 손이 간 base64 코드는 Rene Nyffenegger의 C 쌍(2004-2008)으로, 2008년 12월 4일의 Stack Overflow 질문이 그것을 웹 전체로 퍼뜨렸습니다. 그 이야기에서 가장 좋은 부분은, 누가 등장하지 않았는지입니다: 저자 본인은 답변을 한 번도 달지 않았지만, 그의 스니펫은 모두가 복사한 민요가 되었습니다. 스레드의 한 답변은, 사이트가 사라질 것에 대비해, 그의 개인 웹사이트에서 전체 구현을 재인쇄해 두었습니다.
그 다음, 생태계는 생태계가 하는 일을 했습니다. 2002년, Robert Ramey의 Boost.Serialization이 반복자 어댑터를 싣고 나왔습니다 - C++ 도구상자에서 가장 오래된 base64로, 스페이스 하나에 예외를 던질 만큼 엄격하며, 이미 강제하고 있던 규칙을 RFC 3548이 법제화하기 한 해 전의 것이죠. 2017년, Boost 1.66이 Beast를 가져왔고, 그와 함께 헤더 전용 코덱도 왔습니다. 오늘날에도 그 푸터에 Nyffenegger 기원 표시를 싣고 있죠. 그 사이 표준 자체는 C++11, C++14, C++17, C++20, C++23(2024년 출판)을 지나갔고, 그중 하나같이도 64문자 알파벳을 쳐다보고는 다음으로 넘어갔습니다. C++26은 텍스트 코덱 업무를 위한 새로운 <text_encoding> 헤더를 추가하며, 2022년에 이미 승인받았습니다. 그 기술적 내용은 2026년 3월 런던의 ISO 회의에서 완성되어, 위원회는 114-12-3 투표로 그것을 출판으로 보냈고, 위원회의 그 후 2026년 회의들 - 2026년 11월 16-21일 브라질 Búzios 회의 포함 - 이 이 표준에 투표하는 것이 아니라 다음 워크 드래프트 C++29를 여는 데 쓰입니다. base64는 한 번도 드래프트에 없었습니다. 표준 일곱 개, 30년, 텍스트 인코딩 헤더 하나 - 그리고 이제 위원회는 base64를 추가할 모든 가능한 구실을 가졌으면서도 전부 거부했습니다. C++에서 base64의 실무적 역사는, 그랬고, 여전히, 그 라이브러리들의 역사입니다: OpenSSL의 EVP 루틴, Boost 두 갈래, Windows API 호출 하나, 그리고 당신이 소유한 40줄 스니펫.
재미있는 사실들, C++판
- 같은 함수 쌍은 2008년 Stack Overflow 질문의 답변들과, 기원 표시 푸터가 붙은 Boost.Beast 소스와, 수많은 사적 코드베이스의 헤더 파일들에 등장합니다. C++ 개발자에게 base64가 어디서 왔느냐고 물으면, 가장 정직한 답변은 "모르겠고, 인터넷도 모르죠"입니다.
- Boost의 archive 반복자는 이 글에서 가장 오래된 base64로, 저작권 2002년입니다 - .NET Framework 1.0 SDK가 나온 것과 같은 해죠. 스페이스 하나에 예외를 던지는데, 이것은 RFC들이 따라오기 전에 "알파벳 아닌 문자를 거부하라"는 규칙을 집행하고 있었다는 뜻입니다: RFC 3548은 2003년에 그것을 법제화했고, RFC 4648은 2006년에 반복했습니다.
- OpenSSL의 스트리밍 디코더는 80바이트 내부 버퍼에서 일하지만, base64 문자 64개마다 플러시합니다. 1987년부터 PEM 아머가 써 온 것과 같은 줄 너비가죠. 그 조용한 64는, 2026년에도 옛 포맷이 여전히 핵심 업무를 보는 마지막 장소 중 하나입니다.
- 가장 작을 수 있는 패딩된 base64는 4문자,
TQ==입니다: 2문자 분장을 한 1바이트. 가장 작은 패딩 없는 것은 2문자,TQ입니다. 당신이 디코딩하게 될 쪽은 전적으로 누가 인코딩했느냐에 달려 있고, 그 사람은 당신 생각을 하지 않고 있었죠. - MIME의 계산은 정확합니다: 4/3 곱하기 78/76. 그래서 이메일 첨부 파일은 원래 크기의 약 137퍼센트로 도착하고(위에 헤더 몇백 바이트가 더 얹히며), 당신의 C++ 디코더는 그것을 100퍼센트로 다시 줄여 줍니다. 이것이 이 연습 전체의 고요한 즐거움입니다.
- 전형적인 libstdc++나 MSVC에서,
std::string은 할당 대신 소 문자열 최적화로 작은 페이로드를 스택 버퍼에 실어 나릅니다. 9바이트 입력은 6바이트로 디코딩되고, 힙에는 절대 손을 대지 않습니다. 당신의 작은 설정 덩어리의 base64 형태가 글자 그대로 스택 프레임 안에 살 수 있습니다. 표준 라이브러리가 광고하지 않는 종류의 공짜 점심이죠. - 셸에서 손이 갈 수 있는
openssl base64명령은 애초에 명령이 아닙니다.enc프로그램이argv[0]에서 자기 이름을 확인하고 성격을 바꾸는 것이죠. 문자열 비교로 만든 별칭, C에서 하는 C++식 일이요. - YouTube 동영상 식별자는 base64url입니다: 11문자, 패딩 없고, URL 근처에
+나/는 아예 없습니다. 이 지구에서 가장 많이 시청되는 인코딩 포맷은, RFC 4648이 한 페이지에 들어 맞는 섹션으로 추가한 "URL 및 파일명 안전" 변종으로 돌아갑니다.
대신 싸야 할 때
방금 디코딩한 모든 것은 반대편에서 같은 도구상자로 싸여 온 것입니다: 원샷에는 EVP_EncodeBlock, 스트림에는 EVP_EncodeUpdate에 EVP_EncodeFinal을 더한 것(그리고 64자 줄이 바로 거기서 나오는 것이요), 반대 방향의 같은 버퍼 계산, 그리고 디코딩이 조용히 환불해 주는 그 33퍼센트 세금. 전체 포장 이야기 - 항목별로 나열된 크기 계산, 출력을 NUL로 끝내는 인코더들, 패드 문자를 단 한 번도 만나지 못한 Boost 반복자, base64url, MIME 줄바꿈, 파일, 그리고 CRLF 습관을 가진 Windows API - 은 자매 사이트의 C++ 인코딩 가이드에 있습니다. 가서 읽고 오세요, 그리고 돌아와서 큰 것을 열어 보세요. 전체 게임이 바로 그것입니다: 표준 라이브러리 없음, 세 가지 다른 성질을 가진 신뢰할 수 있는 벤더 세 곳, 상처를 준 정확한 바이트를 가리키는 디코더, 스트리밍 꼬리를 바꾼 2025년 버그 수정, 그리고 영원히 기억할 제로로 채워진 3바이트. 좋은 언패킹 되세요.
마지막 업데이트: 2026-09-08