레거시 백오피스를 점진적으로 마이그레이션하기


모노레포 전환부터 메뉴 단위 이관까지

들어가며

저희 회사에는 오래된 레거시 백오피스가 있었습니다. Vue 2 + Express로 구성된 이 프로젝트는 원래 중앙 개발실 체제일 때 만들어진 것으로, 회사 산하의 여러 서비스(A서비스, B서비스, C서비스 등)가 공유하는 거대한 모노레포였습니다. 각 서비스별로 서버와 프론트엔드 폴더가 분리되어 있고, 40개 이상의 도메인 모듈이 공유 코드로 존재하는 구조였습니다.

legacy-backoffice/
├── service-a/server/ # Express 서버
├── service-a/web/ # Vue 2 프론트엔드
├── service-b/server/ + web/
├── service-c/server/ + web/
├── modules/server/ # 40개 이상의 공유 도메인 모듈
└── modules/web/ # 공유 프론트엔드 모듈

이후 사업부문별로 개발팀이 분리되면서, 각 팀이 자체 서비스에 집중하게 되었습니다. 또한 각자의 서비스 특징에 따라 요구사항도 천차만별이 되었습니다. 하지만 백오피스는 여전히 하나의 레포지토리에 모든 서비스가 묶여 있었고, 이 구조는 시간이 지나면서 여러 문제를 드러냈습니다.

마이그레이션을 결정한 이유

Vue 2 생태계의 한계

레거시 백오피스는 Vue 2.7 + Vuex 3 + Bootstrap 4 기반이었습니다. Vue 2는 이미 지원이 종료되었고, 사내에서도 Vue를 사용하는 프로젝트가 점차 줄어들면서 유지보수 인력이 부족한 상황이었습니다. 실제로 저희 메인 서비스도 Vue에서 Next.js로 마이그레이션을 완료한 상태였기 때문에, 프론트엔드 팀 전체가 React 기반으로 통일되어 있었습니다.

서버와 프론트엔드가 결합된 구조

레거시 백오피스의 Express 서버는 단순한 정적 파일 서빙을 넘어서, 프록시 미들웨어로 downstream API에 요청을 전달하기도 하고, 중앙 패키지를 통해 비즈니스 로직을 직접 실행하기도 하는 구조였습니다. 서버와 프론트엔드가 하나의 프로젝트에 강하게 결합되어 있었고, 이는 프론트엔드 개발자가 서버 코드와 중앙 패키지의 동작까지 신경 써야 하는 부담을 주었습니다.

특히 중앙 개발실 시절에 만들어진 공유 패키지들에 대한 의존성이 깊어, 이를 분리하는 것 자체가 하나의 도전 과제였습니다.

타입 안전성 부재와 거대한 단일 파일들

레거시 백오피스는 JavaScript로 작성되어 있어 타입 안전성이 부족했습니다. 라우터 파일 하나가 96KB에 달할 정도로 수백 개의 라우트가 단일 파일에 정의되어 있었고, Vuex 스토어도 800줄이 넘는 거대한 파일이었습니다. 팩토리 함수로 CRUD 스토어를 대량 생산하는 패턴은 당시에는 효율적이었겠지만, 타입이 없으니 디버깅이 어려웠습니다.

이러한 이유들로 신규 메뉴는 새로운 백오피스에서 개발하고, 기존 메뉴도 점진적으로 이관하기로 결정했습니다.

모노레포 전환 — npm에서 pnpm + Turborepo로

왜 모노레포인가

새로운 백오피스를 별도의 레포지토리로 만들 수도 있었지만, 기존 웹 서비스 레포지토리에 함께 두기로 했습니다. 이유는 간단합니다. 같은 서비스를 위한 프론트엔드 프로젝트라면, 같은 레포지토리에서 관리하는 것이 빌드 파이프라인 관리와 코드 공유 측면에서 유리하기 때문입니다. 특히 CLAUDE.md나 skills 등 AI 관련 파일들을 공유하는 것이 DX를 향상시킬 수 있다고 생각했습니다.

