블로그 목록
Fundamentals8분 읽기

Base64 & 바이너리 기초 — JSON이 MP3를 못 담는 이유

Google TTS는 MP3를 JSON 응답에 base64 문자열로 담아 보냅니다. 그냥 바이너리를 넣으면 안 되는 걸까요? JSON 파서가 ff f3 같은 바이트를 만나면 왜 깨지는지, MP3 파일 안에는 실제로 뭐가 들어 있는지, base64가 어떻게 비트를 6개씩 재배치하는지를 바이너리 기초부터 풀어냅니다. 용량이 33% 늘어나는 이유와 패딩 기호의 정체까지 따라가다 보면, base64가 압축도 암호화도 아닌 안전한 표현 방식이라는 점이 분명해집니다.

Base64BinaryMP3Buffer기초

컴퓨터 안에서 소리는 숫자이고, 숫자를 텍스트로 옮기는 규칙이 base64입니다. Google TTS가 MP3를 JSON으로 보내려면 왜 base64가 필요한지, MP3 파일 안에는 실제로 뭐가 들어있는지, 바이너리 기초부터 정리합니다.


바이너리란 — 컴퓨터가 다루는 진짜 데이터

모든 파일(이미지, 음악, 영상)은 바이트(byte) 단위의 숫자 나열입니다.

MP3 파일을 열어보면:
ff f3 10 c4 00 1c a5 ...

각각이 1바이트 = 8비트 = 0~255 사이의 숫자
ff = 255, f3 = 243, 10 = 16, c4 = 196 ...

사람 눈에는 의미 없는 숫자지만
브라우저의 MP3 디코더는 이 숫자들을 읽어서
스피커로 소리를 재생합니다

MP3 파일 안에 뭐가 들어있지?

MP3 파일은 프레임(frame) 단위로 소리를 저장합니다. 각 프레임은 약 26ms 분량의 오디오입니다.

┌──────────────────────────────────────────┐
│           MP3 파일 전체 구조              │
├──────────────────────────────────────────┤
│  [ID3 태그]  ← 메타데이터 (선택사항)      │
│  - 곡 제목, 아티스트, 앨범 이름          │
│  - 커버 이미지 등                        │
├──────────────────────────────────────────┤
│  [프레임 1]  ← 실제 소리 데이터          │
│  - 헤더: ff f3 (프레임 시작 마커)        │
│  - 헤더: 비트레이트 128kbps              │
│  - 헤더: 샘플레이트 44100Hz              │
│  - 데이터: 약 26ms 분량의 오디오 파형    │
├──────────────────────────────────────────┤
│  [프레임 2]  ← 그 다음 26ms             │
├──────────────────────────────────────────┤
│  [프레임 3]                              │
│  ...                                     │
│  [프레임 N]  ← 마지막 소리              │
└──────────────────────────────────────────┘

3초짜리 MP3 = 약 115개 프레임

프레임 안의 "오디오 파형 데이터"가 뭔지

소리는 공기의 진동입니다. 마이크는 이 진동을 전기 신호(아날로그)로 바꾸고, 컴퓨터는 이 신호를 숫자(디지털)로 변환합니다.

마이크로 "안녕" 녹음하면:

시간  0ms  → 공기압 0.0   → 숫자 0
시간  1ms  → 공기압 +0.8  → 숫자 +26214
시간  2ms  → 공기압 +1.0  → 숫자 +32767
시간  3ms  → 공기압 +0.3  → 숫자 +9830
시간  4ms  → 공기압 -0.5  → 숫자 -16384
...

이 숫자들의 나열이 원본(PCM) 오디오
MP3는 여기서 사람 귀가 못 듣는 주파수를 제거해서
용량을 1/10로 줄인 것

브라우저는 어떻게 MP3를 재생하나?

브라우저의 Audio 디코더가:
1. ff f3 보고 "MP3 프레임이다!" 인식
2. 헤더 읽어서 비트레이트/샘플레이트 파악
3. 압축된 데이터를 원래 파형 숫자로 복원
4. 파형 숫자를 스피커 전기 신호로 변환 → 소리!

