1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
SC-A01부터 SC-A20까지 20개 시나리오를 5일 만에 75%에서 90%까지 끌어올린 Admin Portal MVP 사이클. 혼자 PM·BE·FE 세 역할을 돌리면서 만든 병렬화의 환상은 사실 코드 한 줄 — `VITE_USE_MOCK_API=true` 환경변수 토글과 그 뒤에 붙은 Mock/REST DataProvider 두 구현체에서 시작됐다. Refine + Vite 위에서 14개 page 모듈을 어떤 순서로 박았는지, [FE → PM] 태그로 셀프 보고하던 협업 프로토콜이 왜 효과가 있었는지, Mock에서 REST로 갈아끼울 때 실제로 바뀐 건 한 줄이었는지 — 다섯 H2로 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 5일·1인·세 역할. 1월 9일 ~ 1월 10일 16시 사이에 SC-A01~A20 중 18개 시나리오를 박아 진행률을 75% → 90% 로 끌어올렸다. 혼자서 PM·BE·FE 세 역할을 돌리는 동안 병렬로 일하는 것 같은 환상을 만든 건 코드 한 줄이었다
- 그 한 줄의 정체 —
VITE_USE_MOCK_API. Mock DataProvider와 REST DataProvider를 환경변수 한 칸으로 토글하는 구조 덕분에 BE 미완성 상태에서도 FE를 90%까지 박을 수 있었다. BE는 그 사이에 TransformInterceptor로{success, data, meta}응답 표준을 정해 두었다- 20개 시나리오 → 14개 page 모듈. SC 번호 1:1 매핑이 아니라 도메인 단위로 묶었다.
academies/,contents/,problems/,levels/,diagnostics/,monitoring/,settings/7개 핵심 모듈에 인증·대시보드·운영 모듈 7개를 더해 총 14개. 시나리오 수를 줄인 게 아니라 코드 책임의 자리를 줄인 것[FE → PM]태그가 만든 셀프 보고 프로토콜. 같은 사람이 같은 키보드로 두 역할을 번갈아 박아도, 보고를 문자열로 박아 두면 결정이 휘발되지 않는다. 세션 아카이브를 다시 읽었을 때 회고 가능한 형태로 남는 게 핵심- Mock → REST 전환의 진실 — 한 줄.
rest-data-provider.ts가 한 일은 BE의{ success, data, meta }를 Refine이 기대하는{ data, total }형태로 변환한 것 + 401 자동 처리뿐이다. FE 페이지 코드는 한 줄도 안 바뀌었다. 이게 토글 패턴의 진짜 목적- 속도의 그늘 — 잔존 4종. 90%까지 박았다는 건 10%가 미루어졌다는 뜻이다. SC-A08 비밀번호 초기화, 진단평가 D&D 순서 변경, 콘텐츠 v2.1.2 일부 검증, 운영자 비활성화 후 재활성화 흐름 — 네 자리는 의도적으로 미뤘다
- 앵글 한 줄. MVP의 정의는 “기능의 최소”가 아니라 “결합점의 최소”다. DataProvider라는 단 하나의 결합점만 빠르게 합의해 두면, 그 위에서 BE와 FE가 각자 별개의 시간을 쓸 수 있다. 5일 90%는 그 합의가 만든 숫자다
🧩 MVP의 정의를 바꾼 한 줄 — VITE_USE_MOCK_API=true
처음 SC-A01~A20을 한 화면에 펼쳐 놓고 본 순간 든 생각은 “BE가 다 끝나야 FE가 시작된다” 가 아니라 “BE가 안 끝나도 FE가 끝날 수 있어야 한다” 였어요. 1인 다역에서 직렬은 곧 대기 시간이고, 대기 시간은 곧 세션 컨텍스트의 휘발이거든요. 한 사이클에서 PM 모자를 쓰고 결정한 걸, 다음 사이클에서 BE 모자를 쓰고 까먹는 그림이 가장 무섭습니다.
그래서 제일 먼저 박은 게 환경변수 한 칸이에요.
# .env.example
VITE_USE_MOCK_API=true
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin
// src/providers/data-provider.ts (의도)
const useMock = import.meta.env.VITE_USE_MOCK_API === 'true';
export const dataProvider = useMock
? mockDataProvider
: restDataProvider({ baseUrl: import.meta.env.VITE_API_BASE_URL });
이 한 줄 덕분에 BE가 0% 일 때도 FE는 100% 의 시나리오를 돌릴 수 있어요. Mock DataProvider 안에 useTable, useForm, useShow가 기대하는 모양 — { data, total } — 으로 17개 콘텐츠, 5개 학원(이 시리즈에선 고객사 마스킹), 30개 레벨, 4개 진단평가 버전을 박아 두면 됩니다.
📌 핵심: Mock은 테스트용이 아니라 결합점 합의용입니다. FE가 기대하는 응답 모양을 코드로 먼저 박아 두면, BE는 그 모양을 복원 하면 되고, FE는 BE를 기다리지 않습니다. 1인 다역에서 가장 비싼 자원이 컨텍스트 스위칭인데, 이 패턴이 그 비용을 0으로 떨어뜨려요.
Refine 공식 문서의 DataProvider 챕터는 이 인터페이스를 “data hooks와 backend 사이의 다리” 로 정의해요. 다리를 두 개 깔아 두고 환경변수로 갈아끼우는 식이라고 보면 됩니다.
🗂️ 20개 시나리오 → 14개 page 모듈

