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

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

가끔씩 당신의 Go 프로그램은 바이너리 데이터를 텍스트만 받아들이는 세상에게 넘겨야 합니다. 문자열로 남아 있어야 하는 JSON 필드, 단일 토큰으로 남아 있어야 하는 URL, 7비트 시절을 기억하는 서버들을 건너가는 이메일 첨부 파일, 요청 하나를 건너뛰게 하려고 HTML 안에 살기를 원하는 이미지. Base64는 바로 그 일을 위한 전달사이고, 이 사이트의 홈 페이지는 이미 포맷을 깊이 설명해 두었으니, 이 기사는 포장 기술로 직행합니다. 지구상 모든 디코더가 한마디 시비 없이 열 수 있는 base64 문자열을 Go에서 만들어 내는 일이죠.

좋은 소식을 먼저 드릴게요: 인코더는 이야기에서 순한 쪽입니다. 메서드 하나, 오류 반환 없음, 실패 모드 없음, 첫 안정 릴리스부터 모든 Go 릴리스에서 바이트 단위로 동일한 출력. 흥미로운 것은 전부 그 메서드 주변에 있습니다: 문자열이 지날 채널에 맞는 알파벳 고르기, 당신이 잊으면 당신의 마지막 두 바이트를 조용히 삼키는 Close 호출, 포맷이 짊어지는 크기 대가, 그리고 Go는 Python, Java, Node처럼 출력을 76자에서 절대 래핑하지 않는다는 사실. 먼저 함수를 만나요. 그리고 함정들을 만나요.

실패하지 않는 포장

Go의 인코딩 생활에서 90%는 Encoding 타입 위의 하나의 메서드이며, 이 패키지의 모든 인코딩 진입점(Encode, AppendEncode)처럼, 오류 반환이 없습니다:

func (enc *Encoding) EncodeToString(src []byte) string

바이트를 넘기면 문자열을 돌려줍니다. 그것이 계약 전체입니다:

package main

import (
  "encoding/base64"
  "fmt"
)

func main() {
  packed := base64.StdEncoding.EncodeToString([]byte("Man"))
  fmt.Println(packed) // TWFu
}

오류 값이 없는 이유는 잘못될 것이 없기 때문입니다: 어떤 바이트든 합법적인 입력이고, 알파벳은 언제나 그것을 덮어 주며, 출력은 언제나 순수한 ASCII입니다. 세 가지 성질은 외워 둘 가치가 있습니다. 미래 질문의 절반에 답하기 때문이죠. 첫째, 출력 길이는 입력 길이에 대한 순수한 산술 함수이며, 이 패키지는 그 공식조차 메서드로 내어 줍니다: 패딩 있는 인코딩의 경우 EncodedLen(n)은 (n+2)/3*4를 돌려 주므로, 입력 3바이트는 4문자가, 6바이트는 8문자가 되는 식입니다. 둘째, 이 포맷은 크기 대가를 짊어집니다. 데이터 3바이트마다 4문자로 돌아오는데, 이것이 바로 대략 33% 팽창, 즉 당신의 대역폭 청구서와 저장 쿼터에 등장하는 그 팽창입니다. 셋째, 이 메서드는 결정론적입니다. 같은 바이트는 언제나 같은 문자열을 만들어 내죠. 어떤 머신에서든, Go의 어떤 버전에서든, 영원히. 그 결정론이야말로 base64를 수수께끼가 아니라 직렬화 포맷으로 만드는 것입니다.

입력 쪽의 Go 특이점 하나: 이 메서드는 string이 아니라 []byte를 받습니다. 그리고 []byte(...) 변환은 각 호출 지점에서 명시적입니다 - Go는 당신을 위해 문자열을 슬라이스로 변환해 주지 않으니까요 - 이 변환은 문자열 바이트의 독립된 사본을 만들어 냅니다. 슬라이스가 읽기 전용이고 밖으로 벗어나지 않으면 컴파일러는 그 사본을 생략할 수 있어, 대가는 보통 측정 불가 수준입니다. 하지만 슬라이스가 저장되거나 반환되면, 런타임은 실질적인 O(n) 사본 비용을 치릅니다. Go 프로그램의 텍스트는 관례상 UTF-8이므로, 문자열을 인코딩한다는 것은 그 UTF-8 바이트를 인코딩한다는 뜻이며, 그것이 바로 반대편의 모든 현대 디코더가 기대하는 것입니다. 그 이야기는 텍스트, 바이트, 유니코드 섹션에서 이어집니다.

Go는 어떻게 싣고 오는가

이 기사의 모든 것과 마찬가지로, 인코더는 표준 라이브러리 패키지 encoding/base64에서 옵니다. 이 패키지는 언어의 첫 릴리스부터 함께 왔고, 소스 파일에는 아직 2009년 저작권 헤더가 그대로 남아 있죠. 받아 올 모듈도, 껴야 할 기능 플래그도, 플랫폼 특이점도 없습니다: go version이 동작한다면, go doc encoding/base64가 API 전체를 당신을 위해 출력해 줍니다.

