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

Java에서의 Base64 인코딩: 완전한 가이드

상황을 정리해 보죠. 여러분에게는 바이트가 있습니다. 파일이든, 비밀번호든, 인증서든, 13바이트짜리 인사말이든, 200메가바이트짜리 업로드든. 그리고 그것들을 텍스트만 이해하는 곳에 넣어야 합니다. JSON 필드든, HTTP 헤더든, 데이터베이스 컬럼이든, URL이든, 설정 파일이든. 그것이 Base64의 일 전체이며, 이 가이드는 그것을 잘 해 내는 Java 핸드북입니다. 홈 페이지에서 이 포맷을 한 걸음씩 다루므로, 간단히 방향부터 잡겠습니다. Base64는 데이터의 바이트 3개를 64글자 알파벳에서 고른 문자 4개로 다시 쓰고, 마지막 덩어리가 짧으면 = 패딩을 하나둘 붙입니다. 이 여행의 대가는 크기입니다. 바이트 3개가 문자 4개가 되므로, 인코딩된 출력은 입력보다 약 33퍼센트 더 크며, 줄바꿈이 개입하면 그것보다도 조금 더 커집니다.

헤드라인 뉴스부터. 좋은 소식입니다. 2014년 3월 18일 이후로 모든 JDK는 표준 라이브러리에 완비된 Base64 도구 꾸러미를 함께 싣고 있습니다. java.util.Base64가 그것입니다. 다운로드도, Maven 좌표도, 네이티브 라이브러리도 필요 없습니다. import 하나, 인코더 성격 3종, 그리고 Java 8에서 지금의 Java 26까지 똑같은 동작. 이 글의 모든 내용은 이 클래스 하나를 바탕으로 하며, 이 클래스는 데이터 그 자체에 대해 결코 예외를 던지지 않습니다. 인코더의 일은 잘못된 입력으로 실패할 수 없거든요. 존재할 수 있는 모든 바이트는 인코딩 가능하니까요.

시작 전에 정직한 경계선 하나: 이 글은 인코더 쪽의 이야기입니다. 정확도를 실제로 가르는 문자열에서 바이트로의 결정, 패딩과 줄바꿈 다이얼, 토큰을 위한 패딩 없는 base64url 모드, 그리고 Java 개발자들이 인코딩된 출력을 가장 자주 만나는 용도들을 배우게 됩니다. 대부분의 진짜 고통이 살고 있는 디코딩은 별도의 가이드를 가지며, 이 글의 끝에서 연결해 두었습니다.

import 하나, 다운로드 0개

Java에서 Base64를 설치한다는 것은 화이트보드 앞에서 내리는 한 줄짜리 답변입니다. "JDK에 있습니다." 클래스 java.util.Base64는 1.8부터 java.base 모듈의 일부였으며, 열두 년이 지난 지금도 자바독에는 여전히 Since: 1.8라고 적혀 있습니다. 설치하는 것은 JDK뿐입니다. 어떤 벤더(Oracle, Eclipse Temurin, Amazon Corretto, Zulu)의 Java 8 이상 버전이든 사용되며, Debian 기반 시스템에서는 한 줄이면 됩니다:

sudo apt install openjdk-17-jdk-headless

API는 팩토리 구조입니다. 인코더를 직접 생성하는 일은 결코 없고, 클래스에 하나를 달라고 요청합니다. 인코더 쪽의 문은 네 개이며, 전부 중첩 클래스 Base64.Encoder의 인스턴스를 반환합니다:

팩토리 메서드 알파벳 출력 형태
getEncoder() A-Z a-z 0-9 + / 패딩 있음, 줄바꿈 없음
getUrlEncoder() A-Z a-z 0-9 - _ 패딩 있음, 줄바꿈 없음
getMimeEncoder() A-Z a-z 0-9 + / 패딩 있음, 76자 줄, CRLF
getMimeEncoder(int, byte[]) A-Z a-z 0-9 + / 패딩 있음, 원하는 줄 길이, 원하는 구분자

미리 알아 둘 특성이 세 가지 있습니다. 인스턴스는 스레드 안전이며, 팩토리는 매번 같은 공유 인스턴스를 반환하므로 Base64.getEncoder() == Base64.getEncoder()는 참입니다. 정적 필드에 하나를 만들어 어디에나 공유하세요. 인코더는 데이터에 대해 결코 예외를 던지지 않습니다. 모든 바이트 값에는 인코딩이 있으므로 처리해야 할 "잘못된 입력" 상태라는 것이 없고, 여러분이 마주할 예외는 오직 잘못된 설정(잘못된 줄 구분자)이나 너무 작은 목적지 배열에 대한 것뿐입니다. 그리고 이 목록의 모든 인코더는 기본값으로 패딩을 추가합니다. 그것을 끄는 다이얼, withoutPadding()은 base64url 섹션에서 등장합니다. 여러분이 그것을 필요로 하는 곳이 바로 거기이니까요.

코드베이스에서 오래된 라이브러리를 여전히 만나게 될 테니, 지형도 하나 그려 두겠습니다. 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퍼센트는 세 줄이면 해결됩니다. 위키백과의 Base64 문서가 알파벳을 설명할 때 쓰는 가장 작은 예제를 그대로 가져와, 의식 전체를 보여 드리겠습니다:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstEncode {
  public static void main(String[] args) {
    byte[] text = "Man".getBytes(StandardCharsets.UTF_8);
    String packed = Base64.getEncoder().encodeToString(text);
    System.out.println(packed); // TWFu
  }
}

