Swift에서의 Base64 디코딩: 완전한 가이드
파이프라인 어딘가에서 데이터는 변장을 하고 있습니다. HTTP 헤더에 숨겨진 토큰, JSON 필드 안에 숨은 아바타, 지난주에 봐야지 하고 약속해 둔 .b64 파일, 문자 벽이 되어 도착한 이메일 첨부 파일. Swift에서 그 변장을 벗겨 주는 일은 이 언어에서 가장 즐거운 일 중 하나입니다: 프레임워크 하나, 초기화자 하나, 스티커 메모에 들어갈 만큼 짧은 규칙책.
이 사이트의 홈 페이지는 포맷 자체(인쇄 가능한 문자 64개, 문자당 6비트, 마지막 그룹에 최대 두 개의 = 패딩)를 이미 다루므로, 여기서 그 이야기를 다시 하지는 않겠습니다. 두 가지만 주머니에 넣어 두세요. 첫째, base64는 바이트를 텍스트로 분장시키는 방식이지, 자물쇠가 아닙니다. 둘째, Swift에서 모든 base64 여정은 하나의 타입 Data를 통해 이루어지고, 디코더는 실패 가능한 초기화자로 그 위에 살고 있습니다. 이 하나의 사실이 이 글의 나머지 전체를 좌우합니다. 실패 가능한 초기화자는 그 뒤에 오는 모든 코드의 쓰기를 바꾸기 때문입니다.
하나의 타입이 온갖 일을 독차지한다
Swift는 base64 헬퍼를 열두 개 모듈에 흩어 놓지도 않고, 설치할 것도 아무것도 요구하지 않습니다. 디코더는 Foundation의 Data(base64Encoded:options:)이며, 이 프레임워크의 초기부터 플랫폼의 일부였습니다 (Apple은 이 초기화자를 iOS 8.0, macOS 10.10, tvOS 9.0, watchOS 2.0, visionOS 1.0부터 표기합니다. 인코딩 쪽의 줄 길이 옵션은 iOS 7.0까지 거슬러 올라갑니다). Linux와 Windows에서는 같은 Foundation이 오픈소스 툴체인과 함께 배송되므로, 아래 코드는 iPhone 앱이든, 서버 워커든, 터미널의 스크립트든 똑같이 동작합니다.
형제 초기화자 Data(base64Encoded: Data, options:)도 있습니다. base64가 문자열이 아니라 생 ASCII 바이트로 도착하는 경우를 위한 것이죠. 둘 다 기본값이 []인 options 인수를 받습니다. 그리고 둘은 어떤 옵션보다 더 중요한 공통 성격을 하나 가졌습니다: 실패 가능하다는 것.
import Foundation
let packed = "SGVsbG8sIFN3aWZ0IQ=="
if let data = Data(base64Encoded: packed) {
let text = String(data: data, encoding: .utf8)
print(text ?? "not text after all")
} else {
print("that was not base64")
}
// Hello, Swift!
Apple의 이 초기화자 문서는 놀라울 만큼 단도직입적입니다: "입력이 유효한 Base-64로 인식되지 않으면 nil을 반환한다". 예외도, 던져지는 오류도, 로그 스팸도 없습니다. 조용한 nil 하나와, 그것이 사용자에게 무엇을 의미하는지 결정할 책임뿐. Swift의 base64에서 한 가지만 기억한다면 이것으로 하세요: 디코더는 크래시하지도, 불평하지도 않습니다. 그저 거절할 뿐입니다.
디코더의 판정: 예와 아니오의 표
그러면 이 디코더에게 "유효"란 무엇을 뜻할까요? 결국 엄격한 규칙의 짧은 목록인 것으로 드러나고, 그 목록이 바로 "데모에선 된다"와 "프로덕션에서 살아남는다"의 차이입니다. 아래 표의 모든 행은 현재 툴체인에서 이 초기화자의 실제 동작이므로, 오류 메시지에 그대로 인용해도 좋습니다:
| 입력 | 판정 | 이유 |
|---|---|---|
TWFu |
Man |
완전한 4문자 그룹은 패딩이 아예 필요 없다 |
TQ== |
M |
바이트 하나에 패드 두 개, 교과서적인 경우 |
SGVsbG8h |
Hello! |
8문자는 4의 배수이므로 패드가 필요 없다 |
==== |
빈 Data |
뒤에 아무것도 없어도 패딩은 합법이며, 바이트 0개로 디코딩된다 |
| 빈 문자열 | 빈 Data |
들어가는 것도, 나오는 것도 없는데, 옵셔널은 여전히 성공한다 |
TQ |
nil |
길이 2: 4문자 그룹을 약속하고는 끝내 전달하지 않았다 |
T |
nil |
문자 하나는 6비트를 실어 나르고, 바이트 하나에는 8비트가 필요하다 |
SGVsbG8hTQ |
nil |
10문자: 마지막 그룹이 패드 없이 허공에 매달려 있다 |
TQ=== |
nil |
패드 세 개: 세 번째 패드는 채울 것이 아무것도 남지 않았다 |
TQ==TQ |
nil |
패딩 뒤에 오는 데이터는 정면 거부 |
SGVs bG8h |
nil |
공백 하나도 알파벳 밖이므로, 엄격 모드는 가책을 모른다 |
SGVsbG8h 뒤에 줄바꿈 |
nil |
방금 읽은 파일 끝에 있는 줄바꿈도 노이즈로 집계된다 |
세 행이 두 번째 시선을 받을 만합니다. ==== 행은 if let 검사가 통과하고 여러분의 코드가 바이트 0개로 여유롭게 전진한다는 뜻이므로, 빈 페이로드가 여러분의 앱에서 유효한 상태가 아니라면 디코딩 직후 카운트를 확인하세요. 빈 문자열 행은 분장이 덜한 같은 트릭입니다. 그리고 끝에 줄바꿈이 있는 행은, 아침에 완벽하게 인코딩된 base64 파일이 오후엔 디코딩을 거부하는 가장 흔한 단 하나의 이유입니다: 길 어딘가에 줄 끝이 추가된 것이고, 엄격한 디코더는 본인 일인 양 신경 쓰는 것이죠.
표로는 보이지 않는 유명한 약한 점도 있습니다. TQ==와 TS==를 비교하세요: 둘 다 같은 바이트 M으로 디코딩됩니다. 마지막 문자의 가장 낮은 두 비트는 검사되기도 전에 버려지거든요. 대신 Tg==를 넣으면 저항 없이 N이 나옵니다. 디코더는 문자를 단속하고 끝 비트는 놓아 줍니다. 그 관대함은 버그가 아니지만, 두 다른 문자열이 같은 데이터를 뜻할 수 있다는 뜻이기도 하며, 여러분의 시스템이 base64 값을 비교하거나, 중복 제거하거나, 캐시하는 순간부터 문제가 되기 시작합니다 (보안 섹션에서 더 다룹니다).
입력이 상상보다 더 지저분할 때
실전 base64는 반듯한 한 줄로 도착하는 경우가 드뭅니다. 이메일 첨부 파일은 줄마다 캐리지 리턴과 라인 피드로 76자 래핑되며, 이는 1996년 MIME 사양에서 이어받은 습관이고, 인증서 파일은 64자로 래핑됩니다. 디코더는 그 노이즈를 다룰 옵션을 정확히 하나 가지고 있으며, 그 크기가 상당합니다:
import Foundation
let mimeBody = "SGVs\r\nbG8sIG1h\naWwgbm9pc2Uu"
if let data = Data(base64Encoded: mimeBody, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// Hello, mail noise.
.ignoreUnknownCharacters는 "줄 끝 문자를 포함한 Base-64가 아닌 알 수 없는 바이트를 무시하는" 디코더로 문서화되어 있으며, 그 일에는 딱 맞는 도구입니다: 노이즈는 삭제되고, 알파벳은 살아남으며, 페이로드는 온전히 나옵니다. 하지만 이 옵션에는 사각지대가 있고, Swift 개발자를 가장 세게 무는 것이 바로 그것입니다: base64url의 -와 _를 포함해, 알파벳 밖의 모든 문자를 삭제합니다. 그것들을 +와 /로 번역하는 것이 아니라, 그냥 버려 버립니다. 그 삭제가 무엇을 남겨 놓느냐에 따라, nil를 얻거나(생존자가 더 이상 온전한 그룹을 이루지 못할 때), 더 나쁘게는 바이트 수가 틀린 자신감 넘치는 답을 얻습니다. 12바이트를 인코딩한 16문자 base64url 문자열이 관대한 디코더를 거쳐 9개의 다른 바이트로 돌아올 수 있습니다. 오류도, 사과도 없이요.
기억할 규칙: .ignoreUnknownCharacters는 전송 노이즈(줄바꿈, 복사-붙여넣기에서 기어 들어온 여백)용이지, 알파벳 차이용은 절대 아닙니다. 페이로드가 base64url일 수도 있다면, 다음 섹션이 보여주듯 먼저 스스로 문자를 변환한 뒤, 디코더에게 깨끗한 표준 문자열을 건네세요.
URL 알파벳
RFC 4648의 5절은 여러분이 만나 온 표준 알파벳의 사촌을 정의합니다: base64url. 여기서 +는 -가 되고, /는 _가 되며, = 패딩은 보통 생략됩니다. 이유는 여러분의 URL을 정직하게 유지해 주는 것과 같습니다: 쿼리 문자열에서 +는 폼 파싱이 공백으로 읽고, /는 경로 구분자, =는 키와 값을 구분하니까요. RFC는 둘의 관계에 대해 단도직입적입니다: URL 변형은 "base64 인코딩과 같은 것으로 여겨져서는 안 된다"고요. JWT, Web Push 메시지, YouTube 동영상 ID, 그리고 현대 API 식별자 대부분이 base64url을 쓰므로, 첫날부터 마주칠 각오를 하세요.
디코딩 쪽 레시피는 두 수입니다: 알파벳을 번역하고, 그다음 패딩을 채워 넣는 것. 엄격한 디코더는 여전히 4의 배수를 원하니까요.
import Foundation
extension String {
func dataFromBase64URL() -> Data? {
var fixed = self
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
return Data(base64Encoded: fixed)
}
}
let tokenPart = "0S__zMWaTC-iVgJ-"
if let bytes = tokenPart.dataFromBase64URL() {
print(bytes.count) // 12
}
나머지 연산 한 줄이 트릭의 전부입니다: base64url 페이로드는 보통 패딩 없이 도착하며, = 문자 한두 개(세 개는 절대)가 디코더가 기대하는 4문자 그룹을 되살립니다. 이 5줄 익스텐션의 버전은 의외로 많은 Swift 코드베이스에서 발견할 수 있고, 그럴만한 이유가 있습니다. 나중에 더 짧아질 이유도 하나 있습니다: 최신 Apple SDK(이 글 작성 시점 기준 26.4 이상)는 인코더용 네이티브 .base64URLAlphabet 옵션을 얻었고, 해당 디코딩 옵션은 아직 더 늦은 툴체인을 위한 가용성 마커 뒤, 오픈소스 Foundation에서 성숙하는 중입니다. 그것이 여러분의 최소 배포 대상에 도달할 때까지, 이 익스텐션이 이식 가능한 답안이며, 구성상 모든 버전에서 계속 동작합니다.
바이트 먼저, 단어는 나중에
디코더가 여러분을 대신해 내릴 수 없는 결정이 있습니다: 디코더는 Data, 즉 원본 페이로드가 어떤 문자 인코딩으로 쓰였는지 알 길이 없는 바이트 자루를 건넵니다. 페이로드가 텍스트라면, 그 인코딩을 고르는 일은 여러분의 몫이며, Swift는 기질이 아주 다른, 바이트 세계 밖으로 나가는 두 문을 줍니다.
String(data:encoding:)는 엄격한 문입니다. 옵셔널을 반환하며, 바이트가 여러분이 지명한 인코딩에서 유효하지 않으면nil로 답합니다. 검증에는 이상적이고, 답을 강제 언래핑하면 위험합니다.String(decoding:as:)는 절대 거절하지 않는 문입니다. 항상 문자열을 반환하며, 의미가 통하지 않는 부분은 U+FFFD 대체 문자로 바꿔 넣습니다. 로그와 미리보기에는 이상적이고, 결과를 저장해 데이터라 부르며 대하면 위험합니다.
import Foundation
let bytes = Data([0xC3, 0xA5]) // 링이 있는 a의 UTF-8 표기
print(String(data: bytes, encoding: .utf8) ?? "?") // 링이 있는 a, 올바르게 읽혔다
print(String(data: bytes, encoding: .isoLatin1) ?? "?") // 혼란한 두 문자, 같은 바이트
print(String(decoding: bytes, as: UTF8.self)) // 링이 있는 a, 절대 크래시하지 않는다
거의 모든 것을 커버하는 레시피: 현대 API가 거의 언제나 뜻하는 것이므로, 먼저 엄격한 UTF-8을 시도하세요. 계약이 침묵할 때, 읽을 수 있으면서 틀린 결과를 무음보다 선호한다면 그때만 ISO Latin-1으로 후퇴하세요. 절대 거절하지 않는 문은 디버그 출력용으로 남겨 두세요. 그리고 확인해야 할 보이지 않는 침입자 하나: 페이로드가 UTF-8 BOM(바이트 3개 EF BB BF)로 시작한다면, 엄격한 변환은 그것을 그대로 남기고, 여러분의 문자열은 보이지 않는 U+FEFF 문자로 시작하게 되어 동등성 검사와 JSON 왕복을 조용히 깨뜨립니다. 사양이 BOM을 약속하지 않는다면, 접두사 검사로 제거하세요.
파일 열기
".b64 파일이 있으니, 그 안에 숨긴 것을 꺼내 줘라"는 일은 읽기, 정리, 디코딩, 쓰기로 끝납니다. 정리는 치장용이 아닙니다. 열리는 파일과 nil을 반환하는 파일의 차이가 바로 그것인데, 도구, 메일 클라이언트, 편집기 모두 끝 줄바꿈을 남기는 것을 좋아하기 때문입니다:
import Foundation
let inbox = URL(fileURLWithPath: "Downloads/avatar.b64")
let outbox = URL(fileURLWithPath: "Downloads/avatar.png")
let raw = try String(contentsOf: inbox, encoding: .utf8)
if let data = Data(base64Encoded:
raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
try data.write(to: outbox)
} else {
print("the file was not base64 after all")
}
파일이 MIME 래핑(76자마다 줄바꿈)이라면, 깔끔한 탈출구가 두 개 있습니다: .ignoreUnknownCharacters로 디코딩해 옵션에 줄 끝을 삼키게 하거나, 엄격한 디코딩 전에 replacingOccurrences로 직접 제거하는 것. 둘 다 한 줄이면 됩니다. 그냥 큰 파일은, 전체를 읽지 말고 정렬된 그룹 단위로 디코딩하세요: 4문자 그룹은 각각 독립적으로 디코딩되므로, 읽기 경계를 넘나들며 현재 그룹과 작은 나머지만 반출하면 됩니다.
import Foundation
func decodeBase64Chunks(_ stream: InputStream, into result: inout Data) throws {
let chunkSize = 65_536
var buffer = [UInt8](repeating: 0, count: chunkSize)
var leftover = ""
result = Data()
stream.open()
defer { stream.close() }
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: chunkSize)
if read < 0 { throw CocoaError(.fileReadUnknown) }
if read == 0 { break }
var text = String(decoding: buffer[0..<read], as: UTF8.self)
text = text.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
text = leftover + text
if text.count % 4 != 0 {
let whole = text.count - (text.count % 4)
leftover = String(text.suffix(text.count - whole))
text = String(text.prefix(whole))
} else {
leftover = ""
}
guard !text.isEmpty else { continue }
guard let part = Data(base64Encoded: text) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
if !leftover.isEmpty {
guard let part = Data(base64Encoded: leftover) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
}
파일이 아무리 커도 메모리는 평탄하게 유지됩니다: 읽기 버퍼 하나, 남은 조각 하나, 그리고 만들고 있는 결과. 같은 루프가 와이어를 통해 base64로 도착하는 다운로드, 실제로는 인코딩된 스트림인 로그 파일, 손에 쥘 수 없을 만큼 큰 모든 페이로드를 처리합니다.
JWT: 세 개의 점 읽기
컴팩트 JSON Web Token은 점으로 이어진 base64url 세 부분이며, 그중 앞의 두 부분은 트렌치코트를 입은 평범한 JSON입니다. 패딩 없이 도착하는데, 이는 정확히 엄격한 디코더가 보는 순간 거부하는 조합이므로, URL 섹션의 dataFromBase64URL() 헬퍼가 모든 무거운 일을 합니다:
import Foundation
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
func openPart(_ part: String) -> String? {
var fixed = part
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
guard let data = Data(base64Encoded: fixed) else { return nil }
return String(data: data, encoding: .utf8)
}
let pieces = token.split(separator: ".")
print(openPart(String(pieces[0])) ?? "?")
// {"alg":"HS256","typ":"JWT"}
print(openPart(String(pieces[1])) ?? "?")
// {"sub":"1234567890","name":"John Doe"}
함께 가는 리마인더 두 가지. JWT는 서명되어 있고, 암호화되어 있지 않습니다: 헤더와 페이로드는 공개 정보이며, 바로 그래서 비밀번호는 절대 그 안에 들어가선 안 됩니다 (암호화된 사촌 JWE는 완전히 다른 사양이죠). 그리고 세 번째 점 구분 부분은 문서가 아니라 암호학적 서명이므로, 1, 2 부분만 디코딩하고 나머지는 건드리지 마세요.
Data URI: 쉼표 뒤의 파일
웹 API는 data: 스킴으로 바이너리를 텍스트 속에 숨기는 것을 좋아합니다: 프로필 필드의 PNG, CSS 덩어리의 폰트, 설정 파일의 QR 코드. 포맷은 data:{mime};base64,{payload}이며, 페이로드를 벗기는 것은 스플릿 한 번입니다:
import Foundation
let uri = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
let payload = uri.components(separatedBy: ",").last ?? ""
if let bytes = Data(base64Encoded: payload) {
print(String(decoding: bytes.prefix(6), as: UTF8.self)) // GIF89a
print(bytes.count) // 42
} else {
print("not a base64 data uri")
}
예제는 유명한 42바이트 투명 GIF, 즉 이 포맷의 가장 작은 이미지를 씁니다. 그래서 그 시작 문자는 인터넷의 거의 모든 base64 문자열보다 더 많은 코드베이스에 등장하죠. Apple 플랫폼에서는 파이프라인이 한 줄로 끝납니다: 방금 디코딩한 Data가 UIImage(data:)나 NSImage(data:)로 바로 이어지므로, "API에서 아바타 표시"는 작은 기능이지, 프로젝트가 아닙니다.
HTTP: Basic 헤더와 그 친구들
오래된 Authorization: Basic 헤더는 사용자명과 비밀번호를 콜론으로 이어, 여정을 위해 표준 base64로 압축한 것입니다 (URL 방언이 아닙니다: 여기는 헤더 안에 있으니 +와 /가 전혀 해롭지 않은 자리). 풀기는 스플릿과 디코딩입니다:
import Foundation
let header = "Basic ZWRpdG9yOnMzY3JldA=="
let packed = header.replacingOccurrences(of: "Basic ", with: "")
if let creds = Data(base64Encoded: packed) {
print(String(data: creds, encoding: .utf8) ?? "") // editor:s3cret
} else {
print("malformed header")
}
보안 각주를 크게 두세요. 여러분이 마주칠 모든 base64에 해당하기 때문입니다: 이것은 압축이지, 보호가 아닙니다. Basic 인증은 HTTPS 위에서만 허용할 만하며, 거기서는 TLS가 실제로 지키고 base64는 헤더 문법을 깨지 않게 바이트를 지켜줄 뿐입니다. 같은 논리가 Authorization: Bearer 토큰을 설명합니다: 토큰 자체가 JWT이므로, JWT 섹션의 디코딩 레시피가 그대로 적용됩니다.
이메일: 76자 습관
base64로 인코딩된 이메일 첨부 파일은 CRLF 줄 끝으로 76자 래핑되며, 이는 관대 옵션이 존재하는 바로 그 노이즈입니다. 생 MIME 헤더는 발신자가 어떤 알파벳과 어떤 래핑을 썼는지 알려 줍니다 (Content-Transfer-Encoding: base64)며, 수정은 플래그 하나입니다:
import Foundation
let attachment = "VGhpcyBhdHRhY2htZW50IHN1cnZpdmVk\r\nIHRoZSA3Ni1jaGFyYWN0ZXIgaGFiaXQu"
if let data = Data(base64Encoded: attachment, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// 이 첨부 파일은 76자의 습관을 살아남았다.
편지를 읽는 기능을 만드는 것이 아니라, 보내는 기능을 만든다면, 76자 래핑도 여러분에게 비용을 청구한다는 점을 기억하세요: 76자마다 줄바꿈이 있으면, 인코딩된 텍스트는 원래 크기의 약 137퍼센트에 이릅니다. 그래서 옛날 메일 엔지니어들은 "원래에 1.37을 곱하고, 헤더 약 800바이트를 더하라"는 단축법으로 첨부 크기를 눈대중했습니다. 숫자는 이제 전설이지만, 산술은 여전히 산술입니다.
두 번 감긴 페이로드
base64 세계에서 가장 흔한 "데이터가 깨졌다" 티켓은 두 번 압축된 데이터입니다: 통합 레이어 하나가 인코딩했고, 문서를 한 번도 읽지 않은 두 번째 레이어가 그 결과를 또 인코딩한 것이죠. 방어적인 수: 한 번 디코딩하고, 받은 것을 살펴보고, 결과가 그 자체로 깔끔한 base64 같은 문자열(길이도 알파벳도 맞고, 놀랄 것이 없는)이라면, 이번엔 의도적으로 한 번 더 디코딩한 뒤 멈추세요. 실패할 때까지 디코딩하는 루프는 쓰지 마세요. 그런 루프는 우연히 base64처럼 생긴 내용이 담긴 멀쩡한 파일을 기꺼이 삼키고, 실행이 끝난 뒤엔 원본 데이터가 어디서 시작됐는지 아무도 모릅니다.
import Foundation
func unwrapOnce(_ packed: String) -> Data? {
let cleaned = packed.trimmingCharacters(in: .whitespacesAndNewlines)
return Data(base64Encoded: cleaned)
}
let suspicious = "WVdKag==" // 이미 압축돼 보인다
if let first = unwrapOnce(suspicious) {
let inner = String(data: first, encoding: .utf8) ?? ""
if let second = unwrapOnce(inner) {
print("it was wrapped twice:", String(data: second, encoding: .utf8) ?? "?")
}
}
// it was wrapped twice: abc
언래프 두 번, 의식적인 결정 두 번, 마침내 다시 그냥 abc인 페이로드.
nil에 의미를 실어주기
디코더가 던지는 대신 nil로 답하기 때문에, base64 코드의 오류 처리 스타일은 여러분이 만드는 선택입니다. 나중에 고마워할 선택은, 조용한 거절을 시끄럽고 구체적인 오류로 바꾸는 작은 래퍼입니다:
import Foundation
enum Base64Failure: Error, CustomStringConvertible {
case notBase64(Int)
var description: String {
switch self {
case .notBase64(let length):
return "input of \(length) characters is not valid base64"
}
}
}
func decodeStrict(_ text: String) throws -> Data {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = Data(base64Encoded: cleaned) else {
throw Base64Failure.notBase64(cleaned.count)
}
return data
}
do {
let bytes = try decodeStrict("c3ludGF4IGVycm")
print(String(data: bytes, encoding: .utf8) ?? "?")
} catch {
print(error) // input of 14 characters is not valid base64
}
래퍼는 정규화가 사는 유일한 장소가 됩니다: 정리, 알파벳 변환, 패딩 채우기. 호출하는 쪽은 함수 하나, 실패의 의미 하나, 시야에 ! 제로입니다. Data(base64Encoded:)!를 강제 언래핑하는 것은 잘못된 페이로드가 크래시한 앱이 되는 길이고, 래퍼는 그 대비의 저렴한 보험입니다. 같은 패턴이 명령줄에서도 동작합니다: CommandLine.arguments와 FileHandle 쓰기를 가진 스크립트가, "이 파일을 셸에서 디코딩"을 웹사이트를 돌아가며 복사-붙여넣는 우회 대신 5줄짜리 유틸리티로 만들어 주니까요.
바이트로 재는 보안
- 암호화가 아니다. Base64는 가역적이고 즉시 읽히는 재포장입니다. 위협 모델에 브라우저와 5초를 가진 사람이 포함되어 있다면, 여러분은 보호가 제로이며, JWT 헤더는 매일 이 점을 증명하고 있죠.
- 비교 전에 정규화하세요.
TQ==와TS==가 같은 바이트로 디코딩되므로, 두 시스템이 같은 데이터의 다른 표기를 가질 수 있습니다. 2022년 논문 "실전에서의 Base64 가소성"는 그 깨진 고유성 보장이 실제 세계에서 무엇을 하는지 기록했습니다: 로그 불일치, 서비스 거부 공격, 중복 데이터베이스 항목. Swift 앱이 base64 값을 캐시하거나, 중복 제거하거나, 비교한다면, 문에서 정규 디코딩(또는 정규 재인코딩)을 한 번 실행하세요. - 디코딩 전에 입력에 상한을 두세요. N문자를 디코딩하면, 입력 문자열을 아직 쥐고 있는 동안 N바이트의 약 4분의 3이 할당됩니다. 적대적 클라이언트는 문자
A100메가바이트를 보내, 디코더가 거부하기 전에 메모리가 오르는 것을 지켜볼 수 있습니다. 길이를 먼저, 저렴하게 확인하고, 너무 큰 것은 거부하세요. - 필터로 쓰는 관대 옵션을 경계하세요.
.ignoreUnknownCharacters는 문자를 삭제합니다. 그를 통한 "정화" 패스가 유효한 base64url 페이로드를 오류 없이 다른 데이터로 바꿀 수 있습니다. 그것은 줄바꿈을 위한 노이즈 필터이지, 유효성 검사기가 아닙니다. - 가능한 한 URL에서 멀리 두세요. 쿼리 문자열이나 경로 속 큰 base64 페이로드는 편안한 URL 길이를 뛰어넘어 프록시에게 망가집니다. 대신 요청 본문, 파일, 토큰에 넣으세요.
성능, 간단히
디코더는 룩업 테이블 순회입니다: 각 문자가 작은 테이블에 인덱싱되고, 몇 비트가 출력 바이트로 시프트되고 or됩니다. 현재 툴체인에서는 메모리에 들어가는 것에겐 충분히 빠르고, 기억할 숫자는 출력 비율입니다: 디코딩된 바이트는 입력 길이의 약 4분의 3이므로, 4메가바이트 문자열은 이미 쥐고 있는 문자열 위에 결과로 약 3메가바이트를 추가 비용으로 물립니다. Foundation 자체가 허용되지 않는 길(깊은 임베디드 대상, WebAssembly 번들)에 있다면, 커뮤니티 패키지 swift-extras-base64가 주목할 만한 대안입니다: Foundation 의존성 없는 순수 Swift, base64url과 패딩 옵션을 가진 RFC 4648 준수 인코더와 디코더, 그리고 벤치마크에서 Foundation보다 몇 배 빠른 결과. 같은 패키지의 이전 구현은 swift-nio의 WebSocket 지원 안에까지 함께 들어 있으며, 이는 사이 프로젝트가 프로덕션급에 다가갈 수 있는 가장 가까운 수준이죠. 보통 앱이나 스크립트에는 불필요한 짐이고, 제약받는 Swift 구석에서는 표준 답안입니다.
10년의 언패킹
Swift는 이 중 아무것도 발명하지 않았고, 공구함의 각 도구가 어디서 왔는지 아는 것도 가치가 있습니다:
- 1980년대, 같은 기계 시대. 이 가문의 가장 초기 인코딩(UNIX의 uuencode, TRS-80과 클래식 Mac의 BinHex)은, 반대편도 자신과 비슷할 거라 가정한 기계 사이에서 파일을 옮겼습니다. uuencode는 대문자, 숫자, 구두점 알파벳을 썼고, 그 문자들은 연속된 ASCII 자리에 놓여 있어 인코딩은 룩업 테이블 없이 32를 더하는 일이었습니다. 이 시대의 디코더는 많은 것을 가정할 수 있었지만, 데이터가 생태계를 넘는 순간 무너졌습니다.
- 1987년, 알파벳이 주소를 얻다. RFC 989(Privacy-Enhanced Mail, 1987년 2월)는 64문자 알파벳을 표준화하고, 줄을 정확히 64자로 래핑했으며,
=로 패딩을,*로 인코딩되었지만 암호화되지 않은 데이터를 표시했습니다. 모든 PEM 스타일 블록은 그 문서의 후손입니다. - 1996년, 자유로운 시대. MIME(RFC 2045)은 알파벳을 이메일에 가져와 래핑을 76자로 옮기고, 규격을 따르는 디코더에게 알파벳 밖의 어떤 문자도, CRLF 줄바꿈 같은 것도 무시하라고 했습니다. 이는 한 세대를 관대한 디코더를 기대게 훈련시킨 시대이며, 그 기대를 Swift의 엄격한 기본값이 의도적으로 깨뜨리는 시대입니다.
- 2003~2006년, 규칙이 굳어지다. RFC 3548(2003)은 가문을 통일하려는 첫 스윙을 날렸고; RFC 4648(2006년 10월)은 마무리를 하고, 패딩 규칙을 법제화했으며, URL 안전 알파벳을 추가했습니다. 그 디코더 단락이 바로 Swift가 따르는 것입니다: MIME이 하듯, 지원하는 포맷이 명시적으로 무시하라고 하지 않는 한, 알파벳 밖의 문자를 거부하라.
- 2013~2014년, API가 대기한다. Apple의
NSData클래스는 수년간 base64를 압축하고 풀었고, 디코딩 옵션을 가진 옵션 기반 API는 Swift가 존재하기도 1년 전인 2013년, iOS 7에 도착했습니다. Swift 1.0이 2014년 9월 9일에 출시되자, 디코더는 언어와 함께 들어와 그때부터 같은 성격을 유지해 왔습니다: 엄격한 코어, 관대한 노브 하나, 실패 가능한 초기화자. - 2015년 12월 3일, Linux에 디코더가 생겼다. Swift는 그날 오픈소스화되었고, 함께 Foundation의 base64가 Linux로, 그리고 나중에 Windows로 건너갔습니다. "비-Apple 기계에서 Swift로 Base64 디코딩"은 10년이 채 되지 않는 역사입니다: 1987년에 시작된 파티에 늦게 온 손님.
- 2023~2026년, 재작성. Foundation 재작성(swift-foundation 프로젝트)은
Data를 순수 Swift 코어로 옮겼고, 2025년 커뮤니티 피치가 네이티브 base64url과 패딩 생략 옵션을 추가했습니다. 이 글 작성 시점, 최신 SDK 베타와 오픈소스 툴체인은 인코딩 옵션을 출시하고 있고, 디코딩 옵션은 오픈소스 툴체인에서 여전히 성숙 중이므로, 그 사이 수작업 익스텐션이 보편적 답안으로 남아 있습니다.
작은 놀라움들
====는 합법적인 입력입니다. 패드 4개에 데이터 없이 디코딩하면 빈Data가 나오는데, 전체 내용이 "여기엔 아무것도 없다"인 유일한 base64 문자열이고, Swift도 그 의견에 동의합니다.- 디코더의 문자 경찰은 비트 경찰의 업무를 확인하지 않습니다:
TS==와TQ==는 모두M을 건네고,Tg==는N을 건넵니다. 같은 문법, 다른 비트, 질문은 없습니다. - Swift의
Data는 문자열이 아니라 바이트로 도착한 base64를,Data(base64Encoded: Data)변형을 통해 디코딩할 수 있어, ASCII로 와이어를 건넌 페이로드는 문자열 왕복을 통째로 생략할 수 있습니다. - 테스트 벡터가 태어난 이래 base64 되어 온 단어는
foobar이며,Zm9vYmFy로 압축됩니다. 실제 세계에서 base64 예제를 본 적이 있다면, foobar가 연루될 확률은 적지 않습니다. - 유명한 1x1 투명 GIF는 정확히 42바이트이고 마법의 단어
GIF89a로 시작하며, 그래서 그 인코딩된 처음 여덟 문자는 지구에서 거의 모든 base64 접두사보다 더 많은 코드베이스에 등장합니다. - 현대 오픈소스 디코더는 단일 비교로 잘못된 문자 검사를 합니다: 위치별 룩업 값 4개를 or로 합쳐 센티넬 값과 비교하므로, 분기 하나가 4문자 그룹 전체의 운명을 결정합니다. 이전 구현은 128바이트 테이블로 같은 일을 했는데, 0x80 이상인 어떤 값도 "문자가 아니다"를 뜻했습니다.
- UTF-8 BOM은 보이지 않습니다: 페이로드 시작의
EF BB BF는 엄격한 변환을 견뎌 낸 U+FEFF 문자가 되어, 몇 줄 뒤의 코드에서 동등성 검사를 깨뜨립니다. - Swift는 디코딩하는 알파벳보다 27살 어립니다. 이 언어는 2014년에 출시되었고, 다뤄지는 64문자는 1987년에 표준화된 뒤 지금까지 변하지 않았습니다.
이것이 디코딩 공구함 전체입니다: 짧은 규칙책을 가진 실패 가능한 초기화자 하나, 문서화된 사각지대를 가진 관대한 노브 하나, 5줄 base64url 헬퍼, 여러분 몫인 문자 인코딩 결정, 큰 파일용 청크 루프, nil에 의미를 부여하는 래퍼. 디코딩은 base64가 무는 곳이고, 여러분은 이제 이빨의 이름을 모두 압니다. 일이 반전되어, 풀기 대신 여정을 위해 바이트를 압축하기 시작하면, 약 33퍼센트 가산비가 등장하고 래핑 옵션들이 나타납니다. 연관 인코딩 글은 왕복의 그 절반을 온전히 다룹니다. 반대 방향으로 보내기 준비가 되면 그곳으로 가 보시기 바랍니다.
마지막 업데이트: 2026-09-08
관련 문서: Swift에서의 Base64 인코딩: 완전한 가이드