이 글을 쓰는 시점에서 최신 릴리스는 Go 1.27.1로 2026년 9월 1일에 나왔고, 다른 지원 트랙은 Go 1.26 라인(현재 1.26.8)입니다. go.dev/dl의 공식 tarball, 배포판의 패키지 매니저(sudo apt install golang-go), 또는 여러 버전을 오가는 사람이면 golang.org/dl 래퍼를 통해 Go를 설치하세요. 두 지원 라인에서 base64 API는 동일하며, 아래 표가 무엇이든 바뀐 것의 전체 역사입니다. 이토록 핵심적인 패키지에겐 짧은 목록이죠:

릴리스 연도 encoding/base64에서 무엇이 바뀌었는가
Go 1.0 2012 패키지 첫날부터 안정적; 소스 저작권 2009
Go 1.5 2015 패딩 없는 출력을 위해 RawStdEncoding과 RawURLEncoding 추가
Go 1.8 2017 정준 디코딩을 위해 Strict() 추가 (디코더 쪽)
Go 1.22 2024 AppendEncode와 AppendDecode 추가; WithPadding이 잘못된 인수를 거르기 시작
Go 1.27.1 2026 현재 릴리스; API 불변, Go 1 약속에 따라 동작은 바이트 단위 안정

그 역사로부터의 실용적 결과: 2015년에 이 API를 향해 작성된 코드는 오늘도 동일하게 컴파일되고 동일하게 동작하며, 2026년에 당신의 프로그램이 인코딩한 문자열은 과거든 미래든 어떤 Go 릴리스에서도 올바르게 디코딩됩니다. 직렬화 포맷에게, 그것이 조용한 초능력입니다.

목적지를 위한 알파벳 고르기

인코딩에는 진정한 결정이 하나 있고, 그것은 여행의 문제입니다: 이 문자열은 어디로 가나요? Go는 완성된 인코더 넷을 줍니다. 각 인코더는 다른 채널에 맞춰 조율되어 있죠:

인코더 알파벳 패딩 문자열이 이것을 지날 때 보내세요
StdEncoding A-Z a-z 0-9 + / = JSON 바디, 이메일 MIME 파트, data URL, HTTP Basic auth, PEM, 대부분의 API
URLEncoding A-Z a-z 0-9 - _ = URL 경로와 쿼리, 파일 이름, + 또는 /가 이스케이프를 필요로 하는 곳 어디든
RawStdEncoding A-Z a-z 0-9 + / 없음 패딩이 절대 나와서 안 되는 축약형 표준 알파벳 문자열
RawURLEncoding A-Z a-z 0-9 - _ 없음 JWT 세그먼트, 축약형 식별자, URL에 박힌 토큰

변형들 뒤의 이성은 포맷 자체 뒤의 이성입니다. 표준 알파벳은 MIME과 대부분의 API가 기대하는 것이므로, 아무도 다르게 말해주지 않는 한 기본값이자 안전한 답입니다. URL-safe 알파벳이 존재하는 이유는 +와 /가 URL에서 예약 문자이기 때문입니다: 쿼리 문자열의 플러스는 종종 공백으로 읽히고, 슬래시는 새로운 경로 세그먼트를 시작하므로, URL 안의 표준 base64는 깨지거나, +, /, =를 싣는 문자들에 퍼센트 이스케이프를 해야 하는데, 그것은 전형적 토큰의 몇 %에 해당합니다. 경로, 쿼리, 파일 이름에서 이스케이프 없이 합법인 -와 _로 바꾸는 것이 RFC 4648이 표준화한 해결책입니다. Raw 변형은 꼬리의 등호를 완전히 제거하는데, 패딩이 금지되거나 그저 영원히 쓰이지 않는 맥락, JWT 세그먼트 같은 곳에서 중요합니다. 당신을 대부분의 디버깅으로부터 구하는 규칙: 당신이 고르는 인코더와 반대편이 쓰는 디코더는 하나의 계약이며, 그 계약은 당신이 아니라 목적지가 씁니다.

당신이 대화하는 시스템이 사적인 64문자 알파벳을 정의했다면, base64.NewEncoding("...64 chars...")가 그것을 위한 인코더를 만들어 주고, WithPadding(rune)은 패딩 문자를 바꿀 수 있게 하거나 NoPadding으로 비활성화할 수 있게 합니다. 두 함수 모두 잘못된 인수(틀린 알파벳 길이, 중복 문자, 알파벳 속 줄바꿈, 알파벳과 충돌하는 패딩 문자)에서 패닉하므로, 당신만의 인코더는 시작 시점에 한 번 만들어 두세요. 자주 도는 경로에서 만들면 안 됩니다.

Close 함정

이 패키지의 가장 유명한 함정이 여기서 등장합니다. 문자열이 아니라 스트림을 인코딩할 때에만 나타나죠. NewEncoder는 어떤 io.Writer든 base64 인코딩 라이터로 감싸 주고, base64가 입력 3바이트 블록마다 출력 4문자를 만들어 내는 방식으로 동작하기 때문에, 인코더는 마지막 1-2바이트를 버퍼에 남겨 두고 더 들어올지 기다려야 합니다. 당신이 닫을 때만 플러시되죠:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func main() {
  var buf bytes.Buffer
  enc := base64.NewEncoder(base64.StdEncoding, &buf)
  enc.Write([]byte("hello"))
  fmt.Println(buf.String()) // aGVs  -- "lo"는 어디로 간 걸까?

  buf.Reset()
  enc = base64.NewEncoder(base64.StdEncoding, &buf)
  enc.Write([]byte("hello"))
  enc.Close()
  fmt.Println(buf.String()) // aGVsbG8=  -- "hello"의 완전한 인코딩
}

