캠페인 페이지를 메인 웹뷰에서 분리: 정적 export
우리 메인 RN 웹뷰 앱 안에는 캠페인 페이지가 들어 있었다. src/pages/campaign/에 라우트를, src/campaign/에 컴포넌트·이미지·스타일을 담는 구조였다. 첫 캠페인 한 번 띄울 때는 이 정도면 됐다.
문제는 캠페인이 단발성이라는 점이다. 하나가 끝나면 다음이 들어오고, 끝난 것은 정리해야 한다. 메인 앱과 빌드·배포 사이클을 공유하니 캠페인 한 줄 고치자고 메인 앱 전체를 빌드해야 했고, 캠페인 페이지는 메인 앱의 인증·디자인 토큰·번들에 묶여 있었다. 그 묶임은 캠페인이 늘어날수록 더 비싸진다.
그래서 캠페인을 별도 레포로 분리해 독립적으로 운영하기로 했다. 빌드·배포는 사내 Rancher 흐름을 그대로 가져가되, 런타임은 정적 export + nginx로 가볍게 띄우는 구조다.
분리는 코드량이 가장 적을 때
분리 결정은 늦을수록 비싸다. 캠페인이 늘어나면 코드도, 메인 앱과 얽히는 지점도 늘어난다. 더 위험한 건 그 사이에 캠페인 코드가 슬며시 메인 앱 코드를 import 하기 시작하는 경우다. 한번 묶이면 떼내는 비용이 크게 뛴다.
첫 캠페인이 막 끝난 시점이 가장 떼내기 싼 타이밍이었다.
분리 전에 격리 규칙을 박아뒀다
비용을 줄인 건 분리하기 한참 전부터 박아둔 규칙이었다.
캠페인 코드(src/campaign/)는 메인 앱 코드(src/containers, src/components)를 import 하지 못하게 했다. 필요한 게 있으면 캠페인 폴더 안에 자체 구현했다. 처음엔 조금 불편했지만, 막상 분리 시점에 외부 의존이 단 세 개만 남아 있었다.
useMeSuspense— 로그인 정보 (react-query 훅)openExternalBrowser— 외부 브라우저 열기 (RN 브릿지)useBackTo— 뒤로가기 (라우터 훅)
이 셋만 새 레포에 자체 복제해 들고 가면 됐다. 분리 작업이 처음 예상한 며칠짜리에서 한나절짜리로 줄어든 이유는 이 규칙 덕분이다.
코드 이동
폴더를 통째로 복사해 새 레포를 부트스트랩했다.
src/campaign/containers/*→src/containers/*src/campaign/components/*→src/components/*src/pages/campaign/*→src/pages/*(campaignprefix 제거)src/asset/img/campaign/*→src/assets/img/*
import 경로는 sed로 일괄 변환했다.
sed -i '' 's|@/campaign/components|@/components|g' src/**/*.tsx
sed -i '' 's|@/campaign/containers|@/containers|g' src/**/*.tsx
sed -i '' 's|@/asset/img/campaign/|@/assets/img/|g' src/**/*.tsx외부 의존 셋은 자체 복제하고, package.json과 _app.tsx만 새로 작성했다. 동작 확인까지 반나절이 걸렸다.
Node가 정말 필요한가
분리 자체보다 더 고민한 건 "어떻게 띄울 것인가"였다. 사내 다른 Next.js 프로젝트는 모두 next start로 운영한다. 표준대로 가면 손이 안 가고 운영팀도 익숙하다.
그런데 캠페인 페이지의 요구사항을 다시 봤다.
- SSR — 사용자별 다른 HTML을 그릴 일 없음
- API route — 외부 백엔드 호출만 함
- ISR — 빌드 시점 외 동적 생성 없음
- SEO — RN 웹뷰 안에서만 노출, 검색 엔진 노출 대상 아님
Node 런타임의 장점을 단 하나도 안 쓰는 상태였다. 그럼 Node를 띄우는 비용은 그대로 낭비다.
정적 export로 가기로 했다.
// next.config.js
module.exports = {
output: "export",
images: { unoptimized: true },
trailingSlash: true,
};빌드 결과물이 HTML / JS / CSS 정적 파일로 떨어진다. 이걸 nginx에 얹기만 하면 된다.
Docker 2-stage: Node로 빌드, nginx로 서빙
Dockerfile은 2단계로 나눴다. 1단계는 Node 컨테이너에서 빌드만 하고 버린다. 2단계는 nginx 컨테이너에 빌드 결과물만 올린다.
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # out/ 생성
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY /app/out /usr/share/nginx/html빌더에 Alpine을 안 쓴 건 Next 13.5의 SWC 바이너리(musl)와 호환 문제 때문이고, 런타임은 정적 파일만 서빙하니 Alpine으로 충분했다.
결과 이미지 크기는 42.6MB. 다른 Node 프로젝트의 약 1/10이다. 런타임 컨테이너 안에는 nginx와 정적 파일이 전부고, ps aux를 찍어도 nginx만 떠 있다.
실측치도 그만큼 나왔다.
- 메모리: 약 9MB
- TTFB: 약 3ms
- 리소스 요청: cpu 10m / memory 32Mi
Node가 없으니 매 요청에 React 트리를 그릴 일도, V8도, GC도 없다. 디스크에서 파일을 읽어 응답하는 게 전부다.
인증은 부모 도메인 쿠키로 자동 통과
분리하면서 가장 신경 쓴 건 인증이었다. 메인 앱과 캠페인이 다른 도메인이면 토큰 전달·CORS·SameSite 정책을 일일이 해결해야 한다.
부모 도메인 쿠키 공유로 풀었다.
- 메인 앱: 토큰을
.example.com부모 도메인 쿠키로 set - 캠페인:
event.example.com로 운영, 같은 부모 도메인이라 쿠키 자동 공유
캠페인의 axios interceptor는 쿠키에서 토큰을 꺼내 Bearer 헤더로 붙이기만 한다. 401이 나면 refresh_token으로 새 토큰을 받아 같은 부모 도메인 쿠키로 다시 set 하고 원 요청을 재시도한다. 메인 앱과 캠페인이 같은 토큰 풀을 공유하므로, 한쪽에서 갱신된 토큰을 다른 쪽에서도 그대로 쓴다.
토큰을 별도로 전달하는 작업이 없다. 별도 도메인을 쓰면서 인증은 동일 도메인처럼 동작한다.
도중에 막힌 것들
SWC 바이너리 404. Docker 빌드 중 Next.js가 SWC 바이너리를 받으려다 npm에서 404. next@13.5.11은 publish 됐는데 같은 버전의 SWC 바이너리 패키지(@next/swc-linux-x64-gnu@13.5.11)는 publish 가 누락된 상태였다. 13.5.9로 다운그레이드해서 우회했다.
CORS — wildcard + credentials 조합 금지. /me 호출에 "wildcard '_' + credentials" 에러가 났다. axios에 withCredentials: true가 박혀 있었는데, 서버는 Access-Control-Allow-Origin: _로 응답하고 있었다. 두 조합은 브라우저가 보안상 막는다. 토큰은 Bearer 헤더로 보내고 있어서 어차피 쿠키 자동 전송이 필요 없었고, withCredentials 한 줄 지워서 해결.
Rancher 503. Pod는 Ready 1/1, Service·Ingress 모두 정상인데 도메인으로 호출하면 503이었다. 처음엔 nginx-ingress의 503으로 의심했는데, curl -v로 본 응답 body가 HAProxy 기본 503 페이지였다. 클러스터까지 도달하지 못하고 앞단 LB(HAProxy)에서 막힌 거였다. 사내 인프라팀에 도메인 매핑 등록 요청 한 번으로 해결.
결과
- 메인 앱 빌드가 가벼워졌다. 캠페인 코드가 빠진 만큼 번들도, 빌드 시간도 줄었다.
- 캠페인은 독립 사이클. 한 줄 고치자고 메인 앱을 빌드할 일이 없다.
- 운영 비용은 거의 0. 컨테이너 메모리 약 9MB로 클러스터 부담이 거의 없다.
- 다음 캠페인은 폴더 추가만으로 끝.
pages/{새캠페인}/+containers/{새캠페인}/추가하고 같은 배포 흐름 그대로 가져가면 된다.
정리
분리 자체보다 그 전에 박아둔 격리 규칙이 비용을 줄였다. 외부 의존이 단 세 개로 묶여 있었기 때문에 분리가 금방 끝났다. 규칙이 없었다면 결정 자체를 미뤘을지도 모른다.
Next.js를 쓴다고 무조건 Node로 운영할 필요는 없다. 페이지의 요구사항을 다시 보고 Node가 정말 필요한지부터 묻는 게 빠른 길이었다. 캠페인은 어차피 정적이었다.