마치 .jpg 파일이 ff d8 ff 로 시작하면
브라우저가 "JPEG 이미지다!" 아는 것과 같은 원리

JSON이 바이너리를 못 담는 이유

JSON은 텍스트 포맷입니다. UTF-8로 인코딩된 문자만 담을 수 있습니다.

JSON이 허용하는 값:
  "name": "Frank"          ✅ 문자열
  "age": 25                ✅ 숫자
  "active": true           ✅ boolean
  "data": [1, 2, 3]        ✅ 배열

JSON이 못 담는 것:
  "file": <ff f3 10 c4>    ❌ 바이너리 바이트

왜 못 담느냐? JSON 파서는 UTF-8 텍스트를 기대합니다. 그런데 ff, f3 같은 바이트 값은 UTF-8에서 유효하지 않은 문자이거나 제어문자로 해석되어 파싱이 깨집니다.

JSON 파서가 ff를 만나면:
"이건... 뭐지? UTF-8 문자가 아닌데?"
→ Parse Error!

그래서 바이너리를 안전한 문자로 변환하는 규칙이 필요
→ 이것이 base64

Base64 — 바이너리를 텍스트로 바꾸는 규칙

핵심 아이디어

base64가 사용하는 문자는 딱 64가지입니다:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z  (26개)
a b c d e f g h i j k l m n o p q r s t u v w x y z  (26개)
0 1 2 3 4 5 6 7 8 9                                    (10개)
+ /                                                     (2개)
                                              총 = 64개

전부 ASCII 안전 문자입니다. JSON에 넣어도, URL에 넣어도, 어디서든 깨지지 않습니다.

왜 64개? 왜 6비트?

순서가 중요합니다. 6비트라서 base64가 만들어진 게 아닙니다. 반대입니다.

설계 순서:

1단계: "어떤 문자가 안전한가?" 부터 시작
   → 어떤 시스템(이메일, URL, JSON)에서도 깨지지 않는 문자를 골라야 함
   → A-Z(26) + a-z(26) + 0-9(10) + 기호 2개(+, /) = 64개
   → 이것보다 더 넣으면? !, @, # 등은 시스템마다 특수 의미라 위험

2단계: "64개 문자니까 몇 비트가 필요하지?"
   → 64가지를 구분하려면 6비트 필요 (2^6 = 64)

3단계: "그럼 바이너리를 6비트씩 잘라서 문자로 매핑하자!"

핵심: 문자 집합(64개)이 먼저 → 비트 수(6)는 그 결과

왜 6비트면 64가지를 구분할 수 있나?

비트(bit)는 0 또는 1, 두 가지 상태를 표현합니다.
비트를 하나 추가할 때마다 경우의 수가 2배:

1비트: 0, 1                     → 2가지
2비트: 00, 01, 10, 11           → 4가지
3비트: 000, 001, 010, 011,
       100, 101, 110, 111       → 8가지
4비트:                           → 16가지
5비트:                           → 32가지
6비트: 000000 ~ 111111          → 64가지 ← 딱 맞음!

N비트 = 2^N가지

6비트 조합과 base64 문자의 1:1 매핑

000000 → A    (0번째 문자)
000001 → B    (1번째 문자)
000010 → C    (2번째 문자)
...
001100 → M    (12번째 문자)
...
010000 → Q    (16번째 문자)
...
111110 → +    (62번째 문자)
111111 → /    (63번째 문자)

64개의 6비트 조합 ↔ 64개의 문자
빈 자리 없이 전부 1:1 대응!

그래서 원본 바이너리를 6비트씩 잘라서 각 조각을 문자 1개로 매핑하는 것입니다.

핵심 개념 — 바이트 단위가 아니라 비트를 재배치한다

흔히 "1바이트 → 1문자"로 변환한다고 생각하기 쉽지만, base64는 그렇게 동작하지 않습니다.