첫 번째 출력물이 전체 교훈입니다: Close 없이, 인코더는 첫 번째 완전한 블록뿐을 출력했습니다. "hello"의 3바이트가 "aGVs"가 된 것이고, 나머지 두 바이트는 그저 내부 버퍼로 사라진 것입니다. Close 이후의 두 번째 출력물이 바로, 정확하고 완전한 문자열입니다. 해결책은 기술이 아니라 습관입니다: 인코더를 만든 순간, 그 정리도 함께 만드세요:

enc := base64.NewEncoder(base64.StdEncoding, w)
defer enc.Close() // 프로덕션 코드에서는 반환되는 오류를 검사할 것을 기억하세요

두 가지 디테일이 이 함정을 겉보기보다 더 날카롭게 만듭니다. 첫째, Close는 진짜 일을 합니다. 대기 중인 부분 블록을 플러시하며, 기반 라이터에 쓰므로 실패할 수도 있습니다. 그래서 관용적인 버전은 특히 목적지가 네트워크나 디스크일 때 그 오류를 검사합니다. 둘째, 문서에는 Close 이후 Write를 부르는 것은 오류라고 쓰여 있지만, 런타임은 그 문장을 강제하지 않습니다. 닫은 뒤 다시 쓰면, 인코더는 조용히 새 블록을 시작해 덧붙이고, 결과적으로 중간에 패딩이 낀 문자열을 만들어 내는데, 이것은 대부분의 디코더가 혼란스러운 오프셋과 함께 거절하는 잘못된 base64입니다. 그 계약은 당신이 지켜야 할 것입니다.

줄 래핑, Go 방식

당신이 사용해 온 다른 모든 주요 base64 구현은 출력을 래핑합니다: MIME은 최대 76자의 줄을 원하고, PEM은 64자를 쓰며, 전 세계 이메일 클라이언트는 틈만 나면 CRLF를 집어넣죠. Go의 인코더는 그 어느 것도 하지 않습니다. 페이로드가 아무리 커도 하나의 연속된 줄을 내보내며, 이 패키지가 태어나던 날부터 그래 왔습니다. 1메가바이트 데이터의 출력은 처음부터 끝까지, 한 번도 끊기지 않는 1메가바이트 3분의 1짜리 하나의 줄입니다.

그것은 의도된 선택이지, 놓친 것이 아닙니다. 줄바꿈이 있든 없든 포맷은 동일하게 동작하며, Go 자신의 디코더는 입력 어디에 있든 그것들을 건너뜁니다. 그리고 당신의 데이터에 조용히 CRLF를 집어넣는 인코더라면, 그 문자열을 데이터베이스 컬럼에 저장하거나 동등성을 비교하는 프로그램들을 놀라게 할 겁니다. 대가는, 채널이 요구할 때 당신 자신이 래핑해야 한다는 것인데, 그것은 작은 헬퍼 하나면 됩니다:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func wrapAt(s string, width int) string {
  var out bytes.Buffer
  for i := 0; i < len(s); {
    end := i + width
    if end > len(s) {
      end = len(s)
    }
    out.WriteString(s[i:end])
    out.WriteByte('\n')
    i = end
  }
  return out.String()
}

func main() {
  raw := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 100))
  fmt.Print(wrapAt(raw, 76))
}

여행 방향에 대해 한 마디: Go의 디코더가 어디에 있는 줄바꿈이든 무시하므로, 래핑된 입력은 어떤 다리에서든 Go 쪽에서는 완벽하게 디코딩됩니다. 조심해야 하는 것은 반대 방향입니다: 줄바꿈을 기대하지 않는 소비자(JSON 필드, URL, 토큰)에게 래핑된 출력을 보낸다면, 먼저 그것들을 제거하세요. 그 소비자가 줄바꿈을 손상된 문자로 취급할 수 있으니까요. 당신의 채널이 어느 관례에 살고 있는지 알고, 의도적으로 그에 맞춰 출력하세요.

이메일과 MIME을 위한 포장

이메일은 base64의 가장 오래된 집입니다. 원래의 SMTP 프로토콜은 7비트 ASCII를 운반하도록 설계되었으므로, 첨부 파일은 보내기 전에 base64 인코딩되고 도착할 때 디코딩되었으며, MIME 표준(RFC 2045)은 이 관행을 공식화했습니다: 헤더 Content-Transfer-Encoding: base64가 한 파트를 표시하고, 본문은 CRLF가 사이에 들어 있는 최대 76자의 줄로 끊어져야 합니다.

Go의 net/smtp 패키지는 당신이 준 바이트를 보내 줄 뿐, 당신을 위해 MIME 파트를 만들어 주지 않습니다. 그래서 메일을 작성하는 프로그램에서 base64 부분은 이렇게 생겼습니다:

package main

import (
  "bytes"
  "encoding/base64"
  "fmt"
)

