테크

2026. 07. 14

에브리유니즈 웹 아키텍처 개편기

레거시 웹 분리부터 모바일 트래픽 100% 무중단 전환까지의 기록

에브리유니즈 웹 아키텍처 개편기에브리유니즈 웹 아키텍처 개편기

에브리유니즈는 지난 2025년 6월 BI 리뉴얼에 이어, 올 6월 UI/UX 전면 개편과 기술 스택 대전환을 단행했습니다.


이번 개편을 추진하게 된 이유는 명확했습니다. 기존에는 상품 가격 옆에 붙는 안내 문구 한 줄을 고치는 데도 백엔드 코드를 만지고 서버를 통째로 다시 배포해야 했기 때문입니다. Z세대 커머스 플랫폼 ‘에브리유니즈(everyuneez)’의 웹 서비스를 떠받치는 코드베이스가 2018년 기준의 기술 스택에 머물러 있었고, 프론트엔드와 백엔드가 한 덩어리로 묶여 있었던 탓입니다.


우리는 이 레거시 웹을 프론트엔드와 백엔드로 과감히 분리하고, 프론트엔드를 Next.js 기반의 모던 스택으로 전면 재작성하는 개편 작업을 진행했습니다.


이미 사용자가 쓰고 있는 서비스를 멈추지 않고 안전하게 갈아 끼우기 위해 점진적 전환 방식을 택했습니다. 지난 6월 5일 첫 모바일 트래픽 전환을 시작으로 메인 진입 경로인 에브리타임 인앱 웹뷰까지 단 2주 만에 안정적으로 흡수했고, 마침내 6월 18일 모바일 트래픽 100% 무중단 전환을 성공적으로 마쳤습니다.


이 글은 그 개편 과정에서 내린 기술적 의사결정과 시행착오를 프론트엔드 관점에서 정리한 기록입니다. 배경과 토대(스택·모노레포·디자인 시스템)부터 화면을 그리는 핵심 엔지니어링, 마지막으로 무중단 트래픽 전환까지 우리가 문제를 풀어낸 과정을 순서대로 공유합니다.



1. 배경과 토대

1.1 레거시 분석 | 8년 누적된 부채와 노후화의 징후

1) 아키텍처의 실체

개편 과정을 이야기하려면 먼저 우리가 어떤 기술적 거미줄을 걷어냈는지, 그 실체부터 정확히 짚어보아야 합니다. 기존 에브리유니즈 서비스를 떠받치던 시스템은 다음과 같은 형태의 아키텍처를 유지하고 있었습니다.



이는 과거에 흔히 쓰이던 전형적인 '모놀리식 풀스택 SSR 애플리케이션'입니다. 서버가 Handlebars 템플릿을 해석해 완성된 HTML 화면을 브라우저로 내려보내면, 클라이언트에서는 jQuery가 그 화면 구조(DOM)를 일일이 잡아채서 버튼 클릭이나 토글 같은 역동적인 상호작용을 덧붙이는 방식이었습니다.


이때 화면마다 main.js, item.js, cart.js, buy.js 등 별도의 Webpack 스크립트 파일들이 각각 독립된 덩어리로 빌드되었으며, 서버가 가공한 데이터는 브라우저의 전역 창 객체 속에 직접 주입되는 형태였습니다.


이 구조 자체가 무조건 틀린 것은 아닙니다. 초기 빠른 서비스 런칭과 단순한 관리 관점에서는 최선의 선택이었을 것입니다. 하지만 문제는 비즈니스가 성장하는 동안 8년 동안 누적된 기술 부채가 한계점에 도달했다는 점이었습니다.


2) 노화의 4대 징후

기존 코드베이스에서 관측되던 구체적인 한계와 징후들은 크게 네 가지로 요약할 수 있습니다.


① 수명이 다한 의존성 (Dead Dependencies)

프로젝트의 설정 파일인 package.json을 열어보는 것만으로도 이 코드가 어느 과거의 시대에 멈춰 있는지 알 수 있습니다.

이처럼 관리 주기가 끝난 소프트웨어들은 최신 브라우저 환경에 대응하지 못할 뿐만 아니라, 잠재적인 보안 취약점을 안고 갑니다. 시간이 갈수록 갚아야 할 기술적 부채의 이자가 기하급수적으로 불어나는 구조였습니다.


② 프론트엔드와 백엔드의 강결합

구조적인 결함도 생산성을 발목 잡았습니다. 모든 페이지의 데이터가 서버 가동 시점에 먼저 조회되어 HTML 템플릿에 완전히 박힌 상태로 내려왔습니다. 이로 인해 프론트엔드 화면의 안내 문구 한 줄이나 스타일 하나를 고치려고 해도, 백엔드 코드를 만져야 했고 결국 서버 전체를 다시 빌드하여 배포해야 했습니다. 두 영역의 릴리스 주기(Release Cycle)가 강제로 묶여 있었던 탓입니다.


③ 타입 안전성의 부재

순수 JavaScript와 실행 시점(런타임) 중심의 동적 UI 조작에만 의존하다 보니, 특정 변수나 서버 응답 객체에 "정확히 어떤 데이터가 어떤 형태로 들어있는지"는 코드를 실제로 실행해 보기 전까지 알 길이 없었습니다. 사소한 코드를 수정하더라도 예상치 못한 곳에서 에러가 터지기 일쑤였기에, 리팩터링 작업은 늘 도박과 같았습니다.


④ 페이지 단위의 전체 리로드

화면 전환이 매끄러운 싱글 페이지 애플리케이션(SPA) 구조가 아니었기 때문에, 유저가 링크를 타고 다른 페이지로 이동할 때마다 브라우저는 HTML 문서를 통째로 다시 다운로드하고 화면을 완전히 처음부터 새로 그려야 했습니다. 이는 특히 에브리타임 앱 안에서 구동되는 모바일 웹뷰(WebView) 환경에서 체감 속도를 크게 떨어뜨리고, 장바구니나 스크롤 위치 등의 상태를 유지하기 어렵게 만드는 치명적인 약점이었습니다.


3) 개편을 위한 핵심 과제

우리는 이 고질적인 악순환을 끊어내기 위해, 두 가지 시스템 뼈대를 동시에 바꿔나갔습니다.


  1. 백엔드 독립 분리: 비즈니스 로직을 NestJS 기반의 독립된 API 서버(3-Layer 아키텍처 및 DDD 적용)로 완벽히 떼어내, 프론트엔드를 REST API 소비 구조로 독립시켰습니다.



  2. 프론트엔드 전면 재작성: 완전한 새 독립 저장소(Repository)를 구축하고, Next.js 16 + React 19 + TypeScript 조합의 모던 기술 아키텍처로 바닥부터 완전하게 새로 작성했습니다.




