Search

06-1_직렬화

직렬화: 서버와 클라이언트 사이의 언어

3-4장에서 React 컴포넌트를 renderToString으로 HTML로 변환했을 때, onClick 핸들러가 사라지는 현상을 봤습니다. 버튼은 있는데 클릭해도 아무 일도 일어나지 않죠. 이 문제를 해결하기 위해 Hydration을 도입했지만, 왜 애초에 이런 일이 발생하는지는 깊게 다루지 않았습니다.
function App() { const handleClick = () => alert('클릭!'); return <button onClick={handleClick}>클릭해보세요</button>; }
TypeScript
복사
<!-- 브라우저가 받은 HTML --> <button>클릭해보세요</button> <!-- onClick이 없다 -->
HTML
복사
이 현상을 이해하려면 “직렬화”라는 개념을 알아야 합니다. 그리고 이 개념은 SSR뿐 아니라 RSC, Server Actions, 심지어 API 설계까지 관통하는 근본적인 제약입니다.

HTTP는 텍스트 프로토콜이다

브라우저 DevTools의 Network 탭을 열고 요청을 하나 클릭해보면, 서버가 보낸 응답을 “텍스트”로 볼 수 있습니다. 이게 당연해 보이지만, 사실 핵심적인 제약입니다.
HTTP 응답의 실제 모습:
HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 45 <html><body>Hello World</body></html>
Plain Text
복사
모든 것이 텍스트입니다. 상태 코드도, 헤더도, 본문도. 서버가 클라이언트에게 뭔가를 보내려면 반드시 텍스트(또는 바이트)로 변환해야 합니다.
JavaScript 객체를 그대로 보낼 수 없습니다:
// 이런 건 불가능 { name: "Dan", onClick: () => alert('hi') }
JavaScript
복사
이걸 HTTP로 보내려면 텍스트로 바꿔야 합니다. 이 과정이 직렬화(Serialization)입니다.
그런데 이게 왜 중요할까요? Python으로 만든 서버에서 딕셔너리를 JavaScript 클라이언트로 보내는 상황을 생각해봅시다. Python의 딕셔너리와 JavaScript의 객체는 비슷하게 생겼지만 다른 언어의 다른 자료형입니다.
# Python 서버 user = {"name": "Dan", "age": 30}
Python
복사
// JavaScript 클라이언트 const user = { name: "Dan", age: 30 };
JavaScript
복사
이 둘이 서로 통신할 수 있는 이유는 JSON이라는 공통 언어가 있기 때문입니다. Python은 딕셔너리를 JSON 문자열로 직렬화하고, JavaScript는 그 JSON 문자열을 객체로 역직렬화합니다.
Python 딕셔너리 → JSON 문자열 → JavaScript 객체 {"name": "Dan"} → '{"name":"Dan"}' → { name: "Dan" }
Plain Text
복사
만약 이런 표준화된 형식이 없었다면? JavaScript는 Python의 딕셔너리가 메모리에 어떻게 저장되는지, Java의 HashMap은 어떤 구조인지, Go의 map은 또 어떻게 다른지를 전부 알아야 했을 겁니다. 서버도 마찬가지로 “이 요청이 브라우저에서 온 건지, iOS 앱에서 온 건지, 다른 서버에서 온 건지”에 따라 다르게 처리해야 했겠죠.
JSON(그리고 HTTP)이라는 공통 규약 덕분에 “상대방의 구현 세부사항을 몰라도 통신할 수 있다”는 게 핵심입니다. 이건 나중에 다룰 RSC(React Server Components)에서도 똑같이 적용됩니다. RSC Payload라는 직렬화 형식 덕분에 서버에서 만든 컴포넌트 트리를 클라이언트가 이해할 수 있는 거죠.

네트워크는 바이트만 전송한다

한 단계 더 깊이 들어가면, HTTP 텍스트조차도 실제로는 바이트로 변환되어 전송됩니다. 네트워크는 0과 1의 연속만 전송할 수 있기 때문입니다.
[서버] [네트워크] [클라이언트] { name: "Dan" } → 01101000... → ???
Plain Text
복사
그래서 데이터를 보내려면 먼저 바이트로 변환해야 합니다. 이 과정을 직렬화(Serialization)라고 합니다. 그리고 받는 쪽에서는 바이트를 다시 원래 데이터로 복원해야 하는데, 이걸 역직렬화(Deserialization)라고 합니다.
[서버] [클라이언트] { name: "Dan" } { name: "Dan" } ↓ ↑ 직렬화 역직렬화 ↓ ↑ '{"name":"Dan"}' ──── 네트워크 ──── '{"name":"Dan"}'
Plain Text
복사
웹에서 가장 흔히 쓰이는 직렬화 형식이 JSON입니다. JSON.stringify로 객체를 문자열로 변환하고, JSON.parse로 다시 객체로 복원합니다.