❌ 틀린 생각: 바이트 단위 변환
  1바이트(ff) → 1문자(?)
  1바이트(f3) → 1문자(?)
  1바이트(10) → 1문자(?)
  → 3바이트 → 3문자?  ← 이러면 33% 증가가 안 됨!

✅ 실제: 비트를 이어붙인 뒤 다시 자르기
  3바이트의 비트를 전부 이어붙임 (24비트)
  → 6비트씩 다시 자름 (4조각)
  → 각 조각을 문자 1개로 변환
  → 3바이트 → 4문자
시각적으로 보면:

원본 (8비트 단위):
┌────────┐┌────────┐┌────────┐
│11111111││11110011││00010000│  ← 8비트 × 3 = 24비트
└────────┘└────────┘└────────┘

        ↓ 경계를 무시하고 비트를 이어붙임

하나의 비트열:
111111111111001100010000          ← 24비트 그대로

        ↓ 6비트 단위로 다시 자름

base64 (6비트 단위):
┌──────┐┌──────┐┌──────┐┌──────┐
│111111││111111││001100││010000│  ← 6비트 × 4 = 24비트
└──────┘└──────┘└──────┘└──────┘
   /        /       M       Q

정보량(24비트)은 완전히 동일!
묶는 기준만 8비트 → 6비트로 바뀐 것

그런데 왜 용량이 커지나? — 6비트인데 1바이트로 저장되니까

base64 변환 결과: / / M Q  (4문자)

각 문자는 6비트의 정보를 담고 있지만,
컴퓨터는 문자를 최소 1바이트(8비트)로 저장합니다.

/  = ASCII 47  = 0010 1111  ← 1바이트로 저장
/  = ASCII 47  = 0010 1111  ← 1바이트로 저장
M  = ASCII 77  = 0100 1101  ← 1바이트로 저장
Q  = ASCII 81  = 0101 0001  ← 1바이트로 저장

6비트의 정보를 8비트 공간에 저장
→ 문자당 2비트씩 낭비!

전체로 보면:
원본:  24비트의 정보를 24비트(3바이트)에 저장
변환:  24비트의 정보를 32비트(4바이트)에 저장
                          ↑
                    8비트(1바이트) 낭비 = 33% 증가

정보량은 같은데 "안전한 문자"로 표현하는 대가로
저장 효율이 떨어지는 것입니다.

3 bytes (24bit)
→ 6bit × 4조각
→ 4 characters
→ 4 bytes (저장 시) ← 여기서 33% 증가!

변환 과정 — 단계별로

ff f3 10 (3바이트)을 base64로 변환해보겠습니다.

먼저: ff f3 10이 왜 3바이트인가?

16진수 한 자리 = 4비트 (0~15, 즉 0~f)
16진수 두 자리 = 8비트 = 1바이트

ff = 1바이트 (8비트)
f3 = 1바이트 (8비트)
10 = 1바이트 (8비트)
→ 합계 3바이트 (24비트)
─── STEP 1: 16진수 → 2진수 ───────────────────

ff  =  1111 1111     (255)
f3  =  1111 0011     (243)
10  =  0001 0000     (16)

연결하면:
11111111 11110011 00010000
(24비트 = 3바이트)
─── STEP 2: 6비트씩 자르기 ───────────────────

기존:  11111111 | 11110011 | 00010000   ← 8비트씩 3개
변환:  111111 | 111111 | 001100 | 010000 ← 6비트씩 4개

같은 24비트를 나누는 단위만 바꾼 것!
정보는 1비트도 바뀌지 않음
─── STEP 3: 각 6비트를 10진수로 ──────────────

111111 = 32+16+8+4+2+1 = 63
111111 = 63
001100 = 8+4 = 12
010000 = 16
─── STEP 4: base64 표에서 문자 찾기 ──────────

base64 인덱스 표:
값  0  1  2  3  4 ...12 13 ...16 17 ...62 63
문자 A  B  C  D  E ... M  N ... Q  R ... +  /

63 → /
63 → /
12 → M
16 → Q

결과: "//MQ"

3바이트 ff f3 10이 4글자 //MQ로 변환되었습니다!