1.2 기술 스택 선정 | 왜 Next.js 아키텍처인가

1) 3대 비즈니스 제약조건과 프레임워크 비교

새 스택을 고를 때 우리에게는 양보할 수 없는 제약이 세 가지 있었습니다.


첫째, 커머스 특성상 상품 페이지가 검색에 노출돼야 하므로 SEO(검색 엔진 최적화)가 필수적이었습니다.


둘째, '에브리타임' 인앱 웹뷰에서 소비되는 트래픽으로 인해 SPA 수준의 매끄러운 모바일 상호작용이 필요했습니다.


셋째, 빅뱅 배포는 위험하므로 레거시와 신규를 공존시키며 점진 전환할 수 있어야 했습니다.


이 제약 조건의 교집합이 바로 React Server Components(RSC)를 지원하는 Next.js(App Router)였습니다. 완전한 CSR(Vite + React SPA)은 SEO 제약에서 탈락했고, Vue 진영의 Nuxt도 SSR 후보였지만 선택된 디자인 시스템 프리미티브(Radix UI)와 팀의 자산이 모두 React 기반이라 제외되었습니다.


같은 React-SSR 후보인 Remix와도 견줬지만, 개편 착수 시점 기준으로 RSC·스트리밍·파일 라우팅 처리 등이 가장 성숙하게 묶여 있던 쪽이 Next.js였습니다. 최종 결정의 주된 요인은 도구의 우열보다는 기존에 보유한 React 자산을 온전히 활용하기 위함이었습니다.


2) 핵심 기술 조합 요약

지표 비교만으로는 다 파악할 수 없는 도메인 맞춤형 선택의 이유들을 조금 더 상세히 기술합니다.







물론 프로덕션 커머스 플랫폼에 React 19와 Next.js 16이라는 최신 메이저 버전을 통째로 올리는 일은 분명한 리스크였습니다. 따라서 두 가지 안전장치 안에서 움직였습니다.


첫째, 도입을 보수적으로 가져갔습니다. RSC나 Suspense 같은 핵심 기능은 적극 활용하되, 실험적 API는 충분히 검증한 뒤 점진적으로 채택했습니다.


둘째, 버전을 catalog 기능으로 단일 고정해 앱 간 버전이 어긋날 여지를 없애고 업그레이드를 한 곳에서 통제하며 흔들릴 수 있는 표면적을 최소화했습니다.


"최신을 쓰되, 최신이 흔들 수 있는 표면적을 최소화한다"가 원칙이었습니다.


1.3 인프라 토대 | 모노레포 효율화와 디자인 시스템 구축

1) 모노레포 구축 및 빌드/Docker 최적화

새 프론트엔드는 단일 앱으로 구동되지 않습니다. 메인 커머스 앱(web)을 중심으로 오퍼월과 포인트숍을 담당하는 everytime-point, 독립 설문 및 폼 서빙을 위한 everyuneez-form 등이 핵심 비즈니스 로직과 패키지를 촘촘하게 공유하는 구조입니다. 우리 팀은 이 다중 애플리케이션 아키텍처를 효율적으로 묶기 위해 pnpm workspace 기반의 모노레포 환경을 구축했습니다.


everyuneez-front/
├── apps/
│   ├── web/               # 메인 커머스 앱 (Next.js 16)
│   ├── everytime-point/   # 오퍼월/포인트샵
│   ├── everyuneez-form/   # 폼 애플리케이션
│   └── storybook/         # 디자인 시스템 컴포넌트 문서화
└── packages/
    ├── design-system/     # UI 컴포넌트 및 디자인 토큰
    ├── logger/            # 환경별 공통 로깅 패키지
    ├── tailwind-config/   # 공유 Tailwind 프리셋 설정
    ├── eslint-config/     # 공통 ESLint 규칙 설정
    └── typescript-config/ # tsconfig 베이스 설정


이렇게 구조를 찢어놓았을 때 가장 먼저 마주하는 지뢰는 각 앱마다 의존성 라이브러리 버전이 미묘하게 어긋나며 발생하는 런타임 배포 사고입니다. 이를 원천 차단하기 위해 우리는 최상위 루트 명세 안에서 pnpm catalog 기능을 가동했습니다.


# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

catalog:
  react: 19.2.6
  react-dom: 19.2.6
  next: 16.2.5
  typescript: ^5.8.3
  tailwindcss: ^3.4.14


모든 앱이 개별 버전을 선언하는 대신 "next": "catalog:" 형태로 최상위 단일 출처 명세를 바라보게 조율함으로써 버전 파편화를 근본적으로 묶어냈습니다. 이렇게 정리된 모노레포의 빌드 속도를 비약적으로 올리기 위해, 다음 단계로 turbo.json 파이프라인 설정을 다듬어 캐싱 효율을 극대화했습니다.


// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      "env": ["SENTRY_ORG", "SENTRY_PROJECT", "SENTRY_RELEASE"],
      "passThroughEnv": ["SENTRY_AUTH_TOKEN"]
    },
    "dev": { "cache": false, "persistent": true }
  }
}


Next.js가 생성하는 증분 파일(.next/cache)은 역설적으로 Turborepo의 캐시 해시 키를 수시로 교란해 캐시 히트율을 떨어뜨리므로 outputs 대상에서 명시적으로 도려냈습니다. 또한 빌드 결과물에 영향을 주는 환경변수는 env에 엄격하게 가두어 캐시 키 변동에 반영하고, Sentry 인증 토큰 같은 외부 민감 키는 passThroughEnv로 안전하게 통과시켜 빌드 파이프라인의 무결성을 지켰습니다.


여기서 확보한 빌드 최적화 아이디어는 로컬 환경을 넘어, 최종 프로덕션 이미지를 구워내는 Docker 멀티스테이지 파이프라인으로 자연스럽게 이어집니다. 앱 하나를 빌드하기 위해 레포지토리 전체 소스를 복사해야 했던 비효율을 걷어내기 위해, turbo prune 명령을 중심으로 인프라 빌드 레이어를 정교하게 분리했습니다.


# Stage 1: web 앱이 의존하는 부분 그래프(소스 및 명세)만 정밀 추출
RUN turbo prune web --docker   # 결과물 -> out/json(패키지 명세), out/full(실제 소스)

# Stage 2: 추출된 의존성 명세만 먼저 복사하여 패키지 설치 및 빌드
COPY --from=builder /app/out/json/ .
RUN pnpm install --frozen-lockfile

COPY --from=builder /app/out/full .
RUN --mount=type=secret,id=sentry_auth_token,env=SENTRY_AUTH_TOKEN turbo build --filter=web

# Stage 3: standalone 산출물만 담아 가볍고 안전한 최종 실행 이미지 생성 (non-root 유저 적용)