JSON.stringify의 동작

JSON.stringify가 어떻게 동작하는지 살펴보겠습니다.
// 기본 타입들 JSON.stringify("hello") // '"hello"' JSON.stringify(42) // '42' JSON.stringify(true) // 'true' JSON.stringify(null) // 'null' // 객체와 배열 JSON.stringify({ a: 1 }) // '{"a":1}' JSON.stringify([1, 2, 3]) // '[1,2,3]' // 중첩 구조 JSON.stringify({ user: { name: "Dan" }, tags: ["react", "rsc"] }) // '{"user":{"name":"Dan"},"tags":["react","rsc"]}'
JavaScript
복사
여기까지는 문제없습니다. 하지만 이제 문제가 되는 타입들을 보겠습니다.
// Date - 문자열로 변환됨 const date = new Date('2025-01-01'); JSON.stringify(date) // '"2025-01-01T00:00:00.000Z"' JSON.parse(JSON.stringify(date)) // "2025-01-01T00:00:00.000Z" (문자열!) // 함수 - 사라짐! JSON.stringify(() => {}) // undefined JSON.stringify({ onClick: () => {} }) // '{}' // Map, Set - 빈 객체로 변환 JSON.stringify(new Map([['a', 1]])) // '{}' JSON.stringify(new Set([1, 2, 3])) // '{}' // undefined - 사라짐 JSON.stringify({ a: undefined }) // '{}' // 순환 참조 - 에러 const obj = {}; obj.self = obj; JSON.stringify(obj) // TypeError: Converting circular structure to JSON
JavaScript
복사
정리하면 이렇습니다:
타입
JSON.stringify 결과
string, number, boolean, null
그대로
객체, 배열
그대로
Date
문자열로 변환 (타입 손실)
undefined
사라짐
함수
사라짐
Map, Set
빈 객체 {}
Symbol
사라짐
BigInt
에러
순환 참조
에러

왜 함수는 직렬화할 수 없는가

함수가 사라지는 이유를 이해하려면 함수가 실제로 무엇인지 생각해봐야 합니다.
함수는 단순히 코드 텍스트가 아닙니다. 함수는 코드 + 클로저 + 실행 컨텍스트의 조합입니다.

클로저란 무엇인가