왜 4글자가 나오나?

원본 3바이트 = 24비트
24비트 ÷ 6비트 = 4조각
각 조각이 base64 인덱스 표에서 문자 1개로 매핑됨

인덱스 표 (64가지 문자에 0~63 번호 부여):
번호  0  1  2  3 ...12 ...16 ...62 63
문자  A  B  C  D ... M ... Q ... +  /

63번 → /
63번 → /
12번 → M
16번 → Q

이 표는 전 세계 어디서든 동일 (RFC 4648 표준)
인코더와 디코더가 같은 표를 쓰므로 복원이 보장됨

앞에서 설명한 것처럼, 6비트 정보가 1바이트(8비트) 문자로 저장되므로 문자당 2비트씩 낭비 → 33% 증가입니다.

실제 크기 비교:
MP3 파일:       100,000 바이트
base64 문자열:  133,333 바이트 ← 33% 더 커짐

패딩(=) — 3으로 안 나눠지면?

base64는 항상 3바이트(24비트) 단위로 변환합니다.

왜 3바이트 단위인가?

6비트씩 자르려면, 6과 8의 최소공배수만큼 모아야 깔끔하게 나눠짐

6과 8의 최소공배수 = 24비트 = 3바이트

3바이트(24비트) ÷ 6비트 = 4글자  ← 딱 떨어짐!
1바이트(8비트)  ÷ 6비트 = 1.33... ← 안 떨어짐
2바이트(16비트) ÷ 6비트 = 2.66... ← 안 떨어짐

그래서 3바이트씩 묶어서 4글자로 변환하는 것이
base64의 기본 단위

그런데 원본이 딱 3의 배수가 아니면 어떻게 할까요?

원본이 1바이트일 때: (예: 0x41 = 'A')

41 = 0100 0001

6비트씩 자르면:
010000 | 01???? | ?????? | ??????
  ↑        ↑        ↑        ↑
  Q     나머지2비트  비어있음  비어있음
        뒤에 0 채움

010000 | 010000 | (없음) | (없음)
  Q        Q        =        =

결과: "QQ=="
          ↑↑
         = 패딩 문자!
원본이 2바이트일 때: (예: 0x41 0x42)

0100 0001  0100 0010

6비트씩 자르면:
010000 | 010100 | 0010?? | ??????
  Q        U      나머지   비어있음
                  뒤에 0 채움

010000 | 010100 | 001000 | (없음)
  Q        U        I        =

결과: "QUI="
           ↑
         = 1개
정리:
원본 바이트 수    패딩    결과 길이
3의 배수          없음    4글자씩 딱 맞음
3n + 1            ==      마지막에 = 2개
3n + 2            =       마지막에 = 1개

실제 예시로 확인해보겠습니다.

"Hello" (5바이트)를 base64로 변환:

H        e        l        l        o
48       65       6C       6C       6F       (16진수)
01001000 01100101 01101100 01101100 01101111 (2진수)

3바이트씩 끊기:
[01001000 01100101 01101100] [01101100 01101111 ????????]
 ↑ 처음 3바이트: 딱 맞음     ↑ 나머지 2바이트: 1바이트 부족!

처음 3바이트 → 6비트씩 4조각:
010010 000110 010101 101100 → S G V s

나머지 2바이트 (16비트) → 뒤에 0을 채워서 18비트로:
011011 000110 111100 (없음) → b G 8 =
                       ↑
                  데이터가 없으므로 = 로 표시

결과: "SGVsbG8="
               ↑ = 1개 = "마지막 자리는 빈칸이야"
"Hello!" (6바이트 = 3의 배수):

3바이트 + 3바이트 → 딱 맞음, 패딩 불필요
결과: "SGVsbG8h"  ← = 없음

"Hello!!" (7바이트 = 3×2 + 1):

3바이트 + 3바이트 + 1바이트 → 2바이트 부족!
마지막 1바이트(8비트) → 0 채워서 12비트 → 2조각 + 빈칸 2개
결과: "SGVsbG8hIQ=="
                 ↑↑ = 2개 = "마지막 2자리는 빈칸이야"