func main() {
  body := []byte("hi from Go")
  var part bytes.Buffer
  part.WriteString("Content-Transfer-Encoding: base64\r\n")
  part.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")

  encoded := base64.StdEncoding.EncodeToString(body)
  for i := 0; i < len(encoded); i += 76 {
    end := i + 76
    if end > len(encoded) {
      end = len(encoded)
    }
    part.WriteString(encoded[i:end] + "\r\n")
  }
  fmt.Print(part.String())
}

세 가지를 눈여겨 볼 만합니다. 표준 인코더가 여기에 맞는 것인데, MIME이 원래의 표준 알파벳 맥락이니까요. 줄바꿈은 플랫폼의 네이티브 줄바꿈이 아니라 CRLF인데, 그것이 RFC가 규정하고 메일 파서가 기대하는 것이기 때문입니다. 그리고 당신의 프로그램이 대량으로 진짜 이메일을 보낸다면, 유지 관리되는 MIME 라이브러리가 메시지 전체를 당신을 위해 만들어 줄 겁니다. 이 예시의 요점은 base64 절반인데, 바로 이 패키지에 속하는 부분입니다. 알파벳과 줄 관례만 제대로 잡으면, MIME의 나머지는 남의 일이 됩니다.

파일을 위한 포장

메모리에 들어가는 파일이라면, 패턴은 어디서나 같은 두 줄입니다: 읽고, EncodeToString. 그렇지 않은 파일에는 스트리밍이 당신의 메모리를 평탄하게 유지해 주고, 레시피는 파일 하나, 인코더 하나, 복사, 그리고 올바른 순서의 두 닫기입니다:

in, err := os.Open("photo.jpg")
if err != nil {
  panic(err)
}
defer in.Close()

out, err := os.Create("photo.b64")
if err != nil {
  panic(err)
}
enc := base64.NewEncoder(base64.StdEncoding, out)
if _, err := io.Copy(enc, in); err != nil {
  panic(err)
}
if err := enc.Close(); err != nil {
  panic(err) // 마지막 부분 블록을 플러시한다
}
if err := out.Close(); err != nil {
  panic(err)
}

닫기의 순서가 섬세한 부분이며, 이것은 Close 함정의 파일 버전입니다: 인코더는 파일보다 먼저 닫아야 합니다. enc.Close가 마지막 부분 블록을 파일로 쓰는 것이기 때문이죠. 파일을 먼저 닫으면, 그 블록은 아무것도 쓰지 않는 버퍼에 남겨질 테니까요. defer를 쓴다면, 지연 호출은 역순으로 실행된다는 점을 기억하세요. 그래서 out.Close를 먼저 등록하고 enc.Close를 두 번째로 등록하는 것(또는, 위의 예처럼 파일을 defer하기 전에 인코더를 명시적으로 닫는 것)이 바로 그 순서를 안전하게 만드는 것입니다.

이 패턴을 기준으로 계획을 세울 때, 크기 대가를 머릿속에 두세요: 10메가바이트 사진은 대략 13.3메가바이트의 텍스트가 되고, 100메가바이트 아카이브는 디스크 위에 133메가바이트 문자열이 됩니다. 목적지에 쿼터, 한계, 또는 바이트당 가격이 있다면, 세어지는 것은 당신의 파일의 원본이 아니라 base64 버전입니다.

웹을 위한 포장: Data URL

브라우저는 기꺼이 HTML이나 CSS 안에 사는 문자열에서 이미지나 폰트를 로드해 줍니다. 그 문자열이 바로 data URL입니다: 미디어 타입, ;base64 플래그, 쉼표, 페이로드, 전부 한 URL 안에. Go에는 data URL 헬퍼가 없지만, 하나 만들어 내는 것은 문자열 연결일 뿐입니다. 포맷이 당신 눈앞에 쓰여 있는 계약이니까요:

package main

import (
  "fmt"
  "os"
  "encoding/base64"
)

func main() {
  img, err := os.ReadFile("logo.png")
  if err != nil {
    panic(err)
  }
  url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
  fmt.Println(url)
  // data:image/png;base64,iVBORw0KGgo...
}

두 가지 규칙이 data URL을 골칫거리에서 꺼내 줍니다. 미디어 타입은 언제나 포함하세요. 문법에서는 선택적이지만(기본값은 text/plain;charset=US-ASCII), 당신의 바이너리 페이로드 타입을 추리려는 브라우저는 당신이 원하는 시나리오가 아니니까요. 그리고 data URL은 작은 자산용 트릭으로 다루세요. RFC도 이 스킴은 짧은 값에만 유용하다고 말하며, 33% 팽창이 바로 그 차이를 만듭니다: 요청을 하나 아껴 주는 2킬로바이트 아이콘과, 모든 페이지 로드를 부풀리는 5메가바이트 사진 사이의 차이. 그것을 나눌 캐시도, 누군가에게 건넬 URL도 없으니까요. 아이콘, 파비콘, 작은 스프라이트: 네. 제품 사진: 아니요.

HTTP를 위한 포장

Go 서비스에서 base64를 지배하는 HTTP 맥락은 셋이며, 그 중 둘은 내장된 도움을 함께 제공합니다. 첫 번째는 JSON 바디, 바로 일꾼입니다: 마샬하기 전에 값을 인코딩하고, 필드는 순수한 문자열 하나를 와이어를 타고 건너갑니다:

package main

import (
  "encoding/base64"
  "encoding/json"
  "fmt"
)