의존성 구조 명세(out/json)만 떼어내어 모듈 설치를 먼저 진행하기 때문에, 이후 비즈니스 소스 코드가 수없이 바뀌더라도 무거운 설치(install) 레이어는 캐시를 타고 즉시 패스됩니다. Docker 레이어 캐싱과 모노레포 prune이 유기적으로 맞물리는 지점입니다.


더불어 빌드 시점에는 secret mount를 통해 Sentry 토큰의 흔적을 이미지에 남기지 않고 안전하게 주입하며, 최종 실행 단계에서는 Next.js가 제공하는 standalone 출력물만 추려내어 권한을 낮춘 별도의 비루트 유저(nextjs)로 컨테이너를 실행합니다. 인프라 수준에서도 탄탄한 방어선이 갖춰졌습니다.


2) @repo/design-system 및 토큰 계층화

인프라의 뼈대를 잡은 후 고개를 돌린 곳은 화면을 구성하는 스타일 코드 영역이었습니다. 과거 레거시 웹은 페이지마다 무질서하게 흩어진 수많은 SCSS 파일과 인라인 스타일 범벅으로 유지보수가 불가능한 상태였습니다. 우리는 이를 완전히 갈아엎기 위해 모든 디자인 토큰과 UI 컴포넌트의 명세를 단 하나의 독립 공유 패키지인 @repo/design-system으로 결집시켰습니다.


가장 먼저 집중한 것은 원시 토큰(Primitive)과 시맨틱 토큰(Semantic)의 철저한 격리였습니다. 색상이나 타이포그래피 같은 물리적인 절댓값들을 독립적으로 선언하여 관리했지만, 단순히 원시값 한 겹만 바르는 수준으로는 부족했습니다. 디자인 시스템의 진짜 목적인 "비즈니스 명세에 따른 유연한 스타일 교체"를 달성하기 위해, 우리는 의미 중심의 시맨틱 토큰 계층을 한 겹 더 두터웁게 얹었습니다.



만약 화면의 특정 회색(gray-400)이 비활성화된 글자, 보조 설명 텍스트, 레이아웃 구분선에 똑같이 쓰이고 있다면 기획이 바뀔 때 재앙이 찾아옵니다. "비활성 글자색만 조금 더 어둡게 바꿔주세요"라고 했을 때 시맨틱 계층이 없다면 gray-400 자체를 고칠 수 없기 때문입니다. 그걸 고치면 상관없는 구분선 색까지 도미노처럼 함께 바뀌므로 개발자는 전역 코드를 뒤지며 수작업으로 클래스를 찾아내야 합니다.


컴포넌트가 오직 이 용도가 명시된 시맨틱 토큰만을 소비하게 만들면 "비활성" 정의가 바뀔 때 text-text-disablegray-500 매핑 파일 한 곳만 고치면 끝납니다. 같은 회색을 공유하던 다른 레이아웃 요소들은 보존하고 마크업 코드 자체로 설계의 의도가 선명하게 드러납니다.


이러한 토큰 체계 위에서 UI 컴포넌트를 설계할 때는 바닥부터 만드는 바퀴의 재발명을 멈추고, 웹 표준과 상호작용 접근성을 보장해 주는 검증된 오픈소스와 합성하는 전략을 취했습니다. 모바일 웹 환경에서 포커스 트랩이나 키보드 내비게이션 등의 접근성을 맨땅에서 직접 구현하면 반드시 버그가 터지기 때문입니다.


이에 Dialog, Select, Tabs, Tooltip 등 인터랙션 제어가 까다로운 코어 프리미티브는 전 세계적으로 검증된 헤드리스(Headless) 라이브러리인 Radix UI에 전적으로 위임했습니다. 기능적 토대와 접근성은 Radix UI에 맡기고, 우리는 그 위에 스타일과 브랜드 규칙만 얹는 구조입니다. 이 스타일 합성은 class-variance-authority(cva)tailwind-merge를 결합한 공통 cn 유틸로 처리합니다.


// packages/design-system/src/components/Chips/style.ts
import { cva } from "class-variance-authority";

const chipsVariant = cva(
  [
    "inline-flex", "items-center", "h-32", "rounded-full", "border",
    "border-line-default-normal", "text-text-tertiary", // <- 시맨틱 의미 토큰 매핑
    "eds-btn-regular-3"                                 // <- 타이포그래피 토큰 매핑
  ],
  {
    variants: {
      selected: {
	      true: ["border-brand-default", "text-brand-default", "font-semibold"],
	      false: []
	    },
      disabled: {
	      true: ["border-line-default-normal", "text-text-disable"],
	      false: []
	    },
    },
    defaultVariants: {
	    selected: false,
	    disabled: false
	  },
  }
);


selected, disabled 같은 논리 상태 축을 타입 안전한 Boolean variant로 선언하고, 각 상태에 절대 클래스가 아닌 의미 토큰을 명확히 매핑했습니다. 컴포넌트의 props 타입이 이 축을 따라 자동으로 추출되므로, 존재하지 않는 엉뚱한 variant 조합은 컴파일 단계에서 엄격하게 차단됩니다.


3) 실전 모바일 웹뷰 환경의 3대 지뢰 해결

데스크탑 브라우저와 달리, 실제 에브리타임 인앱 웹뷰 및 iOS/Android 모바일 기기 특유의 런타임 환경은 디자인 시스템에 수많은 예외 케이스를 던져주었습니다. 우리는 이를 도메인 화면 영역으로 전염시키지 않고, 공통 아키텍처 계층에서 한 번에 처리하는 것을 원칙으로 삼았습니다.









2. 핵심 엔지니어링

튼튼한 토대를 깔았으니, 이제 그 위에서 실제로 화면을 그리는 핵심 엔지니어링을 살펴보겠습니다. 레거시 웹의 가장 큰 약점은 분명했습니다. 타입 안전성이 없었고(순수 JS + DOM 조작), 렌더링 모델이 경직(전체 페이지 SSR + 전체 리로드)되어 있었습니다.


새로운 프론트엔드 아키텍처는 이 두 가지 고질적인 한계를 정면으로 돌파합니다. Next.js App Router를 도입해 렌더링 경계를 유연하게 가져가고, Zod를 활용해 외부 데이터가 들어오는 경계를 실행 시점(런타임)까지 완벽하게 방어하여 과거의 구조적 결함을 해결했습니다.


2.1 렌더링 전략 | RSC와 클라이언트의 선언적 경계 수립

App Router의 핵심은 "어디까지 서버에서 그리고, 어디부터 브라우저로 넘길 것인가"를 컴포넌트 단위로 결정하는 것입니다. 여기서 React Server Components(RSC)는 서버에서만 실행되는 컴포넌트로, 그 코드(JS 번들)가 클라이언트에 내려가지 않습니다. 데이터를 직접 읽어와 렌더하되 컴포넌트 코드를 브라우저로 보내지 않으므로 번들 크기와 첫 화면 속도에 유리합니다.


