블로그 목록
Fundamentals8분 읽기

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

컴퓨터 안에서 소리는 숫자이고, 숫자를 텍스트로 옮기는 규칙이 base64. MP3 파일 구조, 6비트씩 자르는 이유, 33% 용량 증가의 원리, Buffer와 Uint8Array 차이까지.

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.