1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄

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 모듈

SC-A01부터 SC-A20까지 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%의 핵심이에요.

refine.dev

⚖️ 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. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루