처음에는 시나리오 1개당 페이지 1개 로 그렸는데, 막상 박기 시작하니 같은 도메인의 시나리오는 같은 모듈에 들어가는 게 자연스러워요. 예를 들어 SC-A04~A07(고객사 CRUD + 상태 변경)은 모두 pages/academies/ 아래 list.tsx, create.tsx, edit.tsx, show.tsx 네 파일에 분배됐고, SC-A08(비밀번호 초기화)만 별도 액션으로 붙입니다.
최종 디렉토리는 이렇게 됐어요.
apps/admin-portal/src/
├─ pages/
│ ├─ login/ # SC-A01 인증
│ ├─ change-password/ # SC-A02 비밀번호 변경
│ ├─ dashboard/ # SC-A03 대시보드
│ ├─ academies/ # SC-A04~A07 고객사 CRUD
│ ├─ contents/ # SC-A09~A12 콘텐츠 관리 v2.1.2
│ ├─ problems/ # SC-A13 문제 등록
│ ├─ levels/ # SC-A14~A15 레벨(L1~L30)
│ ├─ diagnostics/ # SC-A16~A17 진단평가
│ ├─ monitoring/ # SC-A20 시스템 상태/메트릭/로그
│ ├─ settings/ # SC-A18~A19 정책·운영자
│ ├─ activity-logs/ # 운영 보조
│ ├─ app-releases/ # 운영 보조
│ ├─ mail-templates/ # 운영 보조
│ └─ shop-items/ # 운영 보조
├─ components/
│ ├─ ui/ # 공통 위젯
│ ├─ layout/ # AdminLayout, Sidebar
│ └─ common/ # 도메인 공유 컴포넌트
├─ lib/validations/ # zod 스키마
└─ providers/ # mock-data-provider.ts, rest-data-provider.ts, auth-provider.ts
⚠️ 주의: 페이지 수와 시나리오 수를 1:1 로 매칭 하려는 충동이 가장 위험합니다. 20개 시나리오를 20개 라우트로 박으면 사이드바가 폭발하고 권한·레이아웃 분기가 산처럼 늘어나요. 도메인 단위로 묶고 시나리오는 모듈 안의 동작으로 박는 게 1인 다역에서는 거의 항상 정답입니다.
이 매핑을 정해 둔 덕분에 SC 번호별로 어떤 파일을 건드릴지 생각하지 않아도 됩니다. SC-A18 정책 설정이 들어왔을 때 pages/settings/policies.tsx가 자동으로 떠오르거든요. 1인 다역의 비용을 줄이는 두 번째 장치예요.
📨 1인 다역의 협업 프로토콜 — [FE → PM] 태그가 만든 거리감
세션 아카이브에 가장 많이 등장하는 문자열이 [FE → PM], [BE → PM], [PM → ALL] 같은 태그예요. 같은 사람이 같은 키보드로 박는데도 보고를 문자열로 박아 두는 이유는 단 하나 — 다음 사이클의 나는 지금의 나와 다른 사람이거든요.
예를 들어 1월 9일 저녁 워킹트리 정리 보고는 이렇게 남아 있어요.
## [FE → PM] 2026-01-09 - 워킹트리 정리 완료
### 커밋 완료
8e225f8 feat: 콘텐츠 관리 v2.1.2 + 레벨 관리 구현 (Task 9.4, 9.5)
### 미커밋 파일 (로컬 설정, 무시 가능)
- `.claude/settings.local.json` - Claude 로컬 설정
- `apps/admin-portal/tsconfig.tsbuildinfo` - TS 빌드 캐시
### 브랜치 상태
- **브랜치**: `feature/frontend-work`
- **병합 준비 완료**
이걸 왜 PM 모자를 쓰고도 안 보던 노트북에서 새로 읽는 것처럼 적냐면, 그래야 다음에 PM 모자를 다시 쓸 때 결정을 할 정보가 손에 잡히기 때문이에요. 같은 정보가 머릿속에만 있으면 24시간 후에는 사라져 있고, 이틀 뒤에는 그런 결정을 내린 적이 있다는 사실 자체가 휘발됩니다.
🔍 단서: 셀프 보고는 “내가 잊을 것”을 가정하고 적는 메모입니다. 머릿속에 있는 건 회고도 검색도 안 됩니다.
[FE → PM]태그는 거리를 만들기 위한 장치예요 — 같은 사람이 박는 줄 알면서도, 자기 자신과의 거리를 띄우려는.
PM 모자를 쓰고는 우선순위를 박았어요.
### [PM → BE] 다음 작업
**우선순위: HIGH**
1. Mock → REST API 연동 검증
2. CORS 설정 확인
**우선순위: MEDIUM**
3. Problem API 구현 (SC-A13용)
### [PM → FE] 다음 작업
**우선순위: HIGH**
1. Mock → REST API 연동 테스트
**우선순위: MEDIUM**
2. SC-A13 문제 등록 페이지 구현
이 프로토콜이 효과가 있는 두 번째 이유는, 큰 결정을 짧은 글로 강제로 박게 만드는 것이에요. PM 모자가 “이번 사이클에 이거 박자” 라고 머릿속으로 흘려 보내면, FE 모자가 다른 걸 박습니다. 문자열로 적어 둔 우선순위는 자기 자신을 배반하기 어려워요.
🔄 Mock → REST 전환의 진실 — 응답 형식 변환 한 줄
1월 10일 새벽 1시 55분 아카이브에 박혀 있는 결정 — b851e23 feat: BE API 연동 준비 - DataProvider 전환 구조 — 이 사실 이 시리즈에서 가장 많이 회수되는 한 줄이에요. 진짜로 한 일은 단 두 가지였습니다.
1. BE 응답을 Refine 형식으로 변환
// providers/rest-data-provider.ts (재구성)
async function getList(resource, params) {
const res = await fetch(`${BASE_URL}/${resource}?${qs(params)}`, {
headers: { Authorization: `Bearer ${token()}` },
});
if (res.status === 401) {
handle401();
throw new Error('Unauthorized');
}
// BE 응답: { success: true, data: [...], meta: { page, limit, total } }
// Refine 기대: { data: [...], total: number }
const body = await res.json();
return { data: body.data, total: body.meta?.total ?? body.data.length };
}
2. 401 자동 처리 (로그아웃 + 리다이렉트)
function handle401() {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
이게 끝이에요. 페이지 코드는 한 줄도 안 바뀝니다. useTable, useForm, useShow는 모두 Refine의 추상이라서 그 안쪽이 Mock이든 REST든 모르고, 알 필요도 없어요. BE가 같은 시각에 박은 응답 표준이 마침맞게 이 합의를 받쳐줬습니다.
// BE main.ts (인용)
app.enableCors({
origin: ['http://localhost:3002', 'http://localhost:5173'],
credentials: true,
});
// TransformInterceptor — 모든 응답을 { success, data, meta } 형태로
return next.handle().pipe(
map(payload => ({
success: true,
data: payload?.items ?? payload,
meta: payload?.meta,
})),
);
📊 데이터: 이 전환을 박은 1월 10일 아카이브에서 BE가 보고한 테스트 수는 155 tests passed (17 suites). 1월 9일 142개에서 13개가 늘어난 건 SC-A13 Problem API + 응답 표준화 + CORS 케이스를 더한 결과예요. FE는 같은 기간 페이지 코드를 0줄 수정 했습니다.
이 패턴이 던지는 진짜 메시지는 “DataProvider 인터페이스만 합의해 두면, BE와 FE는 같은 시간을 공유하지 않아도 된다” 예요. 1인 다역에서 시간 공유 비용은 사실상 컨텍스트 스위칭 비용이고, 그걸 0에 가깝게 만든 게 5일 90%의 핵심이에요.
⚖️ 5일 75% → 90% 속도의 비결과 그늘
진행률 숫자만 보면 5일에 90% 가 화려해 보여요. 그런데 자세히 보면 마지막 10% 가 의도적으로 남겨져 있습니다. 1월 10일 16시 시점의 미완료 4종이 다음과 같았어요.
| 미완료 | 분류 | 미루기 사유 |
|---|---|---|
| SC-A08 비밀번호 초기화 | 인증 보조 | 메일 발송 인프라 미합의 — 정책 결정 후 1주 내 |
| 진단평가 D&D 순서 변경 | UX 보조 | dnd-kit 도입 검토 중. 순서는 form input으로 임시 처리 |
| 콘텐츠 v2.1.2 일부 검증 | 도메인 | 5개 슬라이더(0~10) 합산 룰 확정 후 추가 |
| 운영자 비활성화→재활성화 흐름 | SC-A19 보조 | 정책상 비활성화는 됐고 재활성화 UI만 누락 |
이 표가 던지는 메시지는 두 가지예요. 첫째, 남겨진 10%는 결정이 부족한 일들이지 손이 부족한 일들이 아닙니다. 둘째, 5일에 90% 라는 숫자가 가능했던 건 결정이 끝난 일만 골라서 박았기 때문이에요. 결정이 부족한 일을 코드에 박으면 일주일 뒤에 다시 뜯어내야 합니다.
💡 인사이트: MVP 속도의 비결은 “더 빨리 박기” 가 아니라 “박지 않을 일을 미리 골라내기”. 1인 다역에서 가장 비싼 사이클은 되돌리는 사이클이에요. SC-A08을 미룬 건 게으름이 아니라 메일 인프라 결정이 안 끝났음을 인정한 것입니다.
세 번째 H2에서 적은 셀프 보고 프로토콜과도 연결돼요. 결정이 부족한 일은 PM 모자를 쓰고 다시 회의를 열어야 하는 일이고, 그걸 FE 모자로 강행하면 일주일 뒤 PM 모자가 자기 결정을 뒤집습니다. 같은 사람이 박는데도요.
📋 정리 — 핵심 요약
| 축 | 1인 다역에서 흔한 함정 | 이번 사이클의 선택 |
|---|---|---|
| 시작점 | BE 끝나면 FE 시작 (직렬) | DataProvider 인터페이스 먼저 박기 (병렬) |
| 결합점 | 페이지마다 fetch 분산 | data-provider.ts 한 자리에서 토글 |
| 매핑 | 시나리오 1개 = 라우트 1개 | 도메인 1개 = 모듈 1개 (14개 모듈) |
| 협업 | 머릿속 메모 | [ROLE → ROLE] 태그로 셀프 보고 |
| 미완료 | ”다음 사이클에 마저” | 결정 부족 4종은 명시적으로 보류 |
이 5일 사이클의 결론을 한 줄로 적으면 이렇게 돼요 — MVP의 정의는 “기능의 최소” 가 아니라 “결합점의 최소”. 결합점이 한 곳이면 그 위에서 BE와 FE는 같은 시간을 공유하지 않아도 되고, 같은 사람이 두 모자를 번갈아 써도 컨텍스트가 휘발되지 않아요.
다음 편(#23)에서는 이 토글을 켰을 때 처음 마주친 예상과 다른 응답 포맷 의 디테일을 다룰 예정이에요. 1월 13일 ~ 14일 사이에 박힌 DTO interface → class 전환과 CORS PATCH 디버깅이 그다음 카드입니다.
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루