Navigation

HCMS

HCMS는 Strapi처럼 DB 테이블과 컬럼을 화면에서 정의하고 그 스키마로 콘텐츠를 운영하는 사내 Headless CMS입니다. 구성 앱에서 프로젝트, 권한과 콘텐츠 스키마를 정의하면 고객사마다 제공하는 운영 CMS 사본이 그 스키마로 엔트리, 폴더, 미디어를 다룹니다. 두 앱의 프론트엔드 개발을 혼자 맡아 서버 스키마가 입력 폼, 목록, 라우트 권한으로 이어지는 연결 구조를 설계하고 구현했습니다.

핵심 기여

개요

항목내용
기간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.02Activity 렌더링 전환과 React 규약, 다국어, 멤버와 권한 화면, 콘텐츠 화면을 운영 CMS 저장소로 분리, 로그인과 OTP, 라우트 역할 가드, 폴더 통합 명세
2026.03폴더와 엔트리 API 연동, 표시 컬럼 설정, 업로드 검증과 사전 서명 URL 업로드 위젯, 미디어 라이브러리 명세와 연동, 규약 갱신

맡은 범위

본인 구현

리딩과 협업

동료 구현

문제와 해결

코드는 저장소의 실제 코드를 옮긴 것이며 내부 주소와 환경값은 지우고 이름과 세부를 일부 줄였습니다.

구성 앱의 스키마가 운영 CMS 화면이 되는 구조

운영자가 구성 앱에서 테이블과 컬럼을 정의하면 운영 CMS는 코드 수정 없이 그 테이블의 입력 폼과 목록을 보여 줘야 했습니다. 운영 CMS는 고객사마다 사본으로 나가므로 스키마가 바뀔 때마다 화면을 고쳐 배포하는 방식으로는 이 구조가 성립하지 않습니다. 두 앱이 같은 OpenAPI 명세에서 생성한 타입을 쓰는 구조는 결정이라기보다 당연히 맞아야 하는 전제였습니다. 제 일은 그 전제가 화면 단에서 깨지지 않게 지키는 쪽이었습니다.

컬럼 메타데이터는 필드 유형(텍스트, 불리언, JSON, 숫자, 날짜, 열거형, 미디어)과 필수, 유니크, 비공개 여부, 정규식, 길이와 값의 범위, 날짜 유형, 열거값을 담습니다. 구성 앱의 컬럼 등록 모달은 필드 유형마다 보여 줄 설정을 표 하나로 정의합니다. 텍스트는 길이와 정규식을, 숫자는 최솟값과 최댓값을, 날짜는 날짜, 날짜와 시간, 시간 중 하나를 고르게 하고 긴 텍스트는 유니크와 기본값을 막습니다. 운영 CMS는 선택한 테이블의 컬럼 정의를 받아 같은 메타데이터로 입력 검증과 셀 표현을 만듭니다.

필드 유형별로 노출할 설정ts
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 컬럼은 파싱 검사를, 문자열 계열은 정규식과 길이 규칙을 받습니다. 숫자는 입력 컴포넌트의 최솟값과 최댓값으로, 날짜는 날짜 유형별 형식으로 문자열과 날짜 값 사이를 변환합니다. 필수 미디어 컬럼은 업로드 중이거나 완료된 파일이 하나라도 있어야 통과하는 별도 규칙을 씁니다.

컬럼 메타데이터에서 입력 규칙 만들기ts
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;
};

어떤 컬럼을 보일지는 테이블 이름을 키로 브라우저에 저장합니다. 저장값이 없거나 비어 있거나 현재 컬럼과 하나도 맞지 않으면 전체 컬럼으로 돌아갑니다. 스키마가 바뀌어 저장된 컬럼 이름이 사라져도 빈 표가 나오지 않게 했습니다.

표시 컬럼 계산ts
// 저장된 설정이 없거나 비어 있으면 전체 컬럼으로 돌아간다. 저장값이 현재 컬럼과 하나도 맞지 않아도 전체로 돌아간다
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;
};
구현 코드: 테이블별 표시 컬럼 스토어
테이블별 표시 컬럼 스토어ts
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에 둡니다.

