HCMS
프로젝트별 권한과 콘텐츠 타입을 먼저 구성하고 그 모델로 엔트리, 폴더, 미디어, 게시판, 감사 로그를 운영하는 Headless CMS 플랫폼입니다. 콘텐츠 모델을 만드는 구성 앱과 그 모델로 콘텐츠를 다루는 운영 CMS 두 저장소로 나뉘고 두 저장소의 프론트엔드를 혼자 구축했습니다. 백엔드 동료와 둘이서 개발실장 기획 아래 만든 사내 제품입니다.
핵심 기여
| 과제 | 본인 기여 | 확인한 동작 |
|---|---|---|
| 콘텐츠 모델과 운영 화면 연결 | 구성 앱과 운영 CMS 프론트엔드 단독 구축 | 컬럼 정의가 엔트리 표와 입력 폼으로 연결 |
| 운영자의 작업 맥락 유지 | URL 조회 조건과 저장할 표시 설정, 편집 초안 분리 | 새로고침 뒤 조회 조건과 테이블별 표시 설정 유지 |
| 인증 만료와 API 변경 | 생성 클라이언트 연결, 단일 refresh와 대기열 처리 | 동시 실패 요청을 갱신 뒤 재실행하고 갱신 실패도 분기 |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2026.01 ~ 2026.03 |
| 역할 | 구성 앱(cms_config)과 운영 CMS(cms) 프론트엔드 단독 구축. 콘텐츠 모델 구성과 운영 화면의 프론트엔드 전체 |
| 인원 | 프론트엔드 1명(본인), 백엔드 1명, 기획은 개발실장. 사내 제품 |
| 서비스 | 콘텐츠 타입 빌더로 테이블과 컬럼을 정의하고 운영 CMS에서 엔트리, 폴더, 미디어, 게시판, 감사 로그를 운영 |
| 구성 앱 화면 | 대시보드, 프로젝트, 멤버, 권한, 설정, 프로필, 콘텐츠 타입 빌더 |
| 운영 CMS 화면 | 콘텐츠 매니저의 엔트리와 폴더, 미디어 라이브러리, 게시판 메뉴, 감사 로그, 멤버, 권한, 설정과 테마, 로그인과 OTP |
| 기술 | React 19, TypeScript, Vite, React Compiler, TanStack Router, TanStack Query, TanStack Form, TanStack Table, Ant Design, i18next, Zustand, zod, Kubb, MSW, Playwright, Vitest, Biome |
두 저장소는 역할이 다릅니다.
| 저장소 | 역할 | 만드는 것 |
|---|---|---|
| 구성 앱 | 프로젝트, 멤버, 권한, 설정과 콘텐츠 모델을 정의하는 구성 레이어 | 테이블과 컬럼 정의, 권한 그룹, 프로젝트 설정 |
| 운영 CMS | 정의된 모델로 실제 콘텐츠를 운영하는 실행 레이어 | 엔트리, 폴더, 미디어, 게시판, 감사 로그 |
맡은 범위
- 구성 앱. 대시보드, 프로젝트, 멤버, 설정, 프로필 화면과 i18next 다국어를 구축했습니다. 콘텐츠 타입 빌더에서 테이블과 컬럼을 만들고 고치고 지우는 흐름과 모달 폼, 선택 상태, 재조회를 정리했습니다.
- 권한. 권한 그룹, 엔드포인트 접근, 멤버와 권한 연결, 저장 상태를 구분한 RBAC 화면을 만들었습니다.
- 운영 CMS. 콘텐츠 타입 빌더가 정의한 테이블과 컬럼이 엔트리, 폴더, 검색 화면까지 그대로 이어지는 구조로 만들었습니다. 미디어 라이브러리의 검색, 필터, 업로드, 미리보기, 삭제 흐름과 게시판 메뉴, 감사 로그, 멤버, 권한, 설정과 테마 화면을 구현했습니다.
- 조회 상태. TanStack Router의 URL query에 검색어, 콘텐츠 타입, 폴더, 정렬, 페이지를 담아 API 요청과 React Query 키에 함께 썼습니다.
- API 연동. OpenAPI에서 Kubb로 생성한 타입과 서비스 코드를 화면별 조회와 저장 요청에 연결하고 변경 뒤 필요한 React Query 캐시만 갱신했습니다. 로그아웃 시 캐시를 비웁니다.
- 인증. 로그인과 OTP 검증, access token과 refresh token 저장, 만료 시 한 번만 갱신하는 interceptor와 대기 요청 재실행을 구현했습니다.
- 검증과 규약. MSW로 API 응답 사례를 재현하고 Playwright로 주요 운영 흐름과 여러 브라우저 렌더링을 확인했습니다. 트리 변환과 업로드 검증 같은 순수 함수는 Vitest로 검증합니다. 여기서 다듬은 React, CSS, TypeScript, Playwright 규약은 별도 저장소로 분리했고 이후 SK AX 프로젝트가 같은 규약으로 진행됩니다.
문제와 해결
코드는 저장소의 실제 코드를 옮긴 것이며 내부 주소와 환경값은 지우고 이름과 세부를 일부 줄였습니다.
구성 앱과 운영 CMS를 한 제품으로 잇기
문제. 저장소가 둘이라 화면을 따로따로 만들면 프로젝트 모델과 권한 구조가 제각각 진화합니다. 운영자가 콘텐츠 타입 빌더에서 정의한 테이블과 컬럼이 운영 CMS의 엔트리, 폴더, 검색 화면에 그대로 나타나야 했습니다.
판단. 두 저장소를 같은 데이터 구조와 운영 규칙을 공유하는 한 플랫폼으로 봤습니다. 구성 앱은 모델을 정의하고 운영 CMS는 그 모델을 읽어 화면을 그립니다. 같은 OpenAPI 명세에서 생성한 타입을 양쪽이 쓰니 모델이 바뀌면 양쪽이 같이 바뀝니다. 이것은 결정이라기보다 당연히 맞아야 하는 구조였고 그 구조가 깨지지 않게 지키는 것이 일이었습니다.
구현. 구성 앱에서 테이블 목록과 컬럼 정의를 만들고 운영 CMS에서는 엔트리 화면이 선택한 테이블의 컬럼 정의를 받아 표와 폼을 그립니다. 미디어 라이브러리, 게시판, 감사 로그 화면도 같은 프로젝트 모델과 권한 모델 위에 있습니다.
flowchart LR
Builder["구성 앱: 콘텐츠 타입 빌더"] -->|테이블과 컬럼 정의| Model["콘텐츠 모델(OpenAPI 타입)"]
Perm["구성 앱: 멤버, 권한, 설정"] --> Model
Model --> Entries["운영 CMS: 엔트리 표와 폼"]
Model --> Folders["운영 CMS: 폴더 트리"]
Model --> Media["운영 CMS: 미디어, 게시판, 감사 로그"]결과. 콘텐츠 타입을 바꾸면 운영 CMS는 화면을 다시 그릴 뿐 코드를 고치지 않습니다. 이 기간 동안 동적 스키마 변경으로 화면이 깨진 사례는 없었습니다.
컬럼 정의를 표와 폼으로 바꾸기
문제. 엔트리 화면은 테이블마다 컬럼이 다릅니다. 컬럼마다 입력 검증과 셀 표시가 달라야 하고 운영자가 보고 싶은 컬럼만 고를 수 있어야 했습니다. 화면에 테이블별 분기를 쌓으면 테이블이 늘 때마다 코드가 늘어납니다.
판단. 컬럼 정의를 입력 필드 설정과 표 컬럼 설정으로 변환하는 규칙을 한 곳에 두고 화면은 그 결과만 그리게 했습니다. 어떤 컬럼을 보일지는 테이블 이름을 키로 브라우저에 저장하고 저장값이 없거나 비어 있으면 전체 컬럼으로 돌아갑니다.
구현. 컬럼 정의에서 필드 유형별 검증과 셀 렌더링을 정하고 표시 컬럼은 Zustand persist 스토어에 둡니다. 컬럼 설정 모달의 결과가 스토어에 들어가면 표가 다시 그려집니다.
// 저장된 설정이 없거나 비어 있으면 전체 컬럼으로 돌아간다. 저장값이 현재 컬럼과 하나도 맞지 않아도 전체로 돌아간다
const getVisibleColumns = (columns: ContentTypeColumnResponse[]): ContentTypeColumnResponse[] => {
const savedVisibleColumnNames = entryColumnVisibilityStore.visibleColumnNamesByTable[tableNameForQuery];
if (!Array.isArray(savedVisibleColumnNames)) return columns;
if (savedVisibleColumnNames.length < 1) return columns;
const visibleColumns = columns.filter((column) => savedVisibleColumnNames.includes(column.name));
if (visibleColumns.length < 1) return columns;
return visibleColumns;
};export const useEntryColumnVisibilityStore = create<EntryColumnVisibilityState & EntryColumnVisibilityAction>()(
persist(
combine({visibleColumnNamesByTable: {}}, (set) => ({
setVisibleColumnNames: (tableName: string, columnNames: string[]) => {
set((state) => ({visibleColumnNamesByTable: {...state.visibleColumnNamesByTable, [tableName]: columnNames}}));
},
reset: () => set({visibleColumnNamesByTable: {}}),
})),
{name: "ecv_s", storage: createJSONStorage(() => localStorage)},
),
);결과. 테이블이 늘어도 엔트리 화면 코드는 그대로입니다. 운영자가 고른 컬럼은 다음에 들어와도 유지되고 잘못된 저장값은 전체 컬럼으로 조용히 돌아갑니다.
조회 조건을 URL에 두기
문제. 검색어, 콘텐츠 타입, 폴더, 정렬, 페이지를 컴포넌트 상태에만 두면 새로고침이나 뒤로가기 뒤에 운영자가 보던 목록이 사라집니다. 운영 CMS에서는 지금 무엇을 보고 있었는지가 작업 맥락 그 자체입니다.
판단. TanStack Router의 URL query를 조회 조건의 기준으로 삼았습니다. zod 스키마로 값을 검증하고 범위를 벗어난 값은 기본값으로 돌립니다. 같은 조회 조건을 API 요청과 React Query 키에 사용했습니다. 서버 데이터의 최신성은 캐시 정책과 재조회로 별도로 관리합니다.
구현. 라우트마다 validateSearch에 스키마를 두고 화면은 Route.useSearch()로 읽습니다. 엔트리는 페이지, 크기, 테이블을, 폴더는 폴더 id와 테이블을 URL에 둡니다.
export const Route = createFileRoute("/(project)/project/(content-manager)/content-manager/(entries)/entries/")({
component: RouteComponent,
validateSearch: z.object({
page: z.coerce.number().int().min(1).default(1).catch(1),
size: z.coerce.number().int().min(1).max(100).default(10).catch(10),
table: z.string().optional(),
}),
});
// 폴더 화면은 폴더 id와 테이블만 URL에 둔다. 펼침과 선택 같은 조작 상태는 화면에만 둔다
validateSearch: z.object({
folder: z.coerce.number().int().min(1).optional().catch(undefined),
table: z.string().trim().min(1).optional().catch(undefined),
}),결과. 새로고침이나 뒤로가기 뒤에도 목록 조건이 남습니다. 잘못된 URL은 오류 대신 기본값으로 열립니다. LG Shield와 SK AX에서도 조회 조건을 URL에 두었고 사용하는 라우터에 맞춰 같은 원칙을 적용했습니다.
한 화면에 몰린 상태를 소유자별로 나누기
문제. 콘텐츠 매니저 한 화면에 폴더 트리, 트리 검색, 엔트리 컬럼 표시, 검색과 필터, 삭제와 이동이 모여 있었습니다. 모든 상태를 한 곳에 두면 어느 것이 서버 값이고 어느 것이 조작 중인 값인지 흐려집니다.
판단. 상태를 소유자별로 나눴습니다. 조회 조건은 URL, 서버 데이터는 React Query, 다시 들어와도 남아야 하는 표시 설정은 persist 스토어, 펼침과 선택과 편집 초안처럼 지금만 의미 있는 값은 컴포넌트 지역 상태입니다.
구현. 폴더 화면은 서버에서 받은 트리를 folderNodes에 두고 편집 중인 트리를 draftFolderNodes로 분리합니다. 펼침 키, 선택 키, 트리 검색어는 지역 상태이며 저장이 끝나면 해당 React Query 키만 다시 불러옵니다.
flowchart TB
URL["URL query: 폴더 id, 테이블, 페이지"] --> Screen["콘텐츠 매니저 화면"]
Server["React Query: 트리, 컬럼 정의, 엔트리 목록"] --> Screen
Persist["persist 스토어: 테이블별 표시 컬럼, 테마"] --> Screen
Local["지역 상태: 펼침, 선택, 편집 초안, 트리 검색어"] --> Screen
Screen -->|저장| Invalidate["해당 쿼리 키만 갱신"]
Invalidate --> Server결과. 같은 화면 안에서도 각 값이 어디서 오고 언제 사라지는지가 분명합니다. 저장 후 필요한 서버 데이터를 다시 조회하고 편집 초안과 표시 설정은 각각의 수명에 맞게 관리했습니다.
권한이 바뀌면 영향받는 캐시를 함께 갱신하기
문제. 권한 그룹을 만들거나 고치면 멤버 목록, 그룹 목록, 로그인한 본인의 권한 정보가 한꺼번에 달라집니다. 하나만 갱신하면 화면 일부가 옛 권한으로 남습니다.
판단. 권한 화면을 권한 그룹, 엔드포인트 접근, 멤버와 권한 연결, 저장 상태로 나누고 저장이 끝나면 영향받는 쿼리 키를 모두 병렬로 갱신합니다. 메뉴는 로그인 뒤 세션에 저장한 역할로 결정합니다.
구현. 생성과 수정은 같은 upsert 훅을 쓰되 id가 있으면 해당 그룹 상세 키까지 갱신합니다.
const mutationPermissionGroupUpsert = usePermissionGroupUpsert({
mutation: {
onSuccess: async (_data, variables) => {
const permissionGroupId = variables.data.permissionGroupRequest.id;
const shared = [
queryClient.invalidateQueries({queryKey: getBaseQueryKey(adminSearchAdminsSuspenseQueryKey)}),
queryClient.invalidateQueries({queryKey: permissionGroupGetListSuspenseQueryKey()}),
queryClient.invalidateQueries({queryKey: authorizationInfoSuspenseQueryKey()}),
];
if (permissionGroupId == null) {
await Promise.all(shared);
return;
}
await Promise.all([...shared, queryClient.invalidateQueries({queryKey: permissionGroupGetItemQueryKey(permissionGroupId)})]);
},
},
});결과. 권한을 바꾼 직후 멤버 표, 그룹 목록, 본인 메뉴가 같은 상태를 보입니다. 어떤 저장이 어떤 캐시에 영향을 주는지가 코드에 적혀 있어 화면을 추가할 때 참고 기준이 됩니다.
업로드 제약을 화면에서 먼저 막기
문제. 엔트리의 미디어 컬럼에 파일을 여러 개 올릴 수 있는데 개수와 총 용량, 확장자별 용량 제한이 서버에 있었습니다. 서버가 거절한 뒤에 알려 주면 운영자가 파일을 다시 골라야 합니다.
판단. 제한을 순수 함수로 정의해 선택 시점에 검사하고 메시지를 바로 보여 줍니다. 파일 하나의 검증과 묶음 검증을 나눠 개수 제한과 총 용량 제한을 따로 계산합니다.
구현. 확장자별 최대 크기 표와 개수 10개, 총 150MB 제한을 상수로 두고 Ant Design 업로더의 beforeUpload에서 검증 함수를 호출합니다. 같은 파일을 다시 고르면 uid로 중복을 제거한 뒤 합산합니다.
구현 코드: 파일 하나와 묶음의 검증
export const ENTRY_MEDIA_UPLOAD_MAX_FILE_COUNT = 10;
export const ENTRY_MEDIA_UPLOAD_MAX_TOTAL_SIZE_BYTES = 150 * ENTRY_MEDIA_UPLOAD_BYTES_PER_MB;
export const ENTRY_MEDIA_UPLOAD_MAX_SIZE_BYTES_BY_EXTENSION: Record<string, number> = {
jpg: 10 * ENTRY_MEDIA_UPLOAD_BYTES_PER_MB,
pdf: 20 * ENTRY_MEDIA_UPLOAD_BYTES_PER_MB,
mp4: 100 * ENTRY_MEDIA_UPLOAD_BYTES_PER_MB,
// 확장자별로 이어진다
} as const;
export const validateEntryMediaUploadFile = (file: Pick<EntryMediaUploadCandidate, "name" | "size">): EntryMediaUploadValidationResult => {
if (typeof file.size !== "number" || file.size < 0) return {isValid: false, message: "File size could not be determined."};
const extension = getEntryMediaUploadExtension(file.name);
if (!extension) return {isValid: false, message: "File extension is required."};
const maxSizeBytes = ENTRY_MEDIA_UPLOAD_MAX_SIZE_BYTES_BY_EXTENSION[extension];
if (file.size > maxSizeBytes) return {isValid: false, message: `.${extension} files must be ${formatEntryMediaUploadSizeMb(maxSizeBytes)} or smaller.`};
return {isValid: true};
};
export const validateEntryMediaUploadBatch = (currentFiles: EntryMediaUploadCandidate[], nextFiles: EntryMediaUploadCandidate[]): EntryMediaUploadValidationResult => {
const nextFileUidSet = new Set(nextFiles.map((file) => file.uid));
const mergedFiles = [...currentFiles.filter((file) => !nextFileUidSet.has(file.uid)), ...nextFiles];
if (mergedFiles.length > ENTRY_MEDIA_UPLOAD_MAX_FILE_COUNT) return {isValid: false, message: `You can upload up to ${ENTRY_MEDIA_UPLOAD_MAX_FILE_COUNT} files.`};
// 총 용량 검사가 이어진다
};결과. 잘못된 파일은 서버에 가기 전에 걸리고 이유가 메시지로 보입니다. 검증이 순수 함수라 Vitest로 확장자 거부, 확장자별 크기 초과, 개수 제한, 총 용량 제한을 각각 검증합니다.
생성 코드를 손대지 않고 API를 연결하기
문제. 엔드포인트가 많고 명세가 바뀌는 환경에서 클라이언트와 타입을 손으로 쓰면 명세와 코드가 어긋납니다. 생성한 코드를 고치기 시작하면 다음 생성에서 수정이 사라집니다.
판단. OpenAPI 명세에서 Kubb로 Axios 클라이언트, TypeScript 타입, React Query 훅, Faker mock, MSW 핸들러를 생성하고 생성 디렉터리는 수정하지 않습니다. 생성 결과도 Biome으로 포맷해 저장소 규약과 같게 둡니다.
구현. 태그별로 파일을 묶고 파일 이름은 kebab-case로 바꿉니다. 화면은 생성된 훅을 그대로 쓰고 변경 뒤 갱신할 캐시만 고릅니다. 로그아웃하면 token과 역할 스토어를 비우고 로그인 화면으로 이동한 뒤 React Query 캐시를 전부 비웁니다.
export default defineConfig({
input: {path: API_DOCS_URL},
output: {path: OUTPUT_ROOT, barrelType: false, clean: true, format: "biome", lint: "biome"},
plugins: [
pluginOas({validate: true, collisionDetection: true}),
pluginClient({client: "axios", dataReturnType: "data", group: {type: "tag", name: ({group}) => `${group}`}}),
pluginTs({group: {type: "tag", name: ({group}) => `${group}`}, unknownType: "unknown"}),
pluginFaker({group: {type: "tag", name: ({group}) => `${group}`}}),
pluginMsw({group: {type: "tag", name: ({group}) => `${group}`}}),
pluginReactQuery({group: {type: "tag", name: ({group}) => `${group}`}}),
],
});const mutationAuthorizationLogout = useAuthorizationLogout({
mutation: {
onSuccess: () => {
tokenStore.reset();
roleStore.reset();
navigate({to: "/login"}).then(() => {
queryClient.clear();
});
},
},
});결과. 명세가 바뀌면 다시 생성하고 화면의 사용처만 확인합니다. 로그아웃 뒤 다른 계정으로 들어와도 이전 계정의 데이터가 화면에 남지 않습니다. 이 생성 코드 경계는 SK AX에서 mapper 층을 더해 그대로 이어졌습니다.
만료된 token을 한 번만 갱신하고 대기 요청을 이어서 보내기
문제. 화면 하나가 여러 요청을 동시에 보내는데 access token이 만료되면 모든 요청이 401을 받습니다. 요청마다 refresh를 보내면 refresh token이 서로 무효화되고 로그인이 풀립니다.
판단. 갱신은 한 번만 하고 그동안 실패한 요청은 대기열에 넣었다가 새 token으로 다시 보냅니다. refresh 자체가 실패하면 저장된 token을 지우고 로그아웃을 요청한 뒤 로그인 화면으로 보냅니다. refresh 요청과 이미 재시도한 요청은 다시 재시도하지 않습니다.
구현. Axios 응답 interceptor에 isRefreshing 플래그와 failedQueue를 두었습니다.
구현 코드: 401 응답의 단일 refresh와 대기열
axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const config = error.config as AxiosRequestConfig & {_retry?: boolean};
const tokenStore = useTokenStore.getState();
const token = tokenStore.refreshToken;
if (error.response?.status !== 401) return Promise.reject(error);
if (!config || config._retry) return Promise.reject(error);
if (config.url?.includes("/auth/refresh")) return Promise.reject(error);
if (isRefreshing) {
return new Promise<void>((resolve, reject) => {
failedQueue.push({resolve, reject});
}).then(() => axiosInstance.request(config));
}
if (!token) return Promise.reject(error);
config._retry = true;
isRefreshing = true;
try {
const result = await authorizationRefreshToken({refreshToken: token});
tokenStore.setTokens(result.data.accessToken, result.data.refreshToken);
failedQueue.forEach(({resolve}) => resolve());
return axiosInstance.request(config);
} catch (refreshError) {
tokenStore.reset();
failedQueue.forEach(({reject}) => reject(refreshError));
authorizationLogout({refreshToken: token}).finally();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
failedQueue = [];
isRefreshing = false;
}
},
);sequenceDiagram
participant A as 요청 A
participant B as 요청 B
participant I as interceptor
participant R as refresh API
A->>I: 401
I->>R: refresh 한 번
B->>I: 401 (갱신 중이라 대기열에 넣는다)
R-->>I: 새 token
I->>A: 재요청
I->>B: 재요청결과. 갱신 중인 요청이 있으면 다른 실패 요청은 대기열로 들어갑니다. 갱신이 실패하면 token과 대기 요청을 정리하는 경로로 이어집니다. 이 구조는 SK AX에서 Host 인증과 분리하면서 대기열을 더 단순하게 다듬어 이어 썼습니다.
규약을 저장소 밖으로 꺼내기
문제. 생성 코드, 라우트 트리 생성 파일, i18n 산출물처럼 자동 생성 자산이 많고 동적 상태도 많아 프론트엔드 패턴이 한번 흐트러지면 유지 비용이 급격히 커지는 구조였습니다.
판단. 화면마다 즉석 분기를 늘리기보다 코드 생성, 라우팅, 데이터 계층의 규칙을 먼저 고정했습니다. 두 저장소가 같은 form, table, search 패턴을 쓰게 하고 규칙은 문서와 lint로 남겼습니다.
구현. Biome으로 lint와 포맷을 통일하고 MSW로 API 응답 사례를 재현해 Playwright로 주요 운영 흐름과 여러 브라우저 렌더링을 확인했습니다. 순수 함수는 Vitest로 검증합니다. 프로젝트가 끝나며 React, CSS, TypeScript, TanStack Router, Playwright 규약을 별도 저장소로 분리했습니다.
describe("entries", () => {
it("CRUD 트리 변환에서 displayed=false TABLE도 포함합니다.", () => {/* ... */});
it("유효하지 않은 TABLE 노드는 제외하고 hasInvalidNode를 true로 반환합니다.", () => {/* ... */});
it("선택 테이블 계산은 매칭 실패 시 첫 번째 테이블을 기본 선택합니다.", () => {/* ... */});
it("MEDIA 업로드 파일 검증은 확장자별 최대 크기를 초과하면 거부합니다.", () => {/* ... */});
it("MEDIA 업로드 배치 검증은 파일 개수 제한을 적용합니다.", () => {/* ... */});
it("MEDIA 업로드 배치 검증은 총 용량 제한을 적용합니다.", () => {/* ... */});
});결과. 분리한 규약 저장소를 다음 프로젝트인 SK AX가 그대로 썼습니다. 규칙이 사람의 기억이 아니라 저장소에 있어서 새 화면을 추가할 때 같은 모양이 나옵니다.
결과와 근거
- 콘텐츠 타입 빌더가 정의한 테이블과 컬럼이 엔트리, 폴더, 검색 화면까지 그대로 이어집니다. 스키마 변경으로 화면이 깨진 사례는 없었습니다.
- 조회 조건이 URL에 있어 새로고침이나 뒤로가기 뒤에도 목록 조건이 남습니다.
- 권한 저장 뒤 멤버 표, 그룹 목록, 본인 권한이 같은 상태를 보입니다.
- 업로드 제약은 서버에 가기 전에 화면에서 걸리고 이유가 보입니다.
- 만료된 token은 한 번만 갱신하고 대기 요청을 이어서 보냅니다.
- 여기서 분리한 규약 저장소를 SK AX가 그대로 썼습니다.
배운 것
- 플랫폼형 관리자 UI의 난점은 화면 수가 아니라 상태 구조를 얼마나 일관되게 묶느냐였습니다. 소유자별 상태 구분이 그 답이었습니다.
- 동적 스키마는 화면 분기가 아니라 변환 규칙 한 곳으로 받아야 테이블이 늘어도 코드가 늘지 않습니다.
- 생성 코드를 수정하지 않는 원칙과 URL을 조회 조건의 기준으로 두는 원칙은 여기서 시작해 이후 프로젝트의 규약이 됐습니다.
- 규칙은 저장소에 남겨야 다음 프로젝트로 옮겨집니다. 문서와 lint로 남긴 규약이 SK AX에서 바로 쓰였습니다.