type avatar struct {
  Data string `json:"data"`
}

func main() {
  png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
  a := avatar{Data: base64.StdEncoding.EncodeToString(png)}
  body, err := json.Marshal(a)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(body))
  // {"data":"iVBORw0KGgo="}
}

한 타입이 많은 곳에서 등장한다면, 깔끔한 Go 방식은 그것에 MarshalJSON과 UnmarshalJSON을 구현하는 것입니다. 그러면 base64 단계는 모든 호출 지점에서 보이지 않게 되죠. 두 번째 맥락은 HTTP Basic 인증으로, 여기서 표준 라이브러리가 전체 일을 해 줍니다: Request.SetBasicAuth(user, pass)가 당신을 위해 Authorization 헤더를 만들어 주는데, RFC 2617이 규정한 user:pass 쌍 위에 표준 인코더를 돌린 것입니다. 거기의 유일한 규칙은, 즉흥적으로 하지 않는 것입니다: Basic auth는 Basic 접두사가 붙은 표준 base64이고, URL-safe 알파벳이나 빠진 패딩 표식은 잘 동작하던 로그인도 아무도 설명할 수 없는 401로 만들어 버립니다.

세 번째 맥락은 URL로, 여기서 문자열은 경로 세그먼트나 쿼리 파라미터의 페이로드입니다. 여기서 표준 알파벳은 나쁜 선택인데, +, /, =가 모두 URL 문법과 충돌하고, 그것들의 모든 출현은 퍼센트 이스케이프를 필요로 하니까요. 대신 URL-safe 변형으로 인코딩하면, 토큰은 URL을 무사히 건너갑니다. 소비자가 그래도 퍼센트 이스케이프를 하면, 깨지는 것은 없지만, 그렇게 하지 않는다면, 당신은 404의 한 문류를 피한 셈이 됩니다.

URL-safe 출력

URL-safe base64는 Go에서 자기만의 섹션을 받을 자격이 있습니다. 표준보다 더 자주 손이 갈 변형이기 때문이고, Go가 이 전환을 무료이기 때문이죠. RFC 4648의 대체 알파벳은 +를 -로, /를 _로 바꿨으므로, 출력은 URL 경로, 쿼리, 파일 이름에서 이스케이프를 필요로 하지 않고, 로그 줄에서는 하나의 깨끗한 토큰으로 읽힙니다. 완성된 인코더 둘은 URLEncoding(패딩)과 RawURLEncoding(패딩 없음)입니다:

raw := []byte{0xfb, 0x0f, 0x67, 0x01}
fmt.Println(base64.StdEncoding.EncodeToString(raw))     // +w9nAQ==
fmt.Println(base64.URLEncoding.EncodeToString(raw))     // -w9nAQ==
fmt.Println(base64.RawURLEncoding.EncodeToString(raw))  // -w9nAQ

입력 하나, 출력 셋입니다: 표준 버전은 플러스 부호를 위해 퍼센트 이스케이프가 필요하고, URL-safe 버전은 토큰 하나, raw 버전은 패딩까지 없앱니다. 각자의 전형적인 Go 용도: 서비스가 생성해 URL, 라우트, 파일 이름에 저장하는 불투명 식별자; 클라이언트가 쿼리 문자열에 붙여 넣는 API 토큰; 그리고 로그 줄에 등장할 모든 것 - 거기서 플러스나 슬래시는 한 글자 차이로 문법으로 오해당할 거리에 있습니다.

이것을 깔끔하게 유지하는 규율은 이 기사의 어디서나 같은 것입니다: 변형은 소비자과의 계약입니다. 반대편이 표준 base64를 기대하는데 당신이 URL-safe를 보내면, 그쪽 디코더는 첫 대시에서 실패하고, 오류는 완벽히 건강한 문자열의 꼬리 근처의 바이트 오프셋이 될 텐데, 이는 분명하게 디버깅할 수 있는 것이 아닙니다. 불확실하다면, 반대편이 무엇을 기대하는지 물으세요. 그쪽이 가리키는 규격을 읽고, 습관이 아니라 목적지에서 인코더를 고르세요.

JWT를 위한 포장

JSON Web Token은 현대 API에서 base64의 가장 눈에 띄는 소비자이며, 정확한 변형을 고정해 둡니다: RFC 7515에 따른 JWS 콤팩트 직렬화는 패딩 없는 base64url 세그먼트 셋을 마침표로 이어 붙인 것입니다. 헤더, 페이로드, 서명. 즉, 당신이 손으로 만드는 무언가에서 고를 인코더는 RawURLEncoding입니다:

package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/base64"
  "encoding/json"
  "fmt"
)

func main() {
  secret := []byte("hmac-secret")
  header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
  payload, _ := json.Marshal(map[string]any{"sub": "1234567890"})

  signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
    base64.RawURLEncoding.EncodeToString(payload)

  mac := hmac.New(sha256.New, secret)
  mac.Write([]byte(signingInput))
  signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  fmt.Println(signingInput + "." + signature)
}

