Java에서의 Base64 디코딩: 완전한 가이드
서포트 티켓, API 응답, Kubernetes 시크릿, 혹은 URL 한가운데 묻혀서 마주칩니다. 영문자와 숫자가 길게 이어져 있고, 그 사이로 간혹 +나 /, -, _가 끼어 있으며, 끝에 = 기호가 하나둘 매달려 있는 형태입니다. 누군가는 이것이 Base64라고, 그 안에 필요한 것이 들어 있다고 말합니다. 비밀번호든, JSON 페이로드든, 인증서든, 사진이든, 어쨌든 필요하다는 것이지요. 이 가이드는 그걸 되찾아 오는 Java 레시피입니다. 홈 페이지에서 이 포맷을 자세히 다루므로, 간단히 방향부터 잡겠습니다. Base64는 데이터의 바이트 3개를 64글자 알파벳에서 고른 문자 4개로 다시 쓰고, 마지막 덩어리가 짧으면 끝에 = 패딩을 하나둘 붙입니다. 디코딩은 이 교환에서 줄어드는 방향입니다. 문자 4개가 들어가면 바이트 3개가 나오므로, 결과는 항상 입력보다 약 4분의 1 작은 공간이면 됩니다.
먼저 헤드라인 뉴스부터. 좋은 소식입니다. 2014년 3월 18일 이후로 모든 JDK는 표준 라이브러리에 완비된 Base64 도구 꾸러미를 함께 싣고 있습니다. java.util.Base64가 그것입니다. 다운로드도, Maven 좌표도, 네이티브 라이브러리도 필요 없습니다. import 하나, 팩토리 메서드 7개, 알파벳 3종, 그리고 Java 8에서 지금의 Java 26까지 똑같은 동작. 이 글의 모든 내용은 이 클래스 하나를 바탕으로 합니다.
시작 전에 정직한 경계선 하나: 이 글은 디코더 쪽의 이야기입니다. 만나는 알파벳에 맞는 디코더를 고르는 법, JDK의 오류 메시지를 의사가 판독물을 보듯 읽는 법, 모지바케 없이 바이트를 텍스트로 바꾸는 법, PEM 아머를 벗기는 법, 수 기가바이트 페이로드를 스트리밍하는 법, 그리고 이 포맷이 길가에 조용히 뿌려 놓은 보안 함정을 알아보는 법을 배우게 됩니다. 반대 방향, 즉 바이트를 문자열로 싸 넣는 일은 별도의 가이드가 있으며, 이 글의 끝에서 연결해 두었습니다.
이미 손에 쥔 것들
Java에서 Base64를 설치한다는 것은 화이트보드 앞에서 내리는 한 줄짜리 답변입니다. "JDK에 있습니다." 클래스 java.util.Base64는 1.8부터 java.base 모듈의 일부였으며, 2026년에도 자바독에는 여전히 Since: 1.8라고 적혀 있습니다. 설치하는 것은 JDK뿐입니다. 어떤 벤더(Oracle, Eclipse Temurin, Amazon Corretto, Zulu)의 Java 8 이상 버전이든 사용되며, Debian 기반 시스템에서는 한 줄이면 됩니다:
sudo apt install openjdk-17-jdk-headless
API는 팩토리 구조입니다. 디코더를 직접 생성하는 일은 결코 없고, 클래스에 하나를 달라고 요청합니다. 7개의 팩토리 메서드는 방향별로 세 가지 성격을 나눠 주는데, 디코더 쪽은 이런 모습입니다:
| 팩토리 메서드 | 알파벳 | 성격 | 이런 때에 쓰면 좋은가 |
|---|---|---|---|
getDecoder() |
A-Z a-z 0-9 + / |
엄격함: 알파벳 밖의 문자는 모두 거부 | 내가 만들거나 통제하는 데이터 |
getUrlDecoder() |
A-Z a-z 0-9 - _ |
엄격함, URL용 알파벳 | JWT, 토큰, ID, URL에서 태어난 모든 것 |
getMimeDecoder() |
A-Z a-z 0-9 + / |
관대함: 알파벳이 아닌 문자는 전부 건너뜀 | 이메일, 정말로 줄바꿈된 입력, PEM 본문 |
getEncoder(), getUrlEncoder(), getMimeEncoder() |
위와 동일 | 인코딩, 자매 가이드의 영역 | Base64를 읽는 대신 만들어 낼 때 |
반환되는 인스턴스의 세 가지 특성은 외워 둘 만합니다. 첫째, 스레드 안전입니다. 자바독은 인스턴스가 "여러 동시 스레드가 사용해도 안전하다"고 말하고, 소스 코드는 팩토리 메서드가 매번 같은 공유 인스턴스를 반환하는 것을 보여 줍니다. 그래서 Base64.getDecoder() == Base64.getDecoder()는 참입니다. 정적 필드에 디코더 하나를 만들어 서비스 전체에서 공유하세요. 복사조차 하지 않으니까요. 둘째, 호출 사이 상태가 없으므로 초기화할 것도, 동기화를 둘러 걱정할 것도 없습니다. 셋째, 바이트 배열이나 문자열이 기대되는 자리에 null을 넘기는 것은 부드러운 무 동작이 아닙니다. 클래스 자바독이 약속한 대로, 정확히 NullPointerException입니다.
코드베이스에서 오래된 라이브러리를 여전히 만나게 될 테니, 지형도 하나 그려 두겠습니다. Apache Commons Codec(현재 1.22.1)은 1.0부터 자체 org.apache.commons.codec.binary.Base64를 싣고 있으며, 엄격/관대 정책과 줄 길이, 구분자를 다이얼로 공개하는 Builder API를 제공합니다. Java 8 이전 JVM을 지원해야 하거나, 그 형태 검사 헬퍼가 필요할 때만 적합한 도구입니다. Guava는 비슷하게 쓸모 있는 노장 com.google.common.io.BaseEncoding을 싣고 있으며, 빅 데이터 스택에서 여전히 인기 있습니다. 현대 JVM에서 도는 것이라면 java.util.Base64가 기본 선택입니다. 종속성이 제로이고, 커뮤니티 벤치마크에서도 계속 이 무리 중 가장 빠른 것으로 꼽힙니다(성능 섹션에서 더 다루겠습니다).
첫 문자열 디코딩하기
디코딩 일상의 90퍼센트는 몇 줄이면 해결됩니다. RFC가 알파벳을 설명할 때 스스로 쓰는 가장 작은 예제를 그대로 가져와, 의식 전체를 보여 드리겠습니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstDecode {
public static void main(String[] args) {
byte[] bytes = Base64.getDecoder().decode("TWFu");
String text = new String(bytes, StandardCharsets.UTF_8);
System.out.println(text); // Man
}
}
방금 일어난 일에 대해 네 문장으로 말씀드리겠습니다. 첫째, 진입점은 클래스가 아니라 인스턴스입니다. decode()는 팩토리에서 받은 Base64.Decoder 객체에 살고 있습니다. 둘째, 그리고 이것이 이 API 전체에서 가장 중요한 설계 결정입니다. 결과는 바이트 배열이지, 결코 String이 아닙니다. 페이로드는 문장이든, JPEG든, 해시든, 손에 든 것이 무엇이인지 모를 때까지 그중 어느 것도 같은 취급을 받아서는 안 되므로, JDK는 일부러 바이트에서 멈춥니다. 셋째, 바이트에서 텍스트로 넘어가는 것은 명시적인 문자 인코딩을 갖는 별개의, 의도적인 단계입니다. 이 단계가 게을러지는 곳이 바로 "café"가 모지바케로 변하는 지점이죠. 아래 문자 인코딩 섹션은 이 단계만을 위해 있습니다. 넷째, 빈 문자열은 1급 값입니다. Base64.getDecoder().decode("")는 예외 없이, 시끄러움 없이 길이 0인 배열을 돌려 줍니다.
머릿속에 두고 쓸 테스트 데이터로, TWFu는 표준이 스스로 쓰는 스모크 테스트라는 점을 기억해 두세요. 디코딩 코드가 이걸 Man으로 바꾼다면, 그 기계는 정직하다는 뜻입니다. 반대 방향 왕복은 같은 API 두 줄이면 되며, 맨 끝에서 연결해 둔 인코딩 가이드에서 제대로 다룹니다.
디코더 라인업
Java는 디코더 하나를 내밀지 않습니다. 셋을 줍니다. 세 가지 사이의 차이는 정책 결정의 문제입니다. 어느 알파벳을 받아들일 것이며, 얼마나 많은 오염을 참을 것인가. 셋 다 같은 중첩 클래스 Base64.Decoder의 인스턴스입니다. 클래스 자바독은 성격별로 한 문장씩 그 구분을 써 놓았습니다. 기본 디코더와 URL용 디코더에 대해서는, 디코더가 "base64 알파벳에 없는 문자를 포함하는 데이터를 거부한다"고 합니다. MIME 디코더에 대해서는, "base64 알파벳 테이블에서 찾을 수 없는 모든 줄 구분자 또는 기타 문자는 디코딩 작업에서 무시된다"고 합니다. 그 두 번째 문장이 MIME 이야기 전체를 한 줄로 요약한 것입니다. 그리고 그 안에는 이빨이 있습니다. "무시"라는 말은 줄바꿈뿐 아니라 알파벳 문자가 아닌 모든 것을 뜻하기 때문입니다.
선택 규칙은 짧습니다. 기본은 getDecoder(). 값이 URL에서 왔거나, 토큰에서 왔거나, "URL-safe"를 약속한 API에서 왔다면 getUrlDecoder()로 바꿉니다. 정말로 MIME 모양의 입력(76자마다 줄바꿈, 메일 시스템에서 바로 온 형태)이 기대될 때만 getMimeDecoder()를 집습니다. 망설여지면 엄격한 쪽을 택하세요. 엄격한 디코더의 일상은 예상 밖의 상황을 실패시키는 것이며, 신뢰 경계에서 원하는 것은 정확히 그것입니다. 반면 관대한 디코더는 부패를 확대하는 돋보기입니다. 엉뚱한 문자가 섞인 문자열은 그럴듯하지만 틀린 결과로 디코딩되고, 오류는 하나도 안 납니다.
디코더의 불평 읽기
엄격한 디코더는 소리 질러서 실패하고, 정확히 실패합니다. 잘못된 입력은 모두 무엇이 잘못되었는지 정확히 알려 주는 메시지와 함께 IllegalArgumentException을 던지므로, 프로덕션 문자열이 처음 터지는 순간에 읽어야 할 것이 바로 이 테이블입니다. 아래 메시지는 현재 JDK의 정확한 표현입니다:
| 입력 (별기 없는 한 getDecoder 대상) | 무엇이 잘못되었는가 | 정확한 메시지 |
|---|---|---|
"SGVs bG8s" |
공백이 섞여 들어옴 | Illegal base64 character 20 |
"SGVs\nbG8s" |
줄바꿈이 섞여 들어옴 | Illegal base64 character a |
"SGVs$bG8s" |
달러 기호는 알파벳에 없음 | Illegal base64 character 24 |
"SGVsbG8-" |
표준 디코더에 URL용 대시 | Illegal base64 character 2d |
"ab+c"를 getUrlDecoder()에 |
URL용 디코더에 더하기 기호 | Illegal base64 character 2b |
"S" |
기호 하나로는 바이트를 만들 수 없음 | Input byte[] should at least have 2 bytes for base64 bytes |
"SG=VsbG8s" |
데이터 중간에 패딩 | Input byte array has wrong 4-byte ending unit |
"Zm8==" |
패딩 하나 자리에 패딩 두 개 | Input byte array has incorrect ending byte at 4 |
"Z=" |
문자 하나 뒤에 패딩이 붙음 | Last unit does not have enough valid bits |
"SGVsbG8sIHdvcmxkIQ==xx" |
패딩 뒤에 쓰레기 | Input byte array has incorrect ending byte at 20 |
메시지 속 그 16진 숫자는 문제의 문자의 바이트 값입니다. Integer.toString(byte, 16)로 찍어 낸 것이죠. 20는 공백, a는 줄바꿈(라인 피드), d는 캐리지 리턴, 24는 달러 기호, 2d는 URL용 대시, 2b는 더하기, 2f는 슬래시, 5f는 언더스코어입니다. 소매에 넣어 둘 특이한 점 두 가지. 첫째, 메시지는 음수가 될 수 있습니다. é가 들어간 문자열을 디코더에 주면 Illegal base64 character -17라고 불평합니다. 그 문자가 먼저 Latin-1 바이트 0xE9로 매핑되고, 그 값은 부호 있는 Java 바이트로 -23이며, -23의 16진수 표현이 -17이기 때문이죠. 잠시만, 여러분의 오류 로거가 부호 연산을 하고 있습니다. 둘째, 위치 문제. incorrect ending byte at N 계열에서 N은 디코더가 의미를 이해하지 못한 첫 바이트의 0 기반 인덱스입니다. 손상된 페이로드를 이분 탐색으로 가를 때라면 감지덕지할 만한 정보입니다.
알아 둘 변장 하나. 디코딩이 랩된 스트림(아래에서 다루는 wrap(InputStream) 변형)을 통해 일어날 때, 같은 문제들은 IOException으로, 0x 접두사가 붙은 채로 표출됩니다. Illegal base64 character 0x20처럼요(현재 JDK 기준. JDK 8의 스트림 디코더는 바이트 대신 조회 값 -1을 찍어 냅니다). 문제는 같고, 예외는 달고, 표기는 살짝 다를 뿐입니다. 그리고 물론 관대한 MIME 디코더는 이 모든 것에 불평하지 않습니다. 그냥 건너뜁니다. 관대한 성격의 값입니다.
패딩 규칙
현실의 Base64 문자열마다 패딩에 대해 침묵의 약속을 하나씩 합니다. 그리고 Java의 약속은 유독 친절합니다. 디코더 자바독이 정확히 말하고 있습니다. 패딩 문자 =는 "수용되어 인코딩된 바이트 데이터의 끝으로 해석되지만, 필수 사항은 아니다". 두세 글자로 된 마지막 단위도 패딩이 있었다는 듯이 디코딩되고, 패딩이 존재한다면 정확히 맞는 양만큼 존재해야 합니다. 현재 JDK의 전형적인 예제들에 대한 동작은 다음과 같습니다:
| 입력 | 결과 |
|---|---|
"" |
빈 바이트 배열, 오류 없음 |
"Zm8" |
"fo", 패딩이 그냥 없음 |
"Zm8=" |
"fo", 정형 표기 |
"Zm8==" |
IllegalArgumentException: incorrect ending byte at 4 |
"Zm9v=" |
IllegalArgumentException: wrong 4-byte ending unit |
"Zg==" |
"f", 바이트 하나 |
"Z=" |
IllegalArgumentException: last unit does not have enough valid bits |
"AA==" |
정확히 바이트 하나, NUL 바이트 0x00 |
"AAAA" |
NUL 바이트 세 개 |
그 표를 두 번 읽으시길 권합니다. 빈 문자열은 아무것도 없이 디코딩되는 반면, AA==는 NUL 바이트 하나로 디코딩됩니다. Base64에서 "아무것도 없음"과 "제로"는 서로 다른 존재이며, 둘 다 완전히 유효한 입력입니다. 그리고 패딩이 존재한다면 정확해야 합니다. Zm8=가 맞고, Zm8==는 틀리며, Zm9v=도 틀리고, 문자열 한가운데에 패딩이 있는 것도 틀렸습니다. 여러분의 프로토콜에 대한 실용적 결론: 하나의 표기(패딩이 있든 없든)를 정해 양쪽 끝에서 강제하세요. 두 표기로 도착할 수 있는 값은, 언젠가 어딘가의 무난한 동등성 검사를 깨뜨릴 수 있는 값이니까요.
base64url: URL을 위해 지어진 알파벳
표준 Base64의 알파벳은 +와 /로 끝납니다. 그리고 이 둘이 바로 URL에서 기가 막힌 행동을 하는 두 문자입니다. 쿼리 문자열 안의 +는 Java가 보기도 전에 이미 공백이며, /는 경로 구분자이고, 나홀로 남은 =는 세 글자 괴물로 퍼센트 인코딩되기를 원합니다. RFC 4648 5절이 해법을 그립니다. URL과 파일명 모두에 안전한 알파벳으로, +가 -가 되고, /가 _가 되며, 길이가 암묵적으로 알려지면 끝의 = 패딩은 보통 뺍니다. RFC는 이름에 대해 단호합니다. 이 인코딩은 "base64 인코딩과 같은 것으로 여겨져서는 안 된다". 여러분은 base64url이라는 이름으로 그와 만나게 될 것이며, JSON Web Tokens, OAuth state 매개변수, API 세션 ID, 11글자 영상 ID가 모두 이 편에서 살고 있습니다.
웹에서 가장 유명한 base64url 페이로드는 JWT이고, 그 안을 엿보는 일은 세 줄이면 됩니다. 토큰 부분은 관례적으로 패딩이 없으며, URL 디코더는 그걸 괜찮게 받아들입니다. 패딩은 수용되지만 필수 사항은 아니었죠, 기억하시다시피:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtPeek {
public static void main(String[] args) {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
+ ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
String[] parts = token.split("\\.");
byte[] header = Base64.getUrlDecoder().decode(parts[0]);
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
System.out.println(new String(header, StandardCharsets.UTF_8));
// {"alg":"HS256","typ":"JWT"}
System.out.println(new String(payload, StandardCharsets.UTF_8));
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
}
}
정직한 면책 고지 두 가지가 여기에 있습니다. 첫째, JWT를 디코딩하는 것은 엿보는 것이지 신뢰하는 것이 아닙니다. 세 번째 부분은 서명이고, 방금 읽은 두 부분은 비공개도, 인증된 것도 아닙니다. 서명을 검증하기 전에 페이로드를 믿는 것이 바로 고전적인 JWT 버그이며, 해결책은 암호화를 직접 굴리는 대신 JJWT(0.13.0)나 nimbus-jose-jwt(10.9.1) 같은 JOSE 라이브러리에 검증을 맡기는 것입니다. 둘째, 오류는 방향을 알려 줍니다. 표준 알파벳 문자열을 getUrlDecoder()에 주면 Illegal base64 character 2b나 2f가, 역방향이면 2d나 5f가 돌아옵니다. 알파벳 불일치는 현실에서 단연 가장 흔한 Base64 디코딩 실패 요인이며, 오류 메시지는 순식간에 그것을 가리킵니다. 쿼리 문자열의 토큰이 어차피 표준 Base64였어야 한다면, 그 +와 /는 여러분에게 도착하기도 전에 전송 경로에서 이미 망가졌을 가능성이 크고, 그 디코딩 오류는 디코더가 아니라 상류에 있는 버그를 알려 주는 것입니다.
바이트에서 단어로
이 글의 모든 디코딩 호출은 일부러 바이트에서 멈춥니다. Base64는 바이트 포맷이니까요, 그 이상도 이하도 아닙니다. "그게 무슨 텍스트였지?"라는 질문의 답은 여러분 몫이며, 현대의 기본 답은 UTF-8입니다. 다만 디코딩 쪽 API에 사람들이 놀라는 문자 인코딩 디테일이 하나 있으니, 여기서 짚어 드리죠. decode(String) 오버로드는 문자열을 UTF-8로 해석하지 않습니다. 자바독이 정확히 말해 줍니다. 호출은 "decode(src.getBytes(StandardCharsets.ISO_8859_1))을 호출하는 것과 정확히 같은 효과를 가진다". 이건 버그가 아니라 트릭입니다. Base64 알파벳은 순수 ASCII이므로, 문자열을 Latin-1로 통과시키면 디코더가 변환 비용 제로로 똑같은 바이트를 손에 넣게 되고, 입력 속 어떤 비-ASCII 문자든 그냥 비유효 심볼이 되어 엄격한 디코더에게 거부당합니다(오류 메시지의 음수 16진 숫자가 여기서 나옵니다).
페이로드의 문자 인코딩은 완전히 별개의 결정, new String(bytes, charset) 단계에서 내리는 결정입니다. 전형적인 사례를 보면, "café"는 UTF-8로 다섯 바이트 63 61 66 C3 A9이며, 인코딩하면 Y2Fmw6k=가 됩니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetDecode {
public static void main(String[] args) {
byte[] packed = Base64.getDecoder().decode("Y2Fmw6k=");
System.out.println(new String(packed, StandardCharsets.UTF_8));
// café, 강세 기호가 살아남음
System.out.println(new String(packed, StandardCharsets.ISO_8859_1));
// caf 뒤에 모지바케, UTF-8 바이트를 Latin-1로 오독한 것
}
}
두 번째 줄, 바로 이 줄이 단숨에 알아챌 수 있어야 할 실패 양상입니다. UTF-8 페이로드를 Latin-1로 읽은 결과, 정확히 한 문자 길고 한 바이트 어긋난 문자열이 나오죠. 치료법은 언제나 생산자와 문자 인코딩을 미리 맞추고, 그것을 명시적으로 전달하는 것입니다. 그리고 머릿속에서만이 아니라 코드 안에서 명시적으로 전달하세요. 인자 없는 new String(bytes) 생성자는 플랫폼 기본 문자 인코딩을 쓰는데, Windows 서버에서는 Cp1252일 수도 있고, 오래된 Linux에서는 기계가 그때그때 기분 내키는 대로 정할 수도 있습니다. JDK 18(JEP 400, "UTF-8 by Default")부터 기본값은 모든 플랫폼에서 UTF-8이므로, 현대 JVM에서 인자 없는 형태는 우연히라도 맞습니다. 그래도 코드는 그것을 말해야 합니다. 코드를 읽을 다음 사람에게 기본값이 무엇인지 알아야 할 의무를 지울 일이 없으려면요. 그리고 페이로드가 애초에 텍스트가 아니라면, 같은 코드에 다른 끝만 붙습니다. 바이트가 들어가서 바이트가 나오고, 마지막 단계까지 그것이 반복됩니다.
페이로드가 파일일 때
가장 흔한 파일 작업은 어떤 내보내기 루틴의 역방향입니다. .b64 텍스트 파일이 도착하고, 원래 파일을 되찾아야 하죠. 엄격한 디코딩만 있으면 이 작업은 이미 프로덕션 수준입니다:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DecodeFile {
public static void main(String[] args) throws Exception {
byte[] packed = Files.readAllBytes(Paths.get("payload.bin.b64"));
byte[] raw = Base64.getDecoder().decode(packed);
Files.write(Paths.get("payload.bin"), raw);
}
}
이 경로에서는 페이로드가 텍스트 파일이든, ZIP 아카이브든, 영상인지를 아무도 신경 쓰지 않습니다. byte[]는 그냥 바이트이니까요. 크기 계산도 여러분의 편입니다. 디코딩된 출력은 인코딩된 입력 길이의 4분의 3이므로, 디코딩은 메모리를 절대 악화시키지 않으며, 수백 메가바이트짜리 인코딩 파일은 둘 중 더 작은 쪽입니다. 좋은 습관은 어떤 라벨을 믿기 전에 바이트로 자기 자신을 발표하게 하는 것입니다. PNG의 첫 여덟 바이트는 언제나 매직 넘버 89 50 4E 47 0D 0A 1A 0A이므로, 여러분이 평생 만나게 될 Base64 인코딩 PNG는 전부 같은 접두사, iVBORw0K로 시작합니다. 페이로드가 이미지라고 "주장"하면서 그렇게 시작하지 않는다면, 이미 어딘가가 잘못된 것입니다.
목적지 버퍼를 이미 갖고 있다면, 두 배열 오버로드는 그 안으로 바로 쓰고, 정확히 몇 바이트가 도착했는지만 돌려 줍니다. 중간 할당은 전혀 없습니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class DecodeInto {
public static void main(String[] args) {
byte[] src = "SGVsbG8sIHdvcmxkIQ==".getBytes(StandardCharsets.ISO_8859_1);
byte[] dst = new byte[16];
int written = Base64.getDecoder().decode(src, dst);
System.out.println(written); // 13
System.out.println(new String(dst, 0, written, StandardCharsets.UTF_8));
// Hello, world!
}
}
그 오버로드는 자바독에 적힌 날카로운 모서리가 하나 있습니다. 목적지가 너무 작으면 바이트가 하나도 쓰이지 않고, IllegalArgumentException: Output byte array is too small for decoding all input bytes가 돌아옵니다. 버퍼 크기는 단순 계산으로 정하세요. 대략 패딩을 뺀 3 * n / 4 정도면, 그 예외는 얼굴을 드러내지 못합니다. ByteBuffer 오버로드도 있는데, limit이 디코딩된 길이에 맞춰 설정된 새 버퍼를 반환합니다. 파이프라인이 NIO에 사는 경우에 편합니다.
와이어에서: 헤더, JSON, data URI
Base64가 Java와 만나는 곳은 대부분 네트워크 가장자리입니다. 세 가지 모양, 각자가 실습 예제를 받을 자격이 있습니다.
모양 하나: HTTP Basic 인증 헤더. 웹에서 가장 오래된 인증 헤더는 여전히 Base64를 타고 갑니다. RFC 7617에 따르면 Basic 요청은 Authorization: Basic 뒤에 username:password의 Base64 인코딩을 보냅니다. RFC는 이것이 보호가 아니라 인코딩이라고 분명히 합니다. 패킷 캡처를 가진 누군가는 키스트로크 한 번으로 양쪽 절반을 모두 읽을 수 있거든요. RFC의 자체 예시, QWxhZGRpbjpvcGVuIHNlc2FtZQ==는 Aladdin:open sesame로 디코딩됩니다. 서버 쪽에서 헤더를 파싱하는 일은 몇 줄이면 됩니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuth {
public static String[] credentials(String header) {
if (header == null || !header.startsWith("Basic ")) {
return null;
}
byte[] packed = header.substring(6).getBytes(StandardCharsets.ISO_8859_1);
byte[] raw = Base64.getDecoder().decode(packed);
String userPass = new String(raw, StandardCharsets.UTF_8);
int colon = userPass.indexOf(':');
if (colon < 0) {
return null;
}
return new String[] {userPass.substring(0, colon), userPass.substring(colon + 1)};
}
}
두 가지 디테일이 이것을 안전하게 지킵니다. 첫 번째 콜론에서 나누는 것이 중요한데, 비밀번호에는 정당하게도 자기 자신의 콜론이 들어 있을 수 있기 때문입니다. 그리고 디코딩된 비밀번호를 저장된 값과 비교할 때는 상수 시간으로 비교해야 합니다. 두 값을 SHA-256으로 해시한 뒤 다이제스트를 MessageDigest.isEqual로 비교하고, 공격자가 타이밍을 재서 사용자 목록을 뽑아내는 평범한 equals는 쓰지 마세요. 이것을 서빙할 때는 HTTPS만 쓰세요. 평문 연결에서는 Base64 계층은 겉치레에 불과합니다.
모양 둘: JSON 안의 바이너리. 현대 API의 큰 부분은 바이너리를 Base64 텍스트로 JSON 안에 넣습니다. 파일 업로드 엔드포인트, 콘텐츠 API, 시크릿 저장소, 웹훅 모두 그렇습니다. 원시 바이트를 그대로 넣으면 JSON 문자열의 이스케이프 규칙을 깨뜨리니까요. 패턴은 언제나 같습니다. 필드는 평범한 문자열로 도착하며, 도메인 객체 내부가 아니라 경계에서 디코딩하는 것입니다:
import java.util.Base64;
public class ApiField {
public static void main(String[] args) {
// 파싱된 JSON이 갖고 온 값: "content" : "iVBORw0KGgoAAA..."
String field = "iVBORw0KGgo=";
byte[] image = Base64.getUrlDecoder().decode(field);
// 어떤 API는 대신 표준 Base64를 씁니다. 규격을 읽고,
// 그에 맞춰 getDecoder() 또는 getUrlDecoder()를 고르세요.
System.out.println(image.length); // 8
}
}
여기의 함정은 디코딩이 아닙니다. 규격을 읽는 것이죠. 어떤 API는 패딩 있는 표준 Base64를 원하고, 어떤 API는 패딩 없는 base64url을 원하며, 일부는 둘 다 관대하게 받아들입니다. 규격이 침묵할 때 가장 싼 해결은 상대방 쪽의 예시 값을 살펴보는 것입니다. 값 어디에 -나 _가 보이면 알파벳이 정해지고, 끝의 =가 보이면 패딩이 정해집니다.
모양 셋: data URI. 누군가가 양식 안에 이미지를 붙여 넣고, 프론트 엔드가 data URI 전체를 건네올 때입니다. data:image/png;base64,iVBORw0KGgo...처럼요. RFC 2397이 모양을 정의합니다. data:, 선택적인 미디어 타입, 선택적인 ;base64 플래그, 쉼표, 그리고 그 뒤에 데이터. 플래그가 있으면 페이로드는 Base64이고, 없으면 페이로드는 퍼센트 인코딩된 평문 텍스트입니다. 더 드물지만 합법적인 경우죠. 미디어 타입이 생략되면 기본값은 text/plain;charset=US-ASCII입니다. 하나를 나누는 일은 직관적입니다:
import java.util.Base64;
public class DataUri {
public static void main(String[] args) {
String uri = "data:image/png;base64,iVBORw0KGgo=";
int comma = uri.indexOf(',');
String meta = uri.substring(5, comma);
String payload = uri.substring(comma + 1);
boolean isBase64 = meta.endsWith(";base64");
String mime = isBase64 ? meta.substring(0, meta.length() - 7) : meta;
byte[] raw = Base64.getDecoder().decode(payload);
System.out.println(mime + " -> " + raw.length + " bytes");
// image/png -> 8 bytes
}
}
이 포맷에는 함정이 두 개 살고 있습니다. 첫 번째는 없는 ;base64 플래그입니다. 플래그 없는 합법적인 data URI는 퍼센트 인코딩된 페이로드를 나르므로, 그것을 Base64.getDecoder()에 넣으면 예외가 던져집니다. 두 번째는 주장하는 미디어 타입입니다. 그것은 발신자의 힌트이지 사실이 아니므로, "png"로 분류하기 전에 디코딩한 결과의 매직 바이트를 먼저 확인하세요. 그리고 RFC의 자기 조언도 기억하세요. data URI는 짧은 값용입니다. URL 안에 수 메가바이트짜리 이미지는 패턴이 아니라 냄새입니다.
이메일, MIME, PEM 아머
Base64는 이메일을 위해 태어났고, 이메일 모양의 Base64는 지금도 Java 프로그램에 끊임없이 도착합니다. MIME 표준(RFC 2045)은 Base64를 바이너리 전송 인코딩 중 하나로 만들었고, 하우스 규칙 두 가지를 추가했습니다. 인코딩된 줄은 76자를 넘지 않아야 하며, 디코더는 줄바꿈을 포함해 알파벳 밖의 모든 문자를 무시해야 합니다. 엄격한 디코더는 첫 번째 줄바꿈조차 거부하며, getMimeDecoder()는 정확히 이 입력을 위해 지어졌습니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class MimeDecode {
public static void main(String[] args) {
String wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
byte[] bytes = Base64.getMimeDecoder().decode(wrapped);
System.out.println(new String(bytes, StandardCharsets.UTF_8));
// Hello, standard
}
}
동작은 합니다. 다만 그 함정도 알아 두셔야 하는데, 함정에는 이빨이 있기 때문입니다. 관대한 디코더는 줄바꿈을 "무시"하는 것이 아니라, 알파벳에 없는 모든 것을 무시합니다. 표준 Base64 문자열에 엉뚱한 문자가 섞여 오염되면, 쓰레기는 사라지고 나머지는 그럴듯한 결과로 디코딩됩니다. 그래서 정말로 MIME 모양의 입력이 기대될 때만 getMimeDecoder()를 쓰세요.
현실에서의 MIME 남매는 PEM 아머, 인증서와 키를 감싸는 -----BEGIN CERTIFICATE----- 세계입니다. 함정은 이것입니다. 아머 줄에는 범용 알파벳 문자가 가득합니다. "BEGIN CERTIFICATE"의 글자들은 그냥 Base64 글자들이므로, 아머를 포함해 PEM 블록 전체를 넣으면 아머까지 데이터인 양 디코딩됩니다. 아머는 직접 벗기고, 그 다음 맨몸의 본문을 디코더에 넘기세요:
import java.util.Base64;
public class PemDecode {
public static void main(String[] args) {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ "TUlJQm96Q0NBVWlnQXdJQkFnSUpBSXBhVDJUaVFvZU1BMEdDU3FHU0liM0RRRUE9\n"
+ "-----END CERTIFICATE-----\n";
String body = pem.replaceAll("(?m)^-----.*$", "").replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(body);
System.out.println(der.length); // DER 본문, 아머 제외
}
}
PEM은 관례적으로 한 줄 64자(MIME은 76자)로 줄바꿈하며, 여백이 사라지면 엄격한 디코더와 MIME 디코더는 결과에 대해 같은 의견을 냅니다. 엄격한 쪽을 쓰세요. 예상 밖의 상황은 최소한 예외를 던지는 예의는 갖추고 있거든요. 지저분하지만 표준적인 경우를 위한 고전 레시피는, 알려진 여백을 걷어낸 뒤 엄격한 인스턴스로 디코딩하고, 남은 쓰레기라면 손상된 인증서 대신 IllegalArgumentException을 치르게 하는 것입니다.
설정, 환경 변수, 데이터베이스 값
Base64는 텍스트 컨테이너입니다. 그래서 예상하지 못한 곳에서 나타납니다. 데이터베이스에서는 바이너리 블롭(파일, 아이콘, 직렬화된 구조)이 Base64로 TEXT 컬럼에 살 수 있는데, 텍스트를 전제하는 모든 도구를 살아남습니다. 저장된 값이 원본보다 약 3분의 1 더 클 것을 기대하고, 컬럼 크기도 그에 맞춰 정하세요. 설정 파일과 환경 변수에서는, Base64가 아니라면 포맷을 깨뜨릴 값들을 밀반입하는 트릭입니다. 세미콜론이 든 DSN, 따옴표가 든 비밀번호, 여러 줄짜리 인증서 같은 것들 말이죠. 시작 시 디코딩하는 것이 일 전체입니다:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigDecode {
public static void main(String[] args) {
String value = System.getenv("DB_DSN_B64");
if (value == null) {
return;
}
byte[] raw = Base64.getDecoder().decode(value);
String dsn = new String(raw, StandardCharsets.UTF_8);
// dsn은 이렇게 될 수 있습니다: pg:host=db;password=qu"ote
}
}
같은 주의를 여기서 두 번 적용해야 합니다. 첫째, 이것은 형식 안전이지 기밀이 아닙니다. 개발자가 설정 파일을 읽는 순간, 한 호출로 값을 디코딩할 수 있습니다. 그래서 시크릿을 Base64로 저장해 놓고 그것이 암호화되었다고 부르지는 마세요. 아래 보안 섹션에서 깊이 다룹니다. 둘째, 시작 시 검증하세요. 손상되거나 반만 붙여 넣힌 환경 변수 값은 엄격한 호출에서 IllegalArgumentException이 되며, 두 줄짜리 검사로 난해한 런타임 오류를 실행 가능한 시작 메시지로 바꿀 수 있습니다. 데이터베이스 쪽을 위한 Java 특유의 노트 하나. 디코딩된 바이너리는 byte[]로 유지하세요(JDBC 코드에서 byte[] 매개변수로서요). 그리고 바이너리를 String을 거쳐 왕복시키지 마세요. 문자열 생성자가 바로 바이너리 페이로드가 죽는 곳이니까요.
거대 페이로드 스트리밍
큰데도 여러분의 버퍼에 들어가는 페이로드라면, 배열 API가 충분합니다. 애초에 메모리에 들어서는 안 될 페이로드라면, 스트림 어댑터가 그 수단입니다. wrap(InputStream)는 읽는 동안 디코딩하는 입력 스트림을 반환하므로, 수 기가바이트짜리 인코딩 파일이 바이트 배열 위에 놓여 있을 필요가 전혀 없습니다:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamDecode {
public static void main(String[] args) throws Exception {
InputStream packed = Base64.getDecoder().wrap(Files.newInputStream(Paths.get("bigfile.b64")));
OutputStream raw = Files.newOutputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = packed.read(buf)) != -1) {
raw.write(buf, 0, n);
}
raw.close();
packed.close();
}
}
알아 둘 디테일 두 가지. 랩된 스트림의 읽기 메서드는 디코딩할 수 없는 바이트에 부딪히면 IOException을 던지므로, 손상된 파일은 IllegalArgumentException 대신 스트림 예외로 실패합니다. 그리고 랩된 스트림을 닫으면 그 아래 스트림도 닫히므로, 예제는 복사 루프가 끝난 뒤 가장 나중에 packed를 닫습니다. 프로덕션에서는 둘 다 try-with-resources 블록에 넣어야겠죠. (8192 버퍼는 그저 넉넉한 읽기 버퍼일 뿐입니다. 랩된 스트림은 내부에서 디코딩하므로, 읽는 크기는 성능 선택이지 프로토콜 요구사항이 아닙니다.)
이제 레거시 경고 하나입니다. 왜냐하면 이것은 이 이야기 전체에서 진짜 버그 하나이고, 버그 번호까지 가진 것이거든요. 16 이전의 모든 JDK에서(버그 리포트는 8, 10, 11에서 재현됩니다), 랩된 디코더를 특정한 버퍼 크기로 읽으면 디코딩된 데이터 끝에 엉뚱한 제로 바이트 두 개가 덧붙여집니다. JDK 8222187. 전형적인 재현은 7바이트 읽기 버퍼와 8바이트짜리 평범한 입력을 짝지은 것이며, JDK 16에서 수정되었습니다. 레거시 JDK 8에서 스트리밍을 해야 한다면, 복사한 뒤 디코딩된 길이를 재확인하세요. 이 버그는 특정한 입력과 버퍼 조합에서 발화하며, 4096바이트 버퍼도 실제로 보고된 적이 있거든요. 그보다 좋으면 JDK를 업그레이드하는 것입니다. 어차피 약 백 개는 되는 다른 것들까지 고쳐 주니까요.
포장 테이프, 자물쇠는 아니다
이제 신중했던 쪽과 화상을 입은 쪽을 가르는 섹션입니다. Base64는 암호화가 아니며, 표준 자체가 두 번이나 그렇게 말합니다. RFC 4648 12절. Base 인코딩은 "비밀번호처럼 본래 쉽게 알아볼 수 있는 정보를 시각적으로 가리지만, 어떤 계산적 기밀성도 제공하지 않는다", 그리고 누군가 프로토콜 교환을 티켓에 붙여 넣고 실수로 비밀번호를 드러내는 상황에서 이것이 "보안 사고를 유발한 사례가 있다"고 덧붙입니다. RFC의 구현자를 위한 조언도 액자에 걸릴 만합니다. "디코더는 예를 들어 임베디드 NUL 문자를 포함한 잘못된 입력에 부딪혀도 깨져서는 안 된다".
더 은근한 함정은 변형 가능성(malleability)입니다. 각 심볼이 6비트를 나른다는 것, 그리고 마지막 단위가 짧으면 올바르게 형성된 인코딩에서 반드시 제로여야 할 여분 비트가 남는다는 것을 기억하세요. 성의 없거나 악의적인 인코더는 그 여분 비트에 쓰레기를 넣을 수 있고, 결과는 여전히 완전히 유효해 보입니다. MQ==와 MT==는 둘 다 숫자 1의 단일 바이트로 디코딩되죠. Java는 이 문제에서 관대한 쪽을 택합니다. Base64.getDecoder().decode("MT==")는 중요하지 않은 비트를 검증하지 않고, 반갑게도 같은 바이트를 손에 쥐여 줍니다. 왜 신경 써야 할까요? 두 서로 다른 문자열이 같은 데이터로 디코딩되면, 해시 검사, 중복 제거, 서명 비교가 조용히 의지하고 있는 "고유한 표기" 전제가 깨지기 때문입니다. 그리고 전송 중 인코딩된 값을 건드릴 수 있는 공격자는 한 표기를 다른 표기로 바꿀 수 있습니다. 2022년 논문 "실전에서의 Base64 변형 가능성"(Chatzigiannis와 Chalkias, ACM ASIA CCS 2022)은 실세계 구현들 사이의 바로 이런 불일치들을 하나씩 훑습니다. RFC의 여분 비트에 대한 자기 말. 그것들은 "정보를 누출하는 데 악용되거나, 문자열 동등성 비교를 우회하거나, 구현 문제를 촉발하는 데 사용될 수 있다". 실용 규칙은 "디코딩을 영원히 하지 마라"가 아니라 "경계를 알라"입니다. 자기 시스템끼리의 데이터라면 JDK의 관대함이 괜찮지만, 신뢰 경계를 건너는 데이터라면, 디코딩한 것을 신뢰하기 전에 정형(올바른 길이, 제로인 여분 비트, 패딩 하나의 표기)을 강제하세요.
성능 노트
좋은 소식으로 한 문장 드리죠. 현대 JVM에서 내장 디코더는 Base64가 거의 영원히 병목이 되지 않을 만큼 빠르고, 이코시스템 나머지 전체가 자신들을 벤치마크하는 기준점이 바로 그것입니다. 한 사례를 들면, 2025년 gRPC-java 프로젝트는 자체 Guava 기반 Base64 처리를 java.util.Base64와 공개 벤치마크했습니다(issue 11857). JDK 17과 21에서 JDK 구현이 인코딩은 대략 2.5배에서 3.8배, 디코딩은 1.3배에서 2.1배 더 빠르 나왔고, 가장 큰 격차는 x86에서였습니다. 이것이 JDK의 구현 노력이 어디로 흘러갔는지에 대한 강한 힌트이며, Base64 벤치마크에서 계속 마주하게 되는 동일한 결론입니다. 지금은 표준 라이브러리 버전이 빠른 쪽이지, 레거시 쪽이 아닙니다.
실용 노트 두 가지. 첫째, 거대한 파일에서는 관리 대상이 속도보다는 메모리 프로필입니다. 그래서 스트리밍 섹션이 존재하는 것이죠. wrap(InputStream)는 워크세트를 여러분의 읽기 버퍼 안에 가둡니다. 둘째, 정말로 수백만 개의 작은 값을 디코딩하는 핫 패스에 서 있다면, 디코더 인스턴스 하나를 공유하세요(팩토리가 이미 같은 공유 인스턴스를 반환합니다, 위에서 언급했듯이). 이미 바이트를 갖고 있다면 decode(String) 오버로드는 건너뛰세요(먼저 문자열을 Latin-1로 복사하거든요). 그리고 decode(byte[], byte[]) 오버로드에게 크기를 미리 정해 둔 목적지 배열에 쓰게 해서 할당 댄스를 건너뛰세요.
Java 억양의 함정들
함정들을 한 곳에 모았습니다. 전부 Java 특유의 것들입니다:
- 알파벳에 맞지 않는 디코더. base64url 문자열을
getDecoder()에 넣는 것(또는 그 역)은 전형적인Illegal base64 character크래시이며, 메시지에2d,5f,2b,2f중 하나가 들어 있는 경우가 대부분입니다. 매번, 프로토콜에 맞는 디코더를 짝지으세요. - 현실에서 오는 끝 공백. 터미널, 환경 변수, 설정 파일에서 복사한 값은 줄바꿈을 붙이고 오는 경우가 많습니다. 엄격한 디코더는 그것을
Illegal base64 character a로 바꿉니다. 입력을strip()하거나, 데이터가 정말로 줄바꿈된 경우에만 MIME 디코더를 쓰세요. - 아머 함정.
getMimeDecoder()는 PEM 헤더를 이해하지 못하며, BEGIN과 CERTIFICATE의 글자들은 데이터로 디코딩됩니다. 아머 줄은 언제나 직접 벗기세요. - 단축키로 쓰는 MIME 관대함. "안전하게" 하려다 MIME 디코더로 디코딩하면, 엉뚱한 비알파벳 문자가 조용히 건너뛰어져 손상된 페이로드가 그럴듯하지만 틀린 결과로 나올 수 있습니다. 진짜 MIME 입력에만 쓰세요.
- 운에 맡긴 문자 인코딩. 인자 없는
new String(bytes)는 플랫폼 기본값을 사용합니다. JDK 18+에서는 UTF-8이지만, 여러분의 코드는StandardCharsets.UTF_8를 명시적으로 전달해야 합니다. 아니라면 다음 서버 마이그레이션 이후 모지바케를 즐기게 되겠죠. - 바이너리를 문자열화하기.
new String(decodedPng)로 바꾸고 다시 되돌리는 것은 데이터 파괴입니다. 여러분의 문자 인코딩에서 유효하지 않은 모든 바이트 시퀀스가 대체 문자가 되고, 왕복은 단방향입니다. 마지막 단계까지 바이트가 들어가면 바이트가 나옵니다. - 여분 비트에 대한 신뢰.
MT==는MQ==와 똑같이 디코딩되므로, 중요하지 않은 비트에 쓰레기를 숨긴 페이로드는 JDK가 수행하는 모든 검사를 통과합니다. 프로토콜이 중요하다면 정형을 강제하세요. - JDK 8, 11, 12의 스트림. 그 버전들의 랩된 디코더는 특정한 버퍼 크기에 대해 엉뚱한 제로 바이트 두 개를 붙일 수 있습니다(JDK 8222187, 16에서 수정). 16 이상에서는 문제가 아니지만, 이전 버전에서는 문제입니다.
- null은 빈 값이 아니다.
null을decode()에 전달하는 것은 빈 배열이 아니라NullPointerException입니다. 변수가 null일 수 있다면, 호출 전에 null이 아닌 값으로 처리해 두세요. - Android는 다른 동물원. Android에서는
java.util.Base64가 API 레벨 26부터만 존재합니다. 그 미만에서는 프레임워크 클래스가 자체 플래그 상수를 가진android.util.Base64입니다. 하나를 검사 없이 하드코딩한 코드는 정확히 여러분이 테스트하지 않은 기기에서 깨집니다. - 보안이 아니라는 점 망각. Base64는 비밀번호를 한눈에 보는 것에서 가릴 뿐, 그 누구에게도 가리지 못합니다. 데이터가 시크릿이라면, 먼저 암호화하고, 그 다음에 채널이 텍스트를 요구할 때에만 싸서 넣으세요.
java.util.Base64까지의 길
포맷의 이야기는 Java보다 오래됐습니다. 1980년대, 인터넷의 메일 인프라는 7비트 ASCII만 실어 날랐고, 바이너리를 옮기고 싶어 하는 사람들은 지역 방언을 발명했습니다. UNIX용 uuencode(알파벳이 연속된 ASCII 코드로 나가므로, 인코딩은 32를 한 번 더하는 것뿐, 조회 테이블도 필요 없었고)과 Apple 머신용 BinHex(알파벳을 선별해서 한눈에 혼동하기 쉬운 7, O, g, o 같은 문자를 빼고)입니다. 1987년, Privacy-Enhanced Mail 프로토콜(RFC 989)이 인증서를 나르기 위해 64글자 알파벳과 64자 줄 단위로 된 64자 스킴을 표준화했고, 1993년 RFC 1421은 알파벳과 패딩 규칙을 그대로 유지했습니다. 1996년, MIME(RFC 2045, 1993년 RFC 1521을 갱신한 것)은 이미 64글자 알파벳을 따라 "base64"라는 이름을 가진 스킴을 물려받았고, 오늘도 여러분의 이메일 첨부파일을 줄바꿈하는 76자 줄 길이를 정해 놓았으며, getMimeDecoder()가 지금도 구현하는 관대한 디코더 규칙을 써 놓았습니다. 2003년 RFC 3548은 이 집안을 정리하려 시도했고, 디코더는 알파벳 밖의 문자를 거부해야 한다고 선언했으며, 2006년 RFC 4648은 모두에게 인용되는 표준이 되었습니다. 알파벳 표, 5절의 base64url 변형, 그리고 이 글의 마지막 섹션을 정직하게 지켜 주는 보안 섹션을 갖추면서요.
Java 자신의 장은 조금 더 극적입니다. 수년간 JDK 안에 있는 유일한 Base64는 내부 쌍 sun.misc.BASE64Encoder와 sun.misc.BASE64Decoder였습니다. 오늘 컴파일되다가 비추천 경고 하나 없이 사라지는 종류의 API였죠. XML 세계에서 Base64가 필요하면, JAXB의 javax.xml.bind.DatatypeConverter도 있었습니다. 나머지 모든 사람은 Apache Commons Codec이나 Guava를 썼습니다. 그러다 2014년 3월 18일. Java 8이 java.util.Base64를 싣고 나왔습니다. 여러분이 줄곧 써 온 팩토리 메서드 패턴으로 RFC 4648과 RFC 2045를 한 클래스에 구현한 것이죠. 세년 반 뒤, Java 9(2017년 9월 21일)가 sun.misc 쌍을 영원히 제거했고, 공식 마이그레이션 가이드는 이를 노골적으로 말합니다. "특히, sun.misc.BASE64Encoder와 sun.misc.BASE64Decoder는 제거되었습니다. 대신, JDK 8에 추가된 지원되는 java.util.Base64 클래스를 사용하세요". 아직도 그것들을 참조하는 오래된 코드에 jdeps를 돌려 보면, 도구는 그 종속성을 "JDK removed internal API"로 표시합니다. Java 11은 JAXB 모듈과 그 안의 DatatypeConverter까지 함께 제거하며 이어갔습니다(JEP 320). 1.8 이후 공개 API는 단 한 메서드도 바뀌지 않았고, 자바독에는 여전히 원래의 Since: 1.8 태그가 붙어 있습니다. 움직인 것은 그 밑의 엔진입니다. 버그 수정(JDK 8222187 스트림 버그, JDK 16에서 수정)과 성능 작업. 그래서 커뮤니티 벤치마크는 계속 같은 결론에 도달합니다. 열두 해, 하나의 API, 그리고 여전히 여러분이 돈을 내지 않아도 되는 가장 빠른 Base64입니다.
재미있는 사실, Java판
완전한 가이드는 미소에서 끝나야 하므로, 그냥 재미있는 Java 특유의 사실들을 모았습니다:
decode(byte[] src, byte[] dst)의 Oracle 자바독은 "IllegalargumentException이 던져지기 전에 출력 바이트 배열에 일부 바이트가 쓰여 있을 수 있다"고 약속합니다. IllegalArgumentException이 아니라, 소문자 a의 IllegalargumentException. 오탈자는 실제 JDK 소스에 있으며, 2014년부터 그대로였습니다. 오탈자에 이토록 헌신적인 문서는 그다지 흔하지 않아야 합니다.é가 들어간 문자열을 디코딩하면 오류 메시지가Illegal base64 character -17입니다. 음수 16진 숫자죠. 그 문자가 Latin-1 바이트0xE9가 되고, 부호 있는 Java 바이트로는 -23이며, JDK는 그것을 16진수로 찍기 때문입니다. 잠시, 여러분의 오류 로거가 부호 연산을 하고 있습니다.Base64.getDecoder() == Base64.getDecoder()는 참입니다. 소스 코드는 매번 같은 공유 정적 인스턴스를 반환하므로, "새것을 가져오는" API는 싱글턴의 변장이고, 스레드 안전 약속은 JVM이 이미 하고 있는 일의 서술일 뿐입니다.- URL 디코더에 언더스코어 네 글자 문자열
"____"을 주면, 순도 100%의0xFF바이트 세 개가 돌아옵니다. 언더스코어는 알파벳 값 63이고, 네 개면 24비트가 되며, 원(1) 24비트는 바이트 삼중조FF FF FF입니다. 위법한 것이 아무것도 없다는 점이 가장 웃긴 부분입니다. AA==는 NUL 바이트 하나로 디코딩되는 반면, 빈 문자열은 아무것도 없이 디코딩됩니다. Base64에서 "아무것도 없음"과 "제로"는 서로 다른 존재이며, 둘 다 완전히 유효한 입력입니다.- 작은 문자열
TWFu는Man으로 디코딩되며, 이코시스템의 애장 스모크 테스트가 되었습니다. RFC에도, 위키백과에도, 참고 매뉴얼에도, 지구상의 대부분의 Base64 튜토리얼에도 등장하므로, 그 이후 쓰인 모든 디코더가 같은 작은 경의를 표해 온 셈입니다. - 여러분이 디코딩해 본 모든 Base64 인코딩 PNG는
iVBORw0K로 시작합니다. 그것은 변장한 PNG 매직 넘버이며, 인터넷에서 가장 알아보기 쉬운 8글자 접두사 중 하나입니다. - RFC 4648의 URL-safe 섹션이 "base64url"이라는 이름이 태어난 곳입니다. 스펙은 이 인코딩을 "base64url이라 부를 수 있다"고 하면서도, "base64 인코딩과 같은 것으로 여겨져서는 안 된다"고 경고합니다. URL-safe 알파벳의 기원은 P2P-hackers 메일링 리스트의 2001년 게시물로 각주 처리되어 있으니, 여러분이 모든 URL에 붙여 넣는 그 이름에는 메일링 리스트의 혈통이 있습니다.
- YouTube 영상 ID는 패딩 없는 base64url, URL 어디에나 붙여 넣을 수 있는 익숙한 11글자 문자열입니다. 이메일 첨부파일을 위해 설계된 포맷이 이제 영상 플랫폼을 움직이고 있고,
getUrlDecoder()는 JDK 안에서 그것을 가능하게 하는 부분입니다. - 문자열
YmFzZTY0을 디코딩하면 단어base64가 돌아옵니다. 패딩 없이요. 여섯은 3의 배수이니까요. 자기 자신을 설명하는 포맷은, 모르세로 말하는 거울의 기술적 동격입니다.
반대 방향
이것이 디코더 쪽의 이야기이고, 고통의 대부분이 살고 있는 곳입니다. 디코딩은 남의 데이터를 만나るところ이니까요. 그들의 패딩 선택, 그들의 줄바꿈, 그들의 문자 인코딩, 그들의 토큰, 그들의 아머. 반대 방향, 즉 바이트를 java.util.Base64의 인코더로 Base64 문자열로 바꾸는 일은 더 온순한 동물입니다. 잘못된 입력에 대해 결코 예외를 던지지 않고(인코딩할 잘못된 입력은 존재하지 않으므로), 읽을 오류 메시지가 아니라 낼 크기 대금이 있습니다. 그리고 그것 특유의 함정들(문자 인코딩 단계, MIME 다이얼, 토큰을 위한 패딩 결정)은 자기만의 가이드를 갖습니다. 이 페이지에서 연결해 둔 Java의 Base64 인코딩은 인코더를 같은 깊이로 다루며, 둘은 쌍으로 읽을 때 편안합니다.
마지막 업데이트: 2026-09-08
관련 문서: Java에서의 Base64 인코딩: 완전한 가이드