수립된 명확한 렌더링 원칙은 다음과 같습니다.


  1. 데이터를 읽어와 첫 화면의 뼈대를 그리는 일은 서버 컴포넌트(RSC)에서 처리한다.


  2. 실시간 데이터 갱신이나 유저 상호작용 영역은 클라이언트 컴포넌트("use client") + TanStack Query로 격리한다.

  3. 세션처럼 매 요청마다 달라지는 값은 서버에서 읽어 클라이언트로 주입한다.


1) 루트 레이아웃의 유저 세션 동적 주입 구조

커머스 서비스에서 가장 까다로운 동적 데이터는 바로 '로그인한 사용자 정보(세션)'입니다. 만약 이를 브라우저가 마운트된 이후에 가져오는 일반적인 CSR 방식으로 구현하면, 유저가 페이지에 진입할 때마다 로그인 버튼과 로그아웃 버튼이 순간적으로 교체되며 화면이 깜빡이는 고질적인 UI 문제가 발생합니다.


이 문제를 해결하기 위해 최상위 루트 레이아웃(app/layout.tsx)에서 서버가 세션 정보를 먼저 조회한 뒤, 그 값을 클라이언트 상태 관리 스토어(Zustand)의 초기값으로 꽂아 내려보내도록 설계했습니다.


// app/layout.tsx
export default async function RootLayout({ children }: { children: React.ReactNode }){
  const [session, { deviceType }] = await Promise.all([
    fetchUserSession(),
    getDeviceInfo(),
  ]);
  const userInfo = 유저정보 // session 데이터를 파싱하여 유저 정보 추출

  return (
    <html lang="kr">
      <body>
        <QueryProviders>
          <UserStoreProvider initialUserInfo={userInfo}>
            <div className="rootContainer">{children}</div>
          </UserStoreProvider>
        </QueryProviders>
      </body>
    </html>
  );
}


서버가 이미 "이 사용자는 현재 로그인 상태다"라는 사실을 이미 인지한 상태로 HTML을 내려보내므로 클라이언트는 하이드레이션(Hydration) 시점에도 깜빡임 없이 완성된 올바른 UI를 즉시 보여줍니다. 과거 전역 객체 주입을 타입 안전한 구조로 깔끔하게 대체한 셈입니다.


⚠️ force-dynamic 설정에 대한 고찰과 향후 과제

세션 쿠키를 읽는 순간 해당 라우트는 동적으로 전환됩니다. 문제는 이를 루트 레이아웃에 걸면 그 아래 트리 전체가 동적이 되어 앱 전역의 정적 생성 및 ISR 캐싱을 포기하게 된다는 점입니다. 더 나은 방향은 세션을 읽는 좁은 경계만 동적으로 격리하고 상품 페이지 같은 경로는 정적으로 남기는 것입니다. 정적 셸(Shell)과 동적 구멍(Hole)을 한 화면 안에서 섞도록 해주는 Next.js 16의 PPR(Partial Prerendering) 도입을 중요한 성능 과제로 인식하고 있습니다.


2) React cache로 요청 내 중복 API 호출 제거

세션 데이터는 레이아웃뿐만 아니라 트리 깊숙한 여러 서버 컴포넌트에서 필요합니다. 매번 API를 때리면 자원 낭비이므로, React의 cache를 도입해 단일 요청 생애주기 동안 결과를 메모이즈합니다. 중복 제거가 정상 작동하려면 모듈 레벨에서 한 번만 래핑해 같은 참조를 공유해야 합니다.


import { cache } from "react";

// React.cache로 감싸 단일 요청(Request Life Cycle) 안에서 결과를 메모이즈
export const getUserSession = cache(async () => {
  return await schemaApiClient({
    method: "get",
    url: "user session api endpoint",
    responseSchema: sessionResponseSchema, // 데이터 구조 검증용 Zod 스키마 바인딩
  });
});


3) Suspense 기반의 선언적 데이터 스트리밍(Streaming)

상호작용 영역은 클라이언트로 내리되, 데이터가 로딩되는 동안 보여줄 영역 선언은 @suspensive/react-query 라이브러리로 단순화했습니다. 명령형 isLoading 분기문 대신 바깥에 경계를 긋는 방식이라 코드가 깔끔해집니다. 실제 서버에서 클라이언트로 데이터가 순차적으로 흘러 들어가는 과정은 React 19 스트리밍 SSR 파이프라인이 수행합니다. 준비되지 않은 영역은 Suspense fallback을 먼저 flush하고, 데이터가 준비되는 대로 청크가 흘러나옵니다.


2.2 타입 안정성 | Zod 스키마 기반 엔드투엔드 계약 방어

에브리타임 운영사 비누랩스 백엔드가 Zod로 API 경계를 지키는 것처럼, 에브리유니즈를 운영하는 비누커머스 프론트엔드 역시 동일한 아키텍처 원칙을 수립했습니다. TypeScript 타입 시스템은 컴파일 타임에만 유효할 뿐, 외부에서 유입되는 런타임 데이터의 실제 형태는 직접 실행해 보기 전까지 결코 신뢰할 수 없기 때문입니다.


이에 우리는 API 통신 레이어 자체를 Zod 스키마 기반으로 구축하여, ‘요청 전송 전 검증’과 ‘응답 수신 직후 검증’이라는 철저한 양방향 가드레일을 완성했습니다.


1) 스키마 기반 API 클라이언트 도입

우리 팀은 수작업 타입 선언과 런타임 검증을 하나로 묶어주는 스키마 기반의 공통 API 클라이언트(schemaApiClient)를 구축했습니다. 요청과 응답 스키마를 제네릭 타입으로 받아 런타임 데이터 검증과 정적 타입 추론을 동시에 처리하는 구조입니다. 이 덕분에 호출하는 쪽에서는 별도의 타입 선언 없이 스키마만 넘겨주면 반환 타입이 z.infer로 정확히 추론됩니다. 스키마 자체가 곧 타입이자 검증기가 되는 셈입니다.