문자열 TWFu는 위키백과의 Base64 문서가 알파벳을 설명할 때 쓰는 예시입니다. 여러분의 인코더가 "Man"을 그것으로 바꾼다면, 그 기계는 정직하다는 뜻이죠. 하지만 그 예시의 첫 번째 줄을 보세요. Java에서 인코딩이 실제로 일어나는 줄이 바로 그 줄이니까요. encodeToString(String) 메서드는 일부러 존재하지 않습니다. Java의 String은 바이트가 아니라 UTF-16 코드 단위 시퀀스이고, Base64는 바이트 포맷이므로, API는 바이트 문제를 여러분 스스로 결정하게 합니다. "Man".getBytes(StandardCharsets.UTF_8)이 바로 그것입니다. 명시적인 문자 인코딩을 가진 이 한 호출은, "café"가 앞으로 백 년 동안 정확한 상태를 유지하는 지점이며, 이 글 전체에서 단연 가장 중요한 습관입니다. 다음 섹션은 이것만을 위해 있습니다. 대안은 고전적인 모지바케 버그니까요.

두 번째 줄에 대한 노트 두 가지. encodeToString()은 인코딩된 바이트로부터 만들어진 String을 반환합니다. 자바독은 결과가 ISO-8859-1 문자 인코딩으로 구성됨을 설명하지만, 실제에서는 문제가 되지 않습니다. 모든 Base64 출력 문자는 순수 ASCII이며, Latin-1, UTF-8, 나머지 대부분의 문자 인코딩 세계에서도 똑같이 보이거든요. 그리고 출력 버퍼를 직접 소유하고 싶다면, encode(byte[])는 새 byte[]를 반환하며, encode(byte[] src, byte[] dst)는 여러분이 공급한 목적지에 써서 그 개수를 반환합니다(목적지가 부족하면 단 하나의 바이트도 쓰지 않은 채 IllegalArgumentException: Output byte array is too small for encoding all input bytes을 던지죠).

문자 인코딩 결정

문자열에서 바이트로 넘어가는 단계를 전형적인 사례로 구체적으로 만들어 보겠습니다. "café"라는 단어는 단 하나의 단어이지만, 바이트로는 전적으로 여러분이 고른 문자 인코딩에 달려 있습니다:

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetEncode {
  public static void main(String[] args) {
    byte[] utf8 = "café".getBytes(StandardCharsets.UTF_8);
    byte[] latin1 = "café".getBytes(Charset.forName("ISO-8859-1"));
    System.out.println(utf8.length + " vs " + latin1.length);
    // 5 vs 4: 강세 기호는 UTF-8에서는 두 바이트, Latin-1에서는 하나
    System.out.println(Base64.getEncoder().encodeToString(utf8));
    // Y2Fmw6k=
    System.out.println(Base64.getEncoder().encodeToString(latin1));
    // Y2Fm6Q==
  }
}

한 단어에 대해 서로 다른 Base64 문자열 두 개. 읽는 쪽이 어떤 문자 인코딩을 쓸지 알려 주면 둘 다 "정확"합니다. 수업 전체는 한 줄이면 됩니다. 인코더는 여러분이 준 바이트에게 충실하고, 바이트에 대한 책임은 여러분에게 있습니다. 실무에서 그것은 이렇게 됩니다. 상대방과 UTF-8을 맞추고, StandardCharsets.UTF_8를 명시적으로 전달하고, 문자 인코딩을 규격이나 스키마, 커밋 메시지에 적어 두세요. 수신 쪽에서 Base64만 봐서 그것을 어림할 사람은 없으니까요. 이 버그의 디코더 쪽 쌍둥이는 자매 가이드의 주제입니다.

버전 노트 하나. 게으른 코드의 실패 양상이 바뀌기 때문입니다. 인자 없는 new String(bytes)와 문자 인코딩 없는 String.getBytes()는 플랫폼 기본 문자 인코딩을 쓰는데, 역사적으로 Windows에서는 Cp1252, Linux에서는 로케일에 따라 달라지는 것이었습니다. JDK 18(JEP 400, "UTF-8 by Default")부터 기본값은 모든 플랫폼에서 UTF-8이므로, 현대 JVM에서 게으른 형태는 우연히라도 맞습니다. 하지만 그것이 안전하다는 뜻은 아닙니다. 여러분의 코드는 그 코드가 쓰인 JDK보다 오래 살 테고, 그것을 물려받을 사람은 기본값이 무엇인지 알아야 할 필요가 없어야 하니까요. 문자 인코딩을 적으세요.

관련 설계 디테일 하나. API 어디에도 encode(String) 오버로드는 없으며, 그건 일부러입니다. 파이프라인의 모든 다른 단계(배열, 버퍼, 스트림)는 바이트를 받고, String을 받는 메서드라면 여러분을 위해 문자 인코딩을 골라 줘야 하는데, 바로 그것이 JDK가 거절하는 결정입니다. 존재하는 단 하나의 String 타입 메서드, encodeToString은 출력 쪽에 있습니다. 문자 인코딩 질문이 존재하지 않는 곳이죠. Base64 출력은 순수 ASCII니까요. API 전체의 모양은 "바이트는 일부러 결정하라"는 작은 논증입니다.

패딩, 줄바꿈, 그리고 MIME 다이얼

Java의 인코더는 기본적으로 여러분을 위해 포맷팅 결정을 두 가지 내립니다. 둘 다 돌릴 수 있는 다이얼이므로, 둘 다 이해할 가치가 있습니다. 첫 번째는 패딩입니다. RFC 4648이 요구하는 대로, 모든 인코더는 출력을 4의 배수로 만드는 = 문자를 추가합니다. 구현체는 참조 규격이 달리 말하지 않는 한 인코딩된 데이터의 끝에 적절한 패딩 문자를 포함해야 합니다. 두 번째는 줄바꿈입니다. 줄을 감는 것은 MIME 인코더뿐이며, 76자에서 캐리지 리턴과 라인 피드로 줄바꿈합니다. 그리고 마지막 부분 줄 뒤에 줄 구분자를 추가하지 않는데, 이 디테일은 자바독이 명시적으로 짚어 주며 다른 도구들은 틀리는 부분입니다:

