SQL에서의 Base64 디코딩: 완전한 가이드
생산 데이터베이스를 충분히 오래 열어 두면, 언젠가 그 변장을 마주하게 됩니다. JSON 익스포트 안에서 문자 벽이 되어 도착한 아바타, 유저 ID 옆 varchar 열에 주차된 JWT, 전송 포맷이 바이너리를 담을 수 없어서 누군가가 문자열로 보내기로 결정한 인증서. 어떤 테이블의 어딘가에서, 여러분의 데이터는 글자라는 옷을 입고 있고, 여러분의 일은 데이터베이스를 떠나지 않고 그 옷을 벗기는 것입니다.
SQL에서 이 일은 한 가지 매우 위로가 되는 성질을 가집니다: 여러분의 방언이 어떤 디코더를 말할 줄 아는지만 알면, 온갖 일이 단 하나의 함수 호출로 줄어듭니다. 포맷 자체는 홈 페이지에서 이미 자세히 설명되어 있습니다(인쇄 가능한 문자 64개, 매 4글자 그룹이 입력 바이트 3개를 대신하며, 마지막 그룹을 채우는 = 기호가 최대 두 개), 그래서 이 글은 그 강의를 건너뜁니다. 챙겨갈 것이 두 가지입니다. base64는 자물쇠가 아니라 바이트를 텍스트로 분장시키는 방식이고, 디코딩은 데이터가 작아지는 방향입니다(인코딩된 크기의 4분의 3으로 돌아오는데, 이는 스토리지 열을 그 크기용으로 설계해 둔 것과 정반대입니다). 진짜 이야기는, SQL이 방언의 가족이라는 것입니다. 가족의 모든 구성원은 디코더를 다른 이름으로 부르며, 잘못된 입력에는 완전히 다른 성격으로 반응합니다. 이 글은 그 가족을 둘러보는 투어입니다.
디코더 라인업
지금 당직 중인 멤버는 누구인지, 그리고 입력이 쓰레기가 되었을 때 각 멤버가 어떻게 굴러가는지 보여 드립니다. 「고장 날 때」 열이 중요한 이유는, 스테이징에서는 우렁차게 고장 나고 프로덕션에서는 조용히 고장 나는 디코더가 바로 현장에서 아바타를 잃어 버리는 경로이기 때문입니다:
| 방언 | 호출 | 돌려받는 것 | 고장 날 때 | 언제부터 |
|---|---|---|---|---|
| MySQL 8.x / MariaDB 10.x | FROM_BASE64(str) |
바이너리 문자열 | 조용한 NULL |
MySQL 5.6 (2013) |
| PostgreSQL | decode(str, 'base64') |
bytea |
힌트가 붙은 우렁찬 ERROR |
7.2 (2002) |
| SQLite (CLI 3.41+) | base64(str) |
BLOB |
읽지 못하는 것은 건너뜀 | 3.41.0 (2023) |
| DuckDB | from_base64(str) |
BLOB |
변환 오류 | 최신 릴리스 |
| ClickHouse 18.16+ | base64Decode(str) |
String |
예외 (INCORRECT_DATA) |
18.16.0 (2018) |
| SQL Server 2025+ | BASE64_DECODE(str) |
varbinary |
Msg 9803, 세 가지 상태 | 2025 |
| Oracle | UTL_ENCODE.BASE64_DECODE(raw) |
RAW |
PL/SQL 예외 | 9i 시절 |
| Snowflake | BASE64_DECODE_BINARY(str) |
BINARY |
오류, 또는 TRY_ 변형이면 NULL |
최신 릴리스 |
표의 모양을 주목하세요. 함수 이름은 결코 어려운 부분이 아닙니다. 어려운 부분은 「고장 날 때」 열인데, 그 열이 여러분의 리포트가 조용히 행을 잃을지, 아니면 배치 작업이 멈추고 도움을 청할지를 결정하기 때문입니다.
MySQL과 MariaDB: 어깨를 으쓱하는 디코더
두 서버는 TO_BASE64() / FROM_BASE64() 쌍을 공유합니다. 디코더는 문자열을 받아 바이너리 문자열을 돌려줍니다: 문자 인코딩이 부착되지 않은 바이트의 수열이죠. NULL이 들어가면 NULL이 나오며, 여기서 처음 암기할 것이 있습니다: 그 외 유효하지 않은 base64는 전부, 경고 없이 NULL입니다. 디코더는 어깨를 으쓱하고, 여러분의 쿼리는 기분 좋게 계속 나아갑니다.
SELECT FROM_BASE64('aGVsbG8=') AS restored;
SELECT HEX(FROM_BASE64('aGVsbG8=')) AS as_hex;
SELECT CONVERT(FROM_BASE64('aGVsbG8gd29ybGQ=') USING utf8mb4) AS as_text;
중간 행에는 주석을 달 가치가 있습니다. 고전적인 혼동의 장면을 설명해 주기 때문이죠. mysql 커맨드라인 클라이언트는 기본적으로 바이너리 문자열을 16진법 표기로 출력합니다(binary-as-hex라는 설정인데), 그래서 그냥 SELECT FROM_BASE64('aGVsbG8=')를 치면 hello 대신 0x68656C6C6F가 보입니다. 이건 버그도 손상도 아닙니다. 클라이언트가 바이너리 데이터에 대해 신중할 뿐입니다. 글자를 보고 싶다면 CONVERT(... USING utf8mb4)로 변환하거나, 클라이언트를 --binary-as-hex=0로 시작하세요. 중간 행의 HEX() 호출은, 클라이언트가 기본적으로 보여 주는 16진수를 의도적으로 부른 버전입니다.
자, 이제 조용한 디코더가 적용하는 규칙입니다. 공백을 무시한 뒤, 남은 문자는 4의 배수를 이루어야 하고, 모든 문자는 표준 알파벳(문자, 숫자, +, /, =)에서 와야 하며, 패딩은 오직 맨 끝에만 나올 수 있습니다:
SELECT FROM_BASE64('aGVsbG8gd29ybGQ=') AS ok;
SELECT FROM_BASE64('aGVsbG8gd29ybGQ') AS missing_padding;
SELECT FROM_BASE64('!!!') AS nonsense;
세 줄 모두 항의 없이 실행되고, 두 번째와 세 번째 행은 NULL을 돌려줍니다. 패딩 누락, 길이 오류, 낯선 문자: 어깨 으쓱은 같습니다. 공백은 유일한 관대함입니다. 줄바꿈, 캐리지 리턴, 탭, 공백은 전부 무시되는데, 이건 먼저 이메일을 지나온 데이터에게는 자비입니다. 반면 URL-safe 알파벳은 으쓱의 대접을 받습니다. 밑줄은 표준 테이블에 없으므로, 길이가 깨끗한 4의 배수라도 FROM_BASE64('yv7K_g==')는 NULL입니다. 호출 전에 알파벳을 여러분 스스로 번역해야 하며, 방법은 아래 URL-safe 섹션에서 보여 드립니다.
알아 두면 좋은 특성이 하나 더 있습니다: 디코더와 인코더는 짝을 이룹니다. 인코더는 출력을 76자의 행으로 끊고, 디코더는 그 줄바꿈을 아침 식사처럼 삼켜 버립니다. 이 같은 데이터베이스 가족 안에서 TO_BASE64()가 채운 열이라면, 디코딩은 완벽한 왕복입니다. 다른 것이 채운 열이라면, 읽기를 계속하세요.
PostgreSQL: 목소리를 높이는 디코더
PostgreSQL은 적어도 2002년의 버전 7.2부터 코어에 base64를 실고 다녀, 근소한 차이로, 이 가족에서 가장 오래된 base64 기계입니다. 호출은 decode(string, 'base64'), 결과는 bytea인데, 이건 데이터베이스의 네이티브 바이너리 타입입니다. 동반자 encode(bytea, 'base64')는 반대로 가고, 여기에 언급되는 이유는 둘이 하나의 포맷 계약을 공유하기 때문일 뿐입니다: RFC 2045 스타일, 76자마다 줄을 끊는 것이죠. 디코더는 그쪽에서, 입력 안의 어디에 있는 캐리지 리턴, 줄바꿈, 공백, 탭이든 무시합니다.
SELECT decode('aGVsbG8gd29ybGQ=', 'base64') AS bytes;
SELECT length(decode('aGVsbG8gd29ybGQ=', 'base64')) AS byte_count;
SELECT convert_from(decode('aMOpbGxv', 'base64'), 'UTF8') AS text;
세 번째 행은 여러분이 끊임없이 찾아갈 그 행입니다: convert_from()는 bytea를 지정한 인코딩의 텍스트로 바꿔 주며, 이건 바이너리 데이터가 필요한 문자 인코딩 단계입니다(나중에 전용 섹션에서 자세히 다룹니다). aMOpbGxv는 héllo로 돌아오는데, 강세부호까지 그대로입니다.
PostgreSQL이 동료들과 차별되는 곳은 바로 「고장 날 때」 열입니다. 잘못된 입력은 하드 에러이고, 에러 메시지는 정확히 어떤 규칙이 깨졌는지 알려 줍니다:
- 알파벳 밖의 문자:
ERROR: invalid symbol "!" found while decoding base64 sequence - 문자열 한가운데의 패딩 기호:
ERROR: unexpected "=" while decoding base64 sequence - 잘린 입력 또는 패딩 누락:
ERROR: invalid base64 end sequence, 힌트는 입력 데이터에 패딩이 없거나, 잘렸거나, 그 밖의 방식으로 손상되었습니다. - URL-safe 밑줄:
ERROR: invalid symbol "_" found while decoding base64 sequence
데이터 정돈 작업에서는 그 목소리가 바로 기능입니다. 쿼리가 실패하고, 여러분은 그 행을 보고, 소스를 고치죠. 대가는, 백만 행 가운데 하나만 독이 물려도 배치 전체가 멈춘다는 것입니다. 그래서 프로덕션 파이프라인에서는 decode()를 부르기 전에 정규식으로 미리 걸러 놓는 경우가 많습니다. 그리고 표시와 관련해 작은 메모: psql은 bytea를 \x 접두사 붙은 16진수로 출력하므로, \x68656c6c6f는 MySQL 클라이언트가 0x68656C6C6F로 보여주는 것과 같은 "hello"입니다. 방언이 두 개, 16진수 방언도 두 개.
SQL Server: 늦게 온 손님
여기가 이 가족 전체의 놀라움입니다. SQL Server는 2025년 버전, 즉 2025년 11월에 일반 가용이 된 버전에서야 BASE64_DECODE()를 출시했습니다. 그 전까지, 엔터프라이즈 지대에서 가장 인기 있는 데이터베이스가 36년 동안 내장 base64 디코더를 갖지 못해, 구전은 우회책으로 가득 찼습니다. 현대 함수는 깔끔합니다: varchar(n) 또는 varchar(max) 식을 받아 varbinary를 돌려줍니다(varchar(n) 식은 varbinary(8000)으로, varchar(max) 식은 varbinary(max)으로 매핑되고), NULL은 그대로 통과합니다.
SELECT BASE64_DECODE('aGVsbG8gd29ybGQ=') AS bytes;
SELECT CONVERT(VARCHAR(100), BASE64_DECODE('aGVsbG8gd29ybGQ=')) AS text;
SELECT BASE64_DECODE('yv7K_g') AS url_safe_also_works;
그 세 번째 행은 정말 훌륭한 디테일입니다: 디코더는 RFC 4648의 두 알파벳을 둘 다 받아들입니다. +와 /가 있는 표준 알파벳과, -와 _가 있는 URL-safe 알파벳이고, 패딩은 선택 사항입니다. 네 가지 공백 문자(줄바꿈, 캐리지 리턴, 탭, 공백)도 무시합니다. 정말 고장 날 때, 에러는 Msg 9803, Level 16이며 텍스트는 타입 "Base64Decode"에 대한 잘못된 데이터, State 값이 여러분이 어떤 규칙에 걸렸는지 알려 줍니다: 어느 알파벳에도 없는 문자면 상태 20, 전부 유효하지만 base64가 만들 수 없는 모양으로 배열된 문자면 상태 21, 패딩이 너무 자주 또는 너무 일찍 나타났다면 상태 23입니다.
2025년 이전 버전에서 발이 묶였다면, 고전적인 우회책은 XML 타입을 빌려 옵니다. XML Schema 시절부터 base64를 이해해 온 타입이죠:
SELECT CAST(N'' AS XML)
.value('xs:base64Binary("aGVsbG8=")', 'VARBINARY(MAX)') AS legacy;
XML 엔진이 그 상수를 base64 디코딩하여 바이트를 돌려줍니다. 동작합니다. 그리고 일대의 SQL Server 개발자들이 사용한 것이 바로 이것입니다. 단점도 있습니다: base64Binary 타입은 모양에 대해 엄격하므로, 안에 줄바꿈이 있는 MIME 래핑 문자열은 파싱되지 않고, 이제 단일 함수가 네이티브로 처리하는 일을 위해 XML 기계의 요금을 치르는 셈입니다. 이미 되어 버린 박물관 전시품처럼 대하세요.
SQLite: 디코더가 없는 방언
SQLite는 분위기 밖의 존재입니다. 왜 그런지 이해하면 사용법이 보입니다. 코어 라이브러리는 작고 임베더블한 엔진이고, 그 표준 함수 목록에는 base64가 없습니다. 열에 base64가 들어 있다면, 디코더는 네 곳 중 하나에서 와야 합니다: 커맨드라인 셸, 로드 가능한 확장, 호스트 애플리케이션이 등록한 커스텀 함수, 아니면 순수 SQL. 하나씩 알아봅시다.
CLI. 3.41.0 버전(2023년 2월)부터, sqlite3 커맨드라인 셸에는 base64() 함수가 동봉됩니다. 텍스트 인수를 BLOB으로 디코딩하므로, 터미널에서 바로 하는 탐색 작업에 완벽합니다:
$ sqlite3 app.db "SELECT hex(base64('aGVsbG8gd29ybGQ='));"
68656C6C6F20776F726C64
알아 두어야 할 성격이 두 가지입니다. 첫째, 관대합니다. 모르는 문자는 보고하지 않고 그냥 건너뛰므로, base64('!!!')는 에러 대신 빈 BLOB을 돌려줍니다. 호기심에는 훌륭하지만 감사(audit)에는 위험합니다. 출력에서는 "비어 있음"과 "누락됨"이 똑같이 보이기 때문이죠. 둘째, 이 함수는 형태를 바꿉니다. BLOB 인수는 72자 행으로 인코딩되어 텍스트가 되고, 텍스트 인수는 BLOB으로 디코딩됩니다. 같은 이름, 두 가지 일, 어떤 일을 할지 인수의 타입이 고릅니다. 이 가족에서 다른 디코더는 하지 않는 일이므로, 입력 타입은 두 번 읽으세요.
순수 SQL. 코어 라이브러리에 base64는 없지만, 재귀 CTE와 산술 연산, 그리고 (3.41.0부터) unhex()가 있어서, 몇십 줄로 진짜 디코더를 만들기에 충분합니다. 레시피: 64행짜리 알파벳 테이블, 4글자 청크로 자른 입력, 각 청크를 24비트 숫자로 바꾸고, 그 숫자를 세 바이트로 나누고, 바이트를 16진수로 모은 뒤 unhex()가 BLOB으로 바꿉니다. 테이블의 열 위에서 동작하는, 그대로입니다:
WITH RECURSIVE
b64(c, v) AS (
SELECT 'A', 0 UNION ALL SELECT 'B', 1 UNION ALL SELECT 'C', 2
UNION ALL SELECT 'D', 3 UNION ALL SELECT 'E', 4 UNION ALL SELECT 'F', 5
UNION ALL SELECT 'G', 6 UNION ALL SELECT 'H', 7 UNION ALL SELECT 'I', 8
UNION ALL SELECT 'J', 9 UNION ALL SELECT 'K', 10 UNION ALL SELECT 'L', 11
UNION ALL SELECT 'M', 12 UNION ALL SELECT 'N', 13 UNION ALL SELECT 'O', 14
UNION ALL SELECT 'P', 15 UNION ALL SELECT 'Q', 16 UNION ALL SELECT 'R', 17
UNION ALL SELECT 'S', 18 UNION ALL SELECT 'T', 19 UNION ALL SELECT 'U', 20
UNION ALL SELECT 'V', 21 UNION ALL SELECT 'W', 22 UNION ALL SELECT 'X', 23
UNION ALL SELECT 'Y', 24 UNION ALL SELECT 'Z', 25 UNION ALL SELECT 'a', 26
UNION ALL SELECT 'b', 27 UNION ALL SELECT 'c', 28 UNION ALL SELECT 'd', 29
UNION ALL SELECT 'e', 30 UNION ALL SELECT 'f', 31 UNION ALL SELECT 'g', 32
UNION ALL SELECT 'h', 33 UNION ALL SELECT 'i', 34 UNION ALL SELECT 'j', 35
UNION ALL SELECT 'k', 36 UNION ALL SELECT 'l', 37 UNION ALL SELECT 'm', 38
UNION ALL SELECT 'n', 39 UNION ALL SELECT 'o', 40 UNION ALL SELECT 'p', 41
UNION ALL SELECT 'q', 42 UNION ALL SELECT 'r', 43 UNION ALL SELECT 's', 44
UNION ALL SELECT 't', 45 UNION ALL SELECT 'u', 46 UNION ALL SELECT 'v', 47
UNION ALL SELECT 'w', 48 UNION ALL SELECT 'x', 49 UNION ALL SELECT 'y', 50
UNION ALL SELECT 'z', 51 UNION ALL SELECT '0', 52 UNION ALL SELECT '1', 53
UNION ALL SELECT '2', 54 UNION ALL SELECT '3', 55 UNION ALL SELECT '4', 56
UNION ALL SELECT '5', 57 UNION ALL SELECT '6', 58 UNION ALL SELECT '7', 59
UNION ALL SELECT '8', 60 UNION ALL SELECT '9', 61 UNION ALL SELECT '+', 62
UNION ALL SELECT '/', 63
),
chunks AS (
SELECT name, b64, (LENGTH(b64) + 3) / 4 AS n
FROM payload
),
seq(name, n, i) AS (
SELECT name, n, 1 FROM chunks
UNION ALL
SELECT name, n, i + 1 FROM seq WHERE i < n
),
vals AS (
SELECT s.name, s.i AS chunk_no, s.n,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 1, 1)), -1) AS v1,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 2, 1)), -1) AS v2,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 3, 1)), -1) AS v3,
COALESCE((SELECT v FROM b64 WHERE c = substr(ch.b64, (s.i - 1) * 4 + 4, 1)), -1) AS v4
FROM seq s
JOIN chunks ch ON ch.name = s.name
),
hexes AS (
SELECT name, chunk_no, n,
(CASE WHEN v1 < 0 THEN 0 ELSE v1 END) * 262144 +
(CASE WHEN v2 < 0 THEN 0 ELSE v2 END) * 4096 +
(CASE WHEN v3 < 0 THEN 0 ELSE v3 END) * 64 +
(CASE WHEN v4 < 0 THEN 0 ELSE v4 END) AS v24,
CASE WHEN v2 >= 0 OR v3 >= 0 THEN 1 ELSE 0 END +
CASE WHEN v3 >= 0 OR v4 >= 0 THEN 1 ELSE 0 END +
CASE WHEN v4 >= 0 THEN 1 ELSE 0 END AS n_bytes
FROM vals
),
acc(name, n, i, hx) AS (
SELECT h.name, h.n, 1,
(CASE WHEN h.n_bytes >= 1 THEN printf('%02X', h.v24 / 65536) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 2 THEN printf('%02X', (h.v24 / 256) % 256) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 3 THEN printf('%02X', h.v24 % 256) ELSE '' END)
FROM hexes h
WHERE h.chunk_no = 1
UNION ALL
SELECT a.name, a.n, a.i + 1,
a.hx || (
SELECT (CASE WHEN h.n_bytes >= 1 THEN printf('%02X', h.v24 / 65536) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 2 THEN printf('%02X', (h.v24 / 256) % 256) ELSE '' END) ||
(CASE WHEN h.n_bytes >= 3 THEN printf('%02X', h.v24 % 256) ELSE '' END)
FROM hexes h
WHERE h.name = a.name AND h.chunk_no = a.i + 1
)
FROM acc a
WHERE a.i < a.n
)
SELECT name, unhex(hx) AS restored
FROM acc
WHERE i = n;
b64 열이 있는 테이블에서 실행하면 행당 BLOB 하나씩, 확장도, 애플리케이션 코드도 없이 얻습니다. 산술은 정수 옷을 입은 평범한 base64입니다: 각 4글자 청크에서 글자 하나당 6비트가 합쳐지고, 가운데 두 글자는 바이트 경계를 가로지르며, 마지막 글자의 최하위 2비트는 버려집니다. 이 페이지에서 가장 느린 옵션(재귀 한 바퀴에 청크당 조회)이므로, 작은 페이로드와 일회성 고고학 작업에 아껴 두세요. 장기 운영 애플리케이션에는 정직한 답이 세 번째 옵션입니다: 호스트 언어에서 한 줄짜리 커스텀 함수를 등록하고(Python의 sqlite3 모듈은 표준 base64 모듈과 함께 create_function()으로 두 줄이면 됩니다) 엔진이 네이티브인 것처럼 호출하게 두세요. sqlean 계열 같은 로드 가능 확장이라는 네 번째 옵션도 존재하지만, 다른 엔진 빌드를 설치해야 한다는 뜻이라 대부분의 팀은 피하고 싶어 합니다.
DuckDB: 엄격하고, 작고, 고집 센
DuckDB는 진짜 바이너리 타입 BLOB을 가진 분석형 데이터베이스이고, 그 주변에는 깔끔하게 정리된 BLOB 함수 가족이 모여 있습니다. 디코더는 from_base64(string)이고, 친구들 to_base64(), hex(), md5(), sha256()과 같은 참조 페이지에 나란히 앉아 있는데, 대부분의 DuckDB 사용자는 바로 거기서 처음 만납니다.
SELECT from_base64('aGVsbG8gd29ybGQ=') AS bytes;
SELECT decode(from_base64('aMOpbGxv')) AS text;
SELECT hex(from_base64('AAEC')) AS padding_optional;
세 번째 행은 예상보다 더 관대한 규칙을 보여 줍니다: 길이가 4의 배수라면 패딩 누락은 문제없고, AAEC는 00 01 02 바이트로 아무 문제 없이 디코딩됩니다. 엄격함은 모양이 틀린 순간부터 나타납니다. DuckDB가 원하는 것은 4의 배수인 길이, 그것뿐이고, 변환 오류는 정확히 그것을 말합니다:
SELECT from_base64('YWJ');
-- Conversion Error: Could not decode string "YWJ" as base64: length must be a multiple of 4
존중해야 할 의견이 두 개 더 있습니다. 첫째, DuckDB 디코더는 표준 알파벳만 말입니다. 밑줄은 알아듣지 못하는 문자이므로, URL-safe 토큰은 도착 전에 번역해 주어야 합니다(레시피는 URL-safe 섹션에 있습니다). 둘째, 공백에 대한 관용이 전혀 없습니다. 안에 76자 줄바꿈이 있는 MIME 래핑 이메일 첨부 파일은 실패하고, 고치는 법은 호출 전에 줄바꿈과 캐리지 리턴에 대한 replace()를 하는 것입니다. 게다가 그 충격을 완화해 줄 try_ 변형도 없으므로, 부드러운 패턴은 같은 쿼리 안에서 미리 검사하는 것입니다:
SELECT CASE
WHEN b64 ~ '^[A-Za-z0-9+/]*={0,2}$'
AND MOD(LENGTH(b64), 4) = 0
THEN from_base64(b64)
END AS maybe_bytes
FROM attachments;
정규식 먼저, 디코더 나중: 쿼리는 도저히 디코딩할 수 없는 것에 대해 NULL을 돌려주므로, 디코더가 보는 것은 늘 모양이 바른 입력뿐입니다.
ClickHouse: 컬럼 디코더
ClickHouse에는 별개의 바이너리 타입이 없습니다. String이 당당하게 바이너리 안전하므로, "문자열로" 디코딩하는 것이 온전한 작업이고 그 뒤에는 변환 단계가 없습니다. 이 함수는 18.16.0 버전(2018)부터 base64Decode()라는 이름으로 존재해 왔으며, MySQL 스타일 별명 FROM_BASE64()도 유지하므로 이식해 온 쿼리는 다시 쓸 필요가 없습니다.
SELECT base64Decode('aGVsbG8gd29ybGQ=') AS text;
SELECT tryBase64Decode('definitely not base64') AS gentle;
SELECT base64URLDecode('aHR0cHM6Ly9jbGlja2hvdXNlLmNvbQ') AS url;
두 번째 행이 ClickHouse의 하우스 스타일이 작동하는 모습입니다. 이 엔진은 try 접두사를 사랑합니다: tryBase64Decode()는 실패를 삼켜 빈 문자열을 돌려주고, 맨 base64Decode()는 코드 INCORRECT_DATA와 문제의 값을 지목하는 메시지로 예외를 던집니다. 잘못된 행이 파이프라인을 멈추게 해야 할 때는 맨 형태를, 리포트가 계속 가기를 원할 때는 try 형태를 고르세요. 그리고 우연이 아니라 의도적으로 고르세요.
ClickHouse는 빨리 움직이므로, 버전 노트 두 가지. 26.7 이전에는 입력의 공백이 거부되었고, 26.7부터는 공백, 탭, 줄바꿈(line feed), 캐리지 리턴, 폼 피드가 전부 무시됩니다. 이메일이나 텍스트 편집기를 한 번이라도 지나온 데이터에 원하는 행동이죠. 그리고 현대 디코더는 4글자 그룹에 바른 패딩을 기대하므로, 들어올 길에서 등호를 잃어버린 토큰은 최선을 다한 결과가 아니라 예외가 됩니다. 2023년에는 동작하던 쿼리가 2026년에 예외를 던지기 시작한다면, 데이터를 탓하기 전에 서버 버전을 보세요.
Oracle: RAW, 아니면 아무것도 아님
Oracle의 base64 기계는 UTL_ENCODE PL/SQL 패키지에 살고 있으며, 그 고유의 성격이 있습니다: RAW를 받아 RAW를 돌려줄 뿐, 그 외 아무것도 하지 않습니다. 텍스트가 들어가지도, 나오지도 않습니다. VARCHAR2는 문자 인코딩을 가진 문자 데이터이고, RAW는 맨 바이트이며, 이 패키지는 그렇지 않은 척하기를 거부합니다. 그래서 동작하는 패턴은 3단 샌드위치입니다: raw로 캐스트, 디코딩, 다시 텍스트로 캐스트:
SELECT UTL_RAW.CAST_TO_VARCHAR2(
UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW('aGVsbG8gd29ybGQ='))
) AS restored
FROM DUAL;
모든 단계가 그 자리를 벌어 왔습니다. UTL_RAW.CAST_TO_RAW()는 텍스트의 바이트를 raw로 다시 해석합니다(데이터베이스 문자 인코딩 기준. 현대 배포에서는 보통 AL32UTF8이므로, 여러분의 UTF-8 입력은 그대로 이동합니다). UTL_ENCODE.BASE64_DECODE()가 실제 일을 하고, UTL_RAW.CAST_TO_VARCHAR2()는 결과 바이트를 그 동일한 데이터베이스 문자 인코딩의 텍스트로 다시 해석합니다. 어떤 층을 건너뛰어도 타입 불일치 에러가 나는데, 이건 Oracle이 명시적이라는 자신의 일을 하는 모습입니다.
잘못된 입력은 조용한 NULL 대신 PL/SQL 예외를 일으키므로, 배치 디코딩은 문제의 행을 기록하는 예외 핸들러 안에서 이루어져야 합니다. 이 패키지에는 같은 시대의 형제 디코더 박물관이 통째로 들어 있습니다: MIME 헤더 디코딩, quoted-printable, uudecode, 텍스트 인코딩. 여러분은 대부분 base64 쌍만 쓰겠지만, 이웃들은 이 패키지가 왜 이렇게 조직되어 있는지를 설명해 줍니다: Oracle은 "전송 옷을 입은 데이터"를 위한 하나의 집을 원했기 때문입니다.
시작 전에 알아 두어야 할 크기 함정이 하나 있습니다. 순수 SQL에서는 RAW 값이 2000바이트로 제한되므로, 약 1500 raw 바이트보다 더 많이 디코딩되는 base64 값은 단 하나의 SELECT 문으로 아예 디코딩할 수 없습니다. 더 큰 페이로드는 2000바이트(또는 그 이하) 청크 단위로 BLOB을 걸어 다니는 PL/SQL 루프가 필요하고, 각 조각을 디코딩한 뒤 결과를 다시 이어 붙입니다. 고전적이지만, 이게 바로 Oracle의 표준 답이며, 이 언어의 1990년대 타입 시스템이 여러분의 2020년대 쿼리를 아직까지 형성하는 그런 곳 중 하나입니다.
Snowflake: 알파벳은 직접 가져오기
Snowflake는 바이너리 타입(BINARY)을 텍스트 타입과 따로 분리해 두고, 이 가족에서 가장 설정 가능한 디코더를 줍니다. 일꾼은 BINARY를 돌려주는 BASE64_DECODE_BINARY(input)이고, 선택 가능한 두 번째 인수는 알파벳을 다시 정의하는 짧은 문자열입니다:
SELECT BASE64_DECODE_BINARY('aGVsbG8gd29ybGQ=') AS bytes;
SELECT TO_VARCHAR(BASE64_DECODE_BINARY('aMOpbGxv'), 'UTF-8') AS text;
SELECT TO_VARCHAR(BASE64_DECODE_BINARY('aHR0cHM6Ly9jbGlja2hvdXNlLmNvbQ', '-_'), 'UTF-8') AS url_safe;
그 알파벳 인수는 위치가 곧 뜻이므로 주의 깊게 읽으세요. 최대 세 글자가 허용됩니다: 첫 두 글자는 알파벳 자리 62와 63을 덮어쓰고(기본값은 +와 /), 세 번째는 패딩 문자를 덮어쓰는 것입니다(기본값 =). "URL-safe 알파벳을 써라"고 하려면 '-_'를 넘깁니다. "URL-safe 알파벳이지만 %로 패딩해라"고 하려면, 실제로 바꾸고 싶은 것이 패딩 문자 하나뿐이라도 세 글자 전부, '-_%'를 넘겨야 합니다. 글자를 빼면 기본값이 유지되고, 자리를 건너뛰면서 그다음 자리를 채울 수는 없습니다.
동반자 두 개가 세트를 완성합니다. BASE64_DECODE_STRING()은 디코딩과 텍스트 변환을 한 호출에 해 주므로, 페이로드가 텍스트라면 TO_VARCHAR()를 건너뛸 수 있습니다. 그리고 TRY_ 변형인 TRY_BASE64_DECODE_BINARY()와 TRY_BASE64_DECODE_STRING()은 에러를 일으키지 않고 잘못된 값에 대해 NULL을 돌려주는데, 이건 ClickHouse의 try 형태의 Snowflake 버전입니다.
바이트에서 텍스트로: 문자 인코딩 단계
디코딩은 여러분에게 바이트를 건넵니다. 페이로드가 문서, 이름, JSON 조각이라면, 아직 한 단계가 남아 있습니다: 지정한 문자 인코딩의 텍스트로 해석하는 것이죠. "디코딩은 했는데 어딘가 이상해"라는 문제는 여기서 나옵니다. 바이트의 수열은 여러분이 어떤 바이트 언어를 읽고 있는지 말해 줄 때야말로 비로소 단어가 되므로. 표는 짧고 암기할 가치가 있습니다:
| 방언 | 바이트에서 텍스트로 | 잘못된 수열 |
|---|---|---|
| MySQL / MariaDB | CONVERT(bin USING utf8mb4) |
다시 해석; 쓰레기가 들어가면 쓰레기가 나옴 |
| PostgreSQL | convert_from(bytes, 'UTF8') |
에러를 일으킴 |
| SQL Server | CAST(bin AS VARCHAR) |
손실 발생, 콜레이션에 따라 다름 |
| Oracle | UTL_RAW.CAST_TO_VARCHAR2(raw) |
데이터베이스 문자 인코딩으로 다시 해석 |
| DuckDB | decode(blob) |
변환 오류 |
| ClickHouse | 불필요; String이 곧 텍스트 |
해당 없음 |
| Snowflake | TO_VARCHAR(bin, 'UTF-8') |
에러를 일으킴 |
| SQLite | CAST(blob AS TEXT) |
검사가 전혀 없음 |
넓은 분산은 의도적입니다. PostgreSQL과 DuckDB는 검사하고 거절하는데, 이게 여러분의 후속 코드를 깨진 문자(mojibake)로부터 지켜 줍니다. MySQL과 Oracle은 조용히 다시 해석하므로 빠르지만, UTF-8 세상에 도착한 Latin-1 페이로드로부터 여러분을 구할 수는 없습니다. SQLite는 아예 안 봅니다. SQLite에서 TEXT 값이란 태그만 붙은 바이트이므로. 실용적 규칙: 디코딩 전에 문자 인코딩을 정하고, 쿼리에 리터럴로 쓰며, 비 ASCII 문자가 들어 있는 페이로드로 테스트하세요(고전적인 aMOpbGxv, 즉 héllo가 좋은 카나리입니다. 틀린 인코딩마다 다르게 깨지므로). 진짜 바이너리 페이로드라면 이 섹션을 통째로 건너뛰고, 바이트를 바이트 그대로 두세요.
JWT: 열에 숨은 점 세 개의 base64
JSON Web Token은 데이터베이스 안에서 마주치는 base64 중 가장 흔한 것입니다. 인증 이벤트가 토큰과 함께 기록되므로. JWT는 점으로 나뉜 세 조각, 헤더, 페이로드, 서명입니다. 첫 두 개는 base64로 압축된 JSON 객체인데, 여기가 사람을 걸리게 하는 반전입니다: JWT는 패딩 있는 표준 형태가 아니라 패딩 없는 URL-safe 알파벳을 씁니다. /는 토큰이 자주 다니는 곳에서 새로운 경로 세그먼트를 시작하게 되고, +는 쿼리 스트링에서는 공백으로 읽히며, 패딩 등호는 순전히 형식에 불과하므로, 명세(RFC 7515와 RFC 7519)는 -와 _로 바꿔 패딩을 버렸습니다.
그래서 SQL에서 토큰을 디코딩하는 것은 네 단계의 춤입니다: 점으로 나누기, URL-safe 문자를 표준 알파벳으로 바꿔 놓기, 패딩 되돌리기, 그리고 JSON을 디코딩해 파싱. JSONB 타입을 가진 PostgreSQL은 그 일을 할 편안한 장소입니다:
WITH parts AS (
SELECT split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 1) AS header_b64,
split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 2) AS payload_b64
)
SELECT convert_from(
decode(replace(replace(payload_b64, '-', '+'), '_', '/')
|| CASE MOD(LENGTH(payload_b64), 4)
WHEN 2 THEN '=='
WHEN 3 THEN '='
ELSE '' END,
'base64'),
'UTF8')::jsonb AS claims
FROM parts;
결과는 어떤 다른 열과 마찬가지로 쿼리할 수 있는 JSONB 값이고, 위의 토큰의 경우 {"iat": 1516239022, "sub": "1234567890", "name": "Dev User"}로 돌아옵니다. 결과에서 클레임 하나를 꺼내는 것은 후속 쿼리에서 claims->>'sub'뿐입니다. 패딩 되돌리기는 CASE 식입니다: 길이가 4의 배수에 두 글자 모자라는 base64url 문자열은 등호 두 개가, 세 글자 모자라면 하나가, 정확히 배수라면 없어도 됩니다.
한 발 더 가면 SQL에서 HS256 서명을 검증할 수도 있습니다. HMAC에는 PostgreSQL의 pgcrypto 확장을 사용합니다(아직 설치되지 않았다면 CREATE EXTENSION IF NOT EXISTS pgcrypto;로 한 번 활성화하세요). 공유 시크릿으로 header.payload에 대한 서명을 다시 계산하고, 같은 base64url 방식으로 형식을 잡아, 비교합니다:
WITH parts AS (
SELECT split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 1) AS header_b64,
split_part('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRldiBVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw', '.', 2) AS payload_b64
)
SELECT rtrim(replace(replace(
encode(hmac((header_b64 || '.' || payload_b64)::bytea,
'sql-secret-key'::bytea, 'sha256'), 'base64'),
'+', '-'),
'/', '_'),
'=') = 'pDDL2Ljz7cK7vo9Ne4sMFMec1JMhasDUWa82vl-fYsw' AS valid
FROM parts;
hmac() 호출이 다이제스트를 만들고, encode(..., 'base64')가 그것을 압축하며, 세 문자열 조작이 토큰이 실어 나르는 패딩 없는 URL-safe 형태로 다시 모양을 만듭니다. 위의 토큰과 시크릿에 대해, 답은 기분 좋은 t입니다. 그래도 경고는 곁에 두세요: HMAC 알고리즘(HS256, HS384, HS512)에서만 동작하고, 공유 시크릿을 데이터베이스 문 안에 넣으며, 리포트, 감사, 디버깅을 위해 만들어진 것입니다. 실제로 접근을 막아야 하는 것은 진짜 JWT 라이브러리로 애플리케이션 계층에서 검증해야 합니다.
데이터 URL: 문자열 안에 갇힌 이미지
데이터 URL 포맷(RFC 2397)은 웹이 파일을 링크 안에 인라인으로 넣는 방법입니다: data:image/png;base64, 뒤에 파일의 base64가 따라옵니다. 브라우저는 클립보드에서 그것들을 붙여넣고, 싱글 페이지 앱은 작은 이미지를 그것 안에 임베드하며, 그 모든 흐름은 결국 데이터베이스 열에서 긴 텍스트 값으로 도착합니다. 포맷은 data:{media type}[;{parameters}][;base64],{data}이고, 디코딩에 중요한 부분은 첫 쉼표 뒤 전부뿐입니다. base64 페이로드가 시작하는 곳이 거기이니까요.
SELECT uri,
CAST(FROM_BASE64(SUBSTRING(uri, LOCATE(',', uri) + 1)) AS BINARY) AS png_bytes
FROM uploads
WHERE uri LIKE 'data:image/png;base64,%';
MySQL에서는 이것이 온전한 작업입니다: 쉼표를 찾고, 그 옆을 지나고, 디코딩하면, 이미지 바이트가 바이너리 식으로 손에 잡힙니다. BLOB 열에 저장하거나, 중복 제거를 위해 해시할 수 있는 식이죠. 다른 방언은 함수만 바꿉니다(대부분 SUBSTR()와 INSTR(), 나머지는 substring()와 position())만, 모양은 동일합니다.
경고가 세 가지입니다. 첫째, 모든 데이터 URL이 base64인 것은 아닙니다. ;base64 마커가 없는 데이터 URL은 퍼센트 인코딩된 텍스트를 실고 있고, 그것을 base64 디코더에 넣는 실수를 막기 위해 위의 LIKE 필터가 존재하는 것입니다. 둘째, 접두사의 미디어 타입은 주장이지 사실이 아닙니다. 같은 문자열이 image/png라고 말하면서 JPEG를 담고 있을 수 있습니다. 내용이 중요하다면, 디코딩된 결과의 매직 바이트를 확인하세요(PNG는 89 50 4E 47로 시작하고, JPEG는 FF D8로 시작합니다). 셋째, 데이터 URL은 큽니다. 4메가픽셀 사진은 약 5.5메가바이트 문자열이 되는데, 이건 문자열 함수 이야기가 아니라 열 크기와 메모리 이야기입니다.
URL-safe Base64: 여행을 다니는 알파벳
RFC 4648의 5절은 base64를 위한 두 번째 알파벳을 정의했습니다. 원래 알파벳에는 URL 문법에서 역할을 하는 문자가 두 개가 있기 때문이죠. 더하기 기호는 쿼리 매개변수가 값을 더하는 방법이고, 슬래시는 경로가 나뉘는 방법이며, 패딩 등호는 쿼리 스트링을 만나는 순간 퍼센트 인코딩됩니다. URL-safe 변형은 +를 -로, /를 _로 바꾸고(둘 다 URL에서 무해합니다), 그 위에 JWT 명세는 패딩을 통째로 버립니다. 그 결과, 링크, 경로 세그먼트, 파일명, 조각 식별자를 통과할 때 퍼센트 기호가 하나도 필요 없습니다.
데이터베이스에서 그것과 마주치는 이유는, 대부분 데이터가 거기서 태어났기 때문이 아니라 토큰과 링크가 저장되었기 때문입니다. 네이티브로 처리할 수 있는 곳과, 2분짜리 수기가 필요한 곳입니다:
| 방언 | 네이티브 URL-safe 디코딩 | 노트 |
|---|---|---|
| SQL Server 2025+ | BASE64_DECODE()가 두 알파벳 모두 받음 |
번역이 전혀 필요 없음 |
| ClickHouse 24.6+ | base64URLDecode() |
+와 /도 여전히 받음 |
| Snowflake | BASE64_DECODE_BINARY(s, '-_') |
알파벳을 위치 인수로 받음 |
| MySQL / MariaDB | 없음 | 문자 번역; 실패 시 NULL 예상 |
| PostgreSQL | 없음 | 문자 번역; 실패 시 에러 예상 |
| Oracle | 없음 | RAW 캐스트 전에 문자 번역 |
| DuckDB | 없음 (밑줄 거부) | 문자 번역; 길이는 4의 배수로 유지 |
| SQLite CLI | 없음 | 문자 번역; 디코더는 모르는 것을 건너뜀 |
수기는 REPLACE() 호출 두 개와 패딩 되돌리기이며, 모든 방언에서 동일합니다. PostgreSQL에서는 이렇게 읽힙니다:
SELECT convert_from(
decode(replace(replace('aGVsbG8', '-', '+'), '_', '/')
|| CASE MOD(LENGTH('aGVsbG8'), 4)
WHEN 2 THEN '=='
WHEN 3 THEN '='
ELSE '' END,
'base64'),
'UTF8') AS text;
-를 다시 +로, _를 다시 /로 바꿔 놓고, 길이의 4 나머지 기준으로 빠진 패딩을 붙이면, 그 뒤는 표준 디코더가 이어갑니다. 입력 aGVsbG8("hello"의 패딩 없는 URL-safe 형태)는 그 단어 자체로 돌아옵니다. 반복되는 두 실수는 바로 CASE 식이 막아 주는 것입니다: 패딩을 잊는 것(엄격한 디코더가 4의 배수가 아닌 길이를 거부하게 되고), 문자 번역을 건너뛰는 것(URL-safe 알파벳을 모르는 디코더가 밑줄에 걸리게 되죠). 번역을 한 번, 데이터베이스의 재사용 함수로 써 두면, 이 문제 전체가 다시 찾아오지 않습니다.
파일, BLOB, 그리고 큰 것들
디코딩은 파일이 열에서 나오는 방법이고, 방언마다 조금씩 다른 출구 문이 있습니다. DuckDB에서는 왕복이 두 문장입니다: 파일을 BLOB으로 읽는 문장 하나, 디코딩된 바이트를 다시 쓰는 문장 하나:
SELECT filename, octet_length(content) AS size
FROM read_blob('/data/uploads/*.png');
읽는 쪽: read_blob()은 파일명, 이름 목록, glob 패턴을 받아 파일마다 filename과 content 열을 돌려주는 테이블 함수입니다. 쓰는 쪽은 그 자체로 한 문장입니다: BLOB 포맷의 COPY는 맨 바이트를 그대로 쓰므로, 따옴표도, 이스케이프도 없습니다. 디코딩된 페이로드가 원하는 것 그대로죠.
COPY (SELECT from_base64(b64) FROM attachments WHERE id = 42)
TO '/data/restored/cat.png' (FORMAT BLOB);
PostgreSQL의 출구 문은 large object API입니다. large object는 OID로 주소 지정한 서버 쪽 바이너리 청크 저장소이고, lo_export()가 그것을 데이터베이스 서버의 파일로 써 냅니다. 슈퍼유저 권한 또는 pg_write_server_files 특권이 필요하고, 목적지는 서버 프로세스가 쓸 수 있는 경로여야 하므로, 실제로는 애플리케이션 코드보다 유지보수 스크립트의 일입니다:
SELECT lo_export(12345, '/tmp/attachments/cat.png');
MySQL에는 제한적인 SELECT ... INTO DUMPFILE이라는 탈출구 하나뿐입니다(단일 행, 서버 쪽 경로, FILE 특권). SQL Server에는 순수 SQL 파일 작성기가 아예 없습니다(디스크에 쓰는 것은 클라이언트 또는 에이전트의 일이며, 내장 익스포트 도구로 합니다). 공정한 설계입니다: 데이터베이스는 바이트를 저장하고, 애플리케이션이 파일이 어디에 속하는지 결정하죠. SQLite는 스펙트럼의 반대 끝에 앉아 있습니다. 거기서 애플리케이션이 바로 호스트이고, BLOB 열은 호스트 언어의 한 호출로 디스크에 바로 쓸 수 있습니다.
그리고 상한(ceiling)이 있습니다. 모두 똑같다고 위장하는 이 데이터베이스들의 상한은, 예상보다 훨씬 더 다릅니다:
| 방언 | 바이너리 타입 | 실용적 상한 |
|---|---|---|
| PostgreSQL | bytea |
값당 1 GB |
| MySQL / MariaDB | BLOB 계열 | max_allowed_packet (MySQL 8 기본값 64 MB) |
| SQL Server | varbinary(max) |
값당 2 GB |
| Oracle | RAW / BLOB |
RAW: SQL에서 2000바이트, BLOB: PL/SQL 청킹으로 4 GB |
| SQLite | BLOB |
파일과 메모리가 허락하는 만큼 |
| DuckDB | BLOB |
매우 큼; 메모리와 디스크가 결정 |
| ClickHouse | String |
열 크기는 가상의 것이고, 행이 단위 |
| Snowflake | BINARY |
기본값은 값당 8 MB (맨 BINARY 열); 명시적 BINARY(N)로 최대 64 MB |
MySQL 행은 이야기가 붙을 만합니다. 프로덕션에서 사람들을 놀라게 하는 것이 바로 이것이기 때문이죠. max_allowed_packet은 클라이언트와 서버 사이 단일 패킷의 크기를 제한하고, base64 문자열은 그 패킷의 일부입니다. 50메가바이트 사진을 base64로 인코딩하면 약 67메가바이트 문자열이 되는데, 이건 기본값 64메가바이트보다 크고, 그 결과는 쿼리에서 읽을 수 있는 에러가 아닙니다: 데이터 손상처럼 보이는 잘린 값 또는 NULL입니다. MySQL 열로 큰 파일을 옮긴다면, 시작 전에 그 한도를 확인하세요. 그리고 생(raw) 바이트가 아니라 인코딩된 형태가 그 한도에 대고 계산된다는 것을 기억하세요.
이메일 래핑과 MIME 줄
이메일 체계를 견뎌 낸 base64는 전부 기념품을 하나 지니고 있습니다: 줄바꿈. MIME, 즉 이메일이 바이너리 첨부 파일을 실을 수 있게 하는 표준 모음(RFC 2045, 6.8절)은 base64 출력을 76자마다 래핑하고, 줄을 캐리지 리턴과 줄바꿈으로 끝냅니다. 이 래핑은 옛 이메일 네트워크가 그보다 긴 줄을 신뢰할 수 없어서 생겨났고, 그 뒤로 관성으로 계속 전해져 왔습니다. 그래서 데이터베이스 열에 저장된 첨부 파일은 76자마다 줄바꿈이 있는 base64 문자열인 경우가 많고, 여러분의 디코더가 그 줄바꿈과 맺는 관계가 이 일이 한 문장이 될지 두 문장이 될지를 결정합니다.
| 디코더 | 래핑을 삼키나? | 안 삼키면 |
|---|---|---|
MySQL / MariaDB FROM_BASE64() |
예 | - |
PostgreSQL decode() |
예 | - |
SQL Server BASE64_DECODE() |
예 | - |
SQLite CLI base64() |
예 | - |
| ClickHouse 26.7+ | 예 | - |
| ClickHouse 26.7 이전 | 아니오 | 먼저 공백 제거 |
DuckDB from_base64() |
아니오 | 먼저 공백 제거 |
Oracle UTL_ENCODE.BASE64_DECODE() |
아니오 | PL/SQL 계층에서 공백 제거 |
"먼저 제거"라는 고치는 방법은 한 식(expression)이며, 항상 안전합니다. 공백은 base64 알파벳의 일부가 아니므로, 정당한 페이로드는 공백, 탭, 줄바꿈을 담을 수 없고, 따라서 그것들을 지워도 정보가 파괴될 수 없습니다. PostgreSQL에서는 관행이 regexp_replace() 하나입니다:
SELECT decode(regexp_replace(attachment_b64, '\s', '', 'g'), 'base64')
FROM email_attachments;
줄바꿈을 포함해 모든 공백 문자가 사라지고, 디코더는 깨끗한 연속 문자열 하나를 봅니다. 이걸 DuckDB(두 줄바꿈 문자에 대한 replace()를 사용하는)나 26.7 이전 ClickHouse에서 실행하면, 래핑된 첨부 파일도 래핑되지 않은 것과 똑같이 디코딩됩니다.
API 페이로드, 설정, 인증 헤더
개별 함수들에서 한 발 물러서면 패턴이 보입니다: 데이터베이스 열의 base64는 거의 항상 세 가지 중 하나입니다. JSON 문서 안의 필드(API가 인라인하기로 결정한 이미지, 인증서, 파일). 설정 값(어떤 도구가 base64를 선호하는 시크릿 또는 자격 증명. base64는 따옴표도, 줄바꿈도, 백슬래시도 없이 YAML 파일 한 줄에 들어맞으므로). 아니면 인증 산출물(Basic 인증 헤더, 저장된 토큰, 세션 blob). 각자의 디코딩 모양과 함께 보여 드립니다.
JSON 필드. JSON은 텍스트로 도착했고, 필드는 문자열이며, base64는 그 안에 숨어 있습니다. 방언의 JSON 함수로 필드를 꺼낸 뒤 디코딩합니다. MySQL에서는 온전한 체인이 한 식입니다:
SELECT event_id,
CAST(FROM_BASE64(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.image'))) AS BINARY) AS image_bytes
FROM api_events
WHERE JSON_TYPE(JSON_EXTRACT(payload, '$.image')) = 'STRING';
PostgreSQL은 JSONB로 같은 일을 합니다. 필드가 ->> 연산자로 텍스트로 나오고, decode()가 이어가죠. 마지막 줄의 JSON_TYPE 가드는 보기에 비해 중요합니다: 필드가 숫자, 중첩 객체, 또는 누락된 행에서 디코더를 멀리 해 두기 때문이며, MySQL에서는 그런 행이 "이미지가 있는 이벤트가 몇 개인가"라는 여러분의 카운트에 조용한 NULL을 기여하게 될 테니까요.
인증 헤더. Basic 인증 헤더는 리터럴 문자열 Basic 뒤에 username:password의 base64가 따라온 것입니다. SQL에서 그것을 디코딩하는 것은 서브스트링 하나와 스플릿 하나인데, 사람들이 이걸 하는 이유가 정확히 여기 있습니다(보통은 어떤 유저가 어떤 엔드포인트를 쳤는지 감사하기 위해서고, 비밀번호를 검증하기 위해서가 아닙니다. 비밀번호는 데이터베이스가 평문으로 봐서는 안 되니까):
SELECT request_id,
SUBSTRING_INDEX(CAST(FROM_BASE64(SUBSTRING(header_value, 7)) AS CHAR), ':', 1) AS username,
SUBSTRING_INDEX(CAST(FROM_BASE64(SUBSTRING(header_value, 7)) AS CHAR), ':', -1) AS secret
FROM http_log
WHERE header_name = 'Authorization'
AND header_value LIKE 'Basic %';
SUBSTRING(header_value, 7)이 Basic 접두사를 벗기고, 디코더가 원래 텍스트를 되찾으며, 두 SUBSTRING_INDEX() 호출이 콜론에서 나눠 줍니다. 앞 부분은 유저에게, 끝 부분은 시크릿에게. PostgreSQL에서는 같은 쿼리가 substring()과 split_part()를 씁니다.
설정 값. 여기의 디코딩 방향은 감사 작업입니다: 누군가가 시크릿을 설정 테이블에 base64로 저장해 두었고(Kubernetes에서 이어 온 습관. 거기서는 시크릿 값이 저장 시 base64입니다), 여러분은 거기 실제로 무엇이 들어 있는지 보고 싶거나, 새 환경이 소비할 익스포트를 만들고 있는 것입니다. 모양은 값당 SELECT 하나이며, 값이 텍스트라면 문자 인코딩 단계가 적용됩니다:
SELECT name,
CONVERT(FROM_BASE64(value) USING utf8mb4) AS plaintext
FROM app_config
WHERE name LIKE '%_secret%';
그 결과를 그에 걸맞은 신중함으로 다뤄야 합니다. 여러분은 방금 저장된 시크릿을 눈에 보이는 쿼리 출력이 만들어 버린 것입니다: 쿼리를 실행하는 계정이 마땅한 권한을 가지는지, 결과가 로그로 복사되지 않는지, 설정 안에 base64를 넣는 습관이 두 번째 시선을 받는지를 확인하세요. Base64는 운송이지 금고가 아니며, 감사 쿼리는 바로 그것이 드러나는 순간입니다.
물고 들어오는 함정들
이 목록의 모든 함정은 적어도 하나의 코드베이스에서 한 오후를 날린 것들이며, 하나같이 base64 자체 때문이 아니라 SQL 방언들이 base64를 다룬 방식에 특화된 것입니다.
- 조용한 NULL. MySQL과 MariaDB는 잘못된 입력을 아무 불만 없이
NULL로 디코딩합니다. 디코딩된 값으로 조인하는 리포트에서, 그런 행은 그저 사라지고, "0행"과 "14행이 독이 물려서 0행"의 차이는 누군가가 카운트가 안 맞는 이유를 물을 때까지 보이지 않습니다. 여러분의 디코더가 조용한 쪽이라면, NULL을 의도적으로 세어 보세요. - 4의 배수 규칙, 고르게 적용되지 않은. 길이가 4의 배수가 아닌 문자열은 base64가 아니지만, 뭘 해야 하는지 방언들이 의견이 다릅니다: PostgreSQL은 에러를 일으키고, DuckDB는 변환 오류를, ClickHouse는 예외를, MySQL은
NULL을 돌려주며, SQLite CLI는 조용히 될 수 있는 만큼 디코딩합니다. 같은 데이터 파일이 다섯 데이터베이스에서 다섯 가지 다른 결과를 만듭니다. 그래서 "Postgres에서는 되었는데"는 테스트가 아닙니다. - 알파벳 불일치. 표준 알파벳 디코더에 URL-safe 토큰(JWT, 링크, 파일명)을 넣는 경우: SQL Server는 받고, ClickHouse의
base64URLDecode()는 받고, Snowflake는 바른 인수로 받으며, 나머지는NULL을 돌려주거나 에러를 일으키거나, SQLite CLI의 경우처럼 밑줄을 조용히 뺀 뒤 틀린 바이트를 건네 줍니다. 틀린 바이트 경우가 교활한 것인데, 결과가 그럴듯하게 보이기 때문이죠. - MIME 래핑. 줄바꿈을 삼키지 않는 디코더(DuckDB, 26.7 이전 ClickHouse, Oracle)에 래핑된 입력을 넣으면 실패하고, 그 실패는 "줄바꿈이 여기 있다"보다는 "뒤 76글자가 쓰레기다"처럼 보이기 일쑤입니다. 에러가 줄바꿈 직후 문자를 가리키기 때문이죠.
- 디스플레이 트릭. mysql 클라이언트는 바이너리를 16진수로 출력하고, psql은 bytea를
\x16진수로, Snowflake는 BINARY를 16진수로, Oracle은 RAW를 16진수로 출력합니다. 클라이언트 네 개, 16진수 표기 네 가지, 그리고 화면에 숫자가 보인다고 데이터가 손상됐다고 결론 내리는 아주 인간적인 실수 하나. 눈으로 결과를 읽기 전에 항상 명시적으로 변환하세요. - 잘못된 곳의 패딩. 등호는 오직 끝에, 하나 또는 두 개만 합법입니다.
YQ==BQ==같은 문자열은 한 벌의 분장을 입은 두 개의 유효한 그룹이고, 엄격한 디코더는 거절하는 동안 관대한 디코더는 아무도 원하지 않은 것으로 디코딩합니다. 저장된 값 한가운데 패딩을 본다면, 그것을 쓴 인코더가 고장 난 것이므로, 데이터를 고치는 것은 일회성 작업입니다. - 문자 인코딩 서프라이즈. 디코딩은 성공하고, 텍스트가 돌아오고, 강세부호가 틀렸습니다. 바이트는 멀쩡했고, 해석이 틀린 것이죠. 이건
utf8mb4여야 할CONVERT(... USING latin1), 잘못된 수열을 삼키는 콜레이션 아래에서 돌았던CAST(bin AS VARCHAR), 아예 검사하지 않는 SQLite의CAST(blob AS TEXT)입니다. 쿼리에 문자 인코딩을 리터럴로 고정하고, 강세부호가 있는 카나리로 테스트하세요. - 상한들. SQL 문장의 Oracle 2000바이트 RAW 한도, MySQL의
max_allowed_packet가 인코딩된 크기에 세금을 거는, PostgreSQL의 1 GB bytea 천장, Snowflake의 8 MB 기본 BINARY 길이. 하나같이 문서화되어 있고, 하나같이 프로덕션에서 발견되며, 하나같이 데이터가 커지기 전에 쓸 수 있었던 크기 검사입니다. - 디코딩된 바이트를 믿는 것. Base64는 따옴표로 가득한 문자열을 포함해 무엇이든 실어 나를 수 있습니다. 디코딩은 정제가 아닙니다. 디코딩된 텍스트로 무엇을 하든(비교, 로그 기록, 다른 문장에 결합) 여전히 통상적인 보호가 필요하고, base64 왕복 이후에도 파라미터화 쿼리는 여전히 파라미터화 쿼리입니다.
안전한 쪽에 머무는 법
- 함수보다 먼저 타입을 정하세요. 페이로드가 바이너리인가, 텍스트인가? 바이너리는 BLOB/bytea/varbinary로 가고 거기 머뭅니다. 텍스트는 명시적인 인코딩으로 문자 인코딩 단계를 통과합니다. SQL의 base64 고통의 절반은 텍스트 열로 헤매어 들어온(또는 그 반대) 바이너리 페이로드가 이제 해석되고 있는 것입니다.
- 디코딩 전에 검사하세요. 아니면 부드럽게 디코딩하세요. 알파벳 위 정규식과 길이 4 나머지 검사는 공짜에 가까우며, 배치를 멈추게 하는 에러를 셀 수 있는
NULL로 바꿔 줍니다. 방언이 try 형태를 제공한다면(ClickHouse의tryBase64Decode, Snowflake의TRY_BASE64_DECODE_BINARY), 리포트용으로는 그것 쓰고, 추측해선 안 되는 파이프라인에는 엄격한 형태를 남겨 두세요. - 데이터베이스뿐 아니라 방언도 버전 검사하세요. ClickHouse 26.7은 공백 처리를 바꿨고, SQL Server 2025는 아예 그 함수가 처음 있는 릴리스이며, SQLite CLI는 3.41이 필요하고, ClickHouse의 패딩 기대는 시간이 지나며 더 엄격해졌습니다. "ClickHouse입니다"는 명세가 아닙니다. "ClickHouse 24.8입니다"가 명세입니다.
- 각 열의 알파벳을 문서화하세요. 표준 base64와 URL-safe base64를 둘 다 담을 수 있는 열은 다음 개발자를 혼란에 빠뜨릴 열입니다. 데이터가 JWT에서 왔다면 스키마 주석에 그렇게 적고, MIME 첨부에서 왔다면 그것도 적어 두세요. 디코더 선택은 쿼리의 속성이 아니라 열의 속성입니다.
- 바이트는 저장하고, 가장자리에서 인코딩하세요. 스키마를 좌우할 수 있다면, BLOB 열에 API 계층의 인코딩이 base64 텍스트 열보다 저장에서도, 색인에서도, 미래의 모든 쿼리에서도 이깁니다. 열 안의 base64는 호환성 세금이며, 세금은 경계에서 한 번 내는 것이 낫습니다.
- 카나리로 왕복 테스트하세요. 새로운 디코딩 경로를 믿기 전에, 알려진 페이로드를 같은 데이터베이스에서 인코딩과 디코딩을 거쳐 보내고 비교하세요. 카나리에는 비 ASCII 문자(문자 인코딩 단계를 걸어 보기 위해), 패딩 꼬리를 남기는 길이(패딩 규칙을 걸어 보기 위해), 그리고 URL-safe 경로라면 어딘가에
-또는_(알파벳 번역을 걸어 보기 위해)가 들어 있어야 합니다. - 쿼리 텍스트에 시크릿을 넣지 마세요. pgcrypto로 하는 JWT 검증은 공유 시크릿을 문 안에 넣고, 설정 감사는 평문 시크릿을 결과에 넣습니다. 둘 다 정당한 작업이지만, 프로덕션 연결 문자열과
SELECT * INTO OUTFILE이 아니라 제한된 계정, 깨끗한 로그, 리뷰를 받을 자격이 있습니다.
SQL에서의 언패킹, 짧은 역사
base64 포맷 자체는 인터넷의 유용한 부분보다 오래됐습니다. 1990년대 중반, MIME을 위해 표준화되었고(RFC 2045, 6.8절. 이 인코딩을 실어 나르던 1993년의 MIME 메시지 본문 명세 RFC 1521를 폐기한 것이죠), 이름은 단순한 셈입니다: 알파벳이 64문자이니까요. URL-safe 변형은 2006년 RFC 4648과 함께 도착했고, 2015년의 JWT 명세가 토큰 열에서 실제로 보는 변형으로 그것을 만들었습니다. 하지만 데이터베이스들은 각자의 일정대로 포맷을 만났고, 그 일정은 각자의 영혼에 대해 뭔가 말해 줍니다.
2002. PostgreSQL 7.2는 이미 base64를 encode()와 decode()의 일급 포맷으로 나열합니다 - Oracle의 9i 시대 UTL_ENCODE와 동시대의 것이며, 근소한 차이로 이 가족에서 가장 오래된 base64 지원입니다. 진짜 바이너리 타입과 포맷 인수를 가진 데이터베이스는 일찍 그곳에 도착했습니다. 답이 열거형 값 하나만 떨어져 있었으니까요.
2000년대 초. Oracle의 UTL_ENCODE 패키지는 9i 시대에 등장해, MIME 헤더, quoted-printable, uuecode 함수 옆에 base64를 실고 있습니다. RAW가 들어가고 RAW가 나오는 것인데, 아주 Oracle답고, 그 모양을 25년 동안 유지해 왔습니다.
2013. MySQL 5.6이 TO_BASE64()와 FROM_BASE64()를 추가하고, MariaDB 10.0이 둘 다 포크로 가져갔습니다. 이 쌍은 76자 행으로 인코딩하고, 공백 관용으로 디코딩하며, 열두 개 메이저 버전 동안 변하지 않은 짝꿍 세트입니다.
2018. ClickHouse 18.16이 MySQL 스타일 별명과 함께 base64Decode()를 출시했습니다. 컬럼형 세계가 로그 스키마에 이미 base64를 실어 나르던 워크로드를 들여오는 중이었기 때문이죠.
2023. SQLite 3.41.0이 base64()와 그 base85 형제를 애플리케이션 정의 함수로 커맨드라인 셸에 추가합니다. 코어 라이브러리는 늘 그렇듯 아무것도 받지 않고, 사람들이 실제로 SQLite 데이터베이스를 만지작거리는 셸이 도구를 받습니다.
2025. 2025년 11월에 일반 가용이 된 SQL Server 2025가, 36년의 부재 끝에 BASE64_DECODE()와 BASE64_ENCODE()를 T-SQL에 추가합니다. 릴리스 노트는 그것을 절제된 기능처럼 취급하고, 커뮤니티는 그것을 구출처럼 취급합니다.
패턴은 보이기만 하면 깨끗합니다. 진짜 바이너리 타입과 포맷 인수를 가진 데이터베이스(PostgreSQL, 그리고 나름대로의 방식으로 Oracle)는 필요가 명확해진 날 base64를 얻었습니다. 나머지는(MySQL, SQL Server) 문자열 편의 기능으로 취급하고 그에 맞게 일정을 잡았고, 임베더블 엔진(SQLite)은 아직 그것을 호스트 애플리케이션의 일로 여기고 있습니다. 친절한 예외, CLI를 제외하면요.
미소가 지어질 만한 사실들
- SQL Server는 1989년부터 2025년까지 base64 디코더 없이 보냈고, 커뮤니티의 답은
CAST(N'' AS XML)안의xs:base64Binary()라는 이름의 XML 함수였습니다. 수많은 엔터프라이즈 쿼리가 XML 파서를 통해 토큰을 디코딩했습니다. XML 파서는 2001년부터 base64를 이해해 왔지만, SQL 엔진은 그러지 못했으니까요. - SQLite CLI의
base64()는 이 가족에서 유일한 형체 변환자입니다: BLOB을 주면 인코딩하고, 텍스트를 주면 디코딩합니다. 함수는 인수의 타입에 따라 일을 바꾸는데, 이건 작은 규모의 SQL 심령술이자 경솔한 사람용 진정한 함정입니다. - PostgreSQL의 인코더는 1996년 MIME 표준과 똑같이 76자에서 래핑하지만, 줄을 캐리지 리턴과 줄바꿈이 아니라 홀로 남은 줄바꿈 하나로 끝냅니다. 명세 20년 뒤, 글자 하나를 덜 쓰는 것. 디코더는 둘 다 무시하므로, 출력을 디프하지 않는 한 이 반란은 보이지 않습니다.
mysql클라이언트에서SELECT FROM_BASE64('aGVsbG8=')는0x68656C6C6F를 출력합니다. 데이터가 16진수라서도, 뭐가 잘못된 것도 아니라, 클라이언트가 여러분을 대신해 바이너리 문자열은 16진수로 보여주는 것이 좋다고 결정해서입니다. 설정 이름은binary-as-hex이며, 수많은 개발자가 자기 디코더가 고장 났다고 믿게 만들어 왔습니다.- Oracle의 SQL 레벨 RAW 타입은 2000바이트로 제한되어, 3킬로바이트 인증서는 RAW 리터럴로 SQL 문에 붙여넣기도 못 합니다. 디코딩은 PL/SQL에서, 청크로, 루프를 통해 이루어져야 합니다. 한도는 1990년대 것인데, 루프는 여전히 권장 답입니다.
- Snowflake는 모든 결과 집합에서
BINARY값을 16진수로 표시하므로, 완벽히 성공한 "hello" 디코딩도 여러분의 화면에는68656C6C6F로 도착합니다. 방언 두 개, 16진수 표시 두 개, 불쾌감은 똑같이 하나. - ClickHouse는 네이티브
base64Decode()옆에 별명FROM_BASE64()를 남겨 두고 있는데, 달리라면 실행도 되지 않을 쿼리를 들고 온 MySQL 난민들에게 작은 배려입니다. - 온 가족이 한 가지 조용한 사실을 공유합니다: base64는 나갈 때 33퍼센트 세금, 들어올 때 25퍼센트 환급이고, 여기 여덟 디코더 중 어느 것도 그것을 먼저 말해 주지 않습니다. 포맷은 의상이고, 옷장은 무료이며, 재단술이야말로 이 글의 주제입니다.
계속 나아가기
이 글은 변장을 벗기는 일에 대해였습니다: 각 방언의 함수, 그 성격, 그리고 그것을 입은 페이로드들(JWT, 데이터 URL, 래핑된 이메일, JSON 필드, 설정 값, 인증 헤더). 반대 방향은 그 자체로 별개의 동물이며, 자기만의 서프라이즈 한 세트를 가집니다: 어떤 인코더가 출력을 76자에서 래핑하고 어떤 것이 그렇지 않은지, 토큰이 기대하는 패딩 없는 URL-safe 형태를 어떻게 만드는지, 여러분의 열 너비를 결정하는 크기 산술, 그리고 SQL Server의 36년 공백이 아직 오래된 버전에 남아 있는 사람들에게 무엇을 의미하는지. 그것들 전부, TO_BASE64()에서 BASE64_ENCODE()까지, 이 페이지에서 링크된 관련 SQL Base64 인코딩 글에서 깊이 다룹니다. 여기에서 디코딩하고, 거기에서 인코딩하면, 온전한 왕복이 한 오후 안에 들어갑니다.
마지막 업데이트: 2026-09-08
관련 문서: SQL에서의 Base64 인코딩: 완전한 가이드