async function schemaApiClient<TReq, TRes>(
	config: ValidatedClientConfig<TReq, TRes>
): Promise<SchemaApiClientReturn<TRes>>{
  const { requestSchema, responseSchema, data, ...axiosConfig } = config;

  // ① 요청(Request) 가드: 전송 전 스키마가 있다면 parse 수행 (규격에 안 맞으면 전송 전 에러 처리)
  const requestBody = requestSchema && data !== undefined
    ? requestSchema.parse(data)
    : data;

  const { statusCode, errorCode, message, result } = await axiosClient({
	  ...axiosConfig,
	  data: requestBody
	});
  if (statusCode !== "SUCCESS") throw new Error(`[${errorCode}] ${message}`);

  // ② 응답(Response) 가드: safeParse로 유효성 정밀 검증 -> 컴파일러가 반환 타입을 z.infer<TRes>로 자동 추론
  if (responseSchema) {
    const parsed = responseSchema.safeParse(result);
    if (parsed.success) return parsed.data;

    // 검증 실패 시 환경별(운영 vs 개발) 대응 분기 처리 — 아래 설명 참고
  }
  return result;
}


2) 환경별(운영 vs 개발) 스키마 검증 실패 대응 전략

응답 스키마 검증이 실패했을 때, 유저 안정성과 개발 효율을 모두 잡기 위해 행동 수위를 명확히 다르게 설계했습니다.




3) 서버와 클라이언트의 Axios 인스턴스 이원화

동일한 인터페이스를 공유하지만 Node.js 기반 환경(RSC/Route Handler)과 브라우저 런타임 환경은 인프라 구성 측면이 근본적으로 다릅니다. 각 환경에 최적화된 라우팅과 헤더 보안 전략을 위해 인스턴스를 철저히 이원화했습니다.


모든 요청에 고유한 X-Request-Id를 심어두었기 때문에 에러가 발생했을 때 프론트 로그–백엔드 로그–Sentry를 단 하나의 요청 ID로 꿰어 추적할 수 있습니다.


4) TanStack Query와 라이브러리의 도메인 단위 결합

과거 jQuery 시절 무질서하게 흩어졌던 비동기 구조를 개선하기 위해, 모든 쿼리를 분리해 도메인 폴더 내부에 격리하고 queryOptions 빌더 패턴을 적용했습니다. 오타로 인한 캐시 미스나 키 충돌을 원천 차단하기 위해 글로벌 상수로 쿼리 키 관리를 집중화했습니다.


import { queryOptions } from "@tanstack/react-query";
import queryKey from "@/constants/queryKey";

export default function orderGroupListQueryOptions({
	userId,
	page = 1,
	limit = 10
}: Props){
  return queryOptions({
    // 오타로 인한 캐시 미스나 키 충돌을 원천 차단하기 위해 글로벌 상수로 키 관리 집중화
    queryKey: [queryKey.GET_ORDER_GROUPS, userId, { page }],
    queryFn: () => fetchOrderGroups({ page, limit }),
    retry: 1
  });
}


이 패턴의 진짜 진가는 서버 prefetch와 클라이언트 hydration이 맞물리는 순간에 드러납니다.


서버 컴포넌트가 prefetchQuery로 미리 채워둔 캐시 데이터를 dehydrate() 유틸로 직렬화해 내려보내면, 브라우저는 를 해석해 클라이언트 스토어에 그대로 복원(hydrate)해 냅니다. 이후 클라이언트 컴포넌트가 useSuspenseQuery로 데이터를 읽을 때, 추가 네트워크 요청 없이 즉시 캐시를 적중(Hit)시킵니다.


단순히 "같은 키를 쓴다"고 자동으로 이어지는 것이 아닙니다. queryOptions라는 명세 객체와 dehydrate / HydrationBoundary라는 직렬화 다리가 서버와 클라이언트 사이를 정교하게 연결하고 있기에 가능한 마법입니다.


입력 폼 영역 역시 react-hook-form@hookform/resolvers 조합을 채택하고 API 명세와 동일한 Zod 스키마를 검증 엔진으로 바인딩했습니다. 이 아키텍처 덕분에 "화면에서 통과한 값은 서버 요청에서도 통과한다"는 일관성이 자연스럽게 따라옵니다.


2.3 코드 지속성 | 코드베이스를 지탱하는 4가지 구조적 의사결정

마지막으로 화면 뒤에서 코드 베이스를 지탱하는 네 가지의 구조적 결정을 차례대로 짚어보겠습니다.


1) 도메인 주도 디렉토리 구조 (Domain-Driven Directory)

src/domains/ 아래 비즈니스 맥락에 맞춰 총 17개의 독립 도메인 공간(order, item, cart 등)을 나열하고 하위 레이어 구조를 정형화했습니다. 백엔드 시스템 패키지 경계와 싱크로율을 100% 일치시킨 설계로, 주문 도메인을 만질 때 프론트–백엔드가 같은 언어로 같은 경계를 이야기하게 되어 소통 비용이 극적으로 절감됩니다.


2) 다중 결제 모듈 처리: 전략 패턴 (Strategy Pattern) 도입

이니시스, 토스, 카카오페이, KB페이 등 결제 수단마다 연동 방식이 다릅니다. 컴포넌트 내부에 비대하게 if (method === ...) 분기문을 늘어놓으면 결제 로직 코드가 오염되고 리스크가 커집니다. 객체지향 디자인 패턴인 전략 패턴(Strategy Pattern)을 도입해 이 결합도를 완전히 찢어냈습니다.


// 구조적 가이드 인터페이스 규격 선언
interface PaymentStrategy {
  requestPayment(payload: PaymentRequest): Promise<PaymentResult>;
}

// 결제 프로세서는 오직 인터페이스 규격에만 의존 (결합도 분리)
class PaymentProcessor{
  constructor(private paymentStrategy: PaymentStrategy) {}

  public executePayment(payload: PaymentRequest) {
    return this.paymentStrategy.requestPayment(payload);
  }
}


새 결제사가 추가돼도 PaymentStrategy 인터페이스를 구현한 전략 클래스 파일 하나만 더하면 되며, 기존 핵심 실행 엔진인 PaymentProcessor 코드는 손대지 않는 OCP(개방-폐쇄 원칙)를 준수했습니다.


3) Sentry 노이즈 박멸: Control-Flow 예외 필터링 구축

Next.js의 notFound()redirect()는 에러가 아니라 제어 흐름을 위해 의도적으로 예외를 던지는 throw 메커니즘입니다. 툴을 순정 상태로 두면 Sentry의 captureRequestError가 이를 진짜 치명적 에러로 보고하여 알람 폭탄을 유발하고 관측성을 훼손합니다.


에러 인터셉터 진입점에 필터 레이어를 구축해 Next.js 고유의 내부 다이제스트 코드를 분석하여 수집 대상에서 제외하도록 차단했고, PAYMENT_TIMEOUT처럼 정상 흐름으로 처리되는 비즈니스 에러 역시 무시 목록으로 걸러냈습니다. 알람 노이즈를 걷어냄으로써 경보 시스템의 신뢰성을 확보하고, 진짜 장애에만 집중할 수 있는 관측 환경을 구축했습니다.


4) 이미지 아키텍처: next/image 대신 CDN + 조합 채택

