Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
📚 교육용 풀스택 SaaS 개발기 시리즈 (23편)
VITE_USE_MOCK_API=false로 토글한 순간 useTable이 멈췄다. BE는 TransformInterceptor로 모든 응답을 `{ success, data, meta }`로 표준화했고, Refine은 `{ data, total }`을 기대했다. dataProvider 어댑터 한 줄로 메우면 끝나는 줄 알았는데 — 경로가 `/api/v1/api/v1/...`로 중복되고, FE가 `order`로 보낸 정렬 키를 BE는 `sortOrder`로 받았으며, 중첩 리소스 `/contents/:id/problems`는 dataProvider 기본 구현이 못 그렸다. 결국 어댑터 한 줄이 아니라 경로 정규화·필드 매핑·중첩 리소스 라우터·401 인터셉터까지 네 자리를 박아야 토글이 진짜 한 줄이 됐다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 토글 한 줄의 환상이 깨진 순간. 이전 편에서
VITE_USE_MOCK_API한 칸으로 Mock과 REST를 자유롭게 갈아끼울 수 있다고 자랑했어요. 막상 BE가 올라와서false로 바꾼 순간useTable이 Cannot read properties of undefined (reading ‘length’) 한 줄을 뱉으면서 멈췄습니다. 토글은 한 줄인데 어댑터가 한 줄이 아니었던 거예요- 포맷 미스매치의 정체. BE는
TransformInterceptor로 모든 응답을{ success, data, meta }로 감쌌고, Refine의useTable은{ data: T[], total: number }를 기대했어요. 두 표준 사이를 메우는 게 rest-data-provider의 첫 번째 책임이 됐습니다- 경로 중복 함정 —
/api/v1/api/v1/.... Controller에@Controller('api/v1/admin/academies')로 박은 상태에서setGlobalPrefix('api/v1')까지 켜자 prefix가 두 번 붙었어요. 경로를 코드에 두 번 박으면 한 번은 반드시 잊는다는 교훈- 필드 매핑 —
order↔sortOrder. FE는 정렬 키를order라 부르고 싶어 했고 BE는sortOrder로 받기로 합의돼 있었어요. dataProvider의getList에서sorters를 변환할 때 키 이름까지 어댑터의 책임이라는 걸 그제서야 알았습니다- 중첩 리소스 —
/contents/:id/problems. Refine 기본 dataProvider는/{resource}/{id}패턴만 그려요. SC-A13 문제 등록은 본질적으로 콘텐츠에 종속된 리소스라 부모 ID를 URL에 박아야 했고, dataProvider에 resource 문자열 자체를 경로처럼 해석하는 로직을 추가해 풀었어요- 401 자동 처리. 토큰 만료 → 401 → 로그인 페이지 리다이렉트. 이걸 페이지마다 박으면 1인 다역에서 죽는 길이라, axios 인터셉터에 한 번 박아 두고 dataProvider가 모르게 했습니다
- 앵글 한 줄. 어댑터가 한 줄이라는 건, 그 한 줄 뒤에 네 자리의 합의가 끝나 있다는 뜻이다. 응답 포맷·경로 규칙·필드 명·인증 흐름 — 네 자리가 마무리돼야 비로소 토글이 진짜 토글이 된다
🪪 재구성 안내. 이 편은 1월 12일 마이그레이션 완료 직후부터 1월 13일 Task 9 종료까지의 실시간 작업 흐름을 세션 아카이브 5개와 LESSONS 항목 2건을 교차 인용해 재구성한 글이에요.
apps/admin-portal/src/providers/rest-data-provider.ts,apps/api/src/common/interceptors/transform.interceptor.ts등 직접 인용한 코드 블록은 당시 동작을 글로 표현하기 위한 재구성본입니다. 변수명·로직 흐름은 커밋 로그(9b4d573,a44bc4a,0937db4,ffc54a8,696b97f,27305e1)와 정합하지만, 라인 단위까지 동일하다고 보증하지는 않아요.
🔥 증상 — 토글 한 줄을 false로 바꾼 순간 useTable이 멈췄다
이전 편 마지막 단락에서 토글 한 줄로 Mock과 REST가 자유롭게 갈리는 구조라고 자신 있게 적었어요. 그래서 1월 12일 11시 5분 [BE → PM] 보고로 “마이그레이션 완료, 서버 정상 작동” 메시지가 들어왔을 때, 저는 그저 .env 파일 한 줄만 바꾸면 90%까지 박아 둔 Admin Portal이 그대로 진짜 데이터를 보고 돌아갈 줄 알았습니다.
# .env (전)
VITE_USE_MOCK_API=true
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin
# .env (후)
VITE_USE_MOCK_API=false
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin
pnpm dev:fe로 Vite를 다시 띄운 뒤 /academies로 들어갔어요. 그러자 콘솔에 빨간 줄이 떴습니다.
TypeError: Cannot read properties of undefined (reading 'length')
at useTable.ts:212
🔍 단서:
useTable.ts:212는 Refine 내부 코드. 여기서 터졌다는 건dataProvider.getList()가 Refine이 기대하는 모양이 아닌 무언가를 던졌다는 뜻이에요. 책임 소재는 dataProvider 쪽
Mock으로 다시 돌리면(toggle을 true로) 멀쩡하게 17건 학원 데이터를 그렸어요. REST로 돌리면 멈췄고요. 같은 useTable 훅, 같은 <List> 컴포넌트, 같은 <Table dataSource={...} />인데 데이터 출처만 다른 두 모드 사이에 차이가 있다 — 그렇다면 차이는 dataProvider의 출력 모양 단 한 곳입니다.
브라우저 Network 탭을 열어 실제 응답을 받아 봤어요.
// GET /api/v1/admin/academies?_start=0&_end=10 — REST 모드 응답
{
"success": true,
"data": [
{ "id": 1, "name": "테넌트 A", "createdAt": "2026-01-12T..." },
{ "id": 2, "name": "테넌트 B", "createdAt": "2026-01-12T..." }
],
"meta": { "total": 17, "page": 1, "perPage": 10 }
}
// useTable이 기대하는 모양
{
data: [...],
total: 17
}
한 칸 차이. success 한 줄과 meta 안의 total 위치 한 줄. 이 두 자리가 어긋나면서 Refine은 응답 객체를 받아 data.length를 읽으려다 해당 없음을 만난 거예요. Mock DataProvider 안에는 처음부터 { data, total }로 박아 뒀으니 통과했고, REST는 한 단계 더 깊숙이 들어가 있어 멈춘 거였습니다.
📌 핵심: 같은 데이터를 다른 모양으로 보내는 두 표준이 만나면, 그 둘 사이에는 반드시 어댑터가 있어야 한다. “토글 한 줄”이라는 약속을 지키려면 그 어댑터가 dataProvider 안에서 끝나야 한다 — 페이지 코드까지 번지면 토글의 의미가 사라져요
이 시점에서 머리에 든 그림은 단순했어요. rest-data-provider 안에서 한 줄 변환만 박으면 끝. 그렇게 시작한 작업이 결국 8시간 동안 네 군데를 더 건드리게 됩니다.
🔬 진짜 범인 — TransformInterceptor가 만든 BE 표준이 너무 풍부했다
BE 쪽 응답 포맷을 정한 건 며칠 전 910e38b 커밋이었어요. 1월 10일 [BE → PM] 보고로 들어온 *“CORS 설정, 응답 형식 표준화 및 Problem API 구현”*이 그 한 줄 변경의 자리. TransformInterceptor를 손봐서 모든 컨트롤러 응답을 { success, data, meta }로 일괄 감싸는 변경이었습니다.
// apps/api/src/common/interceptors/transform.interceptor.ts (재구성)
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((response) => {
if (isPaginated(response)) {
return {
success: true,
data: response.items,
meta: {
total: response.total,
page: response.page,
perPage: response.perPage,
},
};
}
return { success: true, data: response };
}),
);
}
}
이 모양 자체는 서버 표준으로서는 깔끔해요. 모든 엔드포인트가 같은 envelope을 갖고, 클라이언트는 success === false면 즉시 에러 처리하면 되고, 페이지네이션 메타데이터가 meta 안에 격리되어 있어 본 데이터와 섞이지 않거든요. 1인 다역으로 BE/FE를 동시에 돌리는 입장에서, 서버 응답을 어디서 받아도 모양이 같다는 점은 디버깅 시간을 크게 줄여 줍니다.
// apps/api/src/main.ts (재구성)
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
app.useGlobalInterceptors(new TransformInterceptor());
app.enableCors({
origin: ['http://localhost:3002', 'http://localhost:5173'],
credentials: true,
});
문제는 이 표준이 Refine의 표준과 한 칸씩 어긋난다는 거예요. Refine은 4년 가까이 다듬어 온 자체 어댑터 인터페이스가 있고, useTable/useShow/useForm/useMany 같은 훅이 모두 그 인터페이스를 가정합니다. 가장 자주 부딪히는 게 페이지네이션이고, Refine은 두 가지 모양을 받아들여요.
// Refine이 받아들이는 두 가지 모양
type GetListResponse = {
data: BaseRecord[];
total: number;
};
// 또는, simple-rest 같은 일부 어댑터는 헤더로 처리
// X-Total-Count 헤더 + body는 배열 그 자체
NestJS TransformInterceptor는 이 둘 어디에도 안 맞아요. success 한 줄이 더 있고, total이 meta 안에 격리돼 있고, 본 데이터 배열도 한 단계 깊은 data 키 아래에 있거든요. 이 세 가지 미스매치를 한 군데에서 흡수하는 게 어댑터의 첫 번째 임무입니다.
⚠️ 주의: TransformInterceptor를 없애고 Refine 표준에 BE를 맞추자는 유혹이 잠깐 들었지만, 그러면 진짜 클라이언트(Unity, Learning Client, Parent Report) 세 종이 모두 다시 envelope 처리를 해야 해요. 결국 어댑터는 클라이언트 한 종마다 한 곳이 합리적
또 하나 부딪힌 게 BE 표준의 응답을 data로 한 번 푸는 행위가 모든 호출에 일괄 적용되면 안 된다는 점이었어요. useShow가 단건 조회로 받는 모양은 { success: true, data: {...} } 하나라 언래핑이 자동이지만, 페이지네이션 응답은 meta.total을 따로 떠내야 합니다.
// 잘못 박은 첫 시도 — 모든 응답에 같은 변환을 일괄 적용
const unwrap = (res: any) => res.data;
// 결과: getList도 단건 응답을 풀듯이 풀어 버려서
// data가 BaseRecord[]가 아닌 BaseRecord 하나가 되어 또 useTable이 멈춤
🔍 단서: REST 응답을 받는 곳마다 언래핑의 깊이가 달라야 해요. 단건은 한 단계, 페이지네이션은 두 단계(
data+meta.total). 어댑터는 호출 종류별로 분기해야 한다는 사실을 이때 처음 명확히 의식했어요
여기까지가 1월 12일 14시까지의 그림. 어디를 고쳐야 할지는 명확해졌고, 이제 어떻게 차례입니다.
🛠️ 해결 — rest-data-provider에 네 자리를 박아 토글을 진짜 한 줄로