라우트의 URL query 스키마ts
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에서 일어나므로 숨긴 하위 화면이 먼저 렌더링된 다음에야 이동했습니다.

이전: 화면 안에서 역할 판단tsx
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 모두 같은 구조로 바꿨습니다.

이후: 라우트 진입 전 역할 확인ts
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가 있으면 해당 그룹 상세 키까지 갱신합니다.

권한 저장 뒤 캐시 갱신ts
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 선택 복원, 재색인, 노출 전환, 폴더 삭제와 승격, 저장 직렬화를 각각 함수로 두고 화면은 이 함수들을 조합하기만 합니다. 노출 전환 규칙과 직렬화, 응답 정규화는 이 과정에서 새로 정했습니다.

폴더 응답 정규화와 저장 직렬화ts
// 생성 타입의 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["엔트리 화면의 왼쪽 트리"]
폴더 규칙 단위 테스트의 사례 이름ts
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 없이도 상태 전이를 검증할 수 있습니다.

사전 서명 URL로 직접 업로드하고 진행률 전달ts
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로 중복을 지운 뒤 합산합니다.

구현 코드: 파일 하나와 묶음의 검증
파일 하나와 묶음의 검증ts
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 캐시를 전부 비웁니다.

Kubb 설정ts
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}`}}),
	],
});
로그아웃 뒤 캐시 비우기ts
const mutationAuthorizationLogout = useAuthorizationLogout({
	mutation: {
		onSuccess: () => {
			tokenStore.reset();
			roleStore.reset();
			navigate({to: "/login"}).then(() => {
				queryClient.clear();
			});
		},
	},
});

명세가 바뀌면 다시 생성하고 화면의 사용처만 확인했습니다. 생성 타입이 실제 응답과 다를 때는 폴더 트리의 children처럼 화면 쪽 정규화 함수에서 흡수하고 백엔드에 명세 수정을 요청했습니다. 로그아웃 뒤 다른 계정으로 들어와도 이전 계정의 데이터가 화면에 남지 않았습니다.

만료된 토큰을 한 번만 갱신하고 대기 요청을 이어서 보내기

화면 하나가 여러 요청을 동시에 보내는데 액세스 토큰이 만료되면 모든 요청이 401을 받습니다. 요청마다 갱신을 보내면 refresh 토큰이 서로를 무효화하고 로그인이 풀립니다.

갱신은 한 번만 하고 그동안 실패한 요청은 대기열에 넣었다가 갱신이 끝나면 다시 보냅니다. 대기 요청은 새 토큰을 직접 받지 않고 재실행될 때 요청 interceptor가 스토어의 최신 토큰을 싣습니다. 갱신 자체가 실패하면 저장된 토큰을 지우고 대기 요청을 모두 실패 처리한 뒤 로그아웃을 요청하고 로그인 화면으로 보냅니다. 갱신 요청과 이미 재시도한 요청은 재시도하지 않습니다. OTP 검증 요청에는 액세스 토큰 대신 OTP 단계의 토큰을 싣도록 요청 interceptor에서 나눴습니다.

구현 코드: 401 응답의 단일 갱신과 대기열
401 응답의 단일 갱신과 대기열ts
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로 알림을 끕니다. 재시도는 기본으로 끄고 서버 오류 메시지를 그대로 보여 줬습니다.

요청 결과 알림의 기본값과 예외tsx
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 지연을 두어 짧은 요청에서는 나타나지 않습니다.

화면 이동 시 모달 정리와 저장 중 표시tsx
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을 직접 그리지 않는 규칙도 함께 두었습니다.

규약 문서의 메모이제이션 예시ts
// 금지: 단순 가공을 위한 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도 같은 규칙으로 작성했습니다.

규약 문서의 렌더링 분기 예시tsx
// 금지: 삼항 렌더링 분기
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 규약을 별도 저장소로 분리했습니다.

순수 함수 단위 테스트의 사례 이름ts
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 전환에 따른 성능 변화는 측정하지 않았습니다.

배운 것