Next.js의 내장 컴포넌트는 간편하지만, 이미지 리사이징 연산을 웹 서버 CPU 자원에 의존하기 때문에 인프라 부하를 원천 차단하기 위해, 연산 책임을 글로벌 인프라 계층(CloudFront CDN 엣지)으로 100% 위임하는 독립형 CdnImage 컴포넌트를 설계했습니다. 브라우저가 지원하는 최신 압축 포맷(AVIF/WebP)을 스스로 골라 다운로드하도록 마크업 파이프라인을 짰습니다.


// components/CdnImage — 핵심 아키텍처 구조
export default function CdnImage({ sources, alt }: Props){
  return (
    // display: contents 설정을 부여해 래퍼 picture 박스가 브라우저 레이아웃 계산을 방해하지 않도록 증발
    <picture className="contents">
      <source type="image/avif" srcSet={sources.avif} />
      <source type="image/webp" srcSet={sources.webp} />
      <img src={sources.fallback} alt={alt} loading="lazy" />
    </picture>
  );
}


하지만 세상에 공짜는 없었습니다. 편리한 프레임워크의 도구를 버리는 순간, next/image가 백스테이지에서 자동으로 챙겨주던 핵심 성능 가드레일(CLS 방지, LCP 우선순위 제어, DPR별 대응)을 한순간에 잃어버리기 때문입니다. 우리는 이 잃어버린 성능 지표들을 컴포넌트 내부에 항목별로 직접 메워 넣는 수작업을 통해 사수했습니다.






3. 무중단 트래픽 전환 가이드

새 프론트엔드 서비스를 다 만들었다고 해서 과연 개편이 끝난걸까요? 코드를 새로 짜는 것보다 어려운 일은, 이미 수많은 유저가 사용 중인 서비스를 눈치채지 못하게 갈아 끼우는 일입니다. 기존 레거시 웹은 AWS Elastic Beanstalk(이하 BS) 위에서 돌고 있었고, 새 프론트엔드는 AWS ECS(이하 ECS) 환경 위에 떴기에 인프라 환경이 아예 다른 두 시스템을 한동안 공존시켜야 했습니다.


우리는 점진적 배포, 유저 단위 일관성 유지, 원클릭 즉시 롤백이라는 세 가지 조건을 동시에 만족하기 위해 AWS Application Load Balancer(ALB) 리스너 규칙사용자 단위 Sticky 쿠키의 조합으로 이 문제를 풀었습니다.


이 장에서 설명하는 가중치 지표나 '추첨' 프로세스는 트래픽을 점진적으로 끌어올리던 전환 기간의 매커니즘을 설명하기 위한 기술적 예시입니다. 현재는 모바일 트래픽 전량을 성공적으로 이주하여 가중치 100%로 ECS 환경에서 전량 안정 서빙 중인 상태이며, 본 가이드는 이 대이주 과정을 완수하며 얻은 기록과 회고입니다.


3.1 왜 요청 단위 가중치가 아니라 ‘사용자 단위 Sticky’인가?

인프라 수준에서 가장 단순한 점진 전환은 ALB의 가중치 기반 타깃 그룹 분배(Weighted Target Group)입니다. "트래픽의 10%를 ECS로 포워딩"하는 방식이죠. 하지만 가중치 분배는 '요청(Request) 단위'로 추첨이 이루어집니다. 한 유저가 쇼핑을 하며 화면을 이동할 때 메인 페이지 요청은 신규 ECS 서버에 당첨되었다가, 다음 상품 상세 페이지 요청은 레거시 BS 서버로 떨어질 수 있다는 뜻입니다.


새로운 ECS 아키텍처와 과거 BS 모놀리스 아키텍처는 유저 세션 토큰 체계도, URL 라우팅 경로 구조도, 디자인 시스템 레이아웃도 완전히 다릅니다. 한 명의 유저가 두 대륙 사이를 탁구공처럼 오가면 로그인이 풀리거나 화면이 터집니다. 한 번 ECS에 발을 들인 사용자는 계속 ECS에 머물게 하는 Sticky 전략이 필수적이었던 이유입니다.


이 문제를 풀기 위해 최초 진입 시점의 첫 추첨은 로드밸런서 가중치로 수행하되, 당첨되어 ECS에 도달한 사용자 브라우저에는 특수한 고유 식별 쿠키를 심어주는 방식을 고안했습니다. 다음 요청부터는 로드밸런서가 가중치 추첨 룰을 타기 전에 쿠키 값을 먼저 검사하여, 해당 쿠키를 가진 유저는 추첨 없이 신규 ECS 서버로 즉시 패스시킵니다. 쿠키는 사용자 브라우저에 종속되므로, 이러한 쿠키 기반 인프라 라우팅을 통해 별도의 복잡한 복제 서버 없이도 자연스럽게 사용자 단위 Sticky 아키텍처를 달성할 수 있었습니다.


3.2 라우팅 메커니즘 | ALB 룰과 애플리케이션 쿠키 계약

무중단 대이주 아키텍처는 AWS CDK로 관리되는 ALB 리스너 룰과 Next.js 미들웨어의 쿠키 제어가 특수한 라우팅 쿠키 계약을 매개로 동작합니다. 기존 BS 서비스가 사용 중이던 메인 ALB 리스너 설정 안에 쿠키 문자열과 유저 에이전트(UA)를 교차 매칭하는 조건부 규칙들을 주입했습니다.


1) AWS ALB 조건부 리스너 규칙 라우팅 명세

ALB 리스너 규칙은 우선순위(Priority) 숫자가 낮은 오름차순 방향으로 패킷을 엄격하게 평가합니다.

이 라우팅 테이블 파이프라인이 안전하게 작동하기 위해서는 몇 가지 정교한 인프라 디테일이 뒷받침되어야 합니다.


우선 브라우저가 송신하는 쿠키 헤더에는 a=1; 쿠키 식별자=ECS식별값; b=2처럼 여러 쿠키가 무작위로 섞여 들어옵니다. ALB가 값을 정확히 매칭하지 못하고 룰이 깨지는 것을 막기 위해 쿠키 값 양옆에 와일드카드 기호(*cookieName=value*)를 두어 헤더 내부 어디에 쿠키가 있든 인프라가 인식하도록 방어했습니다.


또한 ECS Sticky 규칙에 모바일 User-Agent(UA) 조건을 AND로 묶은 것도 중요한 가드레일입니다. ECS 쿠키를 가진 사용자가 데스크탑으로 접근했을 때 준비되지 않은 모바일 화면이 노출되는 것을 막기 위함입니다. "ECS 쿠키 AND 모바일 UA"일 때만 신규 스택으로 보내고, 데스크탑 접근은 매칭에서 누락시켜 최하단 Default 규칙을 통해 BS로 흘려보냈습니다. 반면 레거시 BS는 데스크탑과 모바일을 모두 지원하므로 레거시 Sticky 룰에는 UA 조건을 걸지 않았습니다.