인코더 출력에 패딩 추가 줄바꿈 줄 구분자
getEncoder() 네 아니요 해당 없음
getUrlEncoder() 네 아니요 해당 없음
getMimeEncoder() 네 예, 76자 CRLF
getMimeEncoder(64, "\n") 네 예, 64자 LF

MIME 다이얼은 남의 포맷을 물려받은 사람들에게 이 API에서 가장 유용한 부분입니다. 표준 생성자는 getMimeEncoder()(76, CRLF, RFC 2045에서 그대로 가져온 것)이고, 두 인자 버전 getMimeEncoder(int lineLength, byte[] lineSeparator)는 다른 관례를 재현할 수 있게 해 줍니다. 알아 둘 특이한 점 두 가지. 줄 길이는 "4의 배수로 아래로 반올림"되므로, 77을 요청하면 조용히 76이 돌아오고, 반올림한 값이 양수가 아니면 줄바꿈이 아예 없으며, 구분자에는 Base64 알파벳의 어떤 문자도 들어가면 안 됩니다. 들어가면 생성자가 그 자리에서 IllegalArgumentException을 던지죠. 데이터와 혼동될 수 있는 구분자는 벌어질 때를 기다리는 버그이니까요. 다이얼이 동작하는 모습, MIME 표준과 PEM풍을 보여 드리죠:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WrapDials {
  public static void main(String[] args) {
    byte[] data = "Hello, wrapped world! This line keeps going and going and going until it finally has to wrap.".getBytes(StandardCharsets.UTF_8);
    Base64.Encoder mime = Base64.getMimeEncoder();
    Base64.Encoder pem = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.ISO_8859_1));
    System.out.println(mime.encodeToString(data));
    // 76자 줄, 그 사이는 CRLF
    System.out.println(pem.encodeToString(data));
    // 64자 줄, 그 사이는 맨 LF
  }
}

실용 노트 두 가지. 소비자가 줄바꿈된 문자열이 줄바꿈으로 끝나기를 기대한다면(그런 이메일 도구가 있습니다), 인코딩 후에 여러분이 직접 추가하세요. JDK는 일부러 마지막 부분 줄에서 멈추거든요. 그리고 URL이나 토큰에 살 데이터 생산 중이라면, 줄바꿈은 완전히 엉뚱한 다이얼입니다. 그런 소비자는 긴 줄 하나, 그리고 보통 패딩 없음을 원합니다. 다음 섹션에서 다룰 내용입니다.

base64url과 패딩 없는 다이얼

표준 Base64의 알파벳은 +와 /로 끝납니다. 그리고 이 둘이 바로 URL에서 기가 막힌 행동을 하는 두 문자입니다. 쿼리 문자열 안의 +는 서버가 파싱하기도 전에 이미 공백이며, /는 경로 구분자이고, 나홀로 남은 =는 세 글자 괴물로 퍼센트 인코딩되기를 원합니다. RFC 4648 5절이 해법을 그립니다. URL과 파일명 모두에 안전한 알파벳으로, +가 -가 되고, /가 _가 되며, 길이가 암묵적으로 알려지면 끝의 = 패딩은 보통 뺍니다. RFC는 이름에 대해 단호합니다. 이 인코딩은 "base64 인코딩과 같은 것으로 여겨져서는 안 된다", 그리고 여러분이 그 이름으로 들을 것은 base64url입니다. JSON Web Tokens, OAuth state 매개변수, API 세션 ID, 11글자 영상 ID가 모두 이 방언에서 살고 있습니다.

Java는 getUrlEncoder()로 그 알파벳을 줍니다. 다만 사람을 걸게 만드는 다이얼이 하나 있습니다. URL용 인코더는 여전히 기본값으로 패딩을 추가하는데, 토큰 표준은 패딩을 원하지 않으니까요. RFC 7515는 JWS 부분이 base64url을 "끝의 '=' 문자가 모두 생략되고... 줄바꿈, 공백, 기타 추가 문자가 포함되지 않은 채로" 쓴다고 분명히 합니다. 그래서 정형적인 Java JWT 레시피는 두 메서드 체인입니다:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TokenParts {
  public static void main(String[] args) {
    Base64.Encoder url = Base64.getUrlEncoder().withoutPadding();
    byte[] header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8);
    byte[] payload = "{\"sub\":\"1234567890\",\"name\":\"John Doe\"}".getBytes(StandardCharsets.UTF_8);
    System.out.println(url.encodeToString(header));
    // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    System.out.println(url.encodeToString(payload));
    // eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0
  }
}

withoutPadding() 호출은 끝의 패딩을 빼는 것만 제외하면 똑같이 행동하는 새 인코더 인스턴스를 반환합니다. 원래 인코더는 건드리지 않으며, 자바독도 그렇게 정확히 말합니다. 디코더 쪽은 패딩이 있는 입력이든 없는 입력이든 둘 다 받아들으므로, 여러분이 패딩 없이 만든 값은 엄격한 디코더로도 읽힐 수 있습니다. 그래서 API 경계를 건너는 모든 것에 패딩 없음이 안전한 선택인 것이죠. 이제, 큰 면책 고지 하나. 위 두 부분은 JWT의 서명되지 않은 절반입니다. 진짜 토큰은 "header.payload"를 계산 대상을 하는 서명이 필요하며, 그것은 인코딩이 아니라 암호학입니다. 프로덕션에서는 JOSE 라이브러리로 토큰을 발행하고 검증하세요. JJWT(0.13.0)나 nimbus-jose-jwt(10.9.1)처럼요. 예를 들어 JJWT의 API 아티팩트는 좌표 하나 떨어진 곳에 있습니다:

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.13.0</version>
</dependency>
<!-- jjwt-impl과 jjwt-jackson은 프로젝트 문서에 따라 런타임에 추가 -->