패딩의 역할:

= 가 없으면 → 원본이 3의 배수, 모든 비트가 실제 데이터
= 가 1개면 → 원본이 3n+2, 마지막 6비트 중 4비트만 진짜
= 가 2개면 → 원본이 3n+1, 마지막 6비트 중 2비트만 진짜

디코더는 =를 보고 "여기는 0으로 채운 빈 자리"라고 판단
→ 채운 0을 버리고 정확한 원본 크기 복원

Google TTS가 base64를 쓰는 이유

Google TTS API의 응답을 보면:

{
  "audioContent": "//NExAARi3ACMAAATgkkkk..."
}

이 JSON 응답 안에 MP3 파일 전체가 base64 문자열로 들어있습니다.

왜 이렇게 하나?

방법 1: JSON으로 응답 (Google이 선택한 방식)
  → MP3를 base64로 인코딩해서 JSON 필드에 넣음
  → 메타데이터(언어, 오디오 설정)와 함께 한 번에 전달
  → 클라이언트가 JSON 파싱 한 번으로 모두 처리

방법 2: 바이너리로 직접 응답
  → HTTP 응답 body에 MP3 바이너리를 직접 넣어서 반환
  → Content-Type: audio/mpeg 헤더로 "이건 MP3야" 알림
  → base64 변환 없이 원본 바이너리 그대로 전송

잠깐 — "JSON에 바이너리 못 넣는다"면서 바이너리 직접 응답은 되나요?

핵심 구분:

JSON = "데이터 포맷" (텍스트 파일의 구조 규칙)
  → { "key": "value" } 형태의 텍스트
  → 텍스트 안에 ff f3 같은 바이트를 넣으면 파싱이 깨짐 ❌

HTTP = "전송 프로토콜" (데이터를 실어 나르는 트럭)
  → body에 어떤 바이트든 그대로 실을 수 있음 ✅
  → 헤더로 "이 화물이 뭔지" 알려줌 (Content-Type)

비유:
  JSON = 편지봉투 (글자만 넣을 수 있음, 돌멩이 못 넣음)
  HTTP = 택배상자 (뭐든 넣을 수 있음, 송장에 내용물 표시)

실제로 우리 route.ts가 이 방식을 사용합니다:

// Google TTS에서 받은 MP3 바이너리를 클라이언트에 직접 반환
return new NextResponse(new Uint8Array(audioBuffer), {
  headers: {
    'Content-Type': 'audio/mpeg',              // "이건 MP3야"
    'Content-Length': audioBuffer.length.toString(),
    'Cache-Control': 'private, max-age=86400', // 24시간 브라우저 캐시
  },
})

// HTTP 응답이 이렇게 나감:
// HTTP/1.1 200 OK
// Content-Type: audio/mpeg    ← JSON이 아님!
// Content-Length: 48000
//
// ff f3 10 c4 00 1c ...       ← MP3 바이너리 그대로
왜 우리는 방법 2(바이너리 직접)를 선택했나?

Google TTS API → 우리 서버 → 클라이언트

Google이 우리에게 줄 때: JSON + base64 (방법 1)
  → Google은 범용 API라서 JSON 일관성이 중요
  → 에러도 JSON, 성공도 JSON → 클라이언트 코드가 단순

우리가 클라이언트에 줄 때: 바이너리 직접 (방법 2)
  → 우리 API는 TTS 전용이라 "성공 = MP3, 실패 = JSON" 구분 가능
  → base64 변환 불필요 → 33% 용량 절약
  → 브라우저가 바로 재생 가능 (디코딩 단계 생략)
  → new Audio(url) 로 바로 재생됨

정리:
  범용 API (Google) → JSON + base64 (호환성 우선)
  전용 API (우리)   → 바이너리 직접 (성능 우선)
Google은 API 일관성을 위해 방법 1을 선택
모든 응답이 JSON이므로 에러 처리가 통일됨

Node.js에서의 변환 — Buffer.from()

