블로그 디테일 페이지를 만들고 /blog에 접속했다. 그런데 콘솔에 빨간 에러가
쏟아졌다. 모든 한국어 slug를 가진 포스트의 .rsc 요청이
500 Internal Server Error를 반환하고 있었다.
TypeError: Cannot convert argument to a ByteString because the character at
index 38 has a value of 50756 which is greater than 255.
at webidl.converters.ByteString (node:internal/deps/undici/undici:3889:17)
이상한 건 직접 URL을 입력해서 접속하면 정상이라는 점이었다.
/blog/frontend/hooks/useState-완전정복을 브라우저 주소창에 직접 치면 잘
뜨는데, 블로그 목록에서 링크를 클릭하면 터진다. 직접 접속은 SSR(서버 사이드
렌더링)이고, 링크 클릭은 RSC(React Server Component) 스트리밍
요청(.rsc)이다. 즉 RSC 경로에서만 문제가 발생하고 있었다.
vinext는 Vite 위에 Next.js API를 재구현한 프레임워크다. Cloudflare가 AI를 활용해서 빠르게 만든 프로젝트인데, 그래서 한 가지 가설이 떠올랐다. Next.js에서 이미 해결된 문제를 vinext가 빠뜨렸을 수 있다.
"next.js non-ascii header params"로 검색해보니 역시나 있었다.
vercel/next.js#27003
에서 동일한 문제를 encodeURIComponent로 해결한 기록이 있었다. HTTP 헤더에
비ASCII 문자를 넣으면 ByteString 제약을 위반하는 문제였고, 해결 방법도 퍼센트
인코딩이었다.
그러면 vinext에서도 같은 패턴이 있는지 확인해보자. vinext의 소스 코드에서
X-Vinext-Params를 검색했다.
// vinext/src/server/app-page-response.ts (line 57)
if (options.params && Object.keys(options.params).length > 0)
headers.set("X-Vinext-Params", JSON.stringify(options.params));
역시나 같은 문제였다. JSON.stringify()가 비ASCII 문자를
그대로 보존하기 때문에, 한국어가 포함된 params를 직렬화하면 ByteString 제약을
위반하게 된다. Next.js는 이미 고쳤지만, vinext는 이 부분을 놓친 것이다.
Fetch 명세
에 따르면 Headers.set()의 값은 ByteString이어야 한다.
ByteString은 모든 문자의 코드 포인트가 255 이하인 문자열이다. 한국어 완은
U+C644(= 50756)이니 당연히 255를 초과한다.
const json = JSON.stringify({
slug: ["frontend", "hooks", "useState-완전정복"],
});
// json = '{"slug":["frontend","hooks","useState-완전정복"]}'
// 0123456789... ^38번째
console.log(json[38]); // "완"
console.log(json.charCodeAt(38)); // 50756 (= U+C644)
정확히 일치한다. 에러 메시지의 index 38, value 50756은 완 문자를 가리키고
있었다.
같은 파일의 buildAppPageHtmlResponse() 함수를 보면 X-Vinext-Params 헤더를
설정하지 않는다. SSR은 HTML을 통째로 내려보내기 때문에 params를 헤더에 실을
필요가 없는 것이다. 반면 RSC 스트리밍은 클라이언트가 서버 응답의 헤더에서
params를 읽어야 하므로 이 헤더가 필요하다.
| 시나리오 | 결과 |
|---|---|
| 비ASCII slug, 직접 URL 접속 (SSR) | 정상 — buildAppPageHtmlResponse는 헤더 미설정 |
| 비ASCII slug, 클라이언트 내비게이션 (.rsc) | 500 에러 — buildAppPageRscResponse에서 크래시 |
| ASCII 전용 slug | 모든 경우 정상 |
Next.js와 동일하게 encodeURIComponent로 퍼센트 인코딩하면 모든 문자가 ASCII
범위에 들어온다.
// Before
headers.set("X-Vinext-Params", JSON.stringify(options.params));
// After
headers.set(
"X-Vinext-Params",
encodeURIComponent(JSON.stringify(options.params)),
);
클라이언트에서 읽을 때는 decodeURIComponent()로 복원하면 된다.
버그의 원인과 수정 방법이 명확했으니, vinext 저장소에 직접 기여해보기로 했다.
먼저 GitHub Issues에 버그 리포트를 올렸다. 좋은 이슈를 쓰는 법은
GitHub 공식 문서
와
Simon Tatham의 "How to Report Bugs Effectively"
를 참고했다. 좋은 이슈에는 다음이 포함되어야 한다.
// 최소 재현 코드 — 이것만으로 에러를 확인할 수 있다
const headers = new Headers();
headers.set("X-Vinext-Params", JSON.stringify({ slug: ["useState-완전정복"] }));
// TypeError: Cannot convert argument to a ByteString...
특히 수정 제안에는 Next.js의 선례 (
vercel/next.js#27003
)를 함께 첨부했다. 이미 검증된 해결 방법이라는 근거를 제시하면 메인테이너가 더 빠르게 판단할 수 있다.
vinext 저장소를 포크하고, 수정 작업을 위한 브랜치를 만들었다.
# 포크한 저장소 클론
git clone https://github.com/seoJing/vinext.git
cd vinext
# 수정 브랜치 생성
git checkout -b fix/non-ascii-params-bytestring-676
# 의존성 설치
pnpm install
수정 자체는 간단했다. encodeURIComponent 한 줄 추가와, 클라이언트 사이드에서
decodeURIComponent 추가. 하지만 오픈소스 기여에서 중요한 건
테스트다.
Run the test suite.
pnpm testruns Vitest.pnpm run test:e2eruns Playwright.
Add tests for new functionality. Unit tests go in
tests/*.test.ts. Browser-level tests go intests/e2e/.
테스트의 종류가 세 가지나 언급된다. 유닛 테스트, E2E 테스트, 그리고 정적 체크. 이게 각각 뭔지, 이번 기여에서 어떻게 적용되는지 정리해보았다.
함수 하나를 격리해서 테스트한다. 외부 의존성 없이, 입력을 넣으면 기대한 출력이 나오는지만 확인한다. 가장 빠르고 가장 많이 작성한다.
import { describe, expect, it } from "vite-plus/test";
describe("buildAppPageRscResponse", () => {
it("percent-encodes non-ASCII params in X-Vinext-Params header", () => {
const response = buildAppPageRscResponse(new ReadableStream(), {
params: { slug: ["useState-완전정복"] },
policy: {},
middlewareContext: { headers: null },
});
const raw = responseheaders
vinext는 Vitest를 테스트 러너로 사용한다.
vite-plus/test에서 describe, expect, it을 import하는 것이 프로젝트의
컨벤션이다. pnpm test로 실행한다.
여러 모듈이 함께 동작하는 것을 테스트한다. 유닛 테스트가 함수 하나를 보는 거라면, 통합 테스트는 함수들이 조합됐을 때 의도대로 동작하는지 확인한다.
예를 들어 유닛 테스트에서는 encodeURIComponent가 잘 적용되는지만 보지만,
통합 테스트에서는 실제 Vite 개발 서버를 띄우고 HTTP 요청을 보내서 응답 헤더에
인코딩된 값이 제대로 들어있는지까지 확인한다.
// 통합 테스트 예시 — 실제 서버에 요청
const server = await startFixtureServer("app-router");
const response = await fetch(`${server.url}/blog/useState-완전정복.rsc`);
const params = response.headers.get("X-Vinext-Params");
expect(JSON.parse(decodeURIComponent(params!))).toEqual({
slug: ["useState-완전정복"],
});
vinext에서는 통합 테스트도 pnpm test로 실행되지만, 유닛 테스트와 분리되어
순차 실행된다. Vite 개발 서버를 여러 개 동시에 띄우면 캐시
충돌이 발생하기 때문이다.
실제 브라우저를 띄워서 사용자가 하는 것과 동일하게 테스트한다. "진짜 Chrome 브라우저에서 링크를 클릭하면 페이지가 정상 로드되는가?"를 확인한다.
// E2E 테스트 예시 — Playwright
import { test, expect } from "@playwright/test";
test("non-ASCII slug navigation works", async ({ page }) => {
// 실제 브라우저에서 블로그 목록 접속
await page.goto("/blog");
// 한국어 slug 링크 클릭
await page.click('a[href="/blog/useState-완전정복"]');
// 페이지가 정상 로드되는지 확인
await expect(page.locator("h1")).toHaveText("useState 완전정복");
});
vinext는 Playwright로 E2E 테스트를 실행한다. pnpm run test:e2e로 실행하며, 실제 Chromium 브라우저를 헤드리스 모드로 띄운다. 가장
느리지만 가장 현실적인 테스트다. CONTRIBUTING.md에서도 브라우저 수준 디버깅의
중요성을 강조한다.
For browser-level debugging (verifying rendered output, client-side navigation, hydration behavior), we recommend agent-browser. Unit tests miss a lot of subtle browser issues.
/\
/ \ E2E — 적게, 핵심 시나리오만
/ \ (실제 브라우저, 느림, 비쌈)
/------\
/ \ 통합 — 중간
/ \ (서버 + 모듈 조합, 보통)
/------------\
/ \ 유닛 — 많이
/________________\ (함수 단위, 빠름, 저렴)
아래로 갈수록 많이 작성하고, 위로 갈수록 적게 작성한다. 유닛 테스트는 빠르고 저렴하니 가능한 많이, E2E 테스트는 느리고 비싸니 핵심 시나리오만 작성하는 것이 일반적이다.
이 ByteString 버그의 경우, 유닛 테스트 하나로 충분했다.
buildAppPageRscResponse 함수에 비ASCII params를 넣었을 때 헤더가 정상적으로
인코딩되는지만 확인하면 된다. 통합 테스트나 E2E 테스트는 있으면 좋지만 필수는
아니다.
직접 기여로 남고 싶어 아쉽지만, CONTRIBUTING.md에 이런 문구가 있다.
At a maintainer's discretion, we may push BigBonk's recommended changes directly into your PR and merge it, or we may create a new PR based on your work. When we do this, we always try to preserve the original author credit.
코드 머지만이 기여가 아니다. 상세한 이슈 리포트 자체가 유의미한 기여다. 메인테이너가 빠르게 수정할 수 있었던 건 이슈에 원인 분석, 최소 재현 코드, 수정 제안까지 다 적어뒀기 때문이다.
무엇보다도 메인테이터가 나를 공동 작업자로 넣어줘서, 기여를 확인할 수 있게 해줬다!
# 1. 포크 후 클론
git clone https://github.com/<your-username>/vinext.git
cd vinext
# 2. 의존성 설치
pnpm install
# 3. 빌드 (monorepo 내부 패키지 빌드)
pnpm run build
CONTRIBUTING.md에서 PR을 열기 전 요구하는 세 가지 검증이다.
# 4. 브랜치 생성
git checkout -b fix/descriptive-branch-name
# 5. 코드 수정 후 테스트
pnpm test # 유닛 + 통합 테스트 (Vitest)
pnpm run test:e2e # E2E 테스트 (Playwright)
pnpm run check # 포맷팅 + 린팅 + 타입 체크 (Vite+)
# 6. 커밋
git commit -m "fix: description of the fix (#issue-number)"
# 7. 푸시 및 PR 생성
git push origin fix/descriptive-branch-name
gh pr create --title "fix: ..." --body "Closes #issue-number"
커밋 메시지는 Conventional Commits 형식을 따른다. fix:,
feat:, docs:, test: 등의 접두사를 사용한다. PR 본문에 Closes #이슈번호를 넣으면 머지 시 이슈가 자동으로 닫힌다.
vinext의 독특한 점은 AI 코드 리뷰다. PR을 올리면 BigBonk라는 Claude Opus 4.6 기반 AI 리뷰어가 코드를 검토한다. CONTRIBUTING.md는 이렇게 말한다.
Every PR goes through AI code review. When you open a PR, a contributor with write access will request a review from BigBonk (Claude Opus 4.6, max thinking mode).
Our bias is towards merging. This is a new project with known gaps, and we're trying to fill them as fast as possible.
한국어 MDX 블로그를 만들다가 프레임워크 버그를 발견하고, 분석하고, 이슈를 올리고, PR까지 시도한 경험이었다. 정리하면 이렇다.
이 과정에서 vinext의 내부 구조(RSC 스트리밍, 헤더 직렬화), Fetch 명세(ByteString 제약), 그리고 오픈소스 기여 워크플로(포크 → 브랜치 → 테스트 → PR)를 실전으로 익힐 수 있었다.