YouTube ID는 이 다이얼의 다른 얼굴입니다. 패딩 없는 base64url 11글자, URL이 허용되는 어디에나 붙여 넣혀도 살아남아야 하는 식별자죠. 여러분의 시스템이 URL을 타고 다니는 식별자를 만든다면, 위 withoutPadding() 체인이 복사할 모양입니다.

파일 인코딩

일상적인 파일 작업은 디코더의 애정 작업을 거울 뒤집은 것입니다. 파일을 읽고, 인코딩하고, 텍스트를 써 나가는 것. java.nio.file로 네 줄이면 됩니다:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class EncodeFile {
  public static void main(String[] args) throws Exception {
    byte[] raw = Files.readAllBytes(Paths.get("report.pdf"));
    String packed = Base64.getEncoder().encodeToString(raw);
    Files.write(Paths.get("report.pdf.b64"), packed.getBytes(StandardCharsets.ISO_8859_1));
    System.out.println(raw.length + " -> " + packed.length());
  }
}

그 마지막 println은 33퍼센트 대금이 눈앞에 보이는 모습입니다. 1 MB 파일은 대략 1.33 MB의 텍스트가 됩니다(원본의 4/3, 패딩 문자 최대 두 개 포함). 그리고 MIME식 줄바꿈을 했다면 줄바꿈이 몇 퍼센트를 더 얹습니다. 옛날 메일 시대의 계산, 지금도 참인 계산은 4/3 곱하기 78/76, 줄바꿈된 MIME 페이로드는 원본의 약 1.37배가 되죠. 결과 두 가지. 첫째, 저장소나 메시지 필드의 크기는 원시 길이가 아니라 인코딩된 길이로 정하세요. 192바이트 원시 값을 기꺼이 받아 주는 VARCHAR(255) 컬럼은 그 256자 인코딩을 거부합니다. 둘째, 메모리를 악화시키는 방향이 인코딩 방향입니다. 그래서 큰 파일에는 배열 버전이 부적절한 도구이고, 스트리밍 섹션이 적합한 도구입니다. 파일 쪽을 위한 작은 기쁨 하나. 출력의 앞 문자들은 입력의 앞 바이트에 대한 순수 함수이므로, Base64 인코딩된 PNG는 전부 iVBORw0K로, 인코딩된 GIF는 전부 R0lGOD로 시작합니다. 단 하나의 바이트도 디코딩하기 전에 파일 종류를 알아볼 수 있죠.

JSON, API, data URI

인코딩된 출력이 와이어에서 가장 흔하게 사는 곳 두 가지.

하나: JSON 안의 바이너리. 파일 업로드 엔드포인트, 콘텐츠 API, 시크릿 저장소, 웹훅은 바이너리를 Base64 텍스트로 JSON 안에 넣습니다. 원시 바이트를 넣으면 JSON 문자열 이스케이프가 깨지니까요. 인코더 쪽은 경계에서 한 줄이면 되고, 유일한 결정은 규격이 어떤 방언을 원하는가입니다:

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class JsonField {
  public static void main(String[] args) throws Exception {
    byte[] image = Files.readAllBytes(Paths.get("logo.png"));
    // 규격이 base64url, 패딩 없음을 요구합니다:
    String field = Base64.getUrlEncoder().withoutPadding().encodeToString(image);
    // "field"를 JSON 라이브러리에 평범한 문자열 값으로 넘겨 주세요.
    System.out.println(field.length());
  }
}

함정은 인코딩이 아니라, 규격을 읽는 것입니다. 어떤 API는 패딩 있는 표준 Base64를 원하고, 어떤 API는 패딩 없는 base64url을 원하며, 일부는 둘 다 관대하게 받아들입니다. 규격이 침묵할 때 가장 싼 해결은 상대방 쪽의 예시 값을 살펴보는 것입니다. 어디에 -나 _가 보이면 알파벳이 정해지고, 끝의 =가 보이면 패딩이 정해집니다. 방언을 잘못 고르면 보통 상대방이 크래시하지는 않습니다. 보통 파일이 손상되는데, 그것은 찾기까지 가장 오래 걸리는 종류의 버그입니다.

둘: data URI. 이미지를 HTML이나 CSS 안에 인라인으로 넣는 data:image/png;base64,... 문자열이 RFC 2397의 data URI입니다. data:, 선택적인 미디어 타입, 선택적인 ;base64 플래그, 쉼표, 그리고 데이터. 하나를 만드는 것은 문자열 연결이며, 유일한 결정은 플래그가 있는가입니다(플래그가 없으면 페이로드는 퍼센트 인코딩된 텍스트인데, 바이너리에는 아무도 그것을 원하지 않죠):

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DataUriBuild {
  public static void main(String[] args) throws Exception {
    byte[] icon = Files.readAllBytes(Paths.get("icon.png"));
    String b64 = Base64.getEncoder().encodeToString(icon);
    String uri = "data:image/png;base64," + b64;
    System.out.println(uri.substring(0, Math.min(40, uri.length())) + "...");
    // data:image/png;base64,iVBORw0KGgo...
  }
}

RFC의 자기 조언이 여기에는 흥미롭게 적용됩니다. data URI는 짧은 값용입니다. 50 KB 아이콘을 인라인으로 넣는 것은 정상적인 트레이드오프(요청이 하나 줄어듦)지만, 5 MB 사진을 인라인으로 넣는 것은 편의라는 옷을 입은 성능 버그입니다. 플래그를 지키고, 미디어 타입을 정직하게 유지하고, 바이트는 작게 유지하세요.

Basic 인증 헤더 만들기