그 예시를 출력을 권하는 것이 아니라, 포맷이 무엇인지에 대한 교훈으로 읽으세요: base64가 어디에 있는지(서명 전에 두 번, 서명한 뒤에 한 번)와, 서명이 원시 JSON이 아니라 인코딩된 세그먼트를 덮는 이유를 정확히 보여 주니까요. 프로덕션에서는 유지 관리되는 라이브러리로 서명하고 검증하세요. JWT에는 base64 레이어가 볼 수 없는 실수들의 긴 꼬리(만료 시계 어긋남, 알고리즘 혼동, 누락된 청중 검사)가 있으니까요. 사실상의 Go 라이브러리는 github.com/golang-jwt/jwt/v5이며, go get github.com/golang-jwt/jwt/v5로 설치합니다:

package main

import (
  "fmt"
  "log"
  "time"

  "github.com/golang-jwt/jwt/v5"
)

func main() {
  secret := []byte("hmac-secret")
  token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "sub": "1234567890",
    "exp": time.Now().Add(time.Hour).Unix(),
  })
  signed, err := token.SignedString(secret)
  if err != nil {
    log.Fatal("signing failed:", err)
  }
  fmt.Println(signed)
}

이 라이브러리는 모든 세그먼트의 base64url 인코딩을 내부에서 수행하므로, 당신은 encoding/base64에 아예 손을 대지 않습니다. 이것이 최고의 결과입니다: 패딩이나 알파벳 실수가 숨을 곳이 하나 줄어든다는 뜻이니까요. 그리고 무료로 주는 가드를 눈여겨 보세요: v5는 당신이 UnsafeAllowNoneSignatureType 상수로 명시적으로 동의하지 않는 한 alg=none를 주장하는 토큰을 거릅니다. 이것은 당신이 생각하지 않아도 원하는 보호입니다.

텍스트, 바이트, 그리고 유니코드

Go의 이 문제에 대한 입장은 모든 주요 언어 중 가장 짧고, 그것이 바로 base64가 여기서 이렇게 반가운 이유입니다: Go의 string은 읽기 전용 바이트 시퀀스이고, 당신의 프로그램 안의 텍스트는 UTF-8입니다. 숨겨진 인코딩 레이어도, "문자열은 실은 UTF-16"이라는 놀람도, 설정할 캐릭터셋 플래그도 없습니다. EncodeToString([]byte(myText))를 쓴다면, 당신은 텍스트의 UTF-8 바이트를 인코딩하는 겁니다. 여기서 마침표:

s := "Café ☕"
packed := base64.StdEncoding.EncodeToString([]byte(s))
fmt.Println(packed) // Q2Fmw6kg4piV

그 한 줄이 바로 현대 텍스트의 전체 이야기입니다. 이모지나 CJK를 포함해서요: base64는 바이트 위에서 동작하고, UTF-8은 그저 바이트 시퀀스이며, 같은 관례를 따르는 반대편의 모든 디코더는 같은 문자열을 돌려줄 테니까요. []byte(...) 변환은 독립된 사본이며, 슬라이스가 읽기 전용이고 밖으로 벗어나지 않으면 컴파일러가 그것을 생략합니다 - 그래서 실용적으로는 측정할 수 없는 비용입니다.

이야기가 길어지는 경우는 하나뿐이며, 바로 레거시 데이터입니다: Windows-1252, Shift JIS, ISO-8859-1 시스템에서 만들어져 유효한 UTF-8이 아닌 바이트. 그 바이트를 있는 그대로 base64 인코딩한다면, 당신은 깨진 텍스트를 충실히 운반한 셈인데, 아무도 그것을 원하지는 않았죠. 해결책은 인코딩 전에 정규화하는 것인데, golang.org/x/text를 써서, base64 문자열이 당신의 프로그램을 떠나는 순간부터 깨끗한 UTF-8을 싣게 합니다:

import (
  "golang.org/x/text/encoding/charmap"
  "golang.org/x/text/transform"
)

legacy := []byte{0x43, 0x61, 0x66, 0xE9} // Windows-1252의 "Café"
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), legacy)
if err != nil {
  panic(err)
}
packed := base64.StdEncoding.EncodeToString(utf8)
// Q2Fmw6k=  -- 같은 "Café", 이제 여행할 준비가 된 깨끗한 UTF-8 바이트

같은 모듈은 charmap 외에 japanese, korean, simplifiedchinese, traditionalchinese를 덮어 줍니다. 실용적인 규칙: 변환은 한 번, 레거시 바이트가 당신의 프로그램에 들어오는 경계에서. 그리고 그 이후 당신이 인코딩하는 모든 것은 UTF-8입니다. 두 번 변환하지 마세요. 추측하지 마세요. 그리고 현대의 소비자가 디코딩해 표시할 base64 문자열에 UTF-8이 아닌 페이로드가 슬쩍 들어가는 것을 절대 허용하지 마세요.

인코더 측정하기

인코더는 입력에 따른 분기도, 출력 문자열을 넘어서는 할당도 없는 테이블 조회이며, 숫자에 그것이 드러납니다. Go 1.26을 돌리는 최신 데스크톱 CPU에서, 500바이트 인코딩은 할당 두 번과 함께 대략 10분의 3 마이크로초가 걸리는데, 1.5기가바이트/초 수준의 속도입니다. 1메가바이트 데이터는 1밀리초를 훨씬 밑돌아 인코딩됩니다. 인코더가 당신이 느낄 수 있는 무언가가 될 일은 드물겠죠.