클로저(Closure)는 함수가 자신이 정의된 환경의 변수들을 “기억”하는 것입니다.
쉽게 말해: 함수가 태어난 곳의 변수들을 계속 가지고 다니는 것.
const secret = "API_KEY_123"; const multiplier = 2; function calculate(x) { console.log(secret); // 클로저로 캡처된 외부 변수 return x * multiplier; // 클로저로 캡처된 외부 변수 } calculate(5); // 10, "API_KEY_123" 출력
JavaScript
복사
이 코드에서 calculate 함수는 secretmultiplier클로저로 캡처했습니다. 함수 코드 자체에는 이 변수들의 값이 없지만, 함수가 정의될 때 “이 변수들을 기억해둬야지”라고 저장해둔 것입니다.

왜 클로저가 직렬화 문제를 일으키는가

calculate 함수를 문자열로 변환한다고 생각해봅시다. 코드 텍스트 자체는 변환할 수 있습니다:
calculate.toString() // "function calculate(x) {\n console.log(secret);\n return x * multiplier;\n}"
JavaScript
복사
문제가 보이시나요? 코드 텍스트에는 secretmultiplier이 없습니다. 그냥 변수 이름만 있을 뿐입니다.
하지만 이걸 다른 환경에서 실행하면?
// 다른 환경 (클라이언트) const fnText = "function calculate(x) { console.log(secret); return x * multiplier; }"; const fn = eval(fnText); fn(5); // ReferenceError: secret is not defined
JavaScript
복사
클라이언트 환경에는 secretmultiplier가 없습니다. 서버에만 있던 변수니까요.
핵심 문제: 클로저로 캡처된 변수들은 함수 코드 텍스트에 포함되지 않습니다.
서버: calculate 함수 + secret="API_KEY_123" + multiplier=2 (모두 있음)
전송: 함수 코드 텍스트만 (클로저 변수 없음)
클라이언트: 함수 코드만 (클로저 변수 없음 → 에러!)
secretmultiplier는 함수가 정의된 시점에 캡처된 클로저 변수입니다. 이 변수들의 값을 함께 전송하지 않으면 함수는 제대로 동작하지 않습니다.
더 복잡한 경우를 봅시다:
const db = new Database('postgres://...'); function getUser(id) { return db.query(`SELECT * FROM users WHERE id =${id}`); }
JavaScript
복사
이 함수를 클라이언트로 전송하려면 db 객체도 함께 보내야 합니다. 하지만 db는 데이터베이스 연결을 포함하고 있고, 네트워크 소켓, 인증 정보 등을 가지고 있습니다. 이런 것들은 직렬화가 불가능합니다.
이것이 “이벤트 핸들러는 클라이언트에 있어야 한다”의 근본적인 이유입니다.

React에서의 직렬화 문제

다시 처음 예제로 돌아가봅시다.
function App() { const handleClick = () => alert('클릭!'); return ( <button onClick={handleClick}> 클릭해보세요 </button> ); }
TypeScript
복사
renderToString(<App />)이 하는 일은 React 컴포넌트를 HTML 문자열로 변환하는 것입니다. 이 과정에서:
1.
<button> 태그는 HTML로 변환 가능
2.
"클릭해보세요" 텍스트는 그대로 포함 가능
3.
onClick={handleClick}은… 함수이므로 변환 불가
그래서 onClick이 사라지는 겁니다. HTML 문자열에 JavaScript 함수를 포함할 방법이 없으니까요.
<!-- 서버가 생성한 HTML --> <button>클릭해보세요</button>
HTML
복사
이 문제를 해결하는 방법이 Hydration입니다. 서버에서 HTML을 보내고, 클라이언트에서 JavaScript를 다시 실행해서 이벤트 핸들러를 붙이는 거죠. 이건 다음 장에서 다룹니다.

JSON의 한계를 넘어서: 커스텀 직렬화

JSON의 한계 중 일부는 커스텀 처리로 해결할 수 있습니다. 예를 들어 Date 객체:
const data = { createdAt: new Date('2025-01-01') }; // 직렬화: Date → ISO 문자열 const json = JSON.stringify(data); // '{"createdAt":"2025-01-01T00:00:00.000Z"}' // 역직렬화: ISO 문자열 → Date (수동으로) const parsed = JSON.parse(json); parsed.createdAt = new Date(parsed.createdAt);
JavaScript
복사
JSON.parse의 reviver 함수를 사용하면 자동화할 수 있습니다:
function dateReviver(key, value) { // ISO 8601 날짜 형식인지 확인 if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { return new Date(value); } return value; } const parsed = JSON.parse(json, dateReviver); console.log(parsed.createdAt instanceof Date); // true
JavaScript
복사
하지만 이 방식은 제약이 많습니다:
1.
타입 정보가 없어서 어떤 문자열이 날짜인지 추측해야 함
2.
Map, Set, Promise 같은 타입은 여전히 처리 불가
3.
순환 참조 처리 불가
4.
스트리밍 불가 (전체 JSON이 완성되어야 파싱 가능)

turbo-stream: React Router의 선택

React Router(구 Remix)는 이 문제를 해결하기 위해 turbo-stream이라는 라이브러리를 사용합니다.
turbo-stream은 JSON이 지원하지 않는 타입들을 직렬화할 수 있습니다:
import { encode, decode } from 'turbo-stream'; const data = { date: new Date(), map: new Map([['key', 'value']]), set: new Set([1, 2, 3]), promise: Promise.resolve(42), }; // 인코딩 const stream = encode(data); // 디코딩 (클라이언트에서) const decoded = await decode(stream); console.log(decoded.date instanceof Date); // true! console.log(decoded.map instanceof Map); // true! console.log(await decoded.promise); // 42
JavaScript
복사
가장 중요한 특징은 Promise를 스트리밍할 수 있다는 점입니다.
[서버] [클라이언트] { user: "Dan", posts: Promise<pending> } ↓ encode (연결 유지) ─────────────────────────→ { user: "Dan", posts: Promise<pending> } ↓ posts가 해결됨 ↓ 후속 청크 전송 ─────────────────────────→ posts Promise 해결됨!
Plain Text
복사
React Router에서는 이게 자동으로 적용됩니다:
// loader에서 Promise 반환 가능 export async function loader() { return { user: await fetchUser(), // 빠름 - 기다림 posts: fetchPosts(), // 느림 - Promise로 전달 createdAt: new Date(), // Date 객체 그대로 전달 }; } // 컴포넌트에서 타입 그대로 사용 function Component() { const { user, posts, createdAt } = useLoaderData(); console.log(createdAt instanceof Date); // true! return ( <Suspense fallback={<Loading />}> <Await resolve={posts}> {(data) => <PostList posts={data} />} </Await> </Suspense> ); }
JavaScript
복사
하지만 turbo-stream도 함수는 직렬화할 수 없습니다. 이건 근본적인 한계입니다.

RSC Payload: React의 선택

React Server Components는 다른 접근을 취합니다. 컴포넌트 자체를 직렬화하는 대신, Client Component를 “참조”로 전송합니다.
// Server Component async function BlogPost({ id }) { const post = await db.posts.get(id); return ( <article> <h1>{post.title}</h1> <LikeButton postId={id} /> {/* Client Component */} </article> ); }
JavaScript
복사
서버는 LikeButton 함수를 직접 전송하는 대신, “여기에 LikeButton이라는 컴포넌트가 들어간다”는 참조만 전송합니다:
// RSC Payload (Flight 형식) 0:["$","article",null,{"children":[ ["$","h1",null,{"children":"Hello World"}], ["$","$L1",null,{"postId":123}] // $L1 = 참조 ]}] 1:I["./LikeButton.js","LikeButton"] // 참조 정보
Plain Text
복사
클라이언트는 이 참조를 받아서: 1. ./LikeButton.js 파일을 로드 2. LikeButton 함수를 가져옴 3. { postId: 123 } props로 렌더링
이 방식의 핵심 통찰은: - 함수를 전송하는 게 아니라, 함수를 찾을 수 있는 “주소”를 전송 - 클라이언트에 이미 있는 코드를 활용

비교: 직렬화 방식들

방식
Date
Map/Set
Promise
함수
스트리밍
사용처
JSON
문자열
범용 API
JSON + reviver
커스텀 처리
turbo-stream
React Router
RSC Payload
참조로 대체
RSC
모든 방식에서 공통적인 점: 함수 자체는 여전히 직렬화 불가.

결론: 직렬화의 근본적 제약

직렬화 기술이 아무리 발전해도 함수를 온전히 전송하는 건 불가능합니다. 함수는 코드뿐 아니라 실행 환경, 클로저, 외부 의존성을 포함하고 있기 때문입니다.
이 제약이 만드는 결과:
1.
Hydration이 필요한 이유
서버에서 보낸 HTML에는 이벤트 핸들러가 없음
클라이언트에서 JavaScript를 다시 실행해서 핸들러를 붙여야 함
2.
’use client’가 필요한 이유
상태와 이벤트 핸들러가 필요한 컴포넌트는 클라이언트에서 실행되어야 함
RSC는 이런 컴포넌트를 “참조”로 표시해서 구분
3.
Server Actions가 동작하는 방식
함수를 전송하는 게 아니라, “이 함수를 호출해달라”는 요청을 전송
실제 함수 실행은 서버에서
다음 장에서는 Streaming SSR을 다룹니다. 직렬화가 스트리밍과 만나면 어떤 일이 일어나는지, React의 Fizz가 어떻게 동작하는지 살펴보겠습니다.

부록: 직렬화 가능한 타입 전체 목록

JSON 표준

string, number, boolean, null
순수 객체 (plain object)
배열

turbo-stream 추가 지원

undefined
Date
Map, Set
RegExp
Error
Promise
Symbol
BigInt
+Infinity, -Infinity, NaN, -0
순환 참조
ArrayBuffer, TypedArray
File, Blob, FormData
ReadableStream

RSC Payload 추가 지원

React Element (Server Component 결과)
Client Component 참조
Server Action 참조

여전히 불가능

함수 (일반 함수, 화살표 함수, 메서드)
클로저를 포함한 모든 것
네이티브 객체 (DOM 노드, 네트워크 연결 등)