웹에서 가장 오래된 인증 헤더는 여전히 Java에서 가장 쉬운 Base64 용도입니다. 정확히 인코딩 호출 하나니까요. RFC 7617에 따르면 Basic 요청은 Authorization: Basic 뒤에 username:password의 Base64 인코딩을 보냅니다. RFC의 자체 예시, QWxhZGRpbjpvcGVuIHNlc2FtZQ==는 "Aladdin:open sesame"이 변장한 것입니다. 클라이언트 쪽에서 헤더를 만드는 것은 Base64 두 줄에 현대적 HTTP 호출 하나입니다:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuthClient {
  public static void main(String[] args) throws Exception {
    byte[] credentials = ("alice:secret123").getBytes(StandardCharsets.UTF_8);
    String header = "Basic " + Base64.getEncoder().encodeToString(credentials);
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://example.com/api/status"))
      .header("Authorization", header)
      .GET()
      .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.statusCode());
  }
}

이 헤더에 속하는 주의가 세 가지입니다. 첫째, RFC는 분명히 Basic은 보호가 아니라 인코딩이라고 합니다. 크레덴셜은 패킷을 볼 수 있는 누구에게나 가독하므로, 이 헤더는 그 아래 HTTPS만큼만 강하며, TLS가 아닌 것에는 나쁜 생각입니다. 둘째, 문자 인코딩: RFC는 US-ASCII 크레덴셜을 전제합니다(그 외는 UTF-8, 그리고 charset 인증 매개변수는 자문 수준), 그래서 StandardCharsets.UTF_8를 고르고 양쪽에서 일관되게 유지하세요. 셋째, 버전 노트: java.net.http 클라이언트는 Java 11부터입니다. 더 오래된 JVM에서는 같은 헤더가 HttpURLConnection에 setRequestProperty 호출 하나로 올라가며, Base64 줄은 어떤 쪽이든 동일합니다. 같은 헤더의 서버 쪽에서는, 파싱과 디코딩은 자매 가이드의 예시입니다. 첫 번째 콜론에서 나누고, 상수 시간 비교를 하죠. 두 쪽은 같은 API의 두 호출이며, 그것이 바로 이것의 조용한 우아함입니다.

설정, 환경 변수, 컬럼 속의 값

Base64는 텍스트 컨테이너입니다. 그래서 예상하지 못한 곳에서 나타납니다. env 파일의 세미콜론이 든 데이터베이스 DSN, properties 파일의 따옴표가 든 비밀번호, config map의 여러 줄짜리 인증서, 그리고 TEXT 컬럼의 바이너리 블롭. 스키마가 아무도 BLOB를 생각하기 전에 설계되었으니까요. 인코딩 쪽은 한 호출이며, 정직한 틀을 씌우면 이것입니다. 포맷 안전의 트릭이지, 기밀의 트릭이 아니라는 것:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigEncode {
  public static void main(String[] args) {
    String dsn = "pg:host=db;password=qu\"ote";
    byte[] raw = dsn.getBytes(StandardCharsets.UTF_8);
    String packed = Base64.getEncoder().encodeToString(raw);
    System.out.println(packed);
    // cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
    System.out.println("DB_DSN_B64=" + packed);
  }
}

이것을 정직하게 지켜 주는 규칙 두 가지. 첫째, 시크릿을 Base64로 저장해 놓고 그것이 암호화되었다고 부르지는 마세요. Base64는 엔트로피를 추가하지도, 정보를 제거하지도 않습니다. 개발자가 파일을 읽는 순간, 한 호출로 값을 디코딩할 수 있죠. RFC의 보안 섹션은 정확히 이 실패를 가리킵니다. "인코딩된" 프로토콜 교환을 붙여 넣다가 크레덴셜을 드러내는 사람들. 값이 시크릿이라면, 먼저 암호화하고, 그 다음에 채널이 텍스트를 요구할 때에만 암호문을 Base64로 싸 넣으세요. 둘째, 크기에 예산을 정하세요. 저장된 값은 원본보다 약 3분의 1 더 크고, 원시 값이 들어 맞던 컬럼이나 필드에 인코딩 값은 들어 맞지 않습니다. 그리고 값이 돌아올 때는, 경계에서 디코딩하고 바이너리라면 바이트로, 텍스트라면 명시적 문자 인코딩의 문자열로 유지하세요. 그 방향은 자매 가이드의 영역입니다.

대형 데이터 스트리밍

인코딩은 메모리를 악화시키는 방향입니다. 그래서 여기 대형 파일 이야기는 워크세트를 작게 유지하는 것에 관한 것입니다. 파일 섹션 예시의 배열 버전은, 파일이 메모리에 넉넉히 들어 맞는 한 괜찮습니다. 그 너머에서는 스트림 어댑터가 그 수단입니다. wrap(OutputStream)는 쓰면서 인코딩하는 출력 스트림을 반환하므로, 수 기가바이트짜리 파일이 단일 바이트 배열로 들려 있지 않아도 됩니다:

import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamEncode {
  public static void main(String[] args) throws Exception {
    OutputStream packed = Base64.getEncoder().wrap(Files.newOutputStream(Paths.get("bigfile.b64")));
    InputStream raw = Files.newInputStream(Paths.get("bigfile.bin"));
    byte[] buf = new byte[8192];
    int n;
    while ((n = raw.read(buf)) != -1) {
      packed.write(buf, 0, n);
    }
    packed.close();
    raw.close();
  }
}

이 스트림에는 하이라이트를 받을 만한 동작이 하나 있습니다. 자바독이 스스로 가리키고 있거든요. 랩된 스트림은 내부에 몇 바이트의 나머지를 보유할 수 있으며, 권장 관행은 "사용 후 반환된 출력 스트림을 즉시 닫되, 닫는 동안 가능한 모든 남은 바이트를 아래 출력 스트림으로 쓸어 넣는다"는 것입니다. 쓰기를 멈추고 닫기 전에 출력 파일을 읽으면, 여러분의 데이터 꼬리는 여전히 인코더 안에 앉아 있으며, 파일은 잘려 보이기만 합니다. 그래서 예제는 다른 무엇이 파일을 건드리기 전에 packed를 닫고, 프로덕션에서는 두 스트림을 try-with-resources 블록에 넣어야겠죠. 습관을 들여 두세요. 인코딩 스트림에서는 닫는 것이 인코딩의 일부입니다.

