모노레포 위의 우주 — js2devlog 개발 환경 구축기
pnpm + Turborepo 모노레포 위에 Next.js 15 정적 사이트를 올리고, GitLab CI/CD와 AI 코드 리뷰, AWS 배포까지 — 프로젝트 스케폴딩부터 프로덕션까지의 기록
시작 — "왜 이걸 만들려고 했을까?"
2025년 초, 평소 관심이 있었던 우주 과학과 물리 시뮬레이션을 웹에서 인터랙티브하게 체험할 수 있는 사이트를 만들고 싶다는 생각이 들었습니다. 단순한 정보 나열이 아니라, Canvas 애니메이션과 Web Audio로 직접 만져보고 들을 수 있는 콘텐츠를 목표로 했습니다.
문제는 규모였습니다. 게임, 시뮬레이터, 천문 데이터 뷰어, 개발 블로그까지 — 하나의 프로젝트에 담기에는 관심사가 너무 다양했습니다. 게다가 나중에 실험용 플레이그라운드도 별도로 분리하고 싶었습니다. 단일 Next.js 앱으로 시작하면 결국 쪼개야 할 시점이 올 게 분명했습니다.
그래서 처음부터 모노레포로 시작하기로 결정했습니다.
모노레포 설계 — pnpm + Turborepo
왜 pnpm인가
npm, yarn, pnpm 세 가지를 비교했을 때 pnpm을 선택한 이유는 명확했습니다.
첫째, 엄격한 의존성 격리입니다. npm은 호이스팅으로 인해 package.json에 명시하지 않은 패키지도 우연히 import할 수 있습니다. 이른바 "팬텀 의존성" 문제인데, 모노레포에서는 이 문제가 더 심각해집니다. 앱 A가 설치한 패키지를 앱 B가 몰래 가져다 쓰는 상황이 생기면, 나중에 한쪽만 업데이트할 때 빌드가 깨집니다. pnpm의 content-addressable storage와 심볼릭 링크 구조는 이 문제를 원천 차단합니다.
둘째, 디스크 효율입니다. 동일 패키지의 동일 버전은 전역 스토어에 한 번만 저장됩니다. React 19, Next.js 15 같은 큰 패키지를 여러 앱에서 공유할 때 수백 MB 단위로 절약됩니다.
셋째, 워크스페이스 프로토콜입니다. "@js2/common": "workspace:*"처럼 내부 패키지를 명시적으로 참조하면, 로컬 개발에서는 소스를 직접 바라보고 배포 시에는 버전으로 치환됩니다.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
이 두 줄이 전체 모노레포의 경계를 정의합니다.
워크스페이스 구조
js2devlog/
├── apps/
│ ├── js2blog/ ← 메인 블로그+게임 (Next.js 15)
│ └── js2playground/ ← 실험용 플레이그라운드
├── packages/
│ └── common/ ← 공유 컴포넌트/훅
├── turbo.json
├── pnpm-workspace.yaml
└── package.json ← type: "module", private: true
packages/common은 빌드 없이 TypeScript 소스를 직접 노출합니다. main과 exports 모두 ./src/index.ts를 가리키도록 설정해, 소비하는 앱(js2blog, js2playground)의 번들러가 직접 트랜스파일합니다. 별도 빌드 스텝을 없앰으로써 개발 중 코드 변경이 즉시 반영됩니다.
// packages/common/package.json
{
"name": "@js2/common",
"main": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"peerDependencies": {
"react": "^19.0.0",
"next": "^15.0.0"
}
}
React와 Next.js를 peerDependencies로 선언한 이유도 중요합니다. dependencies로 넣으면 @js2/common이 자체 React 인스턴스를 갖게 되어 훅이 "Invalid hook call" 에러를 발생시킵니다. 피어 의존성으로 선언하면 소비하는 앱의 React를 공유하므로 이 문제가 사라집니다.
Turborepo — 빌드 오케스트레이션
Turborepo를 선택한 핵심 이유는 위상 정렬 기반 태스크 실행과 원격 캐시 없이도 쓸만한 로컬 캐시입니다.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["out/**", ".next/**"]
},
"dev": {
"persistent": true,
"cache": false
},
"lint": {
"dependsOn": ["^build"]
}
}
}
"dependsOn": ["^build"]는 "내 의존성 패키지의 build가 먼저 완료되어야 한다"는 의미입니다. @js2/blog가 @js2/common에 의존하므로, turbo build 한 번이면 common → blog 순으로 자동 실행됩니다.
dev는 persistent: true로 선언해 터미널을 점유하는 dev 서버 프로세스가 Turbo에 의해 강제 종료되지 않도록 합니다. cache: false는 dev 서버 결과를 캐시하는 건 의미 없기 때문입니다.
루트 package.json의 스크립트는 필터 패턴으로 앱별 실행을 지원합니다.
{
"dev": "turbo dev",
"dev:blog": "turbo dev --filter=@js2/blog",
"build": "turbo build",
"build:blog": "turbo build --filter=@js2/blog",
"check:blog": "pnpm lint:blog && pnpm type:blog && prettier --check \"apps/js2blog/**/*.{ts,tsx,css,json}\""
}
check:blog 같은 통합 검증 명령은 CI에서도 로컬에서도 동일하게 사용됩니다.
Next.js 15 — 정적 사이트로의 결정
output: 'export'
이 프로젝트의 가장 큰 아키텍처 결정 중 하나는 정적 내보내기를 선택한 것입니다.
// next.config.ts
const isDev = process.env.NODE_ENV !== 'production';
const nextConfig: NextConfig = {
output: isDev ? undefined : 'export',
trailingSlash: !isDev,
};
SSR(Server-Side Rendering)은 강력하지만 서버 비용이 발생합니다. 이 프로젝트의 콘텐츠는 대부분 클라이언트 사이드 Canvas 렌더링과 외부 API 호출로 구성되어 있어, 서버에서 HTML을 생성할 이유가 거의 없었습니다. 정적 내보내기를 선택하면 S3 같은 오브젝트 스토리지에 HTML/JS/CSS 파일만 올리면 되므로 인프라 비용이 극적으로 줄어듭니다.
대신 이 선택이 가져온 제약도 있었습니다.
- Server Component 제한:
headers(),cookies()같은 서버 전용 API를 사용할 수 없습니다 - 동적 라우트:
generateStaticParams()로 빌드 시점에 모든 경로를 사전 생성해야 합니다 - API 라우트 불가: CORS 우회가 필요한 외부 API는 개발 환경에서
rewrites로 프록시하고, 프로덕션에서는 클라이언트에서 직접 호출합니다
// 개발 환경에서만 동작하는 CORS 프록시
async rewrites() {
return [
{
source: '/api/exoplanet',
destination: 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync',
},
];
},
국제화 — next-intl과 정적 내보내기의 충돌
가장 까다로웠던 부분입니다. next-intl은 기본적으로 서버 컴포넌트에서 getLocale()을 호출해 로케일을 판별합니다. 하지만 이 함수는 내부적으로 headers()를 사용하기 때문에 output: 'export'와 양립할 수 없습니다.
해결책은 [locale] 동적 세그먼트에서 params를 통해 로케일을 받는 것입니다.
// ❌ 정적 내보내기에서 실패
const locale = await getLocale();
const t = await getTranslations('namespace');
// ✅ params에서 로케일 추출
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'namespace' });
이 패턴은 모든 페이지 컴포넌트에 일관되게 적용해야 해서, 나중에 하나라도 빠뜨리면 빌드 전체가 실패합니다. 빌드 실패 메시지가 "Dynamic server usage: headers" 같은 간접적인 에러로 나타나기 때문에 원인 파악에 시간이 걸리기도 했습니다.
TypeScript 설정
모노레포에서 TypeScript 경로 해석은 중요한 설정입니다.
// tsconfig.base.json (루트)
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"incremental": true
}
}
moduleResolution: "bundler"는 Next.js 15와 함께 도입된 설정으로, Node.js의 exports 필드를 올바르게 해석하면서도 번들러 환경의 유연성을 유지합니다. 이전의 node 모드에서는 @js2/common의 exports 필드를 무시하고 main만 참조하는 문제가 있었습니다.
각 앱의 tsconfig.json은 루트를 상속하면서 경로 별칭을 추가합니다.
// apps/js2blog/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@js2/common": ["../../packages/common/src"],
"@js2/common/*": ["../../packages/common/src/*"]
}
}
}
@/*는 앱 내부 import, @js2/common/*은 공유 패키지 import에 사용합니다. IDE의 자동 완성과 타입 체크가 모두 이 설정을 기반으로 동작합니다.
코드 품질 — 자동화된 검증 파이프라인
ESLint Flat Config
ESLint 9의 flat config로 전환하면서 설정을 단일 파일로 통합했습니다.
// eslint.config.mjs
export default [
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports,
'react-hooks': reactHooks,
prettier: prettier,
},
rules: {
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
simple-import-sort는 import 순서를 자동 정렬해서 diff에서 불필요한 변경을 줄여줍니다. unused-imports는 사용하지 않는 import를 에러로 처리해 번들 크기를 관리합니다.
Husky + lint-staged
커밋 시점에 변경된 파일만 검증하는 게이트를 설정했습니다.
// .lintstagedrc.js
export default {
'apps/**/*.{ts,tsx}': () => ['turbo test:source'],
'packages/**/*.{ts,tsx}': () => ['turbo build'],
'{apps,packages}/**/*.{js,ts,tsx,json,css}': 'prettier --write',
};
앱 코드 변경 시 test:source(타입 체크 + lint + prettier)를, 공유 패키지 변경 시 build를 실행합니다. 의존성 하류에 영향을 주는 공유 패키지 변경은 빌드까지 확인하는 것이 안전합니다.
한 가지 고민이 있었습니다. test:source에 타입 체크(tsc --noEmit)를 포함하면 커밋이 3분 이상 걸립니다. 하지만 타입 에러가 있는 코드가 리포지토리에 들어가면 CI에서 잡더라도 이미 다른 팀원의 작업을 방해할 수 있습니다. 개인 프로젝트이므로 현재는 full check를 유지하고 있지만, 팀 규모가 커지면 타입 체크를 CI로 이관하는 것이 현실적일 수 있습니다.
GitLab CI/CD — 4단계 파이프라인
전체 구조
stages:
- review # AI 코드 리뷰
- prepare # 라벨 감지
- build # 앱 빌드
- deploy # AWS 배포
1단계: AI 코드 리뷰 (ai_review)
MR(Merge Request) 생성 시 자동으로 AI 코드 리뷰가 실행됩니다. Git diff를 추출해 GPT-4o에 전달하고, 리뷰 결과를 MR 댓글로 추가합니다.
ai_review:
stage: review
script:
- node scripts/ai_review.js
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
리뷰 스크립트는 세 가지 축으로 코드를 분석합니다.
- 메이저 이슈 (최대 5건): 버그 가능성, 보안 취약점, 성능 저하 — 파일 경로와 라인 번호, 기존 코드와 개선 코드를 함께 제시
- 마이너 제안 (최대 5건): 코드 스타일, 네이밍, 간소화 가능한 로직
- 전체 평가: ✅ 우수함 / ⚠️ 개선 권고 / 🔴 주의 필요
리뷰 톤도 설정 가능합니다. REVIEW_STYLE 환경 변수로 friendly, performance_focused, security_focused, maintainability_focused 중 선택할 수 있어, 프로젝트 성격에 맞는 피드백을 받을 수 있습니다.
실제로 이 시스템이 유용했던 사례가 몇 가지 있습니다. Canvas 렌더링 코드에서 requestAnimationFrame cleanup을 빠뜨린 메모리 누수를 잡아준 적이 있고, useEffect 의존성 배열에 불필요한 객체 참조가 들어가 무한 리렌더를 유발할 뻔한 코드를 사전에 경고해준 적도 있습니다.
물론 한계도 있습니다. 물리 시뮬레이션의 수학적 정확성이나, Canvas 좌표계의 미묘한 오프셋 버그 같은 도메인 특화 문제는 AI 리뷰가 놓치는 경우가 많습니다. 그럼에도 "명백하지만 놓치기 쉬운" 종류의 실수에는 효과적인 안전망 역할을 합니다.
2단계: 라벨 기반 조건부 빌드
모노레포에서 모든 커밋에 대해 전체 앱을 빌드하는 건 낭비입니다. MR에 js2blog 또는 js2playground 라벨을 부착해 어떤 앱이 변경되었는지 명시하고, 해당 앱만 빌드합니다.
build:js2blog:
stage: build
script:
- |
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if ! echo "${DEPLOY_LABELS}" | grep -q "js2blog"; then
echo "js2blog 라벨 없음 - 빌드 건너뜀"
exit 0
fi
fi
- pnpm --filter @js2/blog build
artifacts:
paths:
- apps/js2blog/out/
빌드 산출물(out/)은 GitLab artifact로 저장되어 deploy 단계에서 사용합니다. prebuild 훅에서 sitemap.xml과 robots.txt를 자동 생성하므로 SEO 관련 파일도 빌드에 포함됩니다.
3단계: 배포
이 부분은 아래 AWS 섹션에서 상세히 다룹니다.
AWS 배포 — S3 + CloudFront
정적 사이트 호스팅 아키텍처
GitLab CI → S3 Bucket → CloudFront CDN → 사용자
Next.js의 output: 'export'로 생성된 정적 파일을 S3에 업로드하고, CloudFront CDN을 통해 전 세계에 배포합니다.
캐싱 전략 — 가장 많이 고민한 부분
정적 사이트 배포에서 캐싱 전략은 사용자 경험과 직결됩니다. 잘못 설정하면 구버전 JS를 로드해 화면이 깨지거나, 반대로 매번 전체 리소스를 다시 받아 성능이 떨어집니다.
Next.js의 _next/static/ 폴더에는 콘텐츠 해시가 파일명에 포함된 JS/CSS 청크가 들어있습니다. 파일명 자체가 버전 역할을 하므로 영구 캐시가 안전합니다.
반면 HTML 파일은 같은 경로(/index.html)를 유지하면서 내용이 바뀌므로 캐시하면 안 됩니다.
이 차이를 배포 스크립트에 반영했습니다.
deploy:js2blog:
script:
# 1단계: JS/CSS 청크 — 영구 캐시
- aws s3 sync apps/js2blog/out/_next/static/
s3://$BUCKET/js2blog/_next/static/
--cache-control "public, max-age=31536000, immutable"
# 2단계: HTML + 나머지 — 캐시 없음, 구파일 삭제
- aws s3 sync apps/js2blog/out/
s3://$BUCKET/js2blog/
--exclude "_next/*"
--delete
--cache-control "no-cache, no-store, must-revalidate"
# 3단계: CloudFront 캐시 무효화
- aws cloudfront create-invalidation
--distribution-id $CF_DIST_ID
--paths "/*.html" "/index.html" "/"
왜 두 단계로 나눴는가? 배포는 원자적이지 않습니다. HTML을 먼저 올리고 JS를 나중에 올리면, 그 사이에 접속한 사용자는 새 HTML이 참조하는 새 JS 청크를 찾지 못해 503 에러를 겪습니다. 반대 순서로 올려도 비슷한 문제가 생길 수 있습니다.
JS/CSS를 먼저 올리고(max-age=31536000), 그 다음 HTML을 올리면(no-cache) 타이밍 갭을 최소화할 수 있습니다. 이전 배포의 JS 청크는 S3에 남아있으므로, 구 HTML을 캐시한 사용자도 정상 동작합니다. --delete 플래그는 HTML 단계에서만 적용해 불필요한 구 HTML을 정리하되, JS 청크는 보존합니다.
CloudFront 무효화
CloudFront 엣지 캐시에는 HTML이 최대 24시간 남을 수 있습니다. create-invalidation으로 배포 직후 HTML 관련 경로를 강제 갱신합니다. _next/static/*은 파일명에 해시가 포함되어 있어 무효화할 필요가 사실상 없지만, 안전장치로 포함했습니다.
외부 API 프록시 — CloudFront로 CORS 해결
이 프로젝트는 NASA APOD, 외계행성 아카이브, 화성 탐사 로봇 RSS 등 여러 외부 API를 사용합니다. 문제는 이 API들이 브라우저에서 직접 호출하면 CORS 에러가 발생한다는 점이었습니다.
개발 환경에서는 Next.js의 rewrites로 프록시를 설정할 수 있습니다.
// next.config.ts — 개발 환경에서만 동작
async rewrites() {
return [
{
source: '/api/exoplanet',
destination: 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync',
},
{
source: '/api/mars-rss',
destination: 'https://mars.nasa.gov/rss/api/',
},
];
},
하지만 output: 'export'로 빌드하면 이 rewrites는 동작하지 않습니다. 정적 파일만 있으니 요청을 가로챌 서버가 없기 때문입니다.
프로덕션에서는 CloudFront의 다중 Origin 설정으로 이 문제를 해결했습니다. CloudFront 배포에 S3 외에 외부 API 서버를 추가 Origin으로 등록하고, 경로 패턴(/api/exoplanet, /api/mars-rss)에 따라 해당 Origin으로 라우팅합니다. 클라이언트 코드는 개발/프로덕션 구분 없이 동일한 상대 경로(/api/exoplanet)를 호출하면 됩니다.
CloudFront Distribution
├─ Default Origin: S3 (정적 파일)
├─ /api/exoplanet → Origin: exoplanetarchive.ipac.caltech.edu
└─ /api/mars-rss → Origin: mars.nasa.gov
이 구성의 장점은 API 키를 클라이언트에 노출하지 않을 수 있다는 점입니다. NASA APOD API 키는 CloudFront Functions에서 요청 헤더에 주입하므로, 브라우저 네트워크 탭에서는 보이지 않습니다.
보안 설정 — 방어 계층 구성
정적 사이트라고 해서 보안 설정이 필요 없는 건 아닙니다. CloudFront와 S3 양쪽에서 여러 방어 계층을 설정했습니다.
CloudFront 응답 헤더 정책으로 모든 응답에 보안 헤더를 자동 추가합니다.
| 헤더 | 값 | 목적 |
|---|---|---|
X-Content-Type-Options | nosniff | MIME 타입 스니핑 방지 |
X-Frame-Options | DENY | 클릭재킹 방지 |
X-XSS-Protection | 1; mode=block | XSS 필터 활성화 |
Strict-Transport-Security | max-age=31536000; includeSubDomains | HTTPS 강제 |
Referrer-Policy | strict-origin-when-cross-origin | 리퍼러 정보 제한 |
Content-Security-Policy | default-src 'self'; ... | 리소스 로드 출처 제한 |
CSP(Content Security Policy)가 가장 까다로웠습니다. 이 사이트는 NASA API, 외부 CDN 폰트, 인라인 스크립트(Next.js 필요) 등 다양한 출처의 리소스를 사용하기 때문에, 너무 엄격하면 정상 기능이 깨지고 너무 느슨하면 의미가 없습니다.
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' cdn.jsdelivr.net;
img-src 'self' https: data:;
connect-src 'self' https://api.nasa.gov;
font-src 'self' cdn.jsdelivr.net;
img-src https:를 허용한 이유는 NASA APOD가 매일 다른 외부 URL의 이미지를 반환하기 때문입니다. 특정 도메인을 화이트리스트할 수 없어 HTTPS 전체를 허용하되, data: 스킴도 Canvas toDataURL() 결과물에 필요해서 추가했습니다.
S3 버킷 정책으로는 CloudFront OAC(Origin Access Control)를 통한 접근만 허용하고, 직접 S3 URL 접근을 차단했습니다. S3 버킷 자체는 퍼블릭 접근이 완전히 비활성화되어 있으며, CloudFront를 경유해야만 콘텐츠를 받을 수 있습니다.
CloudFront Functions — 엣지에서의 요청 가공
CloudFront Functions는 Lambda@Edge보다 가볍고 빠른 엣지 컴퓨팅입니다. 두 가지 용도로 작성했습니다.
첫째, URL 리라이트 함수. 정적 내보내기에서는 /ko/about 요청이 실제로 /ko/about/index.html 파일을 찾아야 합니다. S3는 디렉토리 인덱스를 자동으로 처리하지 않으므로, viewer-request 이벤트에서 URI를 변환합니다.
function handler(event) {
var request = event.request;
var uri = request.uri;
// /ko/about → /ko/about/index.html
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
둘째, API 키 주입 함수. NASA API 호출 시 API 키를 origin-request 이벤트에서 쿼리 스트링에 추가합니다. 키는 CloudFront Functions의 KeyValueStore에 저장되어 코드에 하드코딩되지 않습니다.
이런 엣지 함수를 직접 작성하면서 느낀 점은, 정적 사이트의 "서버리스"라는 말이 "서버가 없다"는 뜻이 아니라 "서버 관리를 하지 않는다"는 뜻이라는 것이었습니다. CloudFront Functions, S3 버킷 정책, 응답 헤더 정책 — 모두 설정해야 할 "서버 측 로직"입니다.
다국어 시스템 — ko/en 이중 서비스
next-intl + 정적 내보내기
한국어와 영어 두 언어를 지원합니다. next-intl 라이브러리를 사용하되, output: 'export'와 양립 가능하도록 설정하는 것이 핵심 과제였습니다.
라우팅 구조는 URL 기반입니다. 모든 페이지에 locale prefix가 붙습니다.
/ko/starship ← 한국어 스타쉽 미션
/en/starship ← English Starship Mission
/ko/devlog/... ← 한국어 개발 블로그
/en/devlog/... ← English dev blog
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ['ko', 'en'] as const,
defaultLocale: 'ko',
});
미들웨어 — locale prefix가 없으면 리다이렉트
사용자가 /about처럼 locale 없이 접근하면, 쿠키(NEXT_LOCALE)에 저장된 선호 언어로 리다이렉트합니다. 쿠키가 없으면 기본값 한국어(/ko/about)로 이동합니다.
// src/middleware.ts
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// URL에 이미 locale prefix가 있으면 통과
const matchedLocale = LOCALES.find(loc => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`));
if (matchedLocale) {
const response = NextResponse.next();
response.headers.set('x-next-intl-locale', matchedLocale);
return response;
}
// locale이 없으면 쿠키 또는 기본값으로 리다이렉트
const locale = getLocaleFromRequest(req);
return NextResponse.redirect(new URL(`/${locale}${pathname}`, req.url));
}
서버 / 클라이언트 양쪽에서의 번역
서버 컴포넌트와 클라이언트 컴포넌트에서 번역을 사용하는 패턴이 다릅니다.
// 서버 컴포넌트 (page.tsx) — getTranslations
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'about' });
const title = t('meta.title');
// 클라이언트 컴포넌트 — useTranslations
('use client');
const t = useTranslations('gameShare');
const text = t('shareText', { game: 'Asteroids', score: '12400' });
// → "🚀 Asteroids에서 12400 달성!" (ko)
// → "🚀 12400 on Asteroids!" (en)
[locale] 레이아웃에서 NextIntlClientProvider가 메시지를 주입하므로, 클라이언트 컴포넌트는 현재 locale을 명시적으로 전달할 필요 없이 useTranslations 훅만 호출하면 됩니다.
메시지 파일 — 30개 네임스페이스
번역 메시지는 messages/ko.json(98KB)과 messages/en.json(89KB) 두 파일로 관리합니다. 30개 네임스페이스로 구성되어 있으며, UI 텍스트뿐 아니라 스크린리더용 aria-label, 게임 공유 텍스트, SEO 메타데이터까지 포함합니다.
{
"asteroids": {
"ui": {
"hudAriaLabel": "게임 상태 표시",
"srStageAndLives": "스테이지 {stage}, 남은 목숨 {lives}"
}
}
}
접근성 관련 텍스트까지 번역하는 것은 번거롭지만, 한국어와 영어 사용자 모두 스크린리더로 동일한 경험을 받을 수 있어야 한다고 생각했습니다.
정적 빌드 — 모든 조합 사전 생성
generateStaticParams()로 빌드 시점에 모든 locale × route 조합을 생성합니다.
// app/[locale]/devlog/[slug]/page.tsx
export async function generateStaticParams() {
const params = [];
for (const locale of routing.locales) {
const posts = getAllPosts(locale);
for (const post of posts) {
params.push({ locale, slug: post.slug });
}
}
return params;
// → [{ locale:'ko', slug:'starship-mission' },
// { locale:'en', slug:'starship-mission' }, ...]
}
18개 포스트 × 2개 언어 = 36개 HTML 파일이 빌드됩니다. 25개 메인 라우트까지 포함하면 총 86개 이상의 정적 페이지가 생성됩니다. SEO 관점에서도 각 언어별 canonical URL과 hreflang alternate 링크가 정확히 매핑되어, 검색 엔진이 언어별 콘텐츠를 올바르게 인덱싱합니다.
Locale-aware 네비게이션
next/link 대신 @/i18n/navigation의 Link를 사용합니다. 이 래퍼는 현재 locale을 자동으로 유지하므로, 개발자가 매번 /${locale}/path를 조합할 필요가 없습니다.
// next/link — locale prefix 수동 관리 필요
<Link href={`/${locale}/about`}>About</Link>
// @/i18n/navigation — locale 자동 처리
<Link href="/about">About</Link>
언어 전환 시에는 경로는 유지한 채 locale만 변경합니다. usePathname()이 locale이 제거된 순수 경로(/about)를 반환하므로, router.replace(pathname, { locale: nextLocale })로 간결하게 전환됩니다.
개발 경험 — 작은 도구들
prebuild SEO 생성
빌드 전에 sitemap.xml과 robots.txt를 자동 생성하는 스크립트를 작성했습니다.
// scripts/generate-seo.mjs
const LOCALES = ['ko', 'en'];
const ROUTES = [
{ path: '/', priority: '1.0' },
{ path: '/starship', priority: '0.8' },
// ... 25개 라우트
];
정적 내보내기에서는 Next.js의 sitemap.ts를 사용할 수 없기 때문에 직접 생성합니다. hreflang 대체 링크를 포함해 검색 엔진이 언어별 페이지를 올바르게 인덱싱하도록 합니다.
CSS Modules 선택
Tailwind를 사용하지 않기로 한 건 의도적인 결정이었습니다. Canvas 중심의 인터랙티브 콘텐츠에서는 CSS 자체가 차지하는 비중이 크지 않고, 게임 오버레이나 HUD 같은 요소는 복잡한 애니메이션과 정밀한 위치 지정이 필요합니다. 유틸리티 클래스의 조합보다는 명시적인 CSS 파일에서 변수와 키프레임을 관리하는 것이 이 프로젝트에는 더 적합했습니다.
CSS Modules의 가장 큰 장점은 이름 충돌이 원천적으로 불가능하다는 점입니다. 20개가 넘는 게임/시뮬레이터가 각각 .overlay, .title, .button 같은 흔한 클래스명을 사용해도 서로 간섭하지 않습니다.
개발 노트 (Devlog) 시스템
블로그 기능을 넣을 때 가장 고민한 부분은 "빌드 타임에 MDX를 처리하면서도 외부 라이브러리 의존성을 최소화하는 것"이었습니다.
초기에는 gray-matter를 사용해 YAML frontmatter를 파싱했는데, Turbopack과의 호환성 문제가 발생했습니다. gray-matter가 의존하는 js-yaml@3.x가 ESM을 완전히 지원하지 않아 번들링 시 에러가 발생한 것입니다.
결국 자체 parseFrontmatter 함수를 작성했습니다. 단순한 key-value와 배열만 지원하지만, 이 프로젝트의 frontmatter에는 그 이상이 필요하지 않았습니다. 외부 의존성 하나를 줄이면서 Turbopack 호환 문제까지 해결한 셈입니다.
되돌아보며
프로젝트 구축 과정에서 가장 많은 시간을 쓴 건 화려한 물리 시뮬레이션이나 Canvas 렌더링이 아니라, 이런 "보이지 않는 인프라" 결정들이었습니다.
모노레포를 택하면서 패키지 경계를 어디에 둘지, 정적 내보내기를 선택하면서 포기하는 것들의 목록을 정리하고, CI 파이프라인의 각 단계에서 "이게 정말 필요한가?"를 반복적으로 검토했습니다.
특히 배포 캐싱 전략을 설계할 때, S3 sync의 두 단계 분리를 떠올리기까지 실제로 "새 HTML + 구 JS" 조합으로 화면이 깨지는 경험을 직접 했습니다. 프로덕션에서 한 번 겪은 뒤에야 올바른 설계가 나왔습니다. CloudFront 보안 헤더와 CSP도 마찬가지입니다. 처음에는 "정적 사이트니까 보안 설정은 나중에"라고 미뤘다가, NASA API 키 노출 문제를 발견하고 나서야 엣지 함수와 응답 헤더 정책을 제대로 구성했습니다.
다국어 시스템 역시 예상보다 까다로웠습니다. next-intl과 정적 내보내기의 호환성, 미들웨어 리다이렉트, 30개 네임스페이스의 메시지 관리, SEO hreflang 매핑까지 — 단순히 "텍스트를 번역한다"가 아니라 "두 개의 완전한 사이트를 하나의 코드베이스로 운영한다"에 가까운 작업이었습니다.
이 모든 결정이 맞았는지는 아직 알 수 없습니다. 하지만 적어도 "왜 이렇게 했는지"를 설명할 수 있는 선택을 하려고 노력했고, 그 과정을 기록으로 남기는 것이 이 글의 목적입니다.