npm에서 pnpm으로

기존 웹 서비스는 npm을 사용하고 있었습니다. 모노레포를 구성하기 위해 pnpm workspace로 전환했습니다. pnpm은 심볼릭 링크 기반의 효율적인 의존성 관리를 제공하고, workspace 기능이 내장되어 있어 별도의 설정이 거의 필요 없었습니다.

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

이 간단한 설정으로 apps/webapps/backoffice가 각각 독립적인 패키지로 인식됩니다. 각 패키지는 자체 package.json을 가지며 독립적인 의존성을 관리합니다.

Turborepo 도입

모노레포에서 여러 앱의 빌드, 린트, 테스트를 효율적으로 관리하기 위해 Turborepo를 도입했습니다.

// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}

build 태스크에서 outputs.next/**(web)와 dist/**(backoffice)를 모두 포함시켜, 두 앱의 빌드 결과물을 캐시할 수 있도록 했습니다.

새로운 백오피스 기술 스택 선정

Vite + React + Tailwind CSS + shadcn/ui

레거시 백오피스 신규 백오피스
───────────────── ─────────────────
Vue 2.7 → React 19
Vuex 3 → React Query v5
Bootstrap 4 + SCSS → Tailwind CSS 4 + shadcn/ui
vue-router 3 → React Router 7
JavaScript → TypeScript (strict)
Vite 4 → Vite 6

백오피스는 SSR이나 SEO가 필요하지 않기 때문에 Next.js 대신 Vite + React 조합을 선택했습니다. 빌드 속도가 빠르고 설정이 간결합니다.

shadcn/ui를 선택한 이유는 여러 가지입니다. 우선 copy-paste 방식으로 컴포넌트를 프로젝트에 가져오기 때문에 커스터마이징이 자유롭습니다. 그리고 이 방식은 AI와의 협업에도 유리합니다. 다른 UI 라이브러리는 패키지 의존성으로 설치하기 때문에 node_modules 안에 코드가 숨어 있어 AI가 내부 구현을 파악하기 어렵습니다. 반면 shadcn/ui는 컴포넌트 코드가 프로젝트 내에 존재하기 때문에 AI가 코드를 읽고 이해하고 수정하기 편합니다. 백오피스 특성상 테이블, 폼, 다이얼로그 등의 컴포넌트가 많이 필요한데, shadcn/ui가 이러한 컴포넌트를 잘 제공하고 있었습니다.

이질감 없는 마이그레이션을 위한 인증 토큰 공유

마이그레이션에서 가장 중요하게 생각한 부분은 사용자가 두 백오피스를 오갈 때 이질감을 느끼지 않도록 하는 것이었습니다. 레거시 백오피스에 33개의 메뉴가 있는데, 이 중 하나의 메뉴만 새로운 백오피스로 이관하더라도 나머지 32개 메뉴는 여전히 레거시에서 동작해야 합니다. 사용자가 메뉴를 이동할 때마다 재로그인을 해야 한다면 마이그레이션은 실패한 것이나 다름없습니다.

쿠키의 domain 설정을 통한 토큰 공유

레거시 백오피스와 신규 백오피스는 같은 상위 도메인의 서로 다른 서브도메인에서 서비스됩니다. 이 점을 이용하여 쿠키의 domain을 상위 도메인으로 설정하면, 두 백오피스에서 동일한 인증 토큰을 공유할 수 있습니다.

여기서 한 가지 문제가 있었습니다. 레거시 백오피스는 인증 토큰을 localStorage에 저장하고 있었습니다. localStorage는 도메인이 다르면 접근할 수 없기 때문에, 신규 백오피스에서 기존 토큰을 읽을 방법이 없었습니다. 이를 해결하기 위해 먼저 레거시 백오피스 쪽에서 토큰을 localStorage와 쿠키에 이중으로 저장하도록 수정했습니다. 기존 코드의 동작은 그대로 유지하면서, 쿠키에도 동일한 토큰을 저장하여 신규 백오피스에서 읽을 수 있게 한 것입니다. 한두 달 정도 이중 저장 기간을 거쳐 안정성을 확인한 뒤, localStorage를 완전히 걷어내고 쿠키 기반으로 전환했습니다.

// 신규 백오피스의 쿠키 유틸
const COOKIE_KEY = `admin:token:${getEnvironment()}`;
export function setCookieToken(token: string) {
const isLocalhost = window.location.hostname === 'localhost';
const domain = isLocalhost ? '' : '.example.co.kr';
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
document.cookie = `${COOKIE_KEY}=${token}; path=/; ${
domain ? `domain=${domain};` : ''
} expires=${expires.toUTCString()}`;
}

쿠키 키도 환경별로 다르게 설정하여, 각 환경의 토큰이 충돌하지 않도록 했습니다.

기존 인증 흐름과의 호환

레거시 백오피스의 인증은 LDAP 기반으로, 중앙 인증 서비스를 통해 토큰을 발급받는 구조였습니다. 신규 백오피스에서는 이 토큰을 그대로 사용하여 검증합니다.

export async function authorize(): Promise<Staff | null> {
const token = getCookieToken();
if (!token) return null;
try {
const response = await apiClient.get('/auth/tokens', {
params: { access_token: token },
});
setCookieToken(token); // 쿠키 갱신
return response.data;
} catch {
clearCookieToken();
return null;
}
}

이 방식 덕분에 사용자는 레거시 백오피스에서 로그인한 상태로 신규 백오피스의 메뉴에 접근하더라도 재로그인 없이 바로 사용할 수 있게 되었습니다. 반대의 경우도 마찬가지입니다.

메뉴 이동의 자연스러운 연결

양쪽 백오피스의 사이드바는 동일한 메뉴 목록을 보여줍니다. 마이그레이션이 완료된 메뉴를 클릭하면 신규 백오피스 내에서 라우팅되고, 아직 마이그레이션되지 않은 메뉴를 클릭하면 같은 창에서 레거시 백오피스의 해당 페이지로 이동합니다. 새 탭을 여는 것이 아니라 같은 창에서 이동하기 때문에, 사용자 입장에서는 하나의 애플리케이션을 사용하는 것과 같은 경험을 할 수 있습니다. 레거시 백오피스의 사이드바에서도 마찬가지로, 마이그레이션된 메뉴를 클릭하면 같은 창에서 신규 백오피스로 이동합니다.

export const MENU_ITEMS = [
{ name: '포인트 관리', path: '/points', internal: true }, // 신규 백오피스
{ name: '주문 관리', path: '/order', internal: false }, // 레거시로 이동
// ...
];

internal: true인 메뉴는 React Router로 라우팅하고, false인 메뉴는 레거시 백오피스의 URL을 생성하여 이동시킵니다. 이렇게 하면 메뉴를 하나씩 마이그레이션할 때마다 해당 메뉴의 internal 값만 true로 바꿔주면 됩니다.

권한 시스템 연동

레거시 백오피스는 중앙 패키지를 통해 권한 정책을 관리하고 있었습니다. 신규 백오피스에서는 이 중앙 패키지에 대한 의존성을 제거하면서도, 동일한 권한 체계를 유지해야 했습니다. 기존 권한 로직을 분석하여 신규 백오피스에 직접 구현했습니다.

export function hasPermission(staffPermissions: string[], requiredPermission: string): boolean {
// 와일드카드 매칭 지원
if (staffPermissions.includes('*')) return true;
if (staffPermissions.includes(requiredPermission)) return true;
// 접두사/접미사 와일드카드 처리
// ...
return false;
}

라우트 보호를 위해 PermissionGuard 컴포넌트를 만들어 필요한 권한이 없으면 접근을 차단합니다.

export const pointRoutes = [
{ index: true, element: <PointsPage /> },
{
path: 'manage',
element: (
<PermissionGuard permission="POINT_WRITE">
<PointsManagePage />
</PermissionGuard>
),
},
];

CI/CD 및 인프라 구성

백오피스는 웹 서비스와 독립적으로 배포되어야 합니다. 이를 위해 별도의 GitHub Actions 워크플로우와 인프라를 구성했습니다.

배포 파이프라인

PR이 올라오면 백오피스 관련 파일이 변경되었는지 확인한 뒤, 변경이 있을 때만 빌드 체크를 실행합니다. 배포가 완료되면 Slack 알림을 보내고, 브랜치명에서 이슈 키를 추출하여 해당 티켓에 자동으로 QA 요청을 보냅니다.

정적 호스팅으로의 전환

레거시 백오피스는 서버(Express)가 포함되어 있었기 때문에 Cloud Run 위에서 동작하고 있었습니다. 하지만 신규 백오피스는 순수한 React SPA이기 때문에, 서버 없이 GCS(Google Cloud Storage) 정적 호스팅만으로 서빙이 가능해졌습니다. 서버 인프라를 유지할 필요가 없어진 것은 React SPA로의 전환이 가져다 준 부수적인 이점이기도 합니다.

API 호출 전략

신규 백오피스에서 API를 호출하는 방식도 고민이 필요했습니다. 레거시 백오피스의 Express 서버에는 많은 비즈니스 로직이 포함되어 있었기 때문에, 처음부터 이 모든 API를 별도로 구현하기에는 부담이 컸습니다.

그래서 단계적인 접근을 택했습니다. 초기에는 레거시 백오피스의 서버를 그대로 API 서버로 활용합니다. 이미 동작하고 있는 서버이므로 프론트엔드 마이그레이션에만 집중할 수 있습니다. 이후 백엔드 팀에서 API 마이그레이션이 진행되는 대로 엔드포인트를 새로운 API 서버로 전환해 나가는 방식입니다. 이렇게 하면 프론트엔드 마이그레이션과 API 마이그레이션을 분리하여 각각의 속도로 진행할 수 있습니다.

마치며

이번 마이그레이션에서 가장 중요하게 생각한 원칙은 점진적 전환이었습니다. 레거시를 한 번에 걷어내는 빅뱅 방식은 리스크가 크고, 현실적으로도 불가능했습니다. 대신 쿠키 기반 토큰 공유로 두 시스템 간의 이질감을 없애고, 메뉴 단위로 하나씩 이관할 수 있는 환경을 만드는 데 집중했습니다.

이 점진적 전환 전략은 기술적인 이점뿐 아니라, 이 작업을 승인받는 데에도 핵심적인 역할을 했습니다. 리소스를 한 번에 크게 투입하는 방식이 아니라, 다른 업무 사이사이에 조금씩 진행할 수 있는 구조였기 때문입니다. 경영진에게 "기존 업무에 무리가 가지 않는 선에서, 남는 시간을 활용해 점진적으로 마이그레이션하겠다"고 설득할 수 있었고, 덕분에 이 작업이 승인될 수 있었습니다.

공유 패키지도 신중하게 접근하고 있습니다. 모노레포이기 때문에 packages/로 공통 코드를 분리할 수 있지만, 무엇이든 공유하기보다는 정말 공유가 필요한 경우에만 패키지로 빼려고 합니다. 예를 들어 날짜 포맷팅 같은 유틸리티는 두 앱에서 동일한 로직이 필요하기 때문에, 코드를 중복으로 두는 것보다 공유 패키지로 관리하는 것이 유지보수 측면에서 확실히 유리합니다. 이렇게 공유의 이점이 명확한 경우에만 패키지로 분리하는 방향으로 진행하고 있습니다.

이제 설계와 기반 작업은 마무리되었습니다. 인증 토큰 공유, CI/CD 파이프라인, 권한 체계, 메뉴별 마이그레이션 구조까지. 새로운 메뉴를 이관할 때 참고할 수 있는 선례가 만들어졌습니다. 이렇게 되면 AI를 통한 작업에도 속도가 나게 됩니다. 앞으로 하나씩 메뉴를 옮겨가면서, 언젠가 레거시 백오피스를 완전히 걷어내는 날이 오기를 기대합니다.