이때 ALB 자체의 한계도 고려해야 했습니다. ALB는 규칙당 조건 값 5개, 와일드카드 6개 제한이 있어 쿠키 1개와 수많은 모바일 UA를 단 하나의 룰에 다 담을 수 없습니다. 이를 우회하기 위해 UA 묶음별로 룰을 쪼개어 분할 등록했습니다.


마지막으로 가장 강력한 방어선은 우선순위에 배치한 미구현 API 보호막입니다. 아무리 가중치 추첨에 당첨된 완벽한 ECS Sticky 사용자라 할지라도, 아직 ECS로 완전히 이주하지 않은 레거시 API 경로로 접근하면 무조건 BS로 패킷을 강제 백업 토스하여 부분 전환기에 유저가 404 에러를 맞닥뜨리는 현상을 원천 차단했습니다.


2) 애플리케이션 계층의 쿠키 발행 메커니즘

ALB 인프라가 길잡이 역할을 하는 사이 쿠키를 유저 브라우저에 구워주는 실제 연산 책임은 Next.js 애플리케이션 레이어의 글로벌 인터셉터인 미들웨어(proxy.ts)가 담당합니다.


가중치 추첨을 뚫고 처음으로 신규 ECS 서버 컨테이너에 도달한 요청에 대해 미들웨어는 HTTP 응답 헤더에 Sticky 라우팅용 영속 쿠키값을 셋팅하여 내려보냅니다. 이 쿠키를 최초 진입 시 단 한 번만 굽는 게 아니라 유저가 ECS 웹 서버에 요청을 보낼 때마다 매번 만료 시계를 최신으로 연장해 재발급하는 Sliding Window 방식을 채택했기 때문에, 활성 유저라면 30일의 수명이 계속 리셋되어 마이그레이션 전체 기간 동안 낙오 없이 신규 시스템에 끈끈하게 고정 상태를 유지하게 됩니다.


3.3 비대칭 Sticky 전략 | 한 방향으로만 흐르는 깔대기 구축

단순히 각자 시스템에 유저를 가둬두기만 하면 점진적 인구 이주가 일어나지 않습니다. 의도했던 설계는 시간이 흐를수록 레거시 대륙의 유저들이 신규 아키텍처 대륙 쪽으로 자연스럽게 빨려 들어와 흡수되는 단방향 깔때기 구조였습니다. 이를 구현하기 위해 BS 진영과 ECS 진영의 쿠키 만료 및 갱신 정책을 철저하게 의도적으로 비대칭(Asymmetric)으로 설계했습니다.



[신규 모바일 유저 진입 (라우팅 쿠키 없음)]
   │
   ▼
[Weighted 룰 진입: 인프라 비율 추첨 가동]
   ├── ⓐ 당첨 (예: 10% 확률 성공) -> ECS 타깃 그룹 포워딩
   │     │
   │     └─► Next.js 미들웨어가 '쿠키 식별자=ECS값 (30일 슬라이딩)' 쿠키 발급
   │           │
   │           └─► 다음 페이지 이동부터는 'ECS Sticky 룰'에 백전백승 매칭
   │                 └─► [결과] 이탈 없이 완전히 신규 시스템에 정착
   │
   └── ⓑ 낙첨 (예: 90% 확률 실패) -> Beanstalk 타깃 그룹 포워딩
         │
         └─► 레거시 서버가 하루(24시간) 절대 만료 스펙의 'BS 전용 쿠키' 발급
               │
               └─► 'BS Sticky 룰'에 매칭되어 당분간 레거시 화면 안정적 서빙
                     │
                     └─► [시간 경과] 하루가 지나 재방문 없으면 레거시 쿠키 소멸
                           │
                           └─► 다음 재진입 시 다시 쿠키 없는 상태로 [최상단 추첨 풀]로 복귀
                                 └─► 점진적으로 ECS 대륙에 당첨되어 완전히 흡수될 기회를 재획득


이 비대칭 깔때기 구조 덕분에 인프라 운영자는 세션 깨짐 장애 걱정 없이 가중치 분배 슬라이더 비율만 서서히 올려주는 것만으로 대규모 유저 마이그레이션을 안전하게 지휘할 수 있었습니다. 장애 감지 시 가중치를 0으로 꺾고 리스너 룰을 걷어내는 실시간 원클릭 롤백 라인도 확보했습니다.


3.4 실전 대이주 과정의 지뢰 | “쿠키 없는 Redirect”가 만든 404 에러

개편 진입 경로를 정리하면서 / 주소로 들어온 유저를 메인 화면인 /main으로 리다이렉트해야 했습니다. 처음에는 너무나 당연하게 next.config.jsredirects() 옵션을 사용했습니다. 하지만 가중치 추첨을 뚫고 신규 ECS로 들어온 유저들이 첫 진입부터 404 에러 화면을 맞닥뜨리는 치명적인 버그가 터졌습니다.


1) ❌ 버그가 터진 이유: Next.js의 실행 순서

Next.js 내부의 요청 처리 순서는 redirects() → 미들웨어(proxy)입니다. redirects()가 먼저 실행되어 302 응답을 브라우저로 튕겨내 버리면, 아래에 있는 미들웨어는 구경도 못 하고 통신이 종료됩니다. 이로 인해 다음과 같은 파멸의 도미노가 작동했습니다.


  1. 유저가 / 요청 (가중치 당첨으로 신규 ECS 서버 도달)

  2. next.config.jsredirects()가 먼저 가동되어 쿠키를 굽지도 못한 채 /main으로 302 응답 발송

  3. 브라우저가 쿠키가 없는 상태로 /main을 다시 요청함

  4. ALB가 쿠키가 없으니 다시 1000번대 가중치 추첨 규칙을 평가해야 하는데, /main은 진입 경로(추첨 대상) 목록에 없음

  5. 결국 최하단 Default 규칙에 걸려 레거시 BS 서버로 패킷이 전달됨

  6. 하지만 레거시 시스템에는 /main이라는 라우팅 경로가 존재하지 않음 ➔ 최종 404 Not Found 에러 발생


2) ⭕ 해결 방법: 리다이렉트 권한을 미들웨어로 일원화

해결책은 next.config.js에서 리다이렉트를 걷어내고, 글로벌 미들웨어 내부에서 리다이렉트 주소를 직접 제어하는 것이었습니다. 미들웨어에서 주소를 옮기면, 브라우저로 튕겨 나가는 302 응답 헤더에 Sticky 쿠키(setStickyRoutingCookie)를 안전하게 실어 보낼 수 있기 때문입니다.