아는 가치가 있는 레버 하나는 자주 도는 루프의 할당 프로파일입니다. EncodeToString은 호출마다 출력 문자열을 할당하는데, 99%의 경우를 위한 올바른 교환입니다. 초당 수천 개의 청크를 자라는 버퍼로 인코딩한다면, Go 1.22에서 추가된 AppendEncode는 인코딩된 바이트를 당신이 재사용하는 슬라이스에 덧붙이고, 버퍼가 크기에 자란 뒤의 정상 상태에서는 할당을 전혀 하지 않습니다:

var out []byte
for _, chunk := range chunks {
  out = base64.StdEncoding.AppendEncode(out, chunk)
}

일회용에는 EncodeToString을, 타이트한 루프에는 AppendEncode를, 스트림과 파일에는 NewEncoder를 쓰세요. 어떤 것을 골라도, 인코더 주위의 네트워크나 디스크가 거의 언제나 느린 부분이라는 점을 기억하세요. 그래서 알파벳을 최적화하기 전에 전체 경로를 프로파일링하세요.

보안 고려 사항

이 기사에서 가장 중요한 보안 문장: base64는 암호화가 아닙니다. 그리고 "우리는 먼저 base64로 합니다"는 보안 조치가 아닙니다. 알파벳은 데이터를 텍스트에 안전하게 만들지, 비밀스럽게 만들지는 못하며, 브라우저의 개발자 도구가 있는 누구든 당신의 base64를 순식간에 읽을 수 있습니다. 기밀성은 TLS와 접근 제어에서 오고, base64의 일은 텍스트 전용 채널을 통해 바이트를 손상시키지 않고 건네는 것입니다. 그 두 일을 설계에서도 문서에서도 나누어 두세요. 그러면 고전적인 리뷰 코멘트, "비밀번호는 보호되어 있단다. 봐, base64잖아"를 피할 수 있습니다.

두 번째 고려 사항은 크기입니다. 이 포맷은 3분의 1 팽창하므로, 당신의 시스템의 모든 한계에는 base64 버전이 있습니다: 4메가바이트의 JSON을 받아 주는 API는 페이로드가 base64 필드일 때 원본 데이터 대략 3메가바이트를 받아 주며, 길이 예산이 있는 URL은 토큰이 URL-safe이고 패딩 없으면 원시 바이트로 더 짧아지고, 원시 값 크기로 정한 데이터베이스 컬럼은 인코딩된 값에는 너무 작을 수 있습니다. 저장하거나, 보내거나, 한계를 두기 전에 EncodedLen으로 셈을 하고, 팽창이 당신이 끝내는 문자열이 아니라, 당신이 시작하는 입력 위라는 점을 기억하세요.

셋째, 인코딩된 문자열이 어디서 관측될 수 있는지 생각해 보세요. base64 문자열은 로그에 친화적이고 화면에도 친화적인데, 그것은 기능이긴 하지만, 20메가바이트 첨부 파일이 26메가바이트 텍스트로 base64가 되어, 당신의 접근 로그가 매 요청마다 성실히 기록하기 시작하는 순간까지는요. 페이로드가 아니라, 길이, 처음 몇십 글자, 식별자를 로그에 남기세요. 그러면 로그는 읽기 좋고 디스크는 살아 있습니다. 마지막으로, URL에서는 URL-safe 변형을 선호하세요. 그러면 당신의 토큰은 어떤 문자도 퍼센트 이스케이프로 쓰지 않게 되는데, 그것은 URL을 부풀리고, 가끔 쿼리 문자열에 무엇이 속하는지에 대해 엄격한 생각을 가진 게이트웨이나 프록시를 넘어뜨리거든요.

재미있는 사실과 Go 특이점

코드 리뷰에서 옳은 편에 서고 싶은 순간들을 위한, 이 패키지에 특화된 사실 몇 가지:

  • EncodeToString은 일꾼이고, 이 패키지의 모든 인코딩 진입점(Encode, AppendEncode)처럼, 오류 반환이 없습니다 - Go에서는 인코딩이 실패할 수 없는데, 이것은 드물고 조용한 종류의 자유입니다: 어떤 바이트든 합법적인 입력이고, 잘못된 문자열을 얻는 유일한 길은 채널에 맞는 알파벳을 잘못 고르는 것.
  • EncodedLen은 순수 산술입니다. 패딩 있는 인코딩의 경우 (n+2)/3*4로, 할당 없이, 루프 없이 계산됩니다. 단 한 바이트도 인코딩하지 않고 버퍼와 쿼터 크기를 정할 수 있도록 존재합니다.
  • 내부 스트림 인코더는 3바이트 입력 버퍼와 1024바이트 출력 버퍼를 숨기고 있습니다. NewEncoder가 청크 단위로 쓰는 이유, 그리고 마지막 부분 블록이 Close를 통해서만 나올 수 있는 이유입니다. 버퍼가 바로 함정의 이유입니다.
  • 문서에는 Close를 부른 뒤 쓰면 오류라고 쓰여 있지만, 런타임은 그 문장을 강제하지 않습니다. 늦은 Write는 받아들여지고, 새 블록이 덧붙여지고, 중간에 패딩이 낀 문자열이 만들어집니다: 잘못된 base64, 정중하게 생성되며, 시야에는 오류 값이 하나도 없습니다.
  • Go의 인코더는 출력을 76자에서 래핑한 적이 결코 없습니다 - Python의, Java의, Node의 인코더처럼, Go의 인코더는 1메가바이트 데이터에 하나의 줄을 만들어 냅니다. 당신의 MIME 래핑 헬퍼는 개인 프로젝트인데, 이것은 또한 이메일 base64의 줄바꿈이 base64의 요구가 아니라 MIME의 관례임을 기억하는 좋은 방법이기도 합니다.
  • 2026년 8월 기준, pkg.go.dev의 공개 패키지 244,000개 이상이 encoding/base64를 import합니다. 당신의 Go 프로그램이 무엇이든, 당신이 알든 몰라도, 어딘가에서 base64를 하고 있을 가능성이 거의 확실합니다.
  • Go 1 호환성 약속은 이 패키지에 특별한 힘으로 적용됩니다: 2013년에 문자열을 인코딩한 프로그램의 출력은 오늘 Go 1.27에서 바이트 단위로 동일합니다. Go에서 base64 문자열은 사실상 불멸입니다.

