Kotlin에서의 Base64 디코딩: 완전한 가이드
읽기를 거부하는 값 하나를 들여다보고 있다: SGVsbG8sIFdvcmxkIQ==. 글자와 숫자가 이어지고, 가끔 +나 /가 섞여 있으며, 대개 끝자락에 = 문자 한두 개가 매달려 있다. 이것이 Base64이고, 이 가이드는 그것을 원래의 모습 - 문장, 이미지, 인증서, 바이너리 덩어리 - 으로 되돌리는 방법, 즉 Kotlin 스타일로 하는 방법을 다루는 것이다. 들어가 보기 전 한 줄로 복습하자: Base64는 매 3바이트를 64개 심볼 알파벳에서 고른 4자로 묶고, 짧은 = 패딩 꼬리가 진짜 데이터가 끝난 자리를 표시한다. 형식 전체에 대한 투어를 홈 페이지에서 하니, 여기서는 문장 하나만 쓰기로 하고, 하나 더: 4자가 3바이트가 담았던 것을 담기 때문에, 텍스트 형태는 원래 데이터보다 대략 3분의 1 더 길어진다.
Kotlin의 좋은 소식: 어떤 패키지도 전혀 필요가 없다. 표준 라이브러리는 수년째 자기 자신의 Base64 구현을 함께 제공하고 있으며, Kotlin 2.2부터 완전히 안정화되었고, 노트북의 JVM부터 Android 폰, Node.js, WASI 엣지 함수까지 Kotlin이 실행되는 모든 플랫폼에서 동작한다. 아래 모든 내용은 프로젝트에 기본으로 들어 있는 Kotlin 그대로에서 동작한다.
먼저 좋은 소식: 실제로 필요한 것은 이것
Gradle에 추가할 base64 아티팩트도, NuGet 스타일 패키지도, npm 모듈도 없다. 찾는 클래스는 kotlin.io.encoding.Base64이며, Kotlin 표준 라이브러리 자체의 일부다. println을 쓸 수 있다면 Base64를 디코딩할 수 있다. Kotlin 프로젝트에서 Base64 업무를 해내는 API는 세 가지이며, 적절한 것을 고르는 것이 첫 번째 실질적인 결정이다:
| API | 동작하는 곳 | 언제 선택해야 할 때 |
|---|---|---|
kotlin.io.encoding.Base64 |
모든 Kotlin 플랫폼: JVM, Android, JS, Native, Wasm | 기본 선택지. Kotlin 2.2부터 안정적이며, 멀티플랫폼이고 현대적인 API |
java.util.Base64 |
JVM 전용 (Java 8+; Android에서는 API 26+) | 이미 Java 상호운용 영역에서 생활하는 JVM 전용 코드베이스 |
android.util.Base64 |
Android 전용 (API 8+) | 레거시 Android 코드, 또는 그 플래그 상수가 특별히 필요할 때 |
알아 두면 좋은 버전 관련 참고가 두 가지 있다. 먼저, 표준 라이브러리의 이 클래스는 처음에 Kotlin 1.8.20(2023년 4월)에서 @ExperimentalEncodingApi 게이트 뒤에 등장했고, Kotlin 2.0.20이 withPadding 다이얼과 엄격한 패딩 규칙을 가져왔으며, Kotlin 2.2.0(2025년 6월)이 이 API를 안정화하고 PEM 인스턴스를 추가했다. 즉, Kotlin 2.2 이상 - 현재 안정 라인인 2.4.x 포함 -에서는 이 가이드의 모든 것을 어노테이션 없이 쓸 수 있다. 둘째, 프로젝트가 Kotlin 1.8과 2.1 사이의 버전에 고정되어 있다면, 같은 클래스가 존재하지만 실험적이라는 표식이 붙어 있고, 함수에 @OptIn 어노테이션을 달지 않으면 컴파일러가 사용을 허용하지 않는다.
오후를 하나 이상 날려 버린 설치 함정이 있다: Debian과 Ubuntu 저장소의 kotlin 패키지는 1.3.31 버전으로, 표준 라이브러리의 Base64 API보다 훨씬 이전이라, 이 글의 예제 하나도 컴파일할 수 없다. 대신 GitHub의 Kotlin 릴리스나 SDKMAN에서 컴파일러를 가져오고, Gradle 프로젝트에서는 플러그인을 명시적으로 고정하자:
plugins {
kotlin("jvm") version "2.4.10"
}
첫 디코딩: 두 줄과 바이트 결과
모든 의식이 문장 두 줄에 들어간다. 시작하기 좋은 곳은 정석 TWFu 문자열이다:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
천천히 읽자. 이 안에 설계 결정 세 가지가 숨어 있다. 첫째, .Default 없이 Base64.decode(...)라 부르는 것은 오타가 아니다: Default는 이 클래스의 companion object이므로, 클래스 자체에서 함수를 호출하는 것은 Base64.Default에서 호출하는 줄임말이다. 오래된 튜토리얼에서 Base64.Default.decode(...)를 볼 수도 있지만, 의미는 정확히 같다. 둘째, 이것은 겉보기보다 중요하다: decode는 ByteArray를 건네고, 결코 String을 주지 않는다. 페이로드는 JPEG일 수도, X.509 인증서일 수도, 문장일 수도 있고, API는 그것이 어느 쪽인지 추측하기를 거부하므로, 바이트에서 텍스트로 건너가는 도약은 별개의, 의도된 단계다. 셋째, 문자 집합 결정은 바로 그 단계에 있으며, 대부분의 "내 Base64가 가비지로 돌아왔다" 버그가 여기서 태어난다. 잠시 후에 거기에 도착한다. 먼저, 디코딩이 정직하다는 것을 보여주는 라운드 트립부터:
import kotlin.io.encoding.Base64
fun main() {
val original = "Hello, World!".encodeToByteArray()
val packed = Base64.encode(original)
val back = Base64.decode(packed)
println(packed) // SGVsbG8sIFdvcmxkIQ==
println(back.contentEquals(original)) // true
}
네 가지 스키마, 네 가지 성향
이 클래스는 인스턴스로 생성되지 않는다. 준비된 인스턴스 네 가지 중 하나를 고르는 것이며, 각각은 서로 다른 기질로 디코딩한다:
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello?".encodeToByteArray()
println(Base64.Default.encode(data)) // SGVsbG8/
println(Base64.UrlSafe.encode(data)) // SGVsbG8_
println(Base64.Mime.encode(data)) // SGVsbG8/
println(Base64.Pem.encode(data)) // SGVsbG8/
}
| 인스턴스 | 알파벳 | 디코딩 방식 |
|---|---|---|
Base64.Default |
A-Z a-z 0-9 + / |
엄격: 알파벳 밖의 문자가 있으면 즉시 예외, 패딩 필수 |
Base64.UrlSafe |
A-Z a-z 0-9 - _ |
엄격, 다만 URL 알파벳 기준. 입력에 +나 /가 있으면 예외 |
Base64.Mime |
A-Z a-z 0-9 + / |
관대: 줄 바꿈 기호와 알파벳 밖의 다른 문자는 무시하지만, = 패딩 뒤에 아무것도 올 수 없음. 패딩 필수 |
Base64.Pem |
A-Z a-z 0-9 + / |
관대, Mime과 동일한 규칙. 같은 알파벳의 PEM/PKI 변형 |
관대/엄격의 나뉨은 체득할 것 중 가장 쓸모 있는 하나다. Default와 UrlSafe는 이방 문자를 범죄 현장으로 간주하고 즉시 예외를 던진다. Mime와 Pem는 줄 바꿈, 공백, 여기저기 흩어진 구두점을 어깨를 으쓱하며 무시한다 - 실제 이메일과 인증서 파일이 바로 그런 것을 담고 있기 때문이다 - 하지만 무한대는 아니다: 패딩 뒤에 데이터 문자가 나타나면, 그것들도 예외를 던진다. 정확한 오류 메시지는 이 글 뒤의 실패 가이드에서 볼 수 있다.
성향이 주는 또 다른 결과: 한 스키마는 다른 스키마의 출력을 읽을 수 없다. base64url 토큰을 Base64.Default에 먹이면, - 문자는 그 알파벳에 없으므로 IllegalArgumentException: Invalid symbol '-'(55) at index ...가 돌아온다. 문자열의 출처가 불분명하면, 기분에 맞는 스키마가 아니라 만들어낸 쪽과 맞는 스키마를 고른다.
URL 안전 Base64와 JWT
표준 알파벳의 두 문자가 데이터가 URL을 통해야 하는 순간 문제를 일으킨다. 쿼리 문자열의 +는 읽는 쪽에 도달할 때쯤이면 습관적으로 공백으로 다시 해석되며, /는 경로 구분자이므로 URL 세그먼트에 아예 나올 수 없다. RFC 4648, 5절은 알파벳의 마지막 두 심볼을 바꾸는 것으로 이 문제를 해결한다: +는 -가 되고, /는 _가 된다. 가장 많이 들을 이름은 base64url이며, Kotlin에서는 Base64.UrlSafe다.
base64url의 가장 큰 소비자는 JSON Web Token이다. 컴팩트 형태의 JWT는 마침표로 이어진 base64url 세 부분, header.payload.signature다. RFC 7515는 이 부분들을 패딩 없는 base64url로 규정하는데, 이것은 단순한 문자 차이가 아니라 평범한 알파벳과의 두 번째 차이다. 토큰을 열어 검사하는 모습은 이렇다:
import kotlin.io.encoding.Base64
fun main() {
val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
val (header, payload, signature) = token.split(".")
val lenient = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(lenient.decode(header).decodeToString())
// {"alg":"HS256"}
println(lenient.decode(payload).decodeToString())
// {"sub":"1234567890","name":"John Doe"}
println(signature.length) // 43
}
눈에 들어와야 할 것이 두 가지다. 토큰의 각 부분은 패딩을 담고 있지 않지만, 기본 상태의 Base64.UrlSafe는 패딩을 요구하므로, withPadding(PRESENT_OPTIONAL) 줄이 실질적인 일을 하고 있다: 패딩이 있는 입력이든 없는 입력이든 모두 받아들인다. 그리고 split(".")에 구조 분해를 더한 것은, 그냥 순수 Kotlin이 형식이 요구하는 일을 하는 것뿐이다. 진지한 경고 하나: JWT를 여는 것은 토큰을 바라보기 위한 것이지, 그것을 신뢰하기 위한 것이 아니다. 헤더와 페이로드는 디코딩 후면 그냥 평범한 데이터이며, 토큰이 진짜라고 말하는 것은 검증된 서명뿐이고, 그것을 위해서는 손수 만든 문자열 분리가 아니라 제대로 된 JWT 라이브러리가 필요하다.
패딩 모드와 엄격도 다이얼
Kotlin에서 패딩은 Base64의 고정된 사실이 아니라 설정이다. 모든 인스턴스는 PaddingOption을 하나씩 갖고, 네 가지 프리셋 인스턴스 모두 PRESENT에서 시작하며, withPadding은 다른 설정을 가진 새로운 인스턴스를 건네주되 원래 인스턴스는 그대로 둔다. 다이얼을 옵션별로 보면 이렇다:
| 옵션 | 패딩 없는 입력 | 패딩이 올바른 입력 |
|---|---|---|
PRESENT (모든 곳의 기본값) |
예외 | 디코딩 |
ABSENT |
디코딩 | 예외 |
PRESENT_OPTIONAL |
디코딩 | 디코딩 |
ABSENT_OPTIONAL |
디코딩 | 디코딩 |
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello".encodeToByteArray()
println(Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(data))
// SGVsbG8
println(Base64.Default.encode(data))
// SGVsbG8=
val eitherWay = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(eitherWay.decode("SGVsbG8").decodeToString()) // Hello
println(eitherWay.decode("SGVsbG8=").decodeToString()) // Hello
}
디코딩 쪽에서는 PRESENT_OPTIONAL이 안전망이다: "보낸 쪽이 패딩을 했는지 모르고, 나는 계속 동작할 의사가 있다"는 옵션이다. 다른 조합의 오류 메시지는 유독 친절해서, 엄격한 디코더가 잘못된 입력과 만나는 순간 바로 알아볼 수 있다: PRESENT에서 패딩이 없으면 The padding option is set to PRESENT, but the input is not properly padded, ABSENT에서 패딩이 있으면 The padding option is set to ABSENT, but the input has a pad character at index 7가 만들어진다. 사람들은 놀라곤 해서 부각할 가치가 있는 동작이 하나 있다: SGVsbG8== 같은 이중 패딩은 "여분이지만 괜찮은" 것이 아니다. 첫 번째 =가 데이터를 끝내면, 두 번째는 데이터가 기대되던 위치에 있는 문자가 되어, 가장 관대한 디코더조차 거부한다.
여기에는 숨겨진 버전 역사도 있다. 실험적 1.8.x API에 맞춰 쓰인 코드를 물려받았다면, 옛 decode는 패딩이 있는지 없는지 관계없이 입력을 받아들였다는 것을 기억하라. Kotlin 2.0.20에서 Default는 엄격한 PRESENT 규칙으로 옮겨갔으므로, 한때 동작하던 패딩 없는 입력은 이제 그 포인트 릴리스를 넘어 업그레이드하면 예외를 던진다. 고치는 방법은 한 줄: withPadding(Base64.PaddingOption.PRESENT_OPTIONAL), 또는 디코딩 전에 입력을 정규화하는 것.
바이트에서 텍스트로: 문자 집합과 유니코드
ByteArray를 손에 넣으면, 질문은 그것이 무엇을 의미하는가이다. 페이로드가 텍스트라면, 기본 답은 decodeToString()으로, 바이트를 UTF-8로 해석하며 모든 플랫폼에서 동작한다. 현대 API, 이메일, 웹 데이터라는 일반적인 경우라면, 앞으로 필요할 것은 이것뿐이며 이모지 포함이다:
import kotlin.io.encoding.Base64
fun main() {
val original = "héllo 😀"
val packed = Base64.encode(original.encodeToByteArray())
println(packed) // aMOpbGxvIPCfmIA=
println(Base64.decode(packed).decodeToString()) // héllo 😀
}
하지만 보낸 쪽이 UTF-8 이외의 것을 썼다면, 문자 집합 결정은 당신의 몫이다. Kotlin의 내장 텍스트 변환은 의도적으로 UTF-8 전용이다: decodeToString()에는 문자 집합 매개변수가 없고, 문자열에서 바이트로 가는 변환 함수에도 그런 매개변수는 없다. JVM에서는 플랫폼의 문자 인코딩 API로 내려가는데, 이것은 정직하고 명시적이다:
import kotlin.io.encoding.Base64
import java.nio.charset.Charset
fun main() {
val latinOne = "héllo".toByteArray(Charsets.ISO_8859_1)
val packed = Base64.encode(latinOne)
println(packed) // aOlsbG8=
val asUtf8 = Base64.decode(packed).decodeToString()
val asLatin = String(Base64.decode(packed), Charsets.ISO_8859_1)
println(asUtf8) // h?llo (é 바이트는 유효한 UTF-8이 아님)
println(asLatin) // héllo
val byName = String(Base64.decode(packed), Charset.forName("ISO-8859-1"))
println(byName) // héllo
}
중간 줄의 그 ?는 폰트 문제가 아니다. 그것은 유효한 UTF-8을 이루지 못하는 바이트를 대신 서 있는 U+FFFD, 유니코드 교체 문자다. 디코딩 후 그것들이 줄을 이어서 보인다면, 페이로드는 멀쩡하고 - 당신의 문자 집합 가정이 안 맞는 것이다. 사람들을 물는 비대칭에도 주목하라: 인코딩 쪽에는 JVM 확장 toByteArray(charset)이 있지만, 디코딩 쪽에 대응하는 것은 생성자 String(bytes, charset)다. 둘 다 문자 인코딩 이름을 받지 않으며, 이름을 쓰려면 Charset.forName("...")이 필요한데, 이것은 만들어낸 이름에 UnsupportedCharsetException을 던지므로, 설정 값의 오타는 조용히 다른 인코딩을 고르는 대신 빨리 실패한다.
바이트 세상에서 한 가지, Kotlin 특유의 함정: Char은 16비트 값이고, 그것에 toByte()를 하면 조용히 낮은 8비트만 남긴다. 문자에서 바이트를 직접 만들어 보면, "中".first().code.toByte()은 45를 주는데, 그 숫자는 그 문자와는 아무 상관도 없다. 올바른 길은 언제나 encodeToByteArray()이며, 이것이 진짜 인코딩 작업을 한다 - 같은 문자는 UTF-8 바이트 3개이고, 그 Base64 형태는 5Lit이다. 인코딩은 표준 라이브러리에 맡기고, 절대로 손으로 문자를 바이트에 넣지 말자.
파일, 부분 문자열과 큰 입력
Base64 데이터는 항상 메모리에 있는 말끔한 문자열은 아니다. 때로는 파일이고, 더 큰 응답의 한 조각이거나, 한 번에 다 담기엔 너무 크다. Kotlin은 세 개의 문을 모두 열어 준다.
파일은 가장 좋은 의미에서 지루한 경우다: 바이트를 읽고, 디코딩하고, 끝. 표준 파일 API 둘 다 동작하므로, 프로젝트가 이미 쓰는 것을 쓰면 된다:
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
fun main() {
val fromFile = File("payload.b64").readBytes()
println(Base64.decode(fromFile.decodeToString()).size) // 디코딩된 바이트 수
val fromPath = Path("payload.b64").readBytes()
println(Base64.decode(fromPath.decodeToString()).size) // 동일한 숫자
}
부분 문자열은 CharSequence 오버로드가 실력을 펼치는 곳이다. decode는 시작과 끝 인덱스를 가진 어떤 문자열 시퀀스든 받으므로, 긴 응답 본문의 조각을 먼저 복사본을 만들지 않고 바로 넘길 수 있다:
import kotlin.io.encoding.Base64
fun main() {
val body = "prefix junk SGVsbG8= trailing junk"
val bytes = Base64.decode(body, 12, 20)
println(bytes.decodeToString()) // Hello
}
출력 크기를 이미 알고 있고 버퍼를 재사용하려면, decodeIntoByteArray가 당신이 고른 목적지 배열에 쓰며 몇 바이트를 썼는지 알려준다. 버퍼가 너무 작으면 메시지에 필요한 용량을 담아 IndexOutOfBoundsException을 던지므로, 오류가 곧 크기 힌트 역할을 해 준다.
JVM에서 진짜로 큰 스트림을 다룬다면, 세 번째 문이 있다: 스트리밍 디코더다. 아직 실험적이라는 표식이 붙어 있어 - 그래서 옵트인 어노테이션이 있는 것이며 - JVM 전용이지만, 모든 것을 메모리에 담아 두지 않고 실시간으로 디코딩한다:
import java.io.ByteArrayInputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.decodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val stream = ByteArrayInputStream("SGVsbG8gV29ybGQh".toByteArray())
stream.decodingWith(Base64.Default).use {
println(it.readBytes().decodeToString()) // Hello World!
}
}
실용적인 디테일 두 가지. 확장 함수는 패키지 최상위에 있으므로, 이름으로 임포트해야 한다 (스타 임포트도 되지만, 이름이 더 친절하다). 그리고 디코더는 패딩을 단단한 정지점으로 취급한다: Base64 구간 이후에도 원본 스트림이 계속 흘러가면, 디코딩된 스트림에서 읽는 것은 =에서 끝나고, 남은 바이트는 원래 스트림에서 그대로 사용 가능하다. Base64를 다른 것 앞에 붙이는 형식에는 이것이 깔끔하다.
현장에서: HTTP API와 JSON 본문
JSON은 원시 바이트를 실어 나를 수 없다 - 텍스트 프로토콜이니까 - 그래서 바이너리(이미지, 인증서, 임의의 덩어리)를 옮겨야 하는 API는 거의 항상 문자열 필드 안에서 Base64로 감싼다. 패턴은 이렇다: JSON을 파싱하고, 필드를 갖고, 디코딩한다. 공식 직렬화 라이브러리라면 JSON 부분은 어노테이션 두 개 거리다:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class ImageResponse(val name: String, val data: String)
fun main() {
val body = """{"name":"icon.png","data":"iVBORw0KGgo="}"""
val response = Json.decodeFromString<ImageResponse>(body)
val bytes = Base64.decode(response.data)
println("${response.name}: ${bytes.size} bytes") // icon.png: 8 bytes
}
이 예제는 직렬화 플러그인과 라이브러리가 필요하며, 빌드에 한 번만 추가한다:
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
라이브러리 없이도 같은 아이디어는 원시 문자열 위에서 동작하며, 빠른 스크립트에 유용하다: substringBetween로 필드를 꺼내서 디코딩하면 된다. 함정은 익숙한 API 쪽의 것들이다: 필드가 실제로는 데이터 URL 전체일 수 있다 (data:image/png;base64, 접두사가 있는, 이 글에서 나중에 다룰), 페이로드가 줄 바꿈으로 MIME 래핑되어 있을 수 있고, 인코딩된 페이로드는 원래 바이너리보다 약 3분의 1 더 클 수 있으니, 큰 응답에서는 메모리 예산을 지키자.
현장에서: 이메일과 MIME 래핑된 입력
이메일은 7비트 텍스트 세계이고, RFC 2045의 바이너리 첨부 파일에 대한 답은 한 가지 반전을 담은 Base64다: 인코딩된 출력은 어떤 줄도 76자를 넘지 않도록 감싸야 한다. 첨부 파일을 텍스트로 받아본 적이 있다면, 그것이 왜 들여쓰기된 Base64 열처럼 보이는지 알 것이다. 정확히 이런 입력을 위해 Base64.Mime가 맞는 디코더인데, 가는 도중 줄 바꿈 기호와 알파벳 밖의 다른 문자를 무시하기 때문이다:
import kotlin.io.encoding.Base64
fun main() {
val wrapped = "SGVs\nbG8=\r\n"
println(Base64.Mime.decode(wrapped).decodeToString()) // Hello
val withJunk = "Y@{mFz!Z!TY}0"
println(Base64.Mime.decode(withJunk).decodeToString()) // base64
}
관대함은 실재하지만 한계가 있다. 입력을 감싸고, 공백을 하나둘 뿌려도 문제없다. 다만 마지막 = 뒤에 데이터 문자를 붙여 보자, Mime조차 예외를 던진다: Symbol 'e'(145) at index 7 is prohibited after the pad character. 그리고 Mime은 여전히 패딩이 있고 올바르도록 요구한다는 것도 기억하라: 패딩 누락도 삼키는 MIME 디코더는 자초지통인 셈이다. 지저분한 수신 이메일 페이로드의 실전 레시피는 Mime 디코딩을 먼저 하고, 그것이 예외를 던지면 메시지를 살펴보는 것 - 어떤 심볼이, 몇 번째 인덱스에서, 규칙을 어겼는지 정확히 알려주니까.
현장에서: 이미지와 데이터 URL
데이터 URL은 파일을 문서 안에 직접 넣는 웹의 방식이다: 미디어 타입, base64, 마커, 페이로드가 한 문자열에 모두 들어 있다. 브라우저, CSS, 임베디드 UI는 작은 자산 - 아이콘, 아바타, 플레이스홀더 그래픽 - 에는 이것을 좋아한다. 두 번째 요청을 만들 필요가 없기 때문이다. 형식은 이 모양이다:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
Kotlin에서 하나를 디코딩하는 것은 문자열 조작에 이은 Base64 디코딩이다. 접두사에 비밀은 없다. 쉼표 뒤의 모든 것이 페이로드다:
import kotlin.io.encoding.Base64
fun main() {
val dataUrl = "data:image/png;base64,iVBORw0KGgo="
val mediaType = dataUrl.substringBefore(";")
val packed = dataUrl.substringAfter("base64,")
val bytes = Base64.decode(packed)
println(mediaType) // data:image/png
println(bytes.size) // 8
println(bytes.contentToString()) // [-119, 80, 78, 71, ...]
}
그 첫 번째 바이트, -119(이것은 0x89), 그 뒤를 이어 PNG라는 글자. 이것이 PNG 파일임을 식별하는 매직 넘버다. 디코딩 후 처음 네 개 또는 여덟 개 바이트를 확인하는 것은, 데이터 URL이 접두사가 주장하는 것을 정말 담고 있는지 확인하는 값싼 방법이다. 정직한 주의점 두 가지: Base64는 크기에 약 3분의 1을 더하므로, 데이터 URL은 네트워크 왕복에 대한 크기 트레이드오프이며, 큰 것은 대개 진짜 URL에서 파일을 서빙하고 캐시가 일하도록 두는 편이 낫다.
현장에서: 설정, 환경 변수와 데이터베이스
Base64는 바이너리 값이 텍스트 전용 통로를 타야 할 때마다 설정 파일과 환경 변수에 나타난다: properties 파일에 박혀 있는 작은 아이콘, 컨테이너의 환경 변수에 저장된 토큰, 제대로된 바이너리 타입이 생기기 전의 스키마 때문에 텍스트 컬럼에 머무는 바이트 덩어리. 디코딩 쪽은 어디든 같은 두 단계다 - 텍스트를 읽고, 디코딩한다:
import kotlin.io.encoding.Base64
fun main() {
val line = "icon: UE5HREFUQQ=="
val packed = line.substringAfter("icon: ").trim()
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // PNGDATA
val fromEnv: String? = System.getenv("MY_ICON_B64")
if (fromEnv != null) {
println(Base64.decode(fromEnv).size)
}
}
이 일대의 함정은 한 줄에 들어간다: Base64는 암호화가 아니다. 이것은 전송의 트릭이지, 자물쇠가 아니다. 누구도 Base64 값을 보고 그 안의 데이터가 숨겨진 것이라고 생각해서는 안 된다. 하나 호출하면 보이며, 당신이 쓰는 모든 로그 줄에서 보이고 있다. 값이 민감하다면, 처음부터 끝까지 민감하게 보관하라 - 시크릿 스토어든, 암호화된 컬럼이든, 스택이 주는 무엇이든 - 그리고 Base64는 데이터를 보호하기 위해서가 아니라, 바이트가 텍스트를 통해 이동하게 하기 위해서만 쓰라.
현장에서: 명령줄
가장 오래된 용도: 명령줄 위의 Base64 덩어리를 파일로 바꾸는 것. 완전한 도구는 Kotlin 8줄이며, 무거운 일은 표준 라이브러리가 해 주니까. Kotlin 컴파일러로 한 번 컴파일하면, 영원히 당신의 것이 된다:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
val packed = if (args.isNotEmpty()) args[0] else readlnOrNull().orEmpty()
val bytes = Base64.decode(packed.trim())
File("decoded.bin").writeBytes(bytes)
println("Wrote ${bytes.size} bytes to decoded.bin")
}
일회성 값에는 인자와 함께, 배치 작업에는 파일을 파이프해서 실행한다: 프로그램은 있으면 첫 번째 인자를 읽고, 없으면 표준 입력으로 내려간다. trim()은 여기서 조용한 복무 중이다. 셸 인자와 붙여 넣은 값은 엄격한 디코더가 거부할 여분의 여백을 달고 오는 것을 좋아하니까. 그리고 페이로드가 base64url이라면, Base64.decode 대신 Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode로 바꾸면, 이 도구는 토큰도 준비된다.
디코딩 실패 현장 가이드
이 가이드의 모든 디코더는 두 예외 타입 중 하나로 실패하며, 모든 메시지는 정확히 무엇이 잘못되었는지 말해 줄 만큼 구체적이다. 표준 라이브러리가 만드는 정확한 메시지와 함께, 전체 지도를 보여 준다:
| 상황 | 예외 | 메시지 (실제로 생성되는 그대로) |
|---|---|---|
| 알파벳 밖의 문자 (공백, 줄 바꿈, 다른 스키마의 심볼) | IllegalArgumentException |
Invalid symbol ' '(40) at index 5 |
| 패딩 뒤에 오는 데이터 문자 | IllegalArgumentException |
Symbol 'e'(145) at index 7 is prohibited after the pad character |
옵션이 PRESENT인데 패딩이 없을 때 |
IllegalArgumentException |
The padding option is set to PRESENT, but the input is not properly padded |
옵션이 ABSENT인데 패딩이 있을 때 |
IllegalArgumentException |
The padding option is set to ABSENT, but the input has a pad character at index 7 |
| 인덱스가 소스의 범위를 벗어날 때 | IndexOutOfBoundsException |
startIndex: 0, endIndex: 100, size: 8 |
startIndex가 endIndex보다 클 때 |
IllegalArgumentException |
startIndex: 3 > endIndex: 2 |
목적지 버퍼가 decodeIntoByteArray에 너무 작을 때 |
IndexOutOfBoundsException |
The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
첫 두 줄의 패턴에 주목하라: 메시지는 문제의 심볼, 괄호 안의 숫자 코드, 그리고 그 인덱스를 이름으로 부른다. 이것은 디버깅의 선물이다. 디코딩이 프로덕션에서 예외를 던지면, 입력의 처음 몇십 자와 메시지의 인덱스를 로그에 남기면, 붙여 넣은 줄 바꿈이든, 잘린 페이로드든, 표준 디코더로 잘못 들어온 base64url 문자열이든, 거의 언제나 몇 초 안에 범인을 잡을 수 있다.
Kotlin 개발자에게 특히 상처를 주는 함정
- 페이로드가 텍스트라고 가정하는 것.
decode는 의도적으로ByteArray를 돌려준다. "아마 텍스트일 거야"라면서 JPEG에decodeToString()을 부르면, 교체 문자 벽이 돌아온다. 바이트를 변환하기 전에 그것이 무엇인지를 결정하라. - 복사-붙여 넣기 여백. 기본 디코더는 엄격하며, 채팅 메시지나 로그에서 가져온 값은 대개 끝 줄 바꿈이나 앞 공백을 달고 온다. 디코딩 전에 트리밍하거나,
Mime로 디코딩하거나,IllegalArgumentException을 받아서 처리하라. - 실험 시대에서 업그레이드. 1.8.x 실험적 API를 위해 쓰인 코드는
@OptIn(ExperimentalEncodingApi::class)어노테이션을 달고, 패딩이 선택적이라는 것에 의존했다. 2.0.20부터, 같은 입력이 예외를 던질 수 있다. 고치는 방법은PRESENT_OPTIONAL, 또는 디코더에 도달하기 전에 입력을 정리하는 것. - 스키마를 잘못된 생산자에 맞춘 것. JWT 부분을
Base64.Default로 디코딩하면-와_에서 실패하고, 표준 알파벳 페이로드를UrlSafe로 디코딩하면+와/에서 실패한다. 예외는 정확한 심볼을 이름으로 부르지만, 고치는 방법은 그 문자열이 어디에서 왔는지 아는 것이다. - 문자 인코딩의 틈.
decodeToString()은 UTF-8 전용이며, 다른 인코딩을 위한 오버로드가 없다. 보낸 쪽이 Latin-1이나 Windows-1252를 썼다면, JVM에서는String(bytes, charset)을 계획하고, 잊었을 때의 증상으로 U+FFFD 교체 문자를 기대하라. - 배포 패키지의 컴파일러. Debian과 Ubuntu의
apt install kotlin는 이 API가 존재하기 이전인 1.3.31을 준다. 예제가 갑자기 "unresolved reference"와 함께 컴파일을 거부하면, PATH에 실제로 어떤 컴파일러가 있는지 확인하라.
디코딩 모범 사례
- 먼저 바이트로 디코딩, 그다음 해석.
Base64.decode와 텍스트 변환을 별개의 단계로 두자. 문자 인코딩을 명시적으로 만들고, 바이너리 페이로드는 바이너리로 유지하며, 테스트도 자명해진다: 문자열이 아니라 바이트 배열을 비교한다. - 생산자와 맞는 인스턴스를 고르자. JWT와 URL로 가는 데이터는
UrlSafe, 이메일과 PEM 파일은Mime또는Pem, 나머지는Default에서 시작. 관대한 디코더는 입력 자체가 지저분한 것으로 알려진 경우를 위한 것이지, 일반적인 안전망이 아니다. - 신뢰할 수 없는 입력은 한 번, 값싸게 정규화. 엄격한 디코딩 전에
trim()을 하고, 형식이 깨끗한 것이 알려져 있다면 여백 제거를 더하면, try-catch을 아무리 쌓아도 못 잡는 실전 실패를 더 많이 잡는다. 출처가 모르는 값에는PRESENT_OPTIONAL폴백을 가진 작은 헬퍼가 좋은 패턴이다. - 할당 전에 크기를 예산으로 잡자. 디코딩된 출력은 입력 길이의 4분의 3을 넘지 않는다 (심볼 4개가 바이트 3개를 담으므로), 그래서 빠른 길이 체크로 디코딩 전에 목적지 크기를 알 수 있는데, 이것은 미리 할당된 버퍼를 채우기 전이나 수 메가바이트 문자열을 받기 전에 정확히 원하는 것이다.
- 오류 메시지를 믿자. 표준 라이브러리는 심볼, 그 코드, 그 인덱스를 보고한다. 신뢰할 수 없는 입력에서는 그 인덱스 주변의 내용을 로그에 남기고, 더 이상 추측하지 말자.
- 숨기기 위해 디코딩하지도, 증명하기 위해 디코딩하지도 말자. Base64는 전송 인코딩이다. 기밀도, 무결성도 추가하지 않는다. 그 둘 중 하나가 필요하면, 그것은 암호학의 일이지 디코더의 일이 아니다.
Base64가 Kotlin에 들어온 길
Base64는 Kotlin보다 몇십 년은 오래되었다 - 76자 줄 규칙을 준 MIME 사양은 1993년으로 거슬러 가고, 알파벳 자체는 1990년대 중반의 RFC까지 - 하지만 Kotlin 특유의 이야기는 짧고 최신이다. kotlin.io.encoding 패키지는 2023년 4월의 Kotlin 1.8.20에 도착했는데, @ExperimentalEncodingApi 어노테이션 뒤에 Base64를 싣고, 세 인스턴스 - Default, UrlSafe, Mime - 와, 오늘날까지도 실험적인 JVM 전용 스트리밍 확장을 함께 가져왔다. 두 해 동안, 그것을 쓰는 것은 모든 함수에 옵트인 한 줄과, API가 움직일지도 모른다는 작은 가능성을 함께 짊어지는 것이었다.
Kotlin 2.2.0, 2025년 6월 출시, 계약이 바뀌었다. API 전체가 한 릴리스에 안정화되었고, Pem 인스턴스가 가문에 합류했다 (PKI 주위에서 쓰이는 RFC 1421의 64자 줄 변형). 패딩이 선택적인 1.8 시대 코드가 업그레이드 후 주의가 필요하게 만드는 그 엄격성은 한 걸음 먼저 왔다: 2.0.20, PaddingOption 값 네 개를 가진 withPadding이 옛 고정 동작을 대체하고 디코더가 패딩을 요구하기 시작했을 때. 2.2 릴리스는 또, Kotlin 1.9부터 실험적이던 16진수 형식 API, kotlin.text의 형제 클래스 HexFormat도 안정화했으니, 바이트 수준의 텍스트 인코딩은 이제 표준 라이브러리에 정착한 보금자리를 갖게 되었다. 그리고 유지보수 참고: Kotlin 2.4.0부터 JVM 표준 라이브러리는 릴리스 라인마다 18개월 지원 기간을 제공하니, 현재 2.4.x 라인의 프로젝트는 이 API를 움직이는 것이 아니라 고정된 점으로 취급할 이유가 하나 더 있다.
재미있는 사실들
- companion object는 일을 한다.
Default가Base64의 companion object이기 때문에, 클래스 이름이 기본 인스턴스 역할을 겸한다:Base64.decode(x)와Base64.Default.decode(x)는 같은 호출이다. 이것이 이 글 맨 위의 두 줄 예제가 두 줄로 남는 이유다. - 문자열 디코딩은 JVM에서 속도로 한 수 부린다. 일반적인 디코딩 루프는 바이트 위에서 동작하지만, Kotlin의 문자열은 문자 시퀀스다. JVM 구현은 공유 루프가 돌기 전에
String의 문자들을 1바이트 ISO-8859-1 값으로 다시 해석해서 변환을 피하는데, 소스 코드의 주석에 따르면 이것은 일반 경로보다 최대 10배 빠르다고 하며,decode(String)가 긴 페이로드에서도 즉각적으로 느껴지는 이유가 이것이다. - 패키지 이름이 힌트다. 이 API는
kotlin.text가 아니라kotlin.io.encoding에서 찾을 수 있는데, 이유는 핵심이 데이터가 바이트라는 데 있기 때문이다. 입력과 출력이 I/O 모양을 하고 있고, 텍스트는 단지 그 뒤에 결과에 일어나는 일에 불과하다. - 오류 메시지는 문자의 코드까지 담는다.
Symbol 'e'(145)는 문제의 심볼을 글형만이 아니라 8진수 값으로 보고한다. 범인이 여백일 때 유용하다:' '(40)는 당신이 공백을 의심하기 훨씬 전에 그것이 공백이었다고 알려준다. - PEM은 늦게 왔다.
Base64.Pem는 원래 1.8.20 API의 일부가 아니라, 2.2 안정화와 함께 등장했다. 2023년 또는 2024년 블로그 글이 인스턴스를 세 개만 나열한다면, 그것은 틀린 것이 아니라 - 그저 두 릴리스 뒤처진 것일 뿐이다. - 플랫폼별로 쓰여 있고, 위임하지 않는다. 표준 라이브러리는 expect/actual 함수로 각 타깃에 대해 코덱을 따로 구현한다. JVM에는 심지어
java.util.Base64에 일을 맡길 주석 처리된 최적화도 있는데, 열린 컴파일러 이슈 뒤에 비활성화되어 있다. 그래서 Kotlin 구현의 동작이 모든 플랫폼에서 기준 동작이다.
정리
Kotlin에서 Base64를 디코딩하는 것은 짧은 목록의 의도된 선택으로 귀결된다: 데이터가 откуда 왔는지에 맞는 인스턴스, 어떻게 보내졌는지에 맞는 패딩 모드, 얼마나 큰지에 맞는 버퍼나 스트림, 그리고 그것이 무엇을 의미하는지에 맞는 문자 인코딩. 표준 라이브러리는 의존성 없이 모든 것을 순수한 함수로 건네주며, 그 오류 메시지는 실패가 미스터리가 아니라 진단이 될 만큼 구체적이다. 반대 방향 - 당신이 Base64를 만드는 쪽일 때, 어떤 스키마, 패딩, 줄 감기를 골라야 하는가 - 은 자기만의 결정과 자기만의 함정을 갖고 있으며, 자매 사이트의 관련 글은 Kotlin에서의 Base64 인코딩을 깊이 있게 다룬다.
마지막 업데이트: 2026-09-08