HCMS
HCMS는 Strapi처럼 DB 테이블과 컬럼을 화면에서 정의하고 그 스키마로 콘텐츠를 운영하는 사내 Headless CMS입니다. 구성 앱에서 프로젝트, 권한과 콘텐츠 스키마를 정의하면 고객사마다 제공하는 운영 CMS 사본이 그 스키마로 엔트리, 폴더, 미디어를 다룹니다. 두 앱의 프론트엔드 개발을 혼자 맡아 서버 스키마가 입력 폼, 목록, 라우트 권한으로 이어지는 연결 구조를 설계하고 구현했습니다.
핵심 기여
- 구성 앱의 컬럼 정의를 운영 CMS의 입력 검증, 표 셀, 표시 컬럼 설정으로 바꾸는 규칙을 한곳에 모아 테이블별 화면 분기를 없앴습니다.
- 라우트
beforeLoad에서 역할 정보를 확보한 뒤 진입을 결정해 권한 없는 화면이 렌더링되기 전에 이동시켰습니다. 권한을 저장한 뒤에는 영향받는 캐시를 함께 갱신했습니다. - 퍼블리싱 초안과 모킹 데이터로 동작하던 폴더 트리에 API를 붙이기 전에 통합 명세를 먼저 썼습니다. 식별자, 저장 형식, 노출 규칙을 순수 함수와 단위 테스트로 고정했습니다.
- 사전 서명 URL로 파일을 직접 올리는 공유 업로드 위젯을 만들고 파일별 진행률을 표시했습니다. 확장자별 용량과 묶음 제한은 선택 시점에 순수 함수로 검사했습니다.
- OpenAPI 생성 코드를 고치지 않는 경계, 동시 401 응답의 단일 갱신 대기열, 전역 요청 알림을 앱 경계에 두었습니다.
- React Compiler를 전제로 한 메모이제이션 규칙과 Activity 렌더링 규칙을 규약으로 정리했고 이 규약을 별도 저장소로 분리해 SK AX에서 이어 썼습니다.
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2026.01 ~ 2026.03 |
| 역할 | 구성 앱과 운영 CMS의 프론트엔드 설계와 개발 |
| 협업과 인원 | FE 1명(본인), 백엔드 동료, 퍼블리셔, 기획은 개발실장. 사내 제품 |
| 서비스 | 구성 앱에서 테이블과 컬럼, 권한, 프로젝트 설정을 정의하고 고객사별 운영 CMS에서 엔트리, 폴더, 미디어, 게시판, 감사 로그를 운영 |
| 기술 | React 19, TypeScript, Vite, React Compiler, TanStack Router, TanStack Query, Ant Design, Zustand, zod, i18next, Axios, Kubb, MSW, Vitest, Biome |
| 링크 | 사내 제품이라 공개 링크가 없습니다 |
두 저장소는 역할이 다릅니다.
| 저장소 | 역할 | 만드는 것 |
|---|---|---|
| 구성 앱 | 프로젝트, 멤버, 권한, 설정과 콘텐츠 스키마를 정의하는 구성 레이어 | 테이블과 컬럼 정의, 권한 그룹, 프로젝트 설정 |
| 운영 CMS | 정의된 스키마로 실제 콘텐츠를 운영하는 실행 레이어. 고객사마다 사본으로 제공 | 엔트리, 폴더, 미디어, 게시판, 감사 로그 |
진행 단계
| 시기 | 주요 작업 |
|---|---|
| 2026.01 | 구성 앱 개발 환경과 OpenAPI 코드 생성 설정, 콘텐츠 타입 빌더의 테이블과 컬럼 편집, 콘텐츠 매니저 초기 화면, 로그인과 인증 만료 대기열 |
| 2026.02 | Activity 렌더링 전환과 React 규약, 다국어, 멤버와 권한 화면, 콘텐츠 화면을 운영 CMS 저장소로 분리, 로그인과 OTP, 라우트 역할 가드, 폴더 통합 명세 |
| 2026.03 | 폴더와 엔트리 API 연동, 표시 컬럼 설정, 업로드 검증과 사전 서명 URL 업로드 위젯, 미디어 라이브러리 명세와 연동, 규약 갱신 |
맡은 범위
본인 구현
- 구성 앱. 개발 환경과 Kubb 생성 설정, 콘텐츠 타입 빌더의 테이블과 컬럼 등록, 수정, 삭제 흐름을 만들었습니다. 대시보드, 프로젝트, 멤버, 권한, 설정, 프로필 화면과 i18next 다국어도 구현했습니다.
- 운영 CMS. 화면 개발 환경, 로그인과 OTP, 엔트리 목록과 입력 폼, 표시 컬럼 설정, 폴더 API 연동과 트리 규칙을 구현했습니다. 미디어 업로드 위젯과 검증, 미디어 라이브러리 API 연동, 멤버와 권한, 테마 설정 API 연결도 맡았습니다.
- 공통 기반. 라우트 역할 가드, 인증 interceptor, 전역 요청 알림, 화면 이동 시 모달 정리를 앱 경계에 두었습니다. React, CSS, 라우트 규약 문서와 Vitest 단위 테스트, MSW 테스트 환경을 만들었습니다.
리딩과 협업
- 폴더 기능은 퍼블리셔의 화면 초안과 백엔드의 저장 API 사이에 통합 명세를 썼습니다. 식별자, 저장 형식, 미결 사항을 백엔드 답변과 함께 정리했습니다.
- 미디어 라이브러리는 샘플 상태였던 퍼블리싱 화면에 API를 붙이기 전에 명세, 계획, 작업 문서를 먼저 작성했습니다.
- 백엔드 동료가 뒤에 추가한 감사 로그와 게시판 라우트도 제가 만든 역할 가드와 라우트 규약을 그대로 사용했습니다.
동료 구현
- 감사 로그, 게시판, 테넌트 화면과 그 테스트는 백엔드 동료가 작성했습니다. 서버 API와 OpenAPI 명세도 백엔드가 맡았습니다.
- 폴더 화면 초안은 퍼블리셔가 모킹 데이터로 만들었습니다. 드래그 이동, 자기 하위로 옮기는 이동 차단, 순서 재색인, 폴더 삭제 시 하위 테이블 승격이 이 초안에 있었습니다. 트리 검색 입력, 전체 펼침 버튼과 화면 레이아웃, 스타일도 퍼블리셔 작업입니다.
문제와 해결
코드는 저장소의 실제 코드를 옮긴 것이며 내부 주소와 환경값은 지우고 이름과 세부를 일부 줄였습니다.
구성 앱의 스키마가 운영 CMS 화면이 되는 구조
운영자가 구성 앱에서 테이블과 컬럼을 정의하면 운영 CMS는 코드 수정 없이 그 테이블의 입력 폼과 목록을 보여 줘야 했습니다. 운영 CMS는 고객사마다 사본으로 나가므로 스키마가 바뀔 때마다 화면을 고쳐 배포하는 방식으로는 이 구조가 성립하지 않습니다. 두 앱이 같은 OpenAPI 명세에서 생성한 타입을 쓰는 구조는 결정이라기보다 당연히 맞아야 하는 전제였습니다. 제 일은 그 전제가 화면 단에서 깨지지 않게 지키는 쪽이었습니다.
컬럼 메타데이터는 필드 유형(텍스트, 불리언, JSON, 숫자, 날짜, 열거형, 미디어)과 필수, 유니크, 비공개 여부, 정규식, 길이와 값의 범위, 날짜 유형, 열거값을 담습니다. 구성 앱의 컬럼 등록 모달은 필드 유형마다 보여 줄 설정을 표 하나로 정의합니다. 텍스트는 길이와 정규식을, 숫자는 최솟값과 최댓값을, 날짜는 날짜, 날짜와 시간, 시간 중 하나를 고르게 하고 긴 텍스트는 유니크와 기본값을 막습니다. 운영 CMS는 선택한 테이블의 컬럼 정의를 받아 같은 메타데이터로 입력 검증과 셀 표현을 만듭니다.
const FIELD_TYPES: FieldType[] = [
{
value: contentColumnRequestFieldTypeEnum.TEXT,
isText: true,
showTextType: true,
showDefaultValue: true,
showRegexpPattern: true,
showRequiredField: true,
showPrivateField: true,
showUniqueField: true,
showMinLength: true,
showMaxLength: true,
},
{
value: contentColumnRequestFieldTypeEnum.MEDIA,
isMedia: true,
showRequiredField: true,
showPrivateField: true,
},
// BOOLEAN, JSON, NUMBER, DATE, ENUMERATION이 같은 형식으로 이어진다
];콘텐츠 타입 빌더의 모달은 입력값이나 null만 돌려주고 요청은 화면이 보냅니다. 화면은 모달 결과로 테이블 요청을 조립해 저장하고 성공하면 해당 프로젝트의 테이블 목록 쿼리만 무효화합니다. 모달 결과로 요청하는 흐름과 취소나 검증 실패 때 요청하지 않는 동작은 테이블과 컬럼 단위 테스트로 확인했습니다.
flowchart LR
Builder["구성 앱: 콘텐츠 타입 빌더"] -->|테이블과 컬럼 정의| Schema["콘텐츠 스키마(OpenAPI 타입)"]
Perm["구성 앱: 멤버, 권한, 설정"] --> Schema
Schema --> Info["운영 CMS: 테이블 컬럼 정보 조회"]
Info --> Form["입력 필드와 검증 규칙"]
Info --> Table["표 컬럼과 셀 표현"]
Folder["폴더 트리"] --> Info콘텐츠 타입을 바꾸면 운영 CMS는 화면을 다시 그릴 뿐 코드를 고치지 않았습니다. 개발 기간 동안 스키마 변경으로 화면이 깨진 사례는 없었습니다. 관계(RELATION) 유형은 명세에 있었지만 빌더에서는 제공하지 않았습니다.
컬럼 정의를 입력 검증과 표 셀로 바꾸기
엔트리 화면은 테이블마다 컬럼이 다릅니다. 컬럼마다 입력 검증과 셀 표시가 달라야 하고 운영자가 보고 싶은 컬럼만 고를 수 있어야 했습니다. 화면에 테이블별 분기를 쌓으면 테이블이 늘 때마다 코드도 늘어납니다.
컬럼 정의를 입력 규칙과 표 컬럼 설정으로 바꾸는 규칙을 한곳에 두고 화면은 그 결과만 그리게 했습니다. 필수 여부는 필수 규칙이 되고 JSON 컬럼은 파싱 검사를, 문자열 계열은 정규식과 길이 규칙을 받습니다. 숫자는 입력 컴포넌트의 최솟값과 최댓값으로, 날짜는 날짜 유형별 형식으로 문자열과 날짜 값 사이를 변환합니다. 필수 미디어 컬럼은 업로드 중이거나 완료된 파일이 하나라도 있어야 통과하는 별도 규칙을 씁니다.
export const buildColumnRules = (
column: Pick<ContentTypeColumnResponse, "required" | "regexpPattern" | "minLength" | "maxLength" | "fieldType">,
): RuleObject[] => {
const rules: RuleObject[] = [];
const isStringRuleTarget =
column.fieldType === contentTypeColumnResponseFieldTypeEnum.TEXT ||
column.fieldType === contentTypeColumnResponseFieldTypeEnum.JSON ||
column.fieldType === contentTypeColumnResponseFieldTypeEnum.ENUMERATION;
if (column.required === true) rules.push({required: true});
if (column.fieldType === contentTypeColumnResponseFieldTypeEnum.JSON) {
rules.push({
validator: async (_rule, value?: string) => {
if (!value?.trim()) return;
try { JSON.parse(value); } catch { throw new Error("Invalid JSON format."); }
},
});
}
if (isStringRuleTarget && typeof column.regexpPattern === "string" && column.regexpPattern.length > 0) {
rules.push({pattern: new RegExp(column.regexpPattern)});
}
// minLength, maxLength도 같은 조건으로 더한다
return rules;
};어떤 컬럼을 보일지는 테이블 이름을 키로 브라우저에 저장합니다. 저장값이 없거나 비어 있거나 현재 컬럼과 하나도 맞지 않으면 전체 컬럼으로 돌아갑니다. 스키마가 바뀌어 저장된 컬럼 이름이 사라져도 빈 표가 나오지 않게 했습니다.
// 저장된 설정이 없거나 비어 있으면 전체 컬럼으로 돌아간다. 저장값이 현재 컬럼과 하나도 맞지 않아도 전체로 돌아간다
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를 조회 조건의 기준으로 삼았습니다. 라우트마다 validateSearch에 zod 스키마를 두고 범위를 벗어난 값은 기본값으로 돌립니다. 같은 조회 조건을 API 요청과 React Query 키에 함께 썼고 서버 데이터의 최신성은 캐시 정책과 재조회로 따로 관리했습니다. 엔트리는 페이지, 크기, 테이블을, 폴더는 폴더 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 값이 실제 데이터와 어긋나는 경우도 정했습니다. 엔트리 화면은 URL의 테이블이 트리에 없으면 첫 번째 테이블을 선택합니다. 폴더 화면은 테이블을 고르면 URL의 폴더 값을 그 테이블의 부모 폴더로 맞추고 다시 들어오면 테이블 값을 우선해 선택을 복원합니다.
새로고침이나 뒤로가기 뒤에도 목록 조건이 남았고 잘못된 URL은 오류 대신 기본값으로 열렸습니다. LG Shield에서 nuqs로 지킨 원칙을 여기서는 TanStack Router의 검증 스키마로 적용했습니다.
라우트 진입 전에 역할을 확인하고 권한 변경을 캐시에 반영하기
권한은 슈퍼관리자와 일반 관리자로 나뉘고 멤버, 권한, 설정, 폴더 화면은 슈퍼관리자만 들어갈 수 있습니다. 초기에는 이 판단을 화면 컴포넌트 안에서 했습니다. 레이아웃이 역할 스토어가 준비될 때까지 하위 화면을 Activity로 숨겼다가 effect에서 대시보드로 이동시키는 방식이었습니다. 판단이 렌더링 뒤 effect에서 일어나므로 숨긴 하위 화면이 먼저 렌더링된 다음에야 이동했습니다.
function RouteComponent() {
const navigate = useNavigate();
const roleStore = useRoleStore();
const isSuperAdmin = roleStore.isSuperAdmin;
const isRoleReady = roleStore.isRoleReady;
useEffect(() => {
if (!isRoleReady || isSuperAdmin) return;
void navigate({to: "/dashboard", replace: true});
}, [isRoleReady, isSuperAdmin, navigate]);
return (
<Activity mode={isRoleReady && isSuperAdmin ? "visible" : "hidden"}>
<Outlet />
</Activity>
);
}판단을 렌더링 전 라우트 경계로 옮겼습니다. 상위 레이아웃의 beforeLoad는 토큰이 없으면 로그인으로 보내고 ensureQueryData로 로그인 사용자의 권한 정보를 확보해 역할 스토어와 맞춥니다. ensureQueryData는 캐시에 권한 정보가 있으면 그대로 쓰고 없을 때만 요청하므로 라우트를 이동할 때마다 권한 API를 다시 부르지 않습니다. 슈퍼관리자 전용 레이아웃은 같은 함수로 역할을 확인하고 권한이 없으면 컴포넌트가 그려지기 전에 redirect를 던집니다. 구성 앱과 운영 CMS 모두 같은 구조로 바꿨습니다.
export const syncRoleState = async (queryClient: QueryClient) => {
const responseAuthorizationInfo = await queryClient.ensureQueryData(authorizationInfoSuspenseQueryOptions());
const roleName = responseAuthorizationInfo.data.permissionGroup.name;
const roleStore = useRoleStore.getState();
const isSuperAdmin = isSuperAdminRoleName(roleName);
if (!roleStore.isRoleReady || roleStore.isSuperAdmin !== isSuperAdmin) {
roleStore.setRoleName(roleName);
}
return isSuperAdmin;
};
export const ensureSuperAdminRouteAccess = async (queryClient: QueryClient) => {
const isSuperAdmin = await syncRoleState(queryClient);
if (!isSuperAdmin) throw redirect({to: "/dashboard", replace: true});
};
// 멤버, 권한, 설정 레이아웃은 진입 전에 확인만 한다
export const Route = createFileRoute("/(dashboard)/dashboard/(members)/members")({
beforeLoad: async ({context}) => {
await ensureSuperAdminRouteAccess(context.queryClient);
},
component: RouteComponent,
});권한 화면은 권한 그룹, 엔드포인트 접근, 멤버와 권한 연결, 저장 상태로 나눴습니다. 권한 그룹을 만들거나 고치면 멤버 목록, 그룹 목록, 로그인한 본인의 권한 정보가 한꺼번에 달라집니다. 하나만 갱신하면 화면 일부가 옛 권한으로 남기 때문에 저장이 끝나면 영향받는 쿼리 키를 모두 병렬로 무효화했습니다. 생성과 수정은 같은 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)})]);
},
},
});권한 없는 화면으로 들어가면 렌더링 전에 다른 경로로 이동했고 권한을 바꾼 직후 멤버 표, 그룹 목록, 본인 메뉴가 같은 상태를 보였습니다. 백엔드 동료가 뒤에 추가한 감사 로그와 게시판 라우트도 같은 가드 한 줄로 접근을 제한했습니다. 이 구현은 프론트엔드의 화면 접근 제어입니다. API 권한은 서버가 요청마다 검증하며 이 가드가 그 역할을 대신하지 않습니다.
한 화면에 몰린 상태를 소유자별로 나누기
콘텐츠 매니저 한 화면에 폴더 트리, 트리 검색, 엔트리 컬럼 표시, 조회 조건, 삭제와 이동이 모여 있었습니다. 모든 상태를 한곳에 두면 어느 값이 서버 값이고 어느 값이 조작 중인 값인지 흐려집니다.
상태를 소유자별로 나눴습니다. 조회 조건은 URL, 서버 데이터는 React Query, 다시 들어와도 남아야 하는 표시 설정은 persist 스토어에 둡니다. 펼침과 선택, 편집 초안처럼 지금만 의미 있는 값은 컴포넌트 지역 상태입니다. 폴더 화면에서는 서버에서 받은 트리를 기준 트리로, 편집 중인 트리를 초안으로 나눠 둡니다. 저장 버튼은 두 트리가 같거나 저장 중이면 비활성화됩니다. 저장이 끝나면 해당 쿼리 키만 다시 불러옵니다.
flowchart TB
URL["URL query: 폴더 id, 테이블, 페이지"] --> Screen["콘텐츠 매니저 화면"]
Server["React Query: 폴더 트리, 컬럼 정의, 엔트리 목록"] --> Screen
Persist["persist 스토어: 테이블별 표시 컬럼, 테마"] --> Screen
Local["지역 상태: 펼침, 선택, 편집 초안, 트리 검색어"] --> Screen
Screen -->|저장| Invalidate["해당 쿼리 키만 갱신"]
Invalidate --> Server같은 화면 안에서도 각 값이 어디서 오고 언제 사라지는지가 분명해졌습니다. 저장 뒤에는 필요한 서버 데이터만 다시 조회했고 편집 초안과 표시 설정은 각자의 수명에 맞게 관리했습니다.
폴더 트리: 퍼블리싱 초안을 API 계약에 맞추기
엔트리 화면의 왼쪽 메뉴는 원래 테이블 목록을 평평하게 보여 줬습니다. 테이블을 폴더로 묶어 FOLDER > TABLE 두 단계까지 정리하는 요구가 생겼고 퍼블리셔가 폴더 화면 초안을 모킹 데이터로 먼저 만들었습니다. 초안에는 드래그 이동, 자기 하위로 옮기는 이동 차단, 순서 재색인, 폴더 삭제 시 하위 테이블 승격이 들어 있었습니다. 백엔드에는 트리 조회와 일괄 저장 API가 이미 있었습니다. 그런데 생성된 타입에서 children은 unknown이었고 tableName은 모든 node에서 optional이었습니다. 모킹 데이터는 Faker 무작위 구조라 트리 제약을 재현하지 못했습니다.
API를 붙이기 전에 통합 명세를 먼저 썼습니다. 확인한 사실과 추론을 나누고 API 쟁점마다 백엔드 답변과 프론트엔드 처리 기준을 적었습니다. 퍼블리싱 화면별 연동 작업, 모킹 문제, 저장 직렬화 규칙, 위험과 미결 사항, 실행 순서도 정리했습니다.
| 쟁점 | 확인한 내용 | 프론트엔드 처리 |
|---|---|---|
| TABLE 식별자 | 백엔드 답변으로 tableName 사용 확정 | 선택, 조회, 저장의 기준 키를 tableName으로 고정하고 name은 표시명으로만 사용 |
tableName optional | 폴더에는 없고 TABLE에만 있음 | 공통 타입은 그대로 두고 nodeType별로 검증하고 직렬화 |
children 타입 | 생성 타입이 unknown | 재귀 타입을 요청하고 화면에서는 배열, 단일 객체, 누락을 배열 트리로 정규화 |
| 노출 여부 | displayed는 노출 여부 | 폴더를 바꾸면 하위 전체를 맞추고 TABLE을 켜면 꺼진 상위 폴더를 다시 켬 |
| 동시 저장 | 슈퍼관리자 1인 운영이면 마지막 저장 우선을 허용 | version 도입은 미결 사항으로 남김 |
| 모킹 | 무작위 구조라 트리 제약 재현 불가 | 고정 fixture로 바꾸는 작업을 명세에 정리 |
초안의 트리 규칙은 화면 파일에서 순수 함수 모듈로 옮겼습니다. 응답 정규화, 선택 문맥 탐색, URL 선택 복원, 재색인, 노출 전환, 폴더 삭제와 승격, 저장 직렬화를 각각 함수로 두고 화면은 이 함수들을 조합하기만 합니다. 노출 전환 규칙과 직렬화, 응답 정규화는 이 과정에서 새로 정했습니다.
// 생성 타입의 children이 unknown이라 배열, 단일 객체, 누락을 모두 배열 트리로 맞춘다
const normalizeNode = (node: ContentFolderNodeResponse): ContentFolderTreeNode => {
const normalizedChildNodes: ContentFolderTreeNode[] = [];
if (Array.isArray(node.children)) {
for (let index = 0; index < node.children.length; index += 1) {
const currentChildNode = node.children[index];
if (!isContentFolderNodeResponse(currentChildNode)) continue;
normalizedChildNodes.push(normalizeNode(currentChildNode));
}
} else if (isContentFolderNodeResponse(node.children)) {
normalizedChildNodes.push(normalizeNode(node.children));
}
return {...node, children: normalizedChildNodes};
};
// 저장 요청은 nodeType으로 필드를 나눈다. TABLE은 tableName을 싣고 FOLDER는 children을 싣는다
export const serializeFolderSaveNodes = (nodes: ContentFolderTreeNode[]): ContentFolderSaveNodeRequest[] => {
return nodes.map((node) => {
if (node.nodeType === "TABLE") {
return {nodeType: "TABLE", name: node.name, tableName: node.tableName, displayed: node.displayed};
}
return {nodeType: "FOLDER", name: node.name, displayed: node.displayed, children: serializeFolderSaveNodes(getChildNodes(node))};
});
};저장에 성공하면 초안을 기준 트리로 삼고 폴더 목록 쿼리를 무효화합니다. 엔트리 화면도 같은 폴더 쿼리로 왼쪽 트리를 그리기 때문에 저장 결과가 바로 반영됩니다. 엔트리 쪽 변환은 이름이나 tableName이 빈 TABLE node를 건너뛰고 그런 node가 있었다는 사실을 경고 모달로 알립니다. 상세 조회와 목록 조회는 기존처럼 tableName 기준 API를 써서 기존 URL의 table 값도 그대로 동작합니다.
flowchart LR
Get["폴더 트리 조회"] --> Norm["children 정규화"]
Norm --> Base["기준 트리"]
Norm --> Draft["편집 초안"]
Draft --> Edit["이동, 이름 변경, 노출 전환, 삭제"]
Edit --> Draft
Draft -->|기준과 다를 때 저장| Ser["nodeType별 직렬화"]
Ser --> Put["일괄 저장"]
Put --> Inv["폴더 쿼리 무효화"]
Inv --> Get
Inv --> Entries["엔트리 화면의 왼쪽 트리"]describe("folders", () => {
it("FOLDER display를 변경하면 하위 TABLE/FOLDER가 동일하게 동기화됩니다.", () => {/* ... */});
it("FOLDER가 OFF여도 TABLE 하나를 ON으로 바꾸면 상위 FOLDER만 ON으로 복구되고 형제 TABLE은 유지됩니다.", () => {/* ... */});
it("FOLDER 삭제 시 하위 TABLE은 루트로 승격합니다.", () => {/* ... */});
it("search.table 우선순위로 트리 선택 key를 복원합니다.", () => {/* ... */});
it("저장 직렬화 시 FOLDER/TABLE 구조를 요청 타입으로 변환합니다.", () => {/* ... */});
});명세 덕분에 퍼블리싱 화면, 생성 타입, 백엔드 계약 사이의 차이를 구현 전에 한 문서에서 정리할 수 있었습니다. 트리 규칙은 화면 밖 순수 함수가 되어 노출 동기화, 상위 폴더 복구, 삭제 승격, 선택 복원, 직렬화를 단위 테스트로 고정했습니다. 다만 드래그 처리 자체는 화면 컴포넌트에 남아 단위 테스트 범위 밖이었습니다. 여러 관리자가 동시에 저장할 때의 충돌 처리도 이번 범위에 넣지 않았습니다.
미디어 업로드: 선택 시점 검증과 사전 서명 URL 직접 업로드
엔트리의 미디어 컬럼에는 파일을 여러 개 올릴 수 있고 서버에는 개수, 총 용량, 확장자별 용량 제한이 있었습니다. 파일은 서버를 거치지 않고 사전 서명 URL(presigned URL)로 스토리지에 직접 올라갑니다. 미디어 라이브러리 연동 요구도 함께 들어왔습니다. 엔트리에서 올린 파일은 라이브러리에 합류하고 라이브러리의 자산은 엔트리에서 다시 쓸 수 있어야 했습니다.
API를 붙이기 전에 명세, 계획, 작업 문서를 먼저 쓰고 결정을 기록했습니다. 엔트리와 미디어 라이브러리의 새 업로드는 모두 사전 서명 URL 발급, 직접 업로드, 서버가 돌려준 파일 메타데이터를 최종 저장값으로 쓰는 2단계 흐름으로 통일했습니다. 이 흐름은 두 화면이 함께 쓰는 공유 위젯으로 올렸고 얇은 업로드 UI 컴포넌트는 그대로 두었습니다. 상태 전이와 fileList 변환은 커스텀 훅 대신 위젯 옆의 일반 .ts 파일에 두어 React 없이 테스트할 수 있게 했습니다. 라이브러리 자산 삭제는 낙관적으로 지우지 않고 서버 성공 응답 뒤에 목록에서 빼기로 정했습니다.
sequenceDiagram
participant U as 운영자
participant W as 업로드 위젯
participant API as CMS API
participant S as 스토리지
U->>W: 파일 선택
W->>W: 확장자별 용량, 개수, 총 용량 검사
W->>API: 사전 서명 URL 요청(테이블, 컬럼, 파일 정보)
API-->>W: 업로드 주소와 파일 메타데이터
W->>S: 파일 직접 전송(파일별 진행률 갱신)
S-->>W: 완료
W->>W: 파일 상태를 완료로 바꾸고 메타데이터 반영
U->>API: 엔트리 저장파일별 진행률은 XMLHttpRequest의 업로드 진행 이벤트로 받았습니다. 위젯은 파일마다 uid를 기준으로 업로드 중, 완료, 실패 상태와 진행률을 따로 갱신합니다. 요청 핸들러는 사전 서명 URL 발급 함수와 fileList 갱신 함수를 주입받아 만들기 때문에 API 없이도 상태 전이를 검증할 수 있습니다.
export const uploadFileToPresignedUrl = (input: WidgetMediaUploaderUploadRequestInput): Promise<void> => {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open(input.method, input.presignedUrl);
if (input.file.type.length > 0) {
request.setRequestHeader("Content-Type", input.file.type);
}
request.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
input.onProgress(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
if (request.status >= 200 && request.status < 300) return resolve();
reject(new Error(`MEDIA upload failed: ${request.status}`));
};
request.onerror = () => reject(new Error("MEDIA upload failed by network error"));
request.send(input.file);
});
};서버가 거절한 뒤에 알려 주면 운영자가 파일을 다시 골라야 합니다. 그래서 제한을 순수 함수로 정의해 Ant Design 업로더의 beforeUpload에서 선택 즉시 검사하고 이유를 메시지로 보여 줬습니다. 파일 하나의 검사와 묶음 검사를 나눠 확장자별 최대 크기, 개수 10개, 총 150MB를 각각 계산합니다. 같은 파일을 다시 고르면 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 (typeof maxSizeBytes !== "number") return {isValid: false, message: `.${extension} files are not supported.`};
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 모킹, MSW 핸들러를 생성하고 생성 디렉터리는 수정하지 않았습니다. 생성 결과도 Biome으로 포맷해 저장소 규약과 같게 둡니다. 태그별로 파일을 묶고 화면은 생성된 훅을 그대로 씁니다. 화면에 맞는 가공은 각 화면이 소유한 파일에서 하고 변경 뒤 갱신할 캐시만 고릅니다. 로그아웃하면 토큰과 역할 스토어를 비우고 로그인 화면으로 이동한 뒤 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();
});
},
},
});명세가 바뀌면 다시 생성하고 화면의 사용처만 확인했습니다. 생성 타입이 실제 응답과 다를 때는 폴더 트리의 children처럼 화면 쪽 정규화 함수에서 흡수하고 백엔드에 명세 수정을 요청했습니다. 로그아웃 뒤 다른 계정으로 들어와도 이전 계정의 데이터가 화면에 남지 않았습니다.
만료된 토큰을 한 번만 갱신하고 대기 요청을 이어서 보내기
화면 하나가 여러 요청을 동시에 보내는데 액세스 토큰이 만료되면 모든 요청이 401을 받습니다. 요청마다 갱신을 보내면 refresh 토큰이 서로를 무효화하고 로그인이 풀립니다.
갱신은 한 번만 하고 그동안 실패한 요청은 대기열에 넣었다가 갱신이 끝나면 다시 보냅니다. 대기 요청은 새 토큰을 직접 받지 않고 재실행될 때 요청 interceptor가 스토어의 최신 토큰을 싣습니다. 갱신 자체가 실패하면 저장된 토큰을 지우고 대기 요청을 모두 실패 처리한 뒤 로그아웃을 요청하고 로그인 화면으로 보냅니다. 갱신 요청과 이미 재시도한 요청은 재시도하지 않습니다. OTP 검증 요청에는 액세스 토큰 대신 OTP 단계의 토큰을 싣도록 요청 interceptor에서 나눴습니다.
구현 코드: 401 응답의 단일 갱신과 대기열
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 토큰 갱신 API
A->>I: 401
I->>R: 갱신 한 번
B->>I: 401 (갱신 중이라 대기열에 넣는다)
R-->>I: 새 토큰
I->>A: 재요청
I->>B: 재요청갱신 중에 실패한 다른 요청은 대기열로 들어갔고 갱신이 실패하면 토큰과 대기 요청을 함께 정리하는 경로로 이어졌습니다. 이 구조는 구성 앱에서 먼저 만들어 운영 CMS로 옮겼습니다.
요청 결과 알림, 저장 중 표시, 모달 정리를 앱 경계에 두기
관리자 화면은 조회와 저장이 많아 화면마다 실패와 성공 알림을 직접 띄우면 문구와 위치가 제각각이 됩니다. 저장 중 표시와 열린 모달의 정리도 화면마다 다르게 구현되기 쉽습니다.
알림은 React Query의 QueryCache와 MutationCache 콜백 한곳에서 띄웠습니다. 기본값으로 조회 실패, 저장 실패, 저장 성공 문구를 두고 화면은 예외만 meta로 선언합니다. meta 타입은 React Query의 Register를 확장해 지정했기 때문에 잘못된 키는 타입 검사에서 걸립니다. 사전 서명 URL 발급처럼 화면이 결과를 따로 다루는 요청은 successToast: null로 알림을 끕니다. 재시도는 기본으로 끄고 서버 오류 메시지를 그대로 보여 줬습니다.
declare module "@tanstack/react-query" {
interface Register {
defaultError: ResponseErrorConfig<Error>;
queryMeta: {errorToast?: string | null};
mutationMeta: {errorToast?: string | null; successToast?: string | null};
}
}
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.errorToast === null) return;
noti.error({title: query.meta?.errorToast, description: error.response?.data?.message ?? error.message});
},
}),
mutationCache: new MutationCache({
// onError는 조회와 같은 방식이다
onSuccess: (_data, _variables, _context, mutation) => {
if (mutation.meta?.successToast == null) return;
noti.success({title: mutation.meta?.successToast});
},
}),
defaultOptions: {
queries: {staleTime: 1000 * 60, gcTime: 1000 * 60 * 5, retry: false, meta: {errorToast: "요청에 실패했습니다"}},
mutations: {retry: false, meta: {errorToast: "요청에 실패했습니다", successToast: "요청에 성공했습니다"}},
},
});루트 라우트에서는 위치가 바뀔 때마다 열린 모달을 모두 닫고 저장 요청이 진행 중이면 전체 화면 스피너를 띄웠습니다. 스피너는 250ms 지연을 두어 짧은 요청에서는 나타나지 않습니다.
useEffect(() => {
Object.keys(modalStore).forEach((modalId) => {
if (modalStore[modalId]?.visible) void NiceModal.hide(modalId);
});
}, [routerState.location]);
function MutationSpin() {
const isMutating = useIsMutating() > 0;
return <UiSpin fullscreen={true} spinning={isMutating} delay={250} />;
}화면 코드에는 알림 호출이 남지 않았고 예외가 필요한 요청만 meta로 드러났습니다. 뒤로가기로 화면을 떠나도 이전 화면의 모달이 남지 않았습니다.
React Compiler를 전제로 한 규약과 Activity 전환
두 앱은 React 19와 React Compiler를 Vite 빌드에 연결해 시작했습니다. 컴파일러가 메모이제이션을 처리하는데 개발자가 useMemo와 useCallback을 습관적으로 쓰면 어떤 메모이제이션이 의도된 것인지 구분하기 어려워집니다.
규약의 핵심 원칙에 “컴파일러 우선”을 두었습니다. useMemo와 useCallback은 기본으로 쓰지 않고 외부 라이브러리가 참조 동일성을 요구하거나 비용이 큰 계산이 실제 병목으로 확인된 경우에만 바로 위에 이유를 주석으로 남기게 했습니다. 단순 매핑과 필터는 메모이제이션 없이 계산합니다. 로딩은 Suspense 경계와 상위 레이아웃에서 처리하고 화면에서 isPending을 직접 그리지 않는 규칙도 함께 두었습니다.
// 금지: 단순 가공을 위한 useMemo
const columns = useMemo(() => buildColumns(response.data.columns), [response.data.columns]);
// 권장: 불가피한 경우에만 주석으로 의도 설명
// 테이블 라이브러리가 columns 참조 동일성을 요구하여 리렌더 폭증을 방지한다.
const columns = useMemo(() => buildColumns(response.data.columns), [response.data.columns]);React 19.2에 들어온 Activity는 새 기능을 직접 검토해 보려고 도입했습니다. 2026.02 초 구성 앱의 조건부 렌더링을 Activity로 바꿨고 JSX 반환부의 삼항 렌더링 분기를 금지하는 규칙을 규약에 넣었습니다. 분기 대상이 둘이면 Activity 두 개로 각각 표시와 숨김을 제어합니다. 이후 운영 CMS도 같은 규칙으로 작성했습니다.
// 금지: 삼항 렌더링 분기
return hasItems ? <ItemList /> : <EmptyState />;
// 권장: Activity로 렌더링 분기
return (
<>
<Activity mode={hasItems ? "visible" : "hidden"}>
<ItemList />
</Activity>
<Activity mode={hasItems ? "hidden" : "visible"}>
<EmptyState />
</Activity>
</>
);Activity의 hidden 모드는 화면을 숨기고 effect를 정리하지만 상태를 유지한 채 자식을 계속 렌더링 대상에 둡니다. 숨긴 분기 안의 값도 비어 있을 수 있다고 보고 작성해야 했습니다. 권한이 필요한 화면의 진입 판단은 앞의 사례처럼 라우트 경계에 두었습니다. 이 전환의 목적은 새 기능 검토였고 렌더링 성능의 변화는 측정하지 않았습니다.
규약을 저장소 밖으로 꺼내기
생성 코드, 라우트 트리 생성 파일, i18n 산출물처럼 자동 생성 자산이 많고 동적 상태도 많았습니다. 프론트엔드 패턴이 한번 흐트러지면 유지 비용이 빠르게 커지는 구조였습니다.
화면마다 즉석 분기를 늘리기보다 코드 생성, 라우팅, 데이터 계층의 규칙을 먼저 고정했습니다. 두 저장소가 같은 폼, 표, 조회 패턴을 쓰게 하고 규칙은 React, CSS, 라우트 규약 문서와 Biome lint로 남겼습니다. 테스트 환경은 MSW 서버와 라우터, 모달, Ant Design 모킹을 공통으로 두고 처리되지 않은 요청은 실패로 다뤘습니다. 트리 변환과 업로드 검증 같은 순수 함수는 Vitest로 검증했습니다. 프로젝트를 마치며 React, CSS, TypeScript, TanStack Router 규약을 별도 저장소로 분리했습니다.
describe("entries", () => {
it("CRUD 트리 변환에서 displayed=false TABLE도 포함합니다.", () => {/* ... */});
it("유효하지 않은 TABLE 노드는 제외하고 hasInvalidNode를 true로 반환합니다.", () => {/* ... */});
it("선택 테이블 계산은 매칭 실패 시 첫 번째 테이블을 기본 선택합니다.", () => {/* ... */});
it("MEDIA 업로드 파일 검증은 확장자별 최대 크기를 초과하면 거부합니다.", () => {/* ... */});
it("MEDIA 업로드 배치 검증은 파일 개수 제한을 적용합니다.", () => {/* ... */});
it("MEDIA 업로드 배치 검증은 총 용량 제한을 적용합니다.", () => {/* ... */});
});분리한 규약 저장소를 다음 프로젝트인 SK AX가 그대로 썼습니다. 규칙이 사람의 기억이 아니라 저장소에 있어 새 화면을 추가해도 같은 모양이 나왔습니다.
결과와 근거
| 확인한 결과 | 근거 |
|---|---|
| 컬럼 정의가 입력 검증, 표 셀, 표시 컬럼 설정으로 이어지고 스키마 변경으로 화면이 깨진 사례가 없음(본인 확인) | modal-column-form.tsx, util-common.ts의 buildColumnRules, entries.index.tsx, use-entry-column-visibility-store.ts, 콘텐츠 타입 빌더 테스트 |
| 권한이 필요한 라우트는 렌더링 전에 역할을 확인해 이동 | route-role-guard.ts, project-role-guard.ts, 2026-02-26 커밋 d5c5f1d, dbd45c8, 동료가 추가한 감사 로그와 게시판 레이아웃의 같은 가드 사용 |
| 폴더 트리를 명세에 맞춰 정규화, 편집, 직렬화 | 260227_cms-content-folder-spec.md, folders.ts, folders.test.ts |
| 업로드 제한을 선택 시점에 검사하고 사전 서명 URL로 직접 업로드 | entries.ts의 검증 함수와 entries.test.ts, widget-media-uploader.ts와 테스트, 미디어 라이브러리 명세 문서 |
| 동시 401 응답에서 갱신을 한 번만 하고 대기 요청을 재실행 | setup-axios.ts, 구성 앱 커밋 2823c94 |
| 요청 결과 알림과 화면 이동 시 모달 정리를 앱 경계에서 처리 | react-query-provider.tsx, __root.tsx |
| React Compiler와 Activity 규칙을 규약으로 정리 | react-convention.md, Activity 전환 커밋 f4880fd |
| 규약을 별도 저장소로 분리해 SK AX에서 사용 | 규약 저장소와 SK AX 사용(본인 확인) |
운영 도입 수, 업무 처리 시간의 개선, Activity 전환에 따른 성능 변화는 측정하지 않았습니다.
배운 것
- 조회 조건은 URL에 두고 생성 코드는 고치지 않는다는 원칙을 여기서 규약으로 만들었습니다. SK AX에서는 URL 조회 조건을 parser와 serializer가 공유하는 계약으로 정했고 생성 코드 바깥에 응답 변환 계층을 두어 명세 변경을 흡수했습니다.
- 인증 만료 대기열은 플래그와 배열로 만들었습니다. SK AX에서는 호스트 인증과 분리하면서 이 대기열을 공용 갱신 promise 하나로 줄였습니다.
- HCMS의 규약은 포맷과 일반 lint 외에는 문서로만 지켜졌습니다. SK AX에서는 page, widget, ui 레이어 경계를 lint 규칙으로 고정해 문서에만 기대지 않게 했습니다.