노 가드를 만나러

물려받은 코드베이스에는 java.util.Base64보다 앞선 Base64 API가 가득하며, 그것들을 알아보는 것은 "왜 내 출력이 줄바꿈되죠?" 미스터리를 피하게 해 줍니다. 실제로 만나게 될 넷:

API 어디서 만나게 되는가 대처 방법
sun.misc.BASE64Encoder / BASE64Decoder Java 8 이전 코드 java.util.Base64로 마이그레이션. Java 9에서 제거됨
javax.xml.bind.DatatypeConverter XML 시대 코드, 오래된 웹 서비스 Java 11에서 제거됨(JEP 320). 마이그레이션
org.apache.commons.codec.binary.Base64 8 이전 JVM에서 돌아가야 하는 코드 8 이전 지원을 위해 유지. 그 외에는 JDK 클래스가 기본
com.google.common.io.BaseEncoding Guava 의존이 강하고 빅 데이터 스택 잘 동작합니다. JDK 클래스는 종속성이 없습니다

sun.misc 쌍이 바로 드라마의 주인공입니다. 내부용, 비지원 API였죠(그날의 JDK에서 아무렇지도 않게 컴파일되다가, 비추천 경고 하나 없이 사라지는 종류). 그리고 그 출력은 자기만의 습관이 있었습니다. 인코딩된 텍스트를 줄바꿈하는 습기 같은 것. 놀랍게도 많은 "내 Base64에 줄바꿈이 있네" 버그가 바로 거기서 옵니다. 2017년 9월 Java 9가 나오자, 모듈 시스템 정리가 이 쌍을 제거했고, 공식 마이그레이션 가이드는 용어를 아끼지 않습니다. "특히, sun.misc.BASE64Encoder와 sun.misc.BASE64Decoder는 제거되었습니다. 대신, JDK 8에 추가된 지원되는 java.util.Base64 클래스를 사용하세요". 아직도 오래된 클래스를 참조하는 코드에 jdeps를 돌려 보면, 도구는 그 종속성을 "JDK removed internal API"로 표시합니다. JDK가 교통 콘에 낼 수 있는 가장 가까운 소리죠. JAXB의 DatatypeConverter는 더 길지만 비슷한 삶을 살았습니다. Java 9 시대에 Java EE 모듈들과 함께 비추천으로 이어졌다가, JEP 320 "Java EE 및 CORBA 모듈 제거"에 의해 Java 11에서 아예 사라졌습니다. 두 마이그레이션 모두 기계적입니다. 오래된 printBase64Binary와 BASE64Encoder().encode 호출은 줄바꿈 차이만 빼면 getEncoder().encodeToString과 1대 1로 대응하며, 코드가 java.util.Base64 위에 오면 8부터 26까지 모든 JDK에서 아무 생각 없이 돌아갑니다.

보안과 속도

보안 섹션은 짧습니다. 인코더의 일은 데이터로 실패할 수 없기 때문이지, 비어 있기 때문이 아닙니다. Base64는 암호화가 아니며, 표준은 문자 그대로 그렇게 말합니다. Base 인코딩은 "비밀번호처럼 본래 쉽게 알아볼 수 있는 정보를 시각적으로 가리지만, 어떤 계산적 기밀성도 제공하지 않는다", 그리고 같은 섹션은 이것이 "보안 사고를 유발한 사례가 있다"고 짚습니다. 인코더 쪽의 실용적 귀결: 시크릿을 안전하게 만들겠다고 인코딩하지 마세요(그것은 더 안전해지지 않습니다. 더 많은 채널을 통과하게 되거든요). 값이 시크릿이라면, 먼저 암호화하고 그 암호문을 인코딩하세요. 그리고 변형 가능성의 쌍둥이도 기억해 두세요. 수신자는 디코딩된 데이터를 바꾸지 않고도 유효한 표기 하나를 다른 표기로 바꿀 수 있습니다(패딩이 달라도, 여분 비트에 쓰레기가 있어도). 결정론적 인코더가 여기서 보조가 됩니다. java.util.Base64는 정확히 하나의 입력에 정확히 하나의 출력을 만들므로, 여러분 자신의 시스템이 값을 쓰고 읽는다면 표기는 안정적이고, 정형 검사 필요 대상은 신뢰 경계 위의 외부 값들입니다.

속도에 관해서, 인코더 쪽은 디코더 쪽과 같은 이야기를 갖습니다. 현대 JVM에서 내장 구현은 Base64가 거의 영원히 병목이 되지 않을 만큼 빠르고, 벤치마크의 기준점이 바로 그것입니다. 자매 가이드에서 언급한 2025년 gRPC-java 벤치마크(issue 11857, JDK 17과 21에서 JMH)가 JDK 인코더의 처리량을 Guava의 대략 2.5배에서 3.8배로 찍었고, 가장 큰 격차는 x86이었습니다. 실용 노트 두 가지: 핫 패스라면 인코더 인스턴스 하나를 공유하세요(팩토리가 이미 같은 공유 인스턴스를 반환합니다). 그리고 할당을 건너뛰려면 크기를 미리 정해 둔 배열로 encode(byte[], byte[])를 선택하세요. 거대한 데이터라면 스트리밍 섹션이 메모리 이야기이며, 줄바꿈의 비용은 디스크 옆에서는 소음일 뿐입니다. Base64에서 유일한 진짜 성능 비용은 크기 자체이며, 어떤 구현도, 이것을 포함해서, 그것을 낮추어 줄 협상을 하지 못합니다.

