서버를 기다리는 대신, 어드민 하나를 먼저 옮겼어요
새 API가 언제 나올지 아무도 모르는 상태였어요. 프론트엔드 인력만으로 먼저 옮기기 위해 BFF를 세우고, 3주 만에 화면 45개를 옮긴 이야기예요.
프론트엔드 · · 16분
저희 회사 사내 어드민은 오랫동안 서버 쪽에 얹혀 있었어요. 화면을 서버 템플릿이 그리고, 거기에 오래된 프론트엔드 프레임워크가 붙어 있고, 그 전부를 서버 개발자들이 관리했어요.
올해 목표가 이 구조를 걷어내는 거였어요. 서버는 템플릿에 묶여 있던 화면 대신 API를 내주고, 프론트엔드는 그 API를 쓰는 새 화면 코드를 만드는 거예요. 그래야 양쪽이 각자 자기 코드만 보고 일할 수 있으니까요. 옮길 어드민은 둘이었어요. A와 B라고 부를게요.
1단계인 A는 끝났어요. 서버가 새 API를 내주고 프론트엔드가 새 화면을 만들어서 양쪽 다요. 2단계가 B였고, 다음 목표로 이미 잡혀 있었어요. 안 하기로 한 일이 아니라, 하기로 정해놓고 시작을 못 한 일이었죠.
순서대로라면 서버가 새 API를 만들고, 그다음에 저희가 그 API로 화면을 만들어요. B의 화면은 아직 전부 레거시 어드민의 로그인 세션 위에서 돌고 있었으니까요. 그런데 그때 서버 쪽은 우선순위가 더 높은 일들에 밀려서, 어드민 이관에 투입할 리소스가 없는 상황이었어요. 착수 시점을 아무도 말할 수 없었고, 몇 주면 될지 몇 달이 걸릴지도 알 수 없었어요.
그럼 프론트엔드는 그동안 뭘 하나요. 저희는 기다리지 않기로 했어요. 갈 방향이 이미 정해져 있다면, 프론트엔드가 먼저 할 수 있는 것부터 하자. 화면을 먼저 옮기고, 서버 API가 준비될 때까지의 간극은 BFF로 메우기로 했어요.
7월 9일에 첫 커밋을 찍었어요. 7월 31일에 마지막 화면 묶음을 옮기면서 이관이 끝났어요. 3주, 커밋 234개, 세 명이 걸린 일이에요.
선택지는 셋이었어요
| 선택지 | 결과 |
|---|---|
| 새 API가 나올 때까지 기다린다 | 프론트엔드가 비는데, 언제까지 비는지를 아무도 몰라요 |
| 새 앱에서 레거시 API를 직접 부른다 | 세션 로그인·레거시 응답 형태가 전부 화면 코드로 새어 들어와요 |
| 프론트엔드가 소유하는 BFF를 하나 세운다 | 레거시를 한 층에 가두고, 화면은 새 규격만 봐요 |
두 번째가 왜 안 되는지는 조금 더 설명이 필요해요. 레거시 API는 로그인 세션 쿠키로 인증하고, 로그인 자체가 브라우저용으로 만들어진 자체 규약이에요. 응답 형태도 새 어드민과 달라요. 이걸 브라우저에서 직접 하면 화면 코드가 레거시의 사정을 전부 알아야 해요. 그리고 나중에 서버가 진짜 API를 내놓으면, 그 사정이 박힌 화면을 전부 다시 고쳐야 해요.
그래서 세 번째를 골랐어요. 다만 목적을 정확히 적어두고 시작했어요. "프론트엔드가 백엔드를 대신한다"가 아니라, "서버 일정과 화면 일정을 떼어놓는다"예요.
이 문장은 지금도 저장소에 그대로 남아 있어요.
BFF 는 선택지이지 강제가 아닙니다 — 필요하면 다른 소스를 써도 됩니다.
BFF를 영구 구조물로 못 박지 않겠다는 뜻이에요. 화면은 BFF를 보는 게 아니라 새 규격을 봐요. 서버 API가 준비되면 훅 하나를 갈아끼우면 되고, 화면은 손대지 않아요.
구조
모노레포에 워크스페이스를 하나 더 만들었어요. Hono로 세웠고, B의 API 요청은 전부 여기를 거쳐요.
flowchart LR
U[관리자 브라우저] --> SA[B 어드민<br/>React SPA]
SA -->|"새 규격 API"| B[BFF<br/>Hono]
SA -.->|"준비되면 갈아끼움"| N[새 API]
B -->|"세션 쿠키<br/>레거시 파라미터"| L[레거시 어드민]
BFF 안은 레거시 쪽 도메인마다 커넥터를 하나 두고, 그 아래를 화면 단위 폴더로 쪼갰어요. 폴더 하나에 파일은 늘 넷이에요.
connectors/<도메인>/<화면>/
├── upstream.ts # 레거시가 주는 형태 (zod) — 변경 감지용
├── schema.ts # 우리가 내보낼 형태 (zod) — 여기가 원본
├── transform.ts # 레거시 형태 → 우리 형태 매핑
└── routes.ts # 주소와 라우트 정의
덕분에 새 화면을 맡은 사람이 뭘 만들어야 하는지 고민할 필요가 없어요. 폴더 하나 만들고 파일 넷을 채우면 돼요.
3주 동안 이 구조로 커넥터 11개가 쌓였어요.
타입은 한 번만 써요
BFF를 세우면서 제일 신경 쓴 건 타입이 두 벌 세 벌로 갈라지지 않게 하는 거였어요. 손으로 쓰는 타입은 커넥터의 zod 하나뿐이고, 나머지는 전부 거기서 나와요.
flowchart TD
S["schema.ts<br/>zod — 손으로 작성"] --> Y["OpenAPI 문서<br/>자동 생성"]
Y --> O["타입 + react-query 훅<br/>자동 생성"]
O --> P["화면 코드"]
zod를 고치면 명령 두 줄로 클라이언트까지 갱신돼요. 하나는 zod에서 OpenAPI 문서를 뽑고, 하나는 그 문서에서 타입과 훅을 만들어요. 생성물은 수정 금지예요. 화면에서 필드가 아쉬우면 화면을 고치는 게 아니라 커넥터의 zod를 고쳐요.
이 방식은 1단계에서 이미 해본 거예요. A를 옮길 때 서버 OpenAPI 스펙에서 API 코드를 생성하기로 했었거든요. 차이가 있다면 그때는 스펙을 주는 쪽이 서버였고, 이번엔 저희라는 거예요. 저희가 계약을 쓰고, 그 계약이 문서와 타입과 훅을 만들어요.
이게 중요한 이유가 있어요. 1단계가 끝난 방식이 곧 2단계가 끝날 방식이거든요. 서버가 B의 API를 내주는 날, 저희는 지금과 똑같이 스펙에서 코드를 생성해서 소스만 바꿔 끼우면 돼요. BFF는 그때까지 계약을 대신 들고 있는 자리예요.
걷어낼 때 무슨 일이 생기나요
"나중에 걷어내면 된다"는 말은 쉽게 하지만, 실제로 어떻게 되는지 안 보이면 믿기 어려워요. 지금 화면 코드는 이렇게 생겼어요.
import { useItemList } from '@/api/bff'
const { data } = useItemList(
{ page, pageSize, search },
{ query: { select: (res) => res.data.body } },
)
서버 API가 나오면 이렇게 돼요.
import { useItemList } from '@/api/server'
const { data } = useItemList(
{ page, pageSize, search },
{ query: { select: (res) => res.data.body } },
)
바뀐 건 첫 줄이에요. 훅을 부르는 모양도, 응답을 꺼내는 방식도, 그 아래 표와 필터도 그대로예요. 두 훅이 같은 생성기에서 나왔고, BFF가 처음부터 서버와 같은 응답 형태로 내려주게 만들어놨기 때문이에요.
정리하면 이렇게 갈려요.
| 구분 | 무엇이 |
|---|---|
| 사라져요 | 커넥터 폴더의 파일 넷, BFF의 세션 처리, BFF 배포와 운영 |
| 남아요 | 화면·부품·표·폼·E2E 전부 |
| 바뀌어요 | import 한 줄 |
물론 서버가 훅 이름이나 파라미터 이름을 다르게 지으면 그 줄도 같이 바뀌어요. 그래도 바뀌는 건 화면당 한두 줄이지, 화면을 다시 만드는 일은 아니에요. 애초에 이걸 노리고 규격을 먼저 맞춰둔 거고요.
레거시가 말없이 바뀔 때
파일 넷 중에 제일 쓸데없어 보이는 게 upstream.ts예요. 어차피 transform.ts에서 변환할 건데 raw 응답을 왜 또 zod로 검증하나 싶거든요.
이유는 하나예요. 레거시가 말없이 바뀌었을 때 화면이 아니라 BFF에서 터지게 하려고요. 필드 하나가 사라졌는데 아무도 모르고 있다가 운영에서 빈 칸으로 발견되는 것보다, 배포 전에 BFF가 먼저 소리 지르는 게 나아요.
그래서 upstream 스키마에는 확인한 근거를 주석으로 남겨요.
/**
* 목록 raw — 레거시가 실제로 내려주는 형태.
* 코드값이 아니라 한글 라벨만 오고, 상태는 숫자다.
*/
const RawItem = z.object({
id: z.number(),
category_name: z.string().nullish(),
name: z.string().nullish(),
status: z.coerce.number().nullish(),
})
레거시 API에는 문서가 없어요. 문서가 없으면 서버 코드가 문서예요. 이 주석들이 나중에 "이 필드 왜 이렇게 매핑했지"에 답해줘요.
이 가드에도 함정이 하나 있었어요. 레거시가 그냥 에러를 준 건데 그걸 "레거시가 바뀌었다"로 잘못 알리던 시기가 있었어요. 가드가 시끄러우면 사람이 가드를 안 믿게 돼요. 그래서 에러와 형태 불일치를 나눠서 보게 고쳤어요.
응답 봉투도 같은 이유로 통일했어요. 레거시가 뭘 어떻게 뱉든 BFF는 서버가 주는 것과 같은 형태로 내려줘요. 에러도 같은 봉투에 담아서요. 앞에서 본 "import 한 줄만 바뀐다"가 성립하는 근거가 여기예요.
옮기는 절차를 스킬로 만들었어요
세 명이 3주에 45개를 옮기려면, 화면마다 "이번엔 어떻게 하지"를 다시 생각하면 안 돼요. 그래서 구조를 정한 다음에 한 일이 절차를 스킬로 만드는 거였어요. 스킬은 코딩 에이전트가 따라야 할 절차를 적어둔 문서예요. 무엇부터 하고, 무엇을 지키고, 어디서 끝내는지가 들어 있어요. 화면 하나를 맡으면 명령 한 줄로 그 절차가 시작돼요.
스킬에 적어둔 순서는 이래요.
- 레거시 화면 하나를 분석하는데, 두 갈래로 나눠 동시에 봐요. 하나는 서버 쪽 — 어떤 뷰가 어떤 파라미터를 받고 무엇을 돌려주는지. 하나는 화면 쪽 — 템플릿과 컨트롤러가 실제로 어떤 요청을 부르는지.
- 둘을 맞대요. 화면이 부르는 요청 목록과 서버에서 찾은 엔드포인트 목록이 같은지 봐요. 다르면 그 코드를 직접 열어서 확정해요.
- 결과를 사내 문서로 남겨요. 모든 항목에 근거가 된 파일과 줄 번호를 같이 적어요.
- 분석이 끝난 그 자리에서 곧바로 BFF 커넥터를 만들어요.
- 정해진 검사를 통과해야 끝이에요. 타입 검사 → 스펙 생성 → 클라이언트 생성 → 앱 전체 타입 검사와 린트.
둘을 맞대보는 단계가 이 절차의 핵심이에요. 목록 조회 하나만 보고 "이 화면 다 봤다"고 넘어가면 나중에 행 액션이나 엑셀이나 드롭다운 데이터에서 구멍이 나요. 서버에서 찾은 것과 화면이 부르는 것을 맞대보는 단계가 있어야 그게 잡혀요.
분석하자마자 만드는 것도 그냥 순서가 아니에요. 분석과 구현 사이가 벌어지면 레거시를 다시 읽어야 하거든요. 방금 읽은 게 머리에 남아 있을 때 바로 만드는 게 제일 싸요.
스킬로 만들었을 때 달라지는 것
절차를 사람이 기억하는 것과 스킬에 적어두는 것은 꽤 달라요.
규칙이 한 번에 모두에게 적용돼요. 스킬에는 이런 규칙이 있어요 — 분석 문서에 요약만 쓰지 말고, 화면의 안내 문구와 필드별 동작을 원문 그대로 옮겨 적을 것. 처음엔 요약만 적었다가, 나중에 그 화면을 다시 만들 때 레거시를 처음부터 다시 판 적이 있어서 생긴 규칙이에요. 이런 건 회고 자리에서 말로 나누면 다음 주에 잊혀요. 스킬을 고치면 그다음 화면부터 그냥 적용돼요.
끝내는 조건이 사람마다 달라지지 않아요. 미구현 목록이 비어 있거나, 남은 항목마다 남은 이유가 적혀 있어야 그 화면을 닫을 수 있어요. "다음에 하겠다"는 사유로 안 쳐줘요. 45개를 옮기는 동안 제일 무서운 건 각자 다른 기준으로 "다 됐다"고 말하는 상황이니까요.
이미 끝난 화면을 다시 볼 때의 규칙도 들어갔어요. 초기에 만든 화면들은 "지금 필요한 조회만" 원칙으로 짜여서 쓰기나 엑셀이 빠진 게 있었어요. 그래서 문서가 있다고 완료로 보지 말고, 문서·실제 코드·화면이 부르는 요청 셋을 다시 대조하라는 규칙이 붙었어요.
레거시 저장소에서 열어도 되는 파일과 안 되는 파일을 가르는 것도 스킬에 적혀 있어요. 자격 증명이나 암호화 유틸 같은 건 애초에 읽지 않아요.
사람이 하는 일은 그대로 남아요. 대조 결과가 맞는지 보고, 만들어진 코드를 읽고, 레거시의 이상한 동작을 그대로 옮길지 고칠지 정하는 건 사람이에요. 스킬이 가져간 건 판단이 아니라 매번 똑같이 해야 하는 일이에요.
제일 오래 발목을 잡은 건 인증이었어요
BFF는 양쪽에 인증이 있어요. 들어오는 요청은 어드민 토큰을 검증하고, 나가는 요청은 레거시에 세션 로그인을 해서 쿠키를 재사용해요.
첫 구현은 서비스 계정 하나를 공유했어요. 하루 만에 갈아엎었어요.
공유 계정으로 붙으면 레거시에는 누가 했든 전부 같은 계정으로 남아요. 값을 바꾼 사람도, 데이터를 지운 사람도 기록에는 그 계정 하나예요. 권한도 의미가 없어지고요. 그 계정이 할 수 있는 일이면 누구나 할 수 있게 되니까요. 누가 뭘 바꿨는지 알 수 없는 어드민은 어드민이 아니에요.
그래서 사용자별 세션으로 바꿨어요. 어드민 로그인에 성공하면 BFF가 그 사용자 자격으로 레거시에 로그인하고, 브라우저에는 HttpOnly 쿠키만 내려줘요. 이후 모든 요청은 본인 세션과 본인 권한으로 나가요.
여기서 잔가시가 꽤 나왔어요.
- 로그인 훅에 레이스가 있었어요. 토큰 저장 뒤에 세션 수립을 부르면 페이지 리로드가 그 요청을 잘라먹어요. 저장 앞에서 부르도록 순서를 바꿨어요.
- 로그인 실패 응답이 그대로 새어 나왔어요. 레거시가 준 원본 응답이 화면까지 올라오고 있었어요. 지금은 사유 메시지만 꺼내서 보여줘요.
- 세션은 만료돼요. 갱신을 붙이고, 만료나 미수립 상태는 원본 에러 대신 재로그인 안내로 바꿨어요.
그리고 제일 교훈적이었던 버그예요.
404와 5xx를 세션 만료로 잘못 읽고 있었어요. 응답 해석기가 상태 코드를 안 보고 "JSON이 아니면 세션 만료"로 뭉뚱그렸거든요. 그래서 경로 오타나 서버 이상으로 돌아온 HTML까지 전부 재로그인 유도로 흘렀어요. 세션 만료는 200 응답일 때만 판정하도록 고쳤어요.
레거시를 감쌀 때 실수하기 제일 쉬운 지점이 여기예요. 레거시는 실패를 상태 코드로 말해주지 않아요. HTML을 줘요. 그 HTML이 로그인 페이지인지 에러 페이지인지 구분하는 책임이 통째로 감싸는 쪽으로 넘어와요.
화면 쪽
B는 새 앱으로 세웠어요. 파일 기반 라우팅이라 페이지 파일이 곧 라우트가 되고, 같은 폴더의 메타 파일이 사이드바 메뉴가 돼요. 메뉴 정의가 따로 있는 게 아니라 라우트에서 메뉴가 나와요. 화면을 추가하면 메뉴에 자동으로 붙고, 지우면 자동으로 빠져요.
화면 구조는 3층으로 못 박았어요.
| 층 | 역할 |
|---|---|
| 페이지 | 라우트 바인딩만 하는 얇은 래퍼예요. 로직이 없어요 |
| 화면 조립 | 부품을 조합해 완성 화면을 만들어요 |
| 도메인 부품 | UI 조각·다이얼로그·필드·훅이고, 페이지를 포함하지 않아요 |
규칙은 의존성 검사 도구로 강제해요. 취향에 맡기면 몇 주만 지나도 무너지거든요. 이건 저희가 A에서 이미 겪어본 거예요.
테스트는 실서버로 했어요
E2E는 목을 쓰지 않아요. 로컬 BFF를 띄우고 사내망에 붙어서 레거시 개발 서버를 직접 때려요. 목으로 통과하는 이관은 이관이 아니라 그림이니까요.
대신 제약을 걸었어요.
운영 데이터 오염 방지: 폼 제출·저장·삭제는 실행하지 않는다. 패널 열림과 필드 렌더까지만 검증한다.
읽기 경로·필터·페이지네이션·상세 진입은 실데이터로 검증하고, 쓰기는 사람이 봐요. 스펙 34개가 이 규칙 위에서 돌아요.
옮기다 보니 나온 것들
레거시 화면을 한 줄씩 읽어 옮기다 보니, 원래 목적과 상관없는 것들이 걸려 나왔어요.
레거시 화면 목록이 곧 이관 목록은 아니었어요. 옮기려던 화면 중 둘은 지금 아무도 쓰지 않는 화면이었어요. 그래서 이관 대상에서 빼고, 이미 만들어둔 코드까지 지웠어요. 레거시에 화면이 있다는 건 과거에 필요했다는 뜻이지 지금 필요하다는 뜻이 아니에요. 목록을 그대로 옮기면 안 쓰는 화면을 새 앱에 다시 심게 돼요.
레거시 버그도 그대로 보였어요. 옮기면서 함께 고친 것들이에요. 이미지 업로드가 전역 상태를 공유하던 문제, 배정한 수량이 유지되지 않던 문제, 삭제 버튼이 아예 없던 화면, 기간 필터가 날짜 형식을 안 보내서 필터가 먹지 않던 문제, 미리보기에서 글자가 깨지던 문제요. 화면을 새로 그리는 김에 고친 게 아니라, 한 줄씩 읽지 않으면 알 수 없는 것들이에요.
쓰기 경로도 실제로 눌러봐야 알았어요. 쓰기 액션을 전량 옮길 때 잘못된 경로가 몇 개 나왔어요. 목록 화면만 보고 있었으면 몰랐을 거예요.
이관은 결국 레거시를 정독하는 일이에요. 정독하면 이런 게 나와요.
비용도 적어둘게요
BFF는 공짜가 아니에요.
- 요청이 거쳐 가는 단계가 하나 늘었고, 운영하고 배포할 서버도 하나 늘었어요. 프로세스 관리, 리버스 프록시 설정, 환경 5개 배포 스크립트를 전부 저희가 만들었어요.
- 그 과정에서 프론트엔드가 평소 안 만나는 문제를 만났어요. CI의 설치 옵션에 막히고, 네이티브 모듈이 런타임 버전과 안 맞아 실행 환경을 고정해야 했어요.
- 레거시가 바뀌면 커넥터가 깨져요. 변경 감지가 이걸 조용한 오작동 대신 눈에 띄는 실패로 바꿔주긴 하지만, 깨진다는 사실 자체는 그대로예요.
- 로컬 개발에 사내망 연결이 필수예요. BFF가 레거시로 나가는 호출이 막혀 있어서, B 작업은 로컬 BFF와 사내망으로만 돼요.
이 비용을 감수할 가치가 있었냐고 물으면, 이 경우엔 그렇다고 답할 수 있어요. 대안이 "언제 끝날지 모르는 대기"였으니까요. 두 주면 될지 두 달이 될지 알 수 없는 상태에서는, 기다리는 쪽이 더 비싸요. 기다림에는 끝이 안 보이고 저 비용에는 목록이 있으니까요.
그 다음
3주 만에 화면 45개, BFF 엔드포인트 195개, E2E 스펙 34개가 섰어요.
이관이 끝나고 8월은 성격이 다른 작업이었어요. 폼 상태 관리를 정리하고, React Compiler를 켜고, 테이블 라이브러리를 올리고, 목록 화면 29개의 조회 배관을 공통 함수 하나로 묶었어요. 묶는 건 이관이 끝난 뒤에 했어요. 세 번째 화면에서 공통화했다면 레거시 화면들이 하나씩 품고 있는 예외를 밀어내지 못했을 거예요. 스무 번 반복되고 나서야 무엇이 진짜 공통인지 보였어요.
배운 걸 여섯 줄로 줄이면 이래요.
- BFF는 서버를 대체하는 물건이 아니라 시간차를 흡수하는 물건이에요. 목표가 "프론트엔드가 백엔드를 한다"였다면 이 구조는 실패했을 거예요. 목표는 서버 일정과 화면 일정을 떼어놓는 거였고, 그래서 처음부터 걷어낼 수 있게 설계했어요.
- 방향이 정해져 있으면 착수를 기다릴 필요는 없어요. 합의된 목표라면, 각자 할 수 있는 조각부터 시작해도 나중에 서로 어긋나지 않아요. 언제 출발할지 모를 때일수록 그래요.
- 같은 일을 여러 번 하게 되면 절차를 글로 적고, 그 절차를 스킬로 만들어요. 45개를 옮기는 동안 담당자가 달라져도 순서가 흔들리지 않은 이유예요. 하다가 새로 배운 게 생기면 스킬을 고쳤고, 그다음 화면부터 바로 반영됐어요.
- 감싸는 계층은 계약을 한 곳에 모아야 해요. zod 하나에서 문서와 타입과 훅이 전부 나오게 한 게, 화면이 마흔 개를 넘어가는 동안 타입 불일치로 밤새우지 않은 이유예요.
- 레거시의 실패는 상태 코드로 오지 않아요. 실패 해석을 감싸는 쪽에서 정확히 나누지 않으면, 엉뚱한 화면으로 새어 나가요.
- 레거시 정독은 부작용이 아니라 자산이에요. 옮기지 않아도 될 화면, 오래된 버그, 눌러봐야 알 수 있는 쓰기 경로가 그 과정에서 드러났어요.
정리하면 저희가 한 건 어드민 하나를 선행 구현한 것이고, BFF는 그걸 가능하게 한 수단이었어요. 목적이 아니라요.
2단계에서 프론트엔드가 할 몫은 끝났어요. 화면 45개를 다 옮겼고, 그 위에 E2E까지 붙었어요. 서버 API가 나오면 그때 2단계 전체가 닫히는데, 그건 저희가 기다리는 동안 만들어둔 자리로 들어오면 되는 일이에요. 커넥터가 빠지고 import가 바뀔 뿐, 화면은 그대로고요.
처음 문제로 돌아가 볼게요. 저희가 곤란했던 건 일이 많아서가 아니라 시작 시점을 저희가 정할 수 없다는 것이었어요. 지금은 서버가 언제 오든 저희 일정이 흔들리지 않아요. 3주로 산 게 그거예요.
도움이 되었다면 눌러주세요
댓글 0
첫 댓글을 남겨보세요.