계속 재연되는 실수들

Go 코드베이스에 계속해서 다시 부상하는 인코딩 실수들, 대략 도착하는 순서대로:

  • 스트림 인코더에서 Close를 잊는 것, 그리고 마지막 1-2바이트가 빠진 문자열을 출하는 것. 길이가 3의 배수인 입력을 쓰는 모든 테스트를 이 버그는 살아남는데, 그것이 바로 프로덕션에 도달하는 방법입니다.
  • 인코더보다 먼저 파일을 닫아, 마지막 부분 블록이 이미 사라진 파일 핸들로 플러시되는 것. 출력은 정확히 같은 양만큼 잘리며, 오류는 기이한 크기의 입력에서만 모습을 보입니다.
  • MIME이나 이메일 출력에서 76자 줄바꿈을 기대하고, Go가 긴 줄 하나를 건네면 당황하는 것. 래핑은 채널의 관례이며, Go에서는 그것을 적용하는 것이 당신의 코드의 일입니다.
  • URL 안에 표준 알파벳을 쓰고, 실은 퍼센트 인코딩 문제인 404와 400을 쫓아다니다가 오후 하나를 보내는 것. 문자열이 URL에 살게 된다면, URLEncoding이나 RawURLEncoding에서 시작하세요.
  • 소비자가 금하는 곳에 패딩을 내보내는 것: JWT 세그먼트, 어떤 토큰 포맷들, 몇몇 엄격한 파서. raw 변형은 정확히 이 때문에 존재하며, 반대편에서 오는 오류 메시지는 종종 당신의 문자열의 맨 끝의 바이트 오프셋입니다.
  • 비밀을 base64로 해 놓고 그것을 보호라고 부르는 것. 아닙니다. 헤더, 토큰, "암호화된" 필드: 누구나 반 초 만에 읽을 수 있습니다. 프로토콜이 원하는 것이 해시라면 TLS를 쓰고, 해시를 쓰세요. 그리고 base64에게는 그 하나의 정직한 일을 하게 두세요.
  • 한계를 정할 때 33%를 잊는 것: 바디 크기, 컬럼 폭, URL 예산, 쿼터 검사. 셈은 EncodedLen 호출 한 번이며, 그것을 건너뛴 대가는 프로덕션의 413이거나 잘린 컬럼입니다.
  • UTF-8이 아닌 텍스트를 인코딩하는 것. 그것은 깨짐을 충실히 운반합니다. 인코딩 전에 golang.org/x/text로 레거시 캐릭터셋을 정규화하세요. 그러면 base64 문자열이 깨끗한 바이트를 싣습니다.
  • 관성 때문이든, 재시도 루프에서든, 닫은 뒤 인코더에 쓰는 것. 오류는 발생하지 않고, 출력은 조용히 잘못된 것이 됩니다.
  • 반대편의 디코더가 Go의 것과 같은 정도로 관대하다고 가정하는 것. Go는 줄바꿈을 어디서든 건너뛰지만, 다른 언어와 파서는 공백과 줄 길이에 대해 더 엄격합니다. 그래서 Go 런타임의 기분이 아니라, 채널의 관례에 맞춰 주세요.

그 반대편

그것이 이야기의 인코딩 쪽 전부입니다: 실패할 수 없는 메서드 하나, 그 문자열들이 지날 채널에 맞춰진 인코더 넷, 필수 Close 하나를 가진 스트림 인코더, 그리고 당신의 데이터를 3분의 1로 팽창시키되 줄을 결코, 절대 래핑하지 않는 포맷. 목적지에서 알파벳을 고르고, 인코더를 닫고, 크기 셈을 미리 해 두면, Go의 base64는 2009년부터 그랬던 그 조용한, 제로 의존 유틸리티로 남습니다.

그리고 흐름이 역전될 때, 당신의 프로그램이 이 문자열 중 하나를 받아서 열어야 할 때, Go에서의 Base64 디코딩을 다룬 관련 기사가 그쪽을 자세히 다룹니다: 디코더의 관용 규칙, 입력이 어긋난 바이트를 알려 주는 오류 오프셋, 까다로운 프로토콜을 위한 엄격 모드, 그리고 반대 방향의 같은 네 인코딩.

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

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