함정 체크리스트

함정들을 한 곳에 모았습니다. 전부 Java 특유의 것들입니다:

  • 빠진 문자 인코딩. 명시적 문자 인코딩 없이 text.getBytes()를 쓰면 플랫폼 기본값이 됩니다. JDK 18+에서는 우연히 맞지만, 그 이전에서는 틀리며, 원칙적으로는 어디든 틀립니다. StandardCharsets.UTF_8를 전달하고, 문자 인코딩을 규격에 적어 두세요.
  • 패딩 있는 JWT. getUrlEncoder()는 기본값으로 패딩을 추가하고, 토큰은 패딩이 없기를 원합니다. withoutPadding() 호출은 레시피의 일부이지, 선택 사항이 아닙니다. 끝의 =가 있는 토큰은 어떤 검증자에게는 거부당하고, 어떤 검증자에게는 망가집니다.
  • 줄바꿈된 출력. MIME 인코더는 76자에서 CRLF로 줄바꿈하고, 끝 줄바꿈은 추가하지 않습니다. 소비자가 끝 줄바꿈을 기대한다면 직접 추가하세요. 소비자가 줄바꿈을 아예 원하지 않는다면 MIME 인코더를 쓰지 마세요.
  • 이중 인코딩. 이미 Base64인 값을 다시 인코딩하면 완벽하게 유효하고 완벽하게 쓸모없는 문자열이 생깁니다. 전형적인 원인: 필드가 API에서 이미 인코딩된 채로 도착하고, 여러분의 코드가 "살짝" 그것을 다시 인코딩합니다. 인코딩 전에 확인하세요.
  • URL의 더하기 기호. 표준 Base64 출력에는 +가 들어 있으며, 쿼리 문자열에서 그것은 서버가 보기 전에 이미 공백입니다. 표준 알파벳 값이 URL을 타야 한다면, 퍼센트 인코딩하거나, 처음부터 URL용 알파벳으로 생성하세요.
  • 33퍼센트 대금. 원시 컬럼에 들어 맞는 값은 인코딩한 채로는 들어 맞지 않습니다. 저장, 메시지 필드, 헤더의 크기는 4 * ceil(n / 3)로 정하고, 줄바꿈된 MIME 출력이 그것 위에 몇 퍼센트를 더 얹는다는 것을 기억하세요.
  • 닫히지 않은 스트림. 랩된 출력 스트림은 닫힐 때까지 남은 바이트를 보유합니다. 닫기 전에 파일을 읽으면 잘린 인코딩이 돌아옵니다. 매번 try-with-resources.
  • 눈에 훤히 보이는 시크릿. Base64는 포장 테이프이지 자물쇠가 아닙니다. 설정 파일, 로그, 환경 변수 속 인코딩된 크레덴셜은 읽을 수 있는 크레덴셜입니다. 먼저 암호화하거나, 하지 마세요.
  • Android의 벽. Android에서는 java.util.Base64가 API 레벨 26부터만 존재합니다. 그 미만에서는 프레임워크 클래스가 android.util.Base64이며, 자체 플래그 상수(NO_PADDING, URL_SAFE, NO_WRAP)를 가집니다. 검사 없이 하나를 하드코딩하면, 정확히 여러분이 테스트하지 않은 기기에서 깨집니다.
  • 줄 길이의 특이함. getMimeEncoder(77, ...)는 조용히 76에서 줄바꿈합니다. 길이는 4의 배수로 아래로 반올림되거든요. 3 이하를 요청하면 줄바꿈이 아예 꺼집니다. 포맷이 홀수 줄 길이를 요구한다면, MIME 다이얼은 그 도구가 아닙니다.

sun.misc에서 표준 라이브러리로

Java의 이야기는 선명한 이전과 이후를 가진 짧은 이야기입니다. 2014년 이전, JDK 안에서 Base64가 필요하면 내부 쌍 sun.misc.BASE64Encoder와 sun.misc.BASE64Decoder가 주어졌는데, 첫날부터 비지원이었고 76자 줄바꿈이라는 자기만의 습관이 있었습니다. 아니면 XML 코드에서 javax.xml.bind.DatatypeConverter를 찾거나, 빌드에 Apache Commons Codec이나 Guava를 추가했습니다. 많은 기업 코드베이스가 Base64 구현 세 개를 가지게 되면서 어떤 게 어떤 것인지도 모르게 된 바로 그 경로입니다. 2014년 3월 18일, Java 8이 java.util.Base64를 싣고 나왔습니다. 한 클래스, 세 알파벳, 제대로 구현된 RFC 4648과 RFC 2045 규칙, 팩토리 패턴, 패딩과 줄바꿈 다이얼, 양방향 스트림 어댑터. 이것은 언어가 처음부터 가져야 했던 Base64였으며, 자바독은 그때부터 Since: 1.8라고 말해 왔습니다.

정리는 두 물결로 왔습니다. Java 9(2017년 9월 21일)가 모듈 시스템 정리의 일부로 sun.misc 쌍을 제거했고, 마이그레이션 가이드는 모든 개발자를 JDK 8 클래스로 가리켰습니다. Java 11은 JAXB 모듈과 그 안의 DatatypeConverter까지 함께 제거했습니다(JEP 320). Java 18(2022년 3월 22일)은 JEP 400 "UTF-8 by Default"를 안고 왔는데, Base64에는 전혀 손을 대지 않으면서도 그것을 먹여 살리는 게으른 getBytes() 호출들의 실패 양상을 바꿨습니다. 플랫폼 기본 문자 인코딩이 모든 OS에서 UTF-8이 되자, 오래된 모지바케 패턴은 새 JVM에서 그저 재현을 멈췄습니다. 1.8 이후 공개 API는 단 한 메서드도 바뀌지 않았습니다. 움직인 것은 그 밑의 엔진입니다. 버그 수정과 성능 작업. 그래서 커뮤니티 벤치마크는 표준 라이브러리 버전이 그 자리를 대체한 레거시 라이브러리를 계속 앞서간다고 발견합니다. 오늘, 8부터 26까지 어떤 JDK에서도, "Java에서 이것을 Base64로 어떻게 하죠?"에 대한 답은 import 하나와 팩토리 호출 하나이며, 그 이상도 이하도 아닙니다. 십여 년간 그랬고요.

