컴퓨터 안에서 소리는 숫자이고, 숫자를 텍스트로 옮기는 규칙이 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 파서는 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 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의 기본 단위
원본이 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개 = "마지막 자리는 빈칸이야"
패딩의 역할:
= 가 없으면 → 원본이 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 바이너리를 클라이언트에 직접 반환returnnewNextResponse(newUint8Array(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 = 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의 배수가 아닐 때 빈 자리 표시