Go에서의 Base64 디코딩: 완전한 가이드
API 응답 어딘가에 긴 문자열 하나가 숨어 있습니다. 값인 척하지만, 실상은 파일이거나 토큰이거나, 이미지이거나, 당신보다 세 살이나 더 늙은 시스템에서 날아온 메시지일 수도 있죠. 당신의 로그 줄, 데이터베이스 행, JSON 페이로드 여기저기서 base64 문자열은 끊임없이 모습을 보입니다. 표준 64문자 알파벳, 때로는 플러스와 슬래시를 달고, 때로는 하이픈과 밑줄을 달고, 간혹 꼬리 끝에 서명처럼 주차된 등호 둘을 달고서요.
이 사이트의 홈 페이지는 포맷 자체를 이미 설명해 줍니다. 각각 6비트를 싣는 64개의 인쇄 가능 문자, 입력 3바이트마다 4문자, 일을 마무리해 주는 패딩. 그래서 이 기사는 흥미로운 결정들이 있는 일의 절반으로 직행합니다. 바로 Go에서 그 문자열들을 여는 일이죠. 좋은 소식은, Go가 이 일을 하기에 멋진 곳이라는 겁니다. 표준 라이브러리 패키지 하나, 의존성 제로, 기본은 엄격하지만 줄바꿈에는 관대한 디코더, 그리고 잘못 난 바이트를 정확히 가리켜 주는 오류 메시지.
Go가 함께 싣고 오는 것들
필요한 모든 것은 이미 표준 라이브러리에 있습니다. 패키지 이름은 encoding/base64이고, 소스 파일에는 아직 이 언어가 태어난 해인 2009년의 저작권 헤더가 그대로 남아 있습니다. 켤 확장도, 받아 올 모듈도, 바꿔야 할 설정도 없죠. go version이 당신의 머신에서 뭔가를 출력해 준다면, 당신은 이미 도구 전체를 소유한 겁니다.
이 글을 쓰는 시점에서 최신 릴리스는 Go 1.27.1로 2026년 9월 1일에 나왔고, 다른 지원 트랙은 Go 1.26 라인(현재 1.26.8)입니다. base64 API는 두 라인에서 완전히 동일하며, Go 1 호환성 약속 덕분에 오늘 base64를 디코딩하는 프로그램은 모든 미래 릴리스에서도 정확히 똑같은 일을 계속합니다. Go 본체는 go.dev/dl의 공식 tarball(예를 들어 go1.27.1.linux-amd64.tar.gz을 /usr/local으로 압축 해제)에서, 또는 배포판의 패키지 매니저에서(Ubuntu 기반 시스템이면 sudo apt install golang-go)를 받아 올 수 있고, 여러 Go 버전을 나란히 놓고 쓰는 걸 좋아한다면 golang.org/dl 래퍼를 통하면 됩니다.
Go가 설치되면 go doc encoding/base64는 읽기 쉬운 열로 API 전체를 출력해 주는데, 이것이 기억을 다시 떠올리는 가장 빠른 방법입니다. 이 기사가 어딘가에서 사용하는 유일한 추가물은 레거시 캐릭터셋을 위한 golang.org/x/text이며, go get golang.org/x/text로 설치합니다. 이는 자기만의 섹션에서 단 한 번만 등장하고, 나머지는 전부 순수 표준 라이브러리입니다.
첫 번째 디코딩
Go의 디코딩 생활에서 90%는 Encoding 타입 위의 하나의 메서드입니다:
func (enc *Encoding) DecodeString(s string) ([]byte, error)
base64 문자열을 주면, 그 문자열이 나타내는 바이트를 돌려주고, 입력이 말썽을 부릴 때는 오류도 함께 드립니다:
package main
import (
"encoding/base64"
"fmt"
)
func main() {
decoded, err := base64.StdEncoding.DecodeString("TWFu")
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(string(decoded)) // Man
}
그 시그니처에서 두 가지는 외워 둘 가치가 있습니다. 첫째, 결과는 문자열이 아니라 []byte입니다. 벗겨 낸 바이트가 완벽하게 유효한 base64이면서 완벽하게 끔찍한 텍스트일 수 있기 때문이죠: PNG 헤더, 압축 아카이브, 바이너리 프로토콜. 페이로드가 텍스트라는 걸 확실히 알 때만 string(...)으로 감싸세요. 둘째, 이 메서드는 언제나 두 값을 반환합니다. nil 오류는 그 문자열이 깨끗한 base64였다는 뜻이고, nil이 아닌 오류는 입력이 어딘가에서 손상됐다는 뜻이며, 받아 든 바이트 슬라이스는 비어 있는 게 아니라 부분 결과일 수 있습니다. 그 행동의 두 얼굴은 아래 오류 섹션에서 모두 보게 될 겁니다.
디코더는 넷, 질문은 하나: 어떤 알파벳인가?
Go는 완성된 Encoding 값 넷을 함께 싣고 오며, 그 중 적절한 것을 고르는 일은 모든 디코딩의 첫 번째이자 진정한 결정입니다. 아래 표가 바로 자리 배치표입니다:
| 변수 | 알파벳 | 패딩 | 어디서 마주치는가 |
|---|---|---|---|
StdEncoding |
A-Z a-z 0-9 + / |
= |
MIME 이메일, data URL, HTTP Basic auth, PEM 파일, 일반 JSON |
URLEncoding |
A-Z a-z 0-9 - _ |
= |
URL 경로와 쿼리, 파일 이름 |
RawStdEncoding |
A-Z a-z 0-9 + / |
없음 | 패딩 없는 표준 base64, 축약형 생성자에서 온 것 |
RawURLEncoding |
A-Z a-z 0-9 - _ |
없음 | JWT 세그먼트, 축약형 API 식별자 |
가장 빠른 선택법은 데이터 자체를 보는 것입니다. +나 /가 들어 있는 문자열은 표준 알파벳 문자열일 수밖에 없으니, 두 Std 디코더 중 하나가 필요합니다. -나 _가 들어 있는 문자열은 RFC 4648의 URL-safe 변형이니, 두 URL 디코더 중 하나가 필요하죠. 그리고 꼬리를 확인하세요. 끝에 =가 있으면 패딩이 있는 변형이고, 없으면 Raw 쪽입니다. 잘못된 선택이 어떤 느낌인지 보여 드리죠:
decoded, err := base64.StdEncoding.DecodeString("P29_")
// err: illegal base64 data at input byte 3
// 밑줄은 표준 알파벳에 없으므로,
// 디코더는 인식이 안 되는 마지막 문자에서 멈춘다
decoded, err = base64.URLEncoding.DecodeString("P29_")
// decoded는 세 바이트 0x3f 0x6f 0x7f, err은 nil
만약 자기만의 64문자 알파벳을 정의한 생성자에서 온 데이터를 디코딩한다면, base64.NewEncoding("...64 chars...")가 그것을 위한 디코더를 만들어 줍니다. 알파벳은 정확히 64개의 고유 바이트 값이어야 하고 줄바꿈을 포함해서는 안 됩니다 - 그렇지 않으면 함수가 패닉하죠 - 문서상으로는 패딩 문자를 알파벳에서 제외할 것을 요구하지만, 함수가 그것을 강제하지는 않습니다 - '='를 포함한 알파벳은 패닉 없이 받아들여지죠. 일상 업무에서는 거의 필요하지 않지만, 있기는 한 것이고, 사적인 스킴을 디코딩할 수 있는 유일한 길이기도 합니다.
관용의 문제: Go는 어떤 입력을 받아들이는가?
모든 base64 디코더는 불편한 결정 하나를 내려야 합니다. 얼마만큼의 쓰레기를 삼켜 주길 원하느냐는 결정이죠. Go의 답은 조심스럽게 그려진 선입니다. 관대해진 쪽으로 보면, 디코더는 입력 어디에 있든 캐리지 리턴과 라인 피드를 건너뜀으로써, 이메일 클라이언트나 PEM 도구에게 여러 줄로 잘려 나간 문자열도 사전 처리 없이 디코딩합니다:
decoded, err := base64.StdEncoding.DecodeString("T\nW\nF\r\nu")
// decoded는 "Man", err은 nil
// 문자열 안의 모든 \r와 \n은 그저 무시됐을 뿐
엄격해진 쪽으로 보면, 그 외의 모든 것은 금지입니다. 공백, 탭, PDF에서 복사해 온 영 폭 문자, 헤더에서 흘러들어 온 기생 콜론: 디코더가 알파벳에 없으면서 줄바꿈도 아닌 문자를 만나자마자, 멈추고 오프셋을 보고합니다. 그리고 이미 디코딩한 것은 그대로 유지하죠:
decoded, err := base64.StdEncoding.DecodeString("TWFu junk")
// decoded는 "Man" (공백 앞의 부분),
// err은: illegal base64 data at input byte 4
이 조합은 사람들을 놀라게 합니다. 실패한 디코딩이더라도 여전히 쓸모 있는 반쪽 결과를 건네 줄 수 있으니까요. 그게 기능인지 위험인지는 당신이 정하는 문제이고, 중요한 것은 데이터가 완전한 유일한 조건이 err == nil이라는 사실입니다.
패딩에는 자기만의 규칙이 있고, 패딩 있는 변형과 raw 변형 사이에서 다릅니다. 패딩 디코더는 그룹 단위로 동작합니다. 한 그룹은 실제 문자 4개, 또는 실제 문자 2개 뒤에 ==가 오는 모양입니다. 문자 하나만으로는 결코 완전한 그룹이 될 수 없으므로 "T"는 실패하고, "TWF"도 실패합니다. 세 문자는 빠져 있는 패딩 표식 하나를 필요로 하니까요. raw 디코더는 패딩 요건을 던져 버리지만, 한 그룹의 네 문자 중 셋이 빠진 길이는 여전히 받아들일 수 없으므로, 거기도 "T"는 실패하는 반면 "TW"는 1바이트로 문제없이 디코딩됩니다.
base64.StdEncoding.DecodeString("T") // 입력 바이트 0에서 오류
base64.StdEncoding.DecodeString("TWF") // 입력 바이트 0에서 오류
base64.RawStdEncoding.DecodeString("TW") // 1바이트, 오류 없음
base64.StdEncoding.DecodeString("TWFu====") // "Man"과 함께 바이트 4에서 오류
마지막으로 하나 더, 반전이 있습니다: Go 1.8에 추가된 Strict()입니다. 엄격 모드에서는 디코더가 RFC 4648 섹션 3.5의 정준 형식을 강제합니다. 마지막 그룹의 사용되지 않는 꼬리 비트가 영이어야 한다는 뜻이죠. 일반 모드에서는 그게 신경 쓰이지 않습니다. 그 비트들은 그저 영원히 사용되지 않으니까, "Qm=="는 한 마디 불평 없이 바이트 B로 디코딩됩니다. 엄격 모드에서는 거릅니다:
decoded, err := base64.StdEncoding.DecodeString("Qm==")
// decoded는 "B", err은 nil (꼬리 비트는 버려졌다)
decoded, err = base64.StdEncoding.Strict().DecodeString("Qm==")
// err은: illegal base64 data at input byte 2
엄격 모드에서도 줄바꿈은 여전히 건너뛰어 준다는 점에 유의하세요. 문서가 짚어 주듯 말입니다. 프로토콜 쪽에서 정준 인코딩을 중시할 때, 혹은 부주의한 생성자의 비트를 조용히 삼키는 대신 거르기를 원할 때 Strict()를 쓰세요.
위치를 알려 주는 오류
이 패키지 안의 모든 실패는 구체적이고 검사할 수 있는 값으로 도착합니다. 입력에 알파벳이 모르는 것이 들어 있거나, 패딩이 틀리면, 디코더는 base64.CorruptInputError를 반환하고, 그 메시지에는 문제의 바이트 오프셋이 포함됩니다:
type CorruptInputError int64
func (e CorruptInputError) Error() string {
return "illegal base64 data at input byte " + strconv.FormatInt(int64(e), 10)
}
그 오프셋이 바로 "뭔가 실패했다"와 "이 900킬로바이트 문자열의 4,102번째 문자가 클립보드에서 흘러들어 온 탭이다" 사이의 차이입니다. 익숙한 Go 관용구로 잡으세요:
package main
import (
"encoding/base64"
"errors"
"fmt"
)
func main() {
_, err := base64.StdEncoding.DecodeString("TWF$")
var corrupt base64.CorruptInputError
if errors.As(err, &corrupt) {
fmt.Printf("bad byte at offset %d: %v\n", int(corrupt), err)
// bad byte at offset 3: illegal base64 data at input byte 3
return
}
fmt.Println("not a corrupt-input error:", err)
}
사람들을 가장 혼란스럽게 하는 입력들의 증상표입니다:
| 입력 (StdEncoding) | 결과 | 왜 그런가 |
|---|---|---|
TWF$ |
바이트 3에서 오류 | $는 알파벳에 없음 |
T |
바이트 0에서 오류 | 한 문자로는 결코 완전한 그룹이 안 됨 |
TWF |
바이트 0에서 오류 | 세 문자는 빠져 있는 = 하나를 필요로 함 |
TWFu junk |
Man과 함께 바이트 4에서 오류 |
공백은 줄바꿈이 아니므로 디코딩은 거기서 멈춤 |
TWFu\t |
Man과 함께 바이트 4에서 오류 |
탭은 건너뛰지 않으며, 건너뛰는 것은 \r와 \n뿐 |
T\nW\nF\nu |
Man, 오류 없음 |
줄바꿈은 어디서든 무시됨 |
==== |
바이트 0에서 오류 | 그룹 시작에 있는 패딩은 유효하지 않음 |
(빈 문자열) |
빈 결과, 오류 없음 | base64 0바이트는 0바이트로 디코딩됨 |
실용적인 팁 하나: 프로덕션에서 디코딩이 실패하면, 오프셋과 그 주변의 짧은 창을 로그에 남기세요. 열 번 중 아홉 번, "손상된" 바이트는 전송 계층이나 클립보드, PDF 뷰어가 문자열에 슬쩍 집어넣은 공백입니다. 그리고 해결책은 재설계가 아니라, 잘라 내거나 제거하는 겁니다.
파일 열기
Base64 파일은 base64를 담은 텍스트 파일일 뿐이므로, Go의 일상적인 파일 도구가 그대로 적용됩니다. 메모리에 넉넉히 들어가는 파일이라면, 통째로 읽어 문자열을 디코딩하세요:
package main
import (
"encoding/base64"
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("payload.b64")
if err != nil {
fmt.Println("open failed:", err)
return
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
fmt.Println("read failed:", err)
return
}
decoded, err := base64.StdEncoding.DecodeString(string(raw))
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println("decoded", len(decoded), "bytes")
}
큰 파일에는 더 나은 패턴, 스트리밍이 있습니다. 패키지 API의 나머지 절반을 쓰죠: NewDecoder는 어떤 io.Reader든 base64 디코딩 리더로 감싸 주므로, 전체 페이로드를 메모리에 한 번도 맡기지 않고 파일에서 파일로 파이프할 수 있습니다:
in, err := os.Open("payload.b64")
if err != nil {
panic(err)
}
defer in.Close()
dec := base64.NewDecoder(base64.StdEncoding, in)
out, err := os.Create("payload.bin")
if err != nil {
panic(err)
}
defer out.Close()
written, err := io.Copy(out, dec)
if err != nil {
panic(err)
}
fmt.Println("wrote", written, "bytes")
DecodeString이 만드는 추가 할당을 피하고 싶을 때 쓸 중간 옵션이 있습니다: Decode는 당신이 통제하는 목적지 버퍼에 쓰는 방식이죠. DecodedLen으로 크기를 정하세요. 주어진 입력 길이에 대해 출력 바이트의 최대 개수를 돌려 주니까요:
raw, err := os.ReadFile("payload.b64")
if err != nil {
panic(err)
}
buf := make([]byte, base64.StdEncoding.DecodedLen(len(raw)))
n, err := base64.StdEncoding.Decode(buf, raw)
if err != nil {
panic(err)
}
data := buf[:n] // 실제 디코딩된 크기
fmt.Println(len(data), "bytes")
다만 마지막 것에 주의하세요: Decode는 버퍼 크기를 당신이 제대로 정할 거라고 믿습니다. 작다면 이 메서드는 오류를 반환하지 않고, 인덱스 범위 초과로 패닉합니다. 써야 할 숫자는 len(raw)가 아니라 DecodedLen입니다.
Go를 위한 base64 명령줄
Unix 시스템은 coreutils에 base64 유틸리티를 함께 싣지만, Go는 동등한 바이너리를 싣지 않습니다. Go 세계에서 관용적인 답은 설치하는 패키지가 아니라, 당신이 소유하는 프로그램입니다: encoding/base64, flag 패키지, 표준 입력을 축으로 만든 작은 명령줄 도구가죠. 완전한 것을 하나 보여 드리죠. 약 40줄로, 파이프된 것이 무엇이든 디코딩해 원시 바이트를 내보냅니다:
package main
import (
"encoding/base64"
"flag"
"fmt"
"io"
"os"
)
func main() {
urlSafe := flag.Bool("url", false, "use the URL-safe alphabet")
flag.Parse()
enc := base64.StdEncoding
if *urlSafe {
enc = base64.URLEncoding
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}
decoded, err := enc.DecodeString(string(raw))
if err != nil {
fmt.Fprintln(os.Stderr, "decode failed:", err)
os.Exit(1)
}
os.Stdout.Write(decoded)
}
한 번 go build -o b64 .으로 빌드하면, Makefile이나 CI 파이프라인, 셸 함수에 끼워 넣을 수 있는 크로스 플랫폼 디코더가 됩니다: printf 'TWFu' | ./b64는 Man을 출력하고, ./b64 -url < token.b64 > token.bin는 URL-safe 토큰을 파일로 벗겨 내죠. 이 설계의 두 성질은 주목할 가치가 있습니다. 디코딩 전에 stdin 전체를 읽기 때문에, 디코더의 줄바꿈 관용 덕분에 줄바꿈이 섞인 래핑된 입력도 문제없이 디코딩됩니다. 그리고 잘못된 입력이면 상태 1로 종료하고 불평을 stderr로 쓰기 때문에, 사과하는 스크립트가 아니라 파이프라인 속 도구처럼 행동합니다. 그것이 바로 Go CLI 전체의 예술입니다: 패키지 하나, 플래그 하나, 표준 입력, 표준 출력, 그리고 종료 코드.
URL-safe 디코딩
URL-safe 변형이 존재하는 이유는 표준 알파벳이 URL 문법과 충돌하기 때문입니다: 쿼리 문자열에서 +는 종종 공백으로 읽히고, /는 새로운 경로 세그먼트를 시작하므로, URL에 박힌 표준 base64 문자열은 문자 하나하나 퍼센트 이스케이프를 해야 하는데, 그러면 파싱은 느려지고 읽기는 보기 싫어집니다. RFC 4648의 대체 알파벳은 +와 /를 -와 _로 바꿨는데, 둘 다 URL 경로, 쿼리, 파일 이름에서 이스케이프 없이 합법입니다.
Go에서는 이 전환이 그저 다른 디코더 변수일 뿐입니다. 데이터가 URL-safe이고 패딩이 있으면 URLEncoding을, URL-safe이고 패딩이 없으면 RawURLEncoding을 쓰세요. 전형적인 경우는 URL이나 파일 이름에 살아가는 식별자입니다:
decoded, err := base64.RawURLEncoding.DecodeString("-w9n")
// decoded는 세 바이트 0xfb 0x0f 0x67
// 대시와 밑줄은 URL-safe 알파벳의 일부이므로,
// StdEncoding은 실패할 자리를 RawURLEncoding이 처리한다
실제 Go 코드에서 어디에서 만나느냐: JWT 세그먼트(다음 섹션에서 다룹니다), 시스템이 생성해 URL에 저장하는 불투명 식별자, 웹 서버나 클라우드 오브젝트 스토어를 깨뜨려서 안 될 파일 이름, 그리고 문서에서 "base64url"을 약속한 모든 API. 경고 하나: URL-safe는 생성자와 소비자 사이의 계약이지, 데이터의 속성이 아닙니다. 문자열에 +나 /가 들어 있으면, 그것은 URL-safe가 아닙니다. 여기서 마침표. URL 디코더로 아무리 다시 시도해도 소용없죠. 먼저 문자를 보고, 그 다음 디코더를 고르세요.
JWT 내부를 들여다보기
JSON Web Token은 마침표로 구분된 base64url 세그먼트 셋입니다: 헤더, 클레임으로 이루어진 페이로드, 그리고 서명. 어느 세그먼트에도 패딩이 없죠. 그래서 JWT는 Go에서 디코딩하게 될 것 중에서도 가장 흔한 것 중 하나이며, 헤더와 페이로드는 어떤 키도 없이 읽을 수 있습니다. 디버깅이든 보안 리뷰든, 기억할 가치가 있는 사실입니다:
package main
import (
"encoding/base64"
"fmt"
"log"
"strings"
)
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parts := strings.Split(token, ".")
if len(parts) != 3 {
log.Fatal("not a JWT: expected three dot-separated parts")
}
for i, name := range []string{"header", "payload"} {
plain, err := base64.RawURLEncoding.DecodeString(parts[i])
if err != nil {
log.Fatalf("bad %s: %v", name, err)
}
fmt.Printf("%s: %s\n", name, plain)
}
// header: {"alg":"HS256","typ":"JWT"}
// payload: {"name":"Go Developer","sub":"1234567890"}
}
디코더 선택에 유의하세요: RawURLEncoding이지 StdEncoding이 아닙니다. JWT 세그먼트는 URL-safe 알파벳을 쓰고 패딩을 싣지 않으므로, 길이가 4의 배수에서 하나나 둘 모자라는 세그먼트는 패딩 있는 디코더에서 맨 마지막에서 실패하는데, 뒤쫓기엔 혼란스러운 오류가 됩니다. 서명 세그먼트는 키 없이는 읽을 수 없으며, 클라이언트가 앞의 두 세그먼트를 위조하는 걸 막는 것이 아무것도 없으므로, 페이로드만으로 무언가를 신뢰하려 해선 안 됩니다. 검증이 필요하면 유지 관리되는 라이브러리를 쓰세요. 사실상의 표준은 github.com/golang-jwt/jwt/v5입니다 (go get github.com/golang-jwt/jwt/v5로 설치):
package main
import (
"fmt"
"log"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte("hmac-secret")
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
log.Fatal("token rejected:", err)
}
claims, _ := parsed.Claims.(jwt.MapClaims)
fmt.Println("subject:", claims["sub"])
}
이 라이브러리에서 알아 둘 가치가 있는 디테일 두 가지. 첫째, 세 세그먼트의 base64url 인코딩과 디코딩은 내부에서 처리하므로, 서명하거나 검증할 때 encoding/base64에 직접 손대지 않습니다. 둘째, v5 라이브러리는 당신이 명시적으로 UnsafeAllowNoneSignatureType 상수를 넘기지 않는 한 alg=none인 토큰을 거릅니다. 이것은 고전적인 "서명 없는 토큰을 받아 줬다"는 실수로부터 당신을 보호해 주죠.
Data URL
Data URL은 페이로드가 데이터 자체인 URL입니다. RFC 2397의 문법은 data:[mediatype][;base64],data: 선택적인 미디어 타입, 선택적인 ;base64 플래그, 쉼표, 그리고 콘텐츠. ;base64 플래그가 있으면 콘텐츠는 표준 base64인데, 그래서 data URL이 이 기사와 섹션을 공유하는 겁니다. 브라우저는 페이지가 요청을 하나 줄이게 하려고, 이미지와 폰트를 HTML과 CSS 안에 그대로 매셔 넣기 위해 data URL을 씁니다:
<img src="data:image/png;base64,iVBORw0KGgo=" alt="pixel">
Go 표준 라이브러리에 data URL 헬퍼는 없지만, 포맷은 손으로 파싱할 수 있을 만큼 단순해서, strings를 써서 하는 것이 대부분의 Go 프로그램이 하는 일입니다:
package main
import (
"encoding/base64"
"fmt"
"strings"
)
func main() {
url := "data:image/png;base64,iVBORw0KGgo="
if !strings.HasPrefix(url, "data:") {
fmt.Println("not a data URL")
return
}
rest := url[len("data:"):]
comma := strings.Index(rest, ",")
if comma == -1 {
fmt.Println("missing comma")
return
}
meta := rest[:comma] // image/png;base64
encoded := rest[comma+1:] // iVBORw0KGgo=
if !strings.HasSuffix(meta, ";base64") {
fmt.Println("this variant is percent-encoded, not base64")
return
}
mediaType := strings.TrimSuffix(meta, ";base64")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(mediaType, "carries", len(decoded), "bytes")
}
기억해 둘 함정 세 가지. 첫째, ;base64 플래그는 선택적이며, 없으면 페이로드는 base64 대신 퍼센트 인코딩된 ASCII입니다. 디코더를 부르기 전에 접미사를 확인하세요. 둘째, 미디어 타입이 생략되면 기본값은 text/plain;charset=US-ASCII인데, 이미지에는 거의 문제가 안 되지만 다른 콘텐츠를 파싱하는 사람들을 놀라게 합니다. 셋째, data URL은 작은 페이로드를 위한 트릭입니다. RFC 자체도 이 스킴은 짧은 값에만 유용하다고 말하며, base64의 33% 크기 팽창은 500킬로바이트 로고를 당신의 HTML에 붙어 있는 666킬로바이트 문자열로 만들어 버리는데, 캐시도 공유도 안 되는 문자열이죠. 비디오가 아니라 아이콘과 섬네일에 쓰세요.
HTTP와 API 작업
Go 웹 서비스에서 가장 흔한 디코딩 단 한 가지는 JSON 바디 필드입니다: 업로드 폼이든, API 응답이든, 웹훅이든, 실상은 파일인 문자열을 당신에게 건네죠. 구조체로 언마샬하고, 그 다음 필드를 디코딩합니다:
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type payload struct {
Avatar string `json:"avatar"`
}
func main() {
body := []byte(`{"avatar": "iVBORw0KGgo="}`)
var p payload
if err := json.Unmarshal(body, &p); err != nil {
fmt.Println("bad JSON:", err)
return
}
img, err := base64.StdEncoding.DecodeString(p.Avatar)
if err != nil {
fmt.Println("bad avatar:", err)
return
}
fmt.Println("avatar is", len(img), "bytes")
}
API가 표준 문자열과 URL-safe 문자열을 모두 받아들인다면, 실용적인 패턴은 디코더 하나를 시도하고, 꼬리 근처에서 CorruptInputError로 실패했다면 포기 전에 다른 쪽을 시도하는 것입니다. 이 춤을 두 번 추지 마세요. "등호를 잘라 버리고 빌면 되겠지"는 일반적인 전략으로 절대 내려가지 마시죠.
HTTP Basic 인증의 경우에는 아무것도 디코딩하지 않습니다. Go가 대신해 주니까요. Go 1.4부터 사용 가능한 Request.BasicAuth는 Authorization 헤더를 당신을 위해 갈라 주고 사용자 이름과 비밀번호를 돌려주며, RFC 2617이 정의한 user:pass 쌍에는 이미 표준 base64 디코더를 돌린 뒤입니다:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "alice" || pass != "s3cret" {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "hello", user)
})
http.ListenAndServe(":8080", mux)
}
Basic auth는 인증이지 보호가 아니라는 점을 기억하세요. 헤더는 base64이지 암호화가 아니므로, HTTPS 위에서만 이동해야 합니다. 클라이언트라면 거울 호출은 req.SetBasicAuth(user, pass)이고, 이것은 표준 인코더로 같은 헤더를 당신을 위해 만들어 줍니다.
API 핸들러를 위한 방어적인 습관 하나: 디코딩 전에 http.MaxBytesReader나 동등한 길이 검사로 바디에 한계를 두세요. base64 문자열은 자기 길이의 약 4분의 3으로 디코딩되므로, 바디 한계 N바이트는 디코딩 결과를 N바이트 아래로 묶어 두고, 악성 클라이언트가 무엇을 올리든 메모리는 유한하게 유지됩니다. 무제한 바디를 디코딩하는 것은 고전적인 메모리 고갈 벡터입니다. 텍스트 몇 메가바이트를 바이너리로 바꿀 수 있는지를 공격자가 통제하니까요.
레거시 캐릭터셋
base64를 디코딩하면 바이트가 나오고, 현대 시스템에서 그 바이트는 거의 언제나 UTF-8입니다. 그런 경우 string(decoded)가 전부입니다. 하지만 base64는 오래된 포맷이며, 그중 상당수가 Windows-1252, ISO-8859-1, Shift JIS, 기타 싱글바이트 또는 더블바이트 레거시 캐릭터셋을 쓰던 시스템에서 만들어졌습니다. 생성자가 그랬다면, 당신이 디코딩하는 바이트는 유효한 UTF-8이 아니며, Go는 그것이 그러한 척하지 않습니다: 시퀀스가 끊어진 곳에다 대체 문자를 보여 줄 뿐입니다.
Go의 답은 golang.org/x/text 모듈입니다. 일반 캐릭터셋에 대해 레거시 인코딩된 바이트를 UTF-8로(그리고 되돌려) 바꿔 주죠. 변환 슬롯은 디코딩 바로 뒤에 있고, 함수 호출 한 번이면 됩니다:
package main
import (
"encoding/base64"
"fmt"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func main() {
// "Café"를 레거시 도구가 Windows-1252로 저장해 두었고,
// 전송을 위해 base64 인코딩한 것
encoded := "Q2Fm6Q=="
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), raw)
if err != nil {
fmt.Println("charset conversion failed:", err)
return
}
fmt.Println(string(utf8)) // Café
}
이 모듈은 캐릭터셋 계열마다 서브패키지 하나씩을 둡니다: Windows와 ISO 싱글바이트 표를 위한 charmap, Shift JIS와 EUC-JP를 위한 japanese, EUC-KR을 위한 korean, GB18030을 위한 simplifiedchinese, Big5를 위한 traditionalchinese. 경험칙은 이렇습니다: 생성자의 캐릭터셋을 실제로 알고 있을 때만 변환하세요. UTF-8 바이트를 두 번 변환하면 크게 소리 치며 실패하지 않거든요. 그저 텍스트를 뭉개 버릴 뿐입니다. 불확실하다면, 페이로드를 바이트로 다루고, 다음 단계의 소비자가 결정하게 두세요.
스트리밍과 청크 디코딩
파일 섹션에서 NewDecoder를 보았습니다. 자기만의 섹션을 가질 만한 가치가 무엇인지 보여 드릴게요. 이것은 진정한 스트리밍 어댑터입니다: 기반 리더에서 필요한 만큼만 뽑아 올리고, 자리에 바로 디코딩하고, 스트림이 나빠지는 순간 CorruptInputError를 돌려 줍니다. 스트림 전체가 테라바이트여도 괜찮습니다. 당신이 붙드는 메모리는 당신의 버퍼와 당신이 쓰는 출력뿐이니까요. 흔한 소비자 패턴 둘은, 작은 스트림을 위한 io.ReadAll과 나머지를 위한 io.Copy입니다:
small, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, r))
// 설정 덩어리나 작은 첨부 파일에는 무난
w, err := io.Copy(out, base64.NewDecoder(base64.StdEncoding, r))
// 비디오, 타르볼, 복구 작업에는 무난
Go 1.22부터 이 패키지에는 AppendDecode도 있습니다. 호출마다 새 슬라이스를 할당하기 대신, 당신이 다시 쓰는 버퍼에 디코딩하죠. 라인 프로세서나 프로토콜 디코더처럼 루프 안에서 수많은 청크를 디코딩하는 자주 도는 경로의 도구입니다:
var buf []byte
for _, chunk := range chunks {
buf, err = base64.StdEncoding.AppendDecode(buf, chunk)
if err != nil {
return err
}
process(buf)
}
이 메서드는 디코딩된 청크를 buf가 이미 붙들고 있는 것에 덧붙여 확장된 슬라이스를 돌려 주며, 필요에 따라 기반 배열을 키웁니다. 버퍼가 이미 적절한 크기로 자란 정상 상태에서는, 청크당 할당이 제로인데, 이는 벤치마크에서 명확히 드러납니다. 당신의 작업이 "드물게 한 번 디코딩"이라면, DecodeString이 더 단순한 선택이고, "타이트한 루프 안에서 수천 번 디코딩"이라면, 손을 뻗어야 할 것은 AppendDecode입니다.
안전하게 지키기
Go 프로그램이 실제로 이 패키지를 쓰는 방식에 특화된 보안 노트 몇 가지. 첫째, base64는 인코딩이지 암호화가 아닙니다. base64 문자열은 웹 브라우저의 개발자 도구가 있는 누구에게나 읽히므로, "보내기 전에 비밀번호를 base64로 해 놓았다"는 것은 보안 조치가 아니라, 전송 편의일 뿐입니다. 기밀성은 알파벳이 아니라 TLS에서 나와야 합니다.
둘째, 입력에 한계를 두세요. base64 문자열의 디코딩된 크기는 길이의 DecodedLen을 넘지 않으므로, 할당 전에 그 숫자를 한계와 대조하고, 디코더에 닿기 전에 요청 바디를 크기 상한으로 감싸세요. 두 검사 모두 한 줄씩이며, 함께 무제한 디코딩을 유한한 디코딩으로 바꿔 놓습니다.
셋째, 부주의한 입력에 대한 당신의 입장을 정하세요. 일반 모드는 마지막 그룹의 사용되지 않는 꼬리 비트를 조용히 버리는데, 이 말은 서로 다른 두 문자열이 같은 바이트로 디코딩될 수 있다는 뜻입니다. 대부분의 데이터에는 중요하지 않습니다. 하지만 프로토콜의 일부인 것, 서명된 메시지, 비교되거나 저장될 값이라면, Strict()가 보수적인 선택입니다. 정준 형식만이 유일하게 받아들여지는 형식이 되니까요.
넷째, 디코딩된 바이트가 어디로 가는지 주의하세요. 디코딩된 값이 파일 이름이나 경로, SQL 조각, 명령 인수가 되면, base64 레이어는 당신을 아무것도 막아 주지 못했습니다: 그 바이트는 이제 당신의 프로그램의 신뢰할 수 없는 입력이며, 다른 어떤 사용자 데이터에 적용하듯, 일상적인 무결화 규칙이 정확히 그대로 적용됩니다.
디코더는 얼마나 빠른가?
Go의 base64는 빠릅니다. 그리고 구현이 리플렉션도, 문자당 할당도 없는 단순한 테이블 조회 루프이기 때문에, 큰 데이터에서도 빠름을 유지하죠. 최신 데스크톱 CPU에서 Go 1.26을 돌리면, 500바이트 문자열이 할당 한 번과 함께 약 4분의 1 마이크로초 만에 디코딩되는데, 2기가바이트/초 수준의 속도입니다. 1메가바이트의 base64는 1밀리초를 훨씬 밑돌아 디코딩되고, 1기가바이트는 1초를 훨씬 밑돕니다. 숫자는 하드웨어와 함께 움직이지만, 모양은 움직이지 않습니다: base64 디코딩이 병목인 경우는 거의 없고, 보통은 그 주위의 네트워크나 디스크가 병목입니다.
자주 도는 루프 안에 있다면, 보야 할 것은 할당 프로파일입니다. DecodeString은 호출마다 결과 슬라이스를 할당합니다. 미리 크기를 정한 목적지를 쓰는 Decode와 재사용 버퍼를 쓰는 AppendDecode는 둘 다 정상 상태에서 그 할당을 완전히 피합니다. 요청마다 몇 번 일어나는 디코딩이라면, 이 중 어느 것도 중요하지 않습니다. 초당 수백만 번 일어나는 디코딩이라면, 이것은 평탄한 메모리 프로파일과 계속 바쁘게 뛰는 가비지 컬렉터 사이의 차이입니다.
패키지의 짧은 역사
base64 패키지는 Go 표준 라이브러리의 가장 오래된 부분 중 하나입니다. 소스 파일의 저작권 헤더에는 2009, 이 언어가 만들어진 해가 적혀 있고, 이 패키지는 2012년 3월의 아주 첫 번째 안정 릴리스 Go 1.0부터 표준 라이브러리의 일부였습니다. 즉, 당신이 오늘 부르는 DecodeString은 10년 넘게 Go 프로그램들이 불러 온 그 API, 그 동작 그대로입니다.
그 이후의 성장은 절제되면서도 유용했습니다. 2015년 8월의 Go 1.5는 패딩 없는 RawStdEncoding과 RawURLEncoding 값을 추가하며, JWT 스타일의 축약 문자열에게 문을 열었습니다. 2017년 2월의 Go 1.8은 Strict()를 추가하며, 프로토콜이 정준 입력을 요구할 수 있는 길을 열었습니다. 2024년 2월의 Go 1.22는 base 인코딩 전체 계열에 AppendDecode와 AppendEncode를 추가하고, WithPadding을 황당한 인수를 거를 정도로 조였습니다. 그리고 2026년 9월 현재, 최신 릴리스가 Go 1.27.1이고 다른 지원 라인이 Go 1.26인 지금, API는 정확히 이 기사가 설명하는 그대로입니다: 완성된 인코딩 넷, 스트림 디코더, 엄격 모드, 그리고 성능을 위한 append 계열.
더 깊은 사실은 호환성 약속입니다. Go 1의 보증은, 이 패키지가 영원히 같은 입력을 받아들이고 거를 것이라는 뜻이므로, 2015년에 만들어진 데이터 포맷을 위해 올해 쓰는 디코더는 계속 동작할 것입니다. 이 정도로 오래되고, 이 정도로 지루한 포맷에게는, 그것이 최고의 뉴스입니다.
당신을 놀라게 할 것들
Go에서 시간을 보내다 보면 base64에 놀라는 일이 멈춥니다. 하지만 처음 몇 번은, 이 사실 몇 개가 강하게 꽂히거든요. 그래서 여기에 올려 둡니다:
- 디코더는 입력 어디에 있든
\r와\n를 건너뛰지만, 공백은, 탭은, 영 폭 공백은 아니죠. 이 관대함은 의도적인 것입니다. MIME 래핑된 입력이 동작하게 하려 존재하며, 규격이 멈추는 바로 그 곳에서 멈춥니다. - 실패한 디코딩도 여전히 진짜 데이터를 돌려줄 수 있습니다. 부분 결과는 나쁜 바이트 전에 디코딩된 전부이며, 오류는 그것을 대신해서가 아니라, 그것과 함께 도착합니다.
CorruptInputError는 말 그대로 메서드가 달린int64입니다. "오류"는 오프셋이고, 메시지는 필요할 때 만들어 집니다.Decode와Encode는 둘 다 목적지 버퍼 크기를 당신이 제대로 정할 거라고 믿습니다. 너무 작은 버퍼를 주면 오류가 오지 않습니다. 패닉이 오죠.- 한 문자는 네 내장 인코딩 중 어디에서도 유효한 입력이 아닙니다. base64 문자 하나는 6비트를 싣고, 바이트는 8비트를 필요로 하므로, 패딩이든 아니든, 한 문자 안에 완전한 그룹은 없습니다.
- 2026년 8월 기준, pkg.go.dev의 공개 패키지 244,000개 이상이 import 목록에
encoding/base64를 올려 둡니다. 조용하지만, 전체 생태계에서 가장 많이 의존되는 패키지 중 하나입니다.
디코딩이 틀어지는 곳
이것들은 Go 코드베이스에 끊임없이 모습을 보이는 디코딩 실수들입니다. 대략 서포트 스레드에 등장하는 순서대로요:
- URL-safe 데이터에
StdEncoding을 고르는 것(혹은 그 반대). 증상은 첫-,_,+또는/에서의 오류이고, 해결법은 디코더를 고르기 전에 문자열을 보는 것입니다. - 터미널, 이메일, PDF에서 문자열을 붙여 넣는 것. 그러면 공백, 탭, 줄끝 잔재가 슬쩍 끼어 듭니다. Go는 진짜 줄바꿈은 건너뛰지만, 문자열 한가운데의 공백은 손상된 바이트이며, 오류의 오프셋은 정확히 거기를 가리킵니다.
- 결과가
[]byte라는 걸 잊는 것. 원시 상태로 출력하면 숫자 목록이 나오고, 문자열을 기대하는 함수에 넘기려면string(...)변환이 필요합니다. - 오류를 검사한 뒤에도 부분 데이터를 그대로 쓰는 것. 반쯤 디코딩된 접두사는 진짜이지만, 페이로드는 아닙니다. 그것을 페이로드로 다루는 코드는, 정확히 반 길이의 데이터로 프로덕션에서 실패하죠.
DecodedLen(len(src))대신len(src)로Decode버퍼 크기를 정하는 것. 첫 번째 크기는 당신이 바라는 반대 방향으로 틀려 있고, 그것이 일으키는 패닉은 큰 입력에서만 일어나는데, 그래서 스테이징 환경의 단골 손님입니다.- JWT 세그먼트가 패딩을 싣는다고 가정하는 것. 싣지 않습니다. 패딩 있는 디코더는 마지막 문자에서 실패하고, 그 오류는 수수께끼처럼 읽히죠.
RawURLEncoding을 쓰세요. - 모든 공백이 건너뛰어진다고 믿는 것. 아닙니다. 건너뛰는 것은 두 줄바꿈 문자뿐이며, 클립보드의 "공백"은 그보다 훨씬 큰 가족입니다.
- 값이 base64의 base64일 때 (자기 자체가 첨부된 이메일에 첨부된 파일) 이중 디코딩을 하거나, 두 번 디코딩하지 못하는 것. 확인 방법은 왕복 한 번입니다: 한 번 디코딩하고, 결과가 아직 base64처럼 보이는지 보고, 그때서야 다시 디코딩하세요.
일의 나머지 절반
그것이 이야기의 디코딩 쪽 전부입니다: 패키지 하나, 완성된 디코더 넷, 큰 데이터를 위한 스트림 디코더, 까다로운 프로토콜을 위한 엄격 모드, 그리고 일이 틀어진 바이트를 알려 주는 오류 메시지. 관용 규칙을 배우고, 문자를 보고 디코더를 고르고, 입력에 한계를 두면, Go의 base64는 설계된 그대로의 지루하고 예측 가능하며 제로 의존인 유틸리티가 됩니다.
일이 뒤집혀, 당신의 Go 프로그램이 base64 문자열을 여는 대신 만들어 내야 할 때, Go에서의 Base64 인코딩을 다룬 관련 기사가 그쪽을 자세히 다룹니다: 인코더의 단 하나의 메서드 API, 당신의 마지막 두 바이트를 조용히 삼키는 Close 호출, MIME을 위한 줄 래핑, 그리고 네 인코딩이 지나가는 채널에 어떻게 대응하는지.
마지막 업데이트: 2026-09-08
관련 문서: Go에서의 Base64 인코딩: 완전한 가이드