너드들의 즐거움 몇 가지

핸드북은 미소로 끝나야 하므로, 그냥 재미있는 Java 특유의 사실들을 모았습니다:

  • 자바독은 Since: 1.8라고 말하고, 열두 년간 그것이 참이었습니다. 한 메서도 추가되지 않았고, 하나도 제거되지 않았으며, 동작 하나도 바뀌지 않았습니다. 이 언어에서 가장 오래 얼어 있는 API 표면 중 하나이며, 여러분은 아무 생각 없이 그것을 씁니다.
  • 자바독에 따르면, encodeToString은 결과 String을 ISO-8859-1 문자 인코딩으로 구성합니다. 실제에서는 완전히 불필요한 디테일입니다. Base64 출력은 순수 ASCII이므로 Latin-1, UTF-8, 나머지 대부분의 문자 인코딩 세계에서도 똑같이 보이거든요. 그런데도 자바독은 말해 줍니다. JDK가 JDK인 까닭입니다.
  • MIME 인코더는 마지막 부분 줄 뒤에 줄 구분자를 추가하지 않습니다. 일부 아주 유명한 메일 라이브러리를 포함해 다른 도구들은 줄바꿈된 출력을 끝에 CRLF로 마칩니다. 참조 구현과의 diff가 정확히 끝의 두 글자라면, 여러분은 이 특이점을 찾은 것입니다.
  • getMimeEncoder에 77자 줄을 요청하면 76이 돌아옵니다. 줄 길이는 4의 배수로 조용히 아래로 반올림되는 것이죠. 왜냐하면 4글자 그룹을 가르는 줄바꿈은 쓰레기를 만들 테니까요. API는 여러분의 허락을 묻는 대신, 깨진 줄을 만드는 것을 거부합니다.
  • Base64.getEncoder() == Base64.getEncoder()는 참입니다. 팩토리 메서드는 매번 같은 공유 인스턴스를 반환하므로, "새것을 가져오는" API는 싱글턴의 변장이고, 스레드 안전 약속은 JVM이 이미 하고 있는 일의 서술일 뿐입니다.
  • Android에서는 쌍둥이 API android.util.Base64가 같은 결정들을 플래그로 공개합니다. NO_PADDING, URL_SAFE, NO_WRAP. 두 API, 한 결정 표. 지금 Base64 설계가 얼마나 정착되었는지에 대한 조용한 증언입니다.
  • RFC 4648 5절이 "base64url"이라는 이름이 태어난 곳입니다. 스펙은 URL용 인코딩을 "base64url이라 부를 수 있다"고 하면서도, "base64 인코딩과 같은 것으로 여겨져서는 안 된다"고 경고합니다. 그 기원은 P2P-hackers 메일링 리스트의 2001년 게시물로 각주 처리되어 있으니, 여러분이 붙여 넣는 모든 URL 속 그 이름에는 메일링 리스트의 혈통이 있습니다.
  • 단어 base64를 인코딩하면 YmFzZTY0가 됩니다. 패딩 없이요. 여섯은 3의 배수이니까요. 자기 자신을 설명하는 포맷은 모르세로 말하는 거울의 기술적 동격이며, 이것은 바로 그 거울의 자기 반영입니다.
  • Java 8 이전 코드에 jdeps -jdkinternals를 돌려 보세요. sun.misc.BASE64Encoder를 "JDK removed internal API"로 표시하는 것을 구경할 수 있습니다. 공식 마이그레이션 가이드의 도구 예시가 Base64 클래스인 것은, JDK가 여러분의 import를 가리켜서 "이건 우리가 얘기했었죠"라고 말하는 것입니다.
  • 1.37 배. 줄바꿈된 MIME 페이로드마다 원본 크기의 약 1.37배가 들어갑니다(알파벳이 4/3, CRLF 리듬이 78/76). 오래된 메일 계산이 아직도 인용할 만큼 안정적인 분수입니다. 1990년대 메일 인프라가 모든 첨부파리에 부과한 통행료가, 바로 오늘 getMimeEncoder()가 청구하는 대금이니까요.

반대 방향으로

이것이 인코더 쪽의 이야기이고, 둘 중 온순한 쪽입니다. 일은 데이터로 결코 실패하지 않고, 함정은 남의 예상 밖의 상황이 아니라 여러분의 결정(문자 인코딩, 패딩, 줄바꿈, 방언)에 관한 것이며, API 전체는 import 하나에 들어갑니다. 반대 방향은 Base64가 편리함을 멈추고 적대적되기 시작하는 곳입니다. 디코딩은 남의 패딩 선택, 남의 줄바꿈, 남의 문자 인코딩, 남의 아머를 만나るところ이고, 여러분과 진실 사이에는 IllegalArgumentException이 서 있거든요. 이 페이지에서 연결해 둔 Java의 Base64 디코딩은 디코더를 같은 깊이로 다룹니다. 세 디코더 성격, 정확한 오류 메시지, 패딩 규칙, base64url과 JWT, MIME과 PEM, 그리고 한 곳에 모인 Java 특유의 함정들. 둘을 쌍으로 읽으면, 이 주제 전체가 여러분의 것이 됩니다.

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

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