3) 💡 핵심 교훈

점진 전환 중에는 "쿠키를 싣지 않고 나가는 모든 응답"이 곧 라우팅 누수를 의미합니다. 리다이렉트, 헤더 변조, 리라이트 연산이 프레임워크 미들웨어 기준 전-후 어느 생애주기에서 실행되는지 정확히 꿰뚫고 있어야 이러한 인프라 유령 버그를 사전에 완벽히 방어할 수 있습니다.


3.5 트래픽 전환 관측과 안전하게 멈추기

전환의 본질은 단순히 전환 비율을 올리는 행위가 아니라, "올린 결과를 보고 계속할지 멈출지 판단하는 루프"에 있습니다. 이 판단 루프를 정상적으로 가동하기 위해 우리는 두 종류의 신호를 설계했습니다.


1) 비즈니스 신호: site_type 마커를 통한 데이터 분리

신규 프론트엔드는 구글 태그 매니저(GTM) 초기화 시점에 출처 마커(site_type)를 심어서 발송합니다. 반면 레거시는 이 마커를 보내지 않으므로 GTM 기본값으로 잡힙니다. 이 간단한 장치 덕분에 유저들이 두 대륙으로 찢어져 쇼핑을 하더라도, 동일한 분석 파이프라인 안에서 신규와 레거시 트래픽을 명확히 분리해 전환율과 행동 지표를 비교 관측할 수 있었습니다.


2) 운영 신호: CloudWatch 알람과 자동 페일오버(Fail-Over)

비즈니스 지표가 좋아도 ECS의 에러율이나 응답 시간이 나쁘면 실패한 전환입니다. 우리는 시스템의 건전성을 확보하기 위해 두 단계의 운영 신호 방어선을 구축했습니다.




다만 가중치를 되돌리는 롤백 자체는 알람을 받은 운영자가 직접 수동으로 제어했습니다. 즉, "헬스체크 단의 자동 차단 + 알람 기반의 수동 램프업(ramp) 조정" 조합을 택한 것입니다. 완전한 자동 롤백은 남은 과제였기에, 리스크 관리를 위해 전환 가중치는 의도적으로 천천히 올렸습니다.



4. 아키텍처의 지속 가능성을 위한 추후 과제

개편은 끝이 아니라 시작입니다. 지금 시점에 우리가 인지하고 있는 한계와 다음 할 일을 정리해 봅니다.


1️⃣ 핵심 비즈니스 도메인 자동화 테스트 구현 (Playwright/E2E)

현재 apps/web에는 단위/통합/E2E 테스트 프레임워크가 들어가 있지 않습니다. 결제·주문 같은 핵심 플로우를 자동화된 테스트 없이 트래픽 전환까지 밀어붙인 것은 우리가 감수한 위험이었습니다. 그 공백을 타입 시스템, Zod 런타임 검증, Sentry 에러 추적, 강화된 알람으로 메웠으나 분명한 한계가 있습니다. 타입과 스키마 검증은 데이터의 형태(Shape)만 막을 뿐, 결제 멱등성·중복 주문·금액 계산 같은 도메인 정합성은 막지 못하기 때문입니다. 결제와 주문 핵심 플로우의 E2E 및 계약 테스트를 확보하는 것이 가장 시급한 부채입니다.


2️⃣ 시각적 회귀 테스트의 CI 릴리스 게이트 자동화 구성

디자인 시스템은 현재 Storybook + Chromatic으로 문서화와 스냅샷 관리를 하고 있지만, Chromatic 실행이 아직 수동입니다. 동료 개발자가 풀 리퀘스트(PR)를 생성해 공유 UI 컴포넌트를 수정할 때마다 GitHub Actions CI 러너가 자동으로 시각적 변경을 감지하고, 승인 없이는 배포 병합을 차단하는 CI 게이트로 자동화 수준을 끌어올려야 합니다.


3️⃣ 전환 완료 후의 정리 (Cleanup)

이번 전환을 위해 만든 코드 중 상당수는 의도적으로 한시적인 코드들입니다. proxy.ts 미들웨어 내부의 레거시 경로 리다이렉트, Sticky 라우팅 인프라 스택과 쿠키 발급 로직, ECS 미구현 API를 BS로 강제하는 룰 등이 이에 해당합니다. 전환이 100%에 도달한 현시점에서 이들을 방치하면 새로운 기술 부채가 됩니다. "언제, 어떤 조건에서 걷어낼지"를 정해두고 정리하는 것까지가 전환 작업의 일부입니다.


4️⃣ API 계약의 자동화 (Codegen)

지금은 Zod 스키마를 프론트엔드에서 손으로 직접 작성합니다. 백엔드가 OpenAPI 스펙을 제공하는 만큼, 스펙으로부터 타입과 스키마를 생성(Codegen)하면 프론트–백엔드 계약 드리프트를 더 줄일 수 있습니다.


5️⃣ 정량적 성능 측정 체계 구축

이번 개편의 효과를 깔끔한 before/after 수치로 제시하기는 어렵습니다. 레거시는 BS 기반이라 지표 자체가 없었고, 무엇보다 렌더링 패러다임이 통째로 바뀌어(전체 페이지 SSR + 전체 리로드 → RSC + 클라이언트 라우팅) 같은 잣대로 비교하는 것은 오해를 부릅니다. 전체 리로드 제거, 코드 스플리팅, 이미지 AVIF/WebP 협상 도입 등 달라진 점은 분명하지만, 이를 숫자로 책임 있게 말하려면 측정 체계부터 갖춰야 합니다. 현재 번들 애널라이저, Lighthouse CI, 성능 예산이 붙어 있지 않습니다. 신규 앱 기준의 LCP, 번들 크기, 이미지 전송량을 지속 추적하는 체계를 세우는 것이 다음 숙제입니다.



지금까지 에브리유니즈 서비스의 웹 개편을 프론트엔드 관점에서 정리해 보았습니다.


기술 스택을 바꾸는 일의 본질은 "최신 도구를 쓰는 것"이 아니라, 각 문제에 맞는 도구를 고르고 그 경계를 정확히 긋는 것이라고 생각합니다. 모노레포의 경계, RSC와 클라이언트의 경계, 타입과 런타임의 경계, 그리고 레거시와 신규 사이 트래픽의 경계까지, 이 글이 비슷한 전환을 고민하는 분들께 하나의 참고 사례가 되기를 바랍니다.


-


Written by 김민석 | 비누커머스 소프트웨어 엔지니어

학생들이 매일 쓰는 서비스를, 더 빠르고 안정적으로 만드는 데 집중합니다.

캠퍼스 라이프의 시작과 끝엔 언제나 에브리유니즈, 많은 이용 부탁드립니다.