// Google TTS 응답에서 base64 문자열 추출
const { audioContent } = await res.json()
// audioContent = "//NExAARi3ACMAAA..."
// ↑ 수만 글자의 문자열 (MP3 전체가 들어있음)

// base64 → 바이너리(Buffer)로 디코딩
const buffer = Buffer.from(audioContent, 'base64')
// buffer = <Buffer ff f3 10 c4 00 1c ...>
// ↑ 실제 MP3 바이너리 데이터

// Buffer: Node.js 전용 바이너리 타입
// 메모리에 있는 바이트 배열
// 파일 저장, 네트워크 전송, 스트리밍 등에 사용
변환 흐름 정리:

"안녕하세요" (텍스트)
    ↓ Google TTS API
"//NExAARi..." (base64 문자열 — JSON 안에)
    ↓ Buffer.from(_, 'base64')
<Buffer ff f3 10 c4 ...> (바이너리 — 메모리에)
    ↓ 브라우저로 전송 or R2 업로드
🔊 소리 재생!

Buffer vs Uint8Array — 왜 두 가지?

// 클라이언트에 MP3를 응답할 때
return new NextResponse(new Uint8Array(audioBuffer), {
  headers: { 'Content-Type': 'audio/mpeg' }
})

BufferUint8Array로 변환하나?

Buffer     = Node.js 전용 바이너리 타입 (서버에서만 존재)
Uint8Array = 웹 표준 바이너리 타입 (브라우저 + 서버 공통)

Next.js의 Response는 웹 표준(Web API)을 따르므로
Uint8Array를 기대합니다.

Buffer는 Uint8Array를 상속하므로
new Uint8Array(buffer) 로 간단히 변환 가능
내용물(바이트)은 완전히 동일합니다.

Base64는 "압축"이 아니다

흔한 오해: "base64로 인코딩하면 데이터가 압축되나요?"

아닙니다. 오히려 33% 커집니다!

압축 (ZIP, MP3): 정보를 줄여서 크기를 작게
인코딩 (base64): 정보는 그대로, 표현 방식만 변환

base64의 목적은 크기를 줄이는 게 아니라:
1. 전송 안정성 — 바이너리가 깨지는 환경에서 안전하게 전달
2. 프로토콜 호환 — JSON, XML 등 텍스트 포맷 안에 바이너리 삽입

어디에 쓰이나?

1. API payload (이미지, 음성)
   Google TTS → { "audioContent": "//NExA..." }
   이미지 업로드 → { "image": "iVBORw0KGgo..." }

2. JWT (JSON Web Token)
   로그인 토큰 = header.payload.signature
   각 부분이 base64url로 인코딩됨 (+ 대신 -, / 대신 _)
   eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZnJhbmsifQ.abc123
   ↑ base64url            ↑ base64url

3. 이메일 첨부파일 (MIME)
   이메일은 원래 7비트 ASCII만 지원 (1970년대 설계)
   이미지, PDF를 첨부하려면 base64로 변환해야 함
   Content-Transfer-Encoding: base64

4. Data URI (HTML/CSS에 이미지 인라인)
   <img src="data:image/png;base64,iVBORw0KGgo..." />
   별도 HTTP 요청 없이 HTML 안에 이미지 삽입

정리

소리 → 공기 진동 → 숫자(PCM) → 압축(MP3) → 바이너리

바이너리를 JSON으로 보내야 할 때:
바이너리 → base64 인코딩 → 안전한 문자열

받는 쪽에서:
base64 문자열 → Buffer.from(_, 'base64') → 바이너리 복원

핵심:
- base64는 암호화도 압축도 아님 — 인코딩(표현 방식 변환)
- 정보 손실 없음 — 완벽하게 원본 복원 가능
- 33% 용량 증가 — 안전한 전송을 위한 비용
- 6비트씩 자르는 이유 — 64가지 문자 = 2^6
- 3바이트 단위인 이유 — 6과 8의 최소공배수 = 24비트
- = 패딩 — 3의 배수가 아닐 때 빈 자리 표시

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.