rest-data-provider를 처음 박을 때 머리에 든 구조는 단순했어요. Refine 인터페이스(getList, getOne, create, update, deleteOne, getMany, custom) 일곱 개 메서드를 axios 호출로 채워 두고, 각 메서드 안에서 응답을 한 번씩 변환만 해 주면 끝.
// apps/admin-portal/src/providers/rest-data-provider.ts (재구성)
import axios, { AxiosInstance } from 'axios';
import type { DataProvider } from '@refinedev/core';
type Envelope<T> = {
success: true;
data: T;
meta?: { total: number; page: number; perPage: number };
};
export const restDataProvider = (baseUrl: string): DataProvider => {
const http: AxiosInstance = axios.create({ baseURL: baseUrl });
http.interceptors.request.use((config) => {
const token = localStorage.getItem('admin-access-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
http.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('admin-access-token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
return {
getApiUrl: () => baseUrl,
getList: async ({ resource, pagination, sorters }) => {
const params = new URLSearchParams();
if (pagination) {
params.set('_start', String((pagination.current - 1) * pagination.pageSize));
params.set('_end', String(pagination.current * pagination.pageSize));
}
if (sorters?.[0]) {
params.set('_sort', mapSortKey(sorters[0].field));
params.set('_order', sorters[0].order);
}
const url = buildResourceUrl(resource);
const { data } = await http.get<Envelope<unknown[]>>(`${url}?${params}`);
return {
data: data.data as any,
total: data.meta?.total ?? data.data.length,
};
},
getOne: async ({ resource, id }) => {
const url = buildResourceUrl(resource, id);
const { data } = await http.get<Envelope<unknown>>(url);
return { data: data.data as any };
},
// create / update / deleteOne / getMany / custom 생략 — 동일 패턴
} satisfies DataProvider;
};
여기서 getList 안에 별다른 마법이 없는데, 이 30줄이 BE의 { success, data, meta }를 Refine의 { data, total }로 정확히 변환해 줘요. 페이지네이션은 _start/_end 파라미터 — Refine simple-rest 어댑터의 컨벤션을 그대로 따랐고, BE도 같은 파라미터를 읽도록 합의돼 있었습니다.
문제는 나머지 세 자리. 코드에서 mapSortKey와 buildResourceUrl이라는 두 헬퍼와, 인터셉터의 401 처리. 이 셋이 어댑터를 진짜 토글로 만든 자리예요.
자리 1: buildResourceUrl — 경로 중복 함정과 중첩 리소스
가장 먼저 터진 게 경로 중복이었어요. .env에 VITE_API_BASE_URL=http://localhost:3000/api/v1/admin이라 박아 뒀고, BE main.ts에서도 setGlobalPrefix('api/v1')로 prefix를 박아 뒀어요. 그런데 컨트롤러 데코레이터가 이렇게 되어 있었습니다.
// ❌ Before — apps/api/src/admin/academy/academy.controller.ts (재구성)
@Controller('api/v1/admin/academies')
export class AcademyController {
@Get()
list() { /* ... */ }
}
그러면 실제 라우팅이 /api/v1 + /api/v1/admin/academies 가 돼서 /api/v1/api/v1/admin/academies라는 이중 경로가 됩니다. axios가 404를 뱉고 콘솔에 그건 그것대로 빨간 줄을 한 줄 더 추가했어요.
// ✅ After
@Controller('admin/academies')
export class AcademyController {
@Get()
list() { /* ... */ }
}
📌 핵심: 경로를 코드에 두 번 박으면 한 번은 반드시 잊는다.
setGlobalPrefix를 쓰는 한, 컨트롤러 데코레이터는 prefix 다음 토큰부터 시작해야 해요. 1인 다역에서 이 규칙은 코드 리뷰가 없어도 실수가 안 나도록 컨벤션화하는 게 맞다
레벨 API에서도 비슷한 일이 났어요. 처음에 /api/v1/levels로 박았다가 Admin 영역인데 prefix가 빠져 있다는 걸 한 번 알아챈 자리. 커밋 9b4d573 fix: 레벨 API 경로를 /api/v1/admin/levels로 수정이 그 한 줄 정정이에요. 이 두 가지를 한꺼번에 잡고 나서 buildResourceUrl을 다음과 같이 정리했어요.
// apps/admin-portal/src/providers/rest-data-provider.ts (재구성)
const buildResourceUrl = (resource: string, id?: BaseKey): string => {
// resource 자체가 경로 역할을 한다 — "contents/123/problems" 같은 형태도 허용
const base = resource.startsWith('/') ? resource : `/${resource}`;
return id !== undefined ? `${base}/${id}` : base;
};
이 한 줄이 또 다른 함정 — 중첩 리소스 문제까지 같이 풀어 줍니다. SC-A13의 문제 등록은 콘텐츠 종속 리소스라 부모 ID를 URL에 박아야 해요. Refine 기본 dataProvider는 /{resource}/{id} 패턴만 지원해서, 페이지 코드에서 useTable({ resource: "contents/42/problems" })처럼 부모 ID 자체를 resource 문자열에 합쳐 넣었습니다. ffc54a8 feat: dataProvider 중첩 리소스 지원 및 문제 관리 API 연동 개선 커밋이 그 자리예요.
// apps/admin-portal/src/pages/problems/list.tsx (재구성)
const { contentId } = useParams<{ contentId: string }>();
const { tableProps } = useTable({
resource: `contents/${contentId}/problems`,
});
⚠️ 주의: Refine 공식 문서에서는 meta.parentId 같은 패턴으로 중첩 리소스를 풀라고 권장하기도 해요. 우리는 resource 문자열 자체를 경로처럼 해석하는 길을 골랐고, 그 결정이 합리적이었던 이유는 BE의 라우트 자체가
/contents/:id/problems로 URL에 부모 ID를 박는 구조였기 때문이에요. 어댑터의 모양은 서버 라우트의 모양을 따라가는 게 결국 가장 단순합니다
자리 2: mapSortKey — FE의 order를 BE의 sortOrder로
레벨 관리 페이지(SC-A14)에서 정렬을 켜자 BE가 400 Bad Request를 던졌어요. body에 sorters[0].field === "order"가 들어왔는데, BE의 LevelListQueryDto는 _sort=sortOrder만 인식하도록 박혀 있었습니다.
// ❌ Before — FE 페이지 코드에서 직접 'sortOrder'로 박는 안티패턴
const { tableProps } = useTable({
resource: "admin/levels",
sorters: { initial: [{ field: "sortOrder", order: "asc" }] },
});
// 문제: FE 도메인 언어가 'order'인데, 페이지 곳곳에 BE 키 'sortOrder'가 새어들어옴
// ✅ After — dataProvider 안에서 키를 변환
const SORT_KEY_MAP: Record<string, string> = {
order: 'sortOrder',
// 필요하면 추가
};
const mapSortKey = (field: string): string => SORT_KEY_MAP[field] ?? field;
페이지에서는 sorters: [{ field: "order", order: "asc" }]로 FE 도메인 언어를 그대로 쓰고, 어댑터가 그걸 BE 키로 바꿉니다. 매핑 테이블이 한 곳에 모이니까 BE 키가 바뀌어도 페이지를 안 건드린다는 게 큰 안심이었어요.
💡 인사이트: 어댑터는 단순한 변환기가 아니라 두 도메인 언어 사이의 사전이다. 응답 변환만 해서는 부족하고, 요청 파라미터의 키 이름까지 사전에 등재해 두지 않으면 페이지가 BE 키를 알게 되고, 그러면 BE 키가 바뀔 때마다 페이지 N개를 같이 건드리게 돼요
자리 3: 401 인터셉터 — 토큰 만료를 한 곳에서
처음에는 페이지마다 try/catch로 401을 잡으려 했어요. 콘솔에 401 한 줄이 뜨면 <ProtectedRoute> 안에서 잡아 navigate('/login')을 호출하는 식. 그런데 SC-A20 모니터링 페이지에 들어가자마자 대시보드 위젯 6개가 동시에 401 를 뱉으면서 navigate가 6번 큐에 박혔어요. URL이 한 번에 6번 갈리진 않지만, 경합은 그 자체로 불쾌했고요.
axios 인터셉터에 한 번만 박았어요.
http.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('admin-access-token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
window.location.href로 하드 리다이렉트를 쓴 이유는 React Query 캐시·localStorage·Refine 내부 상태를 모두 한 번에 초기화하고 싶어서였어요. SPA navigate로는 메모리 상태가 살아남아서, 다시 로그인했을 때 직전 사용자의 잔향이 한 번씩 보입니다.
🔍 단서: 401을 어디서 잡느냐가 인증 흐름의 단순함을 결정해요. 페이지가 잡으면 페이지마다 분기 코드, 컴포넌트가 잡으면 컴포넌트마다 prop drilling — 인터셉터가 잡으면 모든 호출이 같은 운명을 따르고, 1인 다역의 머릿속이 가벼워져요
자리 4: 페이지네이션·필터·정렬을 dataProvider에서 차단
BE의 _start/_end 컨벤션은 좋지만, 중첩 리소스 일부에서는 pageSize/page 같은 다른 컨벤션을 따르는 엔드포인트가 한두 개 있었어요. dataProvider에서 경로별 분기를 박는 대신, 컨벤션 통일을 BE에 부탁하는 길을 골랐습니다.
[FE → BE] 2026-01-13 16:40
모니터링 통계 API(/admin/monitoring/usage)만
?from=2026-01-01&to=2026-01-13 형태로 받고 있어요.
다른 곳들이 _start/_end로 통일돼 있어서, 이 한 자리만 통일해 주실 수 있을까요?
이쪽 dataProvider에서 분기로 처리하는 것보다 BE에서 한 번 통일하는 게 깨끗해 보입니다.
이런 자리들을 [FE → BE] 셀프 보고로 한 줄씩 박아 둔 게 결국 dataProvider를 경로별 분기 없는 평평한 어댑터로 유지해 줬어요.
📌 핵심: 어댑터는 분기를 모를수록 좋다. 분기가 많아지면 어댑터가 BE의 사정을 알게 되고, 그러면 어댑터의 변경 빈도가 BE의 변경 빈도와 같아져 1인 다역의 셀프 협상 부담이 두 배가 돼요
✅ 검증 — Mock과 REST를 번갈아 띄우면서 페이지가 멀쩡한지
8시간 동안 네 자리를 박고 1월 13일 20시 즈음, 토글을 다시 시험했어요. .env의 VITE_USE_MOCK_API만 한 번씩 갈아주면서 18개 시나리오를 한 바퀴 돌렸습니다.
# Mock 모드
VITE_USE_MOCK_API=true pnpm --filter admin-portal dev
# → 17개 학원, 30개 레벨, 4개 진단평가, 7개 정책 — 모두 정상
# REST 모드
VITE_USE_MOCK_API=false pnpm --filter admin-portal dev
# → 같은 화면, 다른 출처 — useTable/useShow/useForm 모두 그대로 동작
세션 아카이브 2026-01-13_2000.md의 Task 9 완료 요약에 이 결과가 담겼어요. Subtasks 9.1~9.8 전부 체크. 그 사이에 박은 커밋이 9b4d573(레벨 경로), a44bc4a(정책 경로), 0937db4(레벨 API 연동), ffc54a8(중첩 리소스), 696b97f(Admin Level PATCH), 27305e1(Admin Auth Merge) 여섯 자리. 어댑터 한 줄이 코드로는 여섯 커밋이었어요.
// 페이지 코드 — Mock에서 REST로 갈아끼우는데 한 줄도 안 바뀌었다
const { tableProps } = useTable({
resource: "academies",
sorters: { initial: [{ field: "order", order: "asc" }] },
});
return (
<List>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="name" title="이름" />
<Table.Column dataIndex="createdAt" title="등록일" />
</Table>
</List>
);
이 코드가 Mock일 때나 REST일 때나 글자 하나 안 바뀌고 같은 화면을 그린다는 점이 어댑터의 가장 큰 효용이에요. 페이지를 26개 박아 둔 1인 다역에서 26개를 다시 안 건드려도 된다는 사실은 디버깅 시간이 다른 자리에 갈 수 있다는 뜻이거든요.
📊 데이터: 어댑터 한 곳을 박는 데 8시간, 그 사이에 페이지 코드 0줄 변경. 만약 페이지에서 envelope을 푸는 길을 골랐다면 26개 페이지 × 평균 3개 호출 × 평균 2줄 변경 = 156줄을 26곳에 분산해서 박았을 거예요. 8시간이 156줄에 26개의 회귀 테스트 가능 면적과 바꿀 만한 거래였는지 — 답은 명확합니다
🛡️ 예방 — 어댑터의 책임 경계를 다음 시리즈에 가져갈 때
이 한 사이클을 마치면서 어댑터의 책임 경계를 한 줄로 정리해 뒀어요. 다음에 Academy Portal·Parent Report·Learning Client 같이 클라이언트 종이 늘어날 때 같은 자리를 다시 박지 않으려고요.
어댑터의 책임 = 두 도메인 언어 사이의 사전
1. 응답 envelope 풀기 ({ success, data, meta } → { data, total })
2. 요청 파라미터 키 변환 (FE 'order' → BE 'sortOrder')
3. 경로 정규화 (resource 문자열 → URL 빌더, 중첩 리소스 허용)
4. 인증 흐름 한 곳에서 차단 (axios 인터셉터의 401)
그 외에 어댑터가 알면 안 되는 것
- 페이지의 비즈니스 로직 (어댑터는 폼 검증을 모른다)
- BE의 도메인 모델 (어댑터는 'AcademyDto'를 모른다, 그냥 unknown으로 통과)
- 사용자 메시지 (에러 토스트는 호출 측에서)
docs/architecture.md에 이 다섯 줄을 박아 두니까, 이후 Academy Portal에서 dataProvider를 새로 박을 때 같은 8시간을 다시 쓰지 않아도 됐어요. 세 클라이언트 × 8시간 = 24시간이 세 클라이언트 × 1시간 = 3시간으로 줄었습니다 — 1인 다역에서 21시간이라는 건 다른 시나리오 7개를 박을 시간과 같아요.
⚠️ 주의: 이 책임 경계가 항상 옳다는 건 아니에요. 클라이언트가 한 종이고 BE 표준이 자주 바뀌는 환경에서는 차라리 페이지에서 envelope을 푸는 길이 더 단순할 수 있어요. 우리는 클라이언트가 다섯 종에 BE 표준이 안정된 환경이라 어댑터 격리가 이득이었던 거지, 모든 1인 다역에 일반화되는 결정은 아닙니다
📋 정리 — 핵심 요약
| 자리 | ❌ 안티패턴 | ✅ 권장 패턴 |
|---|---|---|
| envelope 풀기 | 페이지에서 res.data.data 직접 풀기 | dataProvider에서 일괄 변환, 페이지는 { data, total }만 본다 |
| 경로 prefix | 컨트롤러에 @Controller('api/v1/admin/...') + setGlobalPrefix('api/v1') 동시 사용 | setGlobalPrefix만 사용하고 컨트롤러는 그 다음 토큰부터 |
| 정렬 키 매핑 | 페이지에서 BE 키(sortOrder)를 직접 사용 | FE 도메인 언어(order) 사용 + dataProvider에 매핑 사전 |
| 중첩 리소스 | meta.parentId 같은 우회로 | resource 문자열 자체를 경로로 해석 (contents/42/problems) |
| 401 처리 | 페이지/컴포넌트마다 try/catch | axios 인터셉터 한 곳 + window.location.href 하드 리다이렉트 |
| BE 컨벤션 통일 | dataProvider에 경로별 분기 박기 | [FE → BE] 셀프 보고로 BE에서 통일 부탁 |
다음 편 [#24] 에서는 이 dataProvider를 가지고 DTO interface → class 전환 삽질기로 들어가요. Swagger가 응답 구조를 못 그려 주는 한 자리에서 다시 한 번 서버 표준의 모양과 부딪힙니다.
📚 교육용 풀스택 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에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루