어드민 백오피스 구축 및 고도화
권한이 다른 여러 운영자가 쓰는 어드민 백오피스를 만들었다. 역할마다 접근할 수 있는 화면이 다르고, 로그인 직후 이동해야 하는 시작 페이지도 달랐다. 이 글은 실무에서 구축한 구조와, 이후 그 설계를 다듬어 검증한 기록을 함께 다룬다.
페이지 하나를 추가하려면 세 곳을 고쳐야 했다. 라우터에 라우트를 등록하고, 사이드바에 메뉴를 추가하고, 접근 권한 로직에 경로를 반영한다. 셋은 각자 다른 자리에서 따로 관리됐다.
이 구조의 문제는 수정량이 아니라 불일치의 가능성이다. 라우트는 있는데 메뉴가 없거나, 메뉴는 보이는데 권한 처리가 빠지는 식으로, 세 곳 중 한 곳을 놓친 채 배포될 수 있다. 그런데 따져 보면 라우트, 메뉴, 권한은 서로 다른 정보가 아니라고 판단했다. 어떤 역할이 어떤 경로에서 어떤 화면을 보는지, 그중 무엇이 메뉴에 노출되는지라는 하나의 사실을 세 가지 형태로 옮겨 적은 것이다.
세 곳이 어긋나지 않게 하는 방법은 크게 두 갈래다. 동기화를 잘하거나, 동기화할 대상을 없애거나. 체크리스트와 리뷰로 세 곳을 맞추는 방식은 결국 사람의 주의에 기대는데, 사람은 실수하기 마련이다. 어긋나는 원인이 같은 정보의 중복이라면 중복 자체를 없애는 쪽이 맞다고 판단했다. 목표는 단일 정의(SSOT)다. 선언은 한 곳에서 하고, 라우터와 사이드바와 권한은 그 정의에서 파생되게 한다.
새로운 라우팅 라이브러리를 사용하거나 만들 필요까지는 없다고 봤다. 기존에 쓰던 React Router의 RouteObject 타입을 확장하는 것으로 충분하다고 판단했다. 라우트 정의에 메뉴 메타데이터를 함께 선언할 수 있도록 handle을 구체화했다. 준비 중인 기능을 메뉴에 보이되 접근은 막아야 한다는 요구사항이 있었기 때문에, 메뉴 정보와 함께 비활성 여부도 같은 정의에서 선언한다.
export type Route = RouteObject & {
handle?: {
label?: string; // 사이드바에 표시될 메뉴명
iconName?: string; // 메뉴 아이콘
};
disabled?: boolean; // 메뉴 비활성화 여부
children?: Route[];
};이제 페이지 하나의 선언은 이렇게 끝난다.
{
path: 'brand/collaborator',
element: <BrandCollaboratorPage />,
handle: { label: '협업', iconName: 'DocumentWritten' },
}path와 element는 라우터가 쓰고, handle은 사이드바가 쓴다. 반대로 메뉴에 없어야 하는 화면들도 있었다. 생성 폼이나 상세 페이지처럼 메뉴의 페이지를 거쳐서만 진입하는 화면들이다. 이런 화면은 handle을 생략하는 것으로 표현이 끝난다. 라우트는 존재하되 메뉴에는 노출되지 않는다.
handle은 임의로 붙인 이름이 아니다. React Router가 라우트에 메타데이터를 달 수 있도록 열어 둔 필드이고, any로 열려 있는 그 자리를 메뉴 정보 타입으로 좁혀 쓴 것이다.
접근 제어는 별도의 권한 검사 로직 대신 등록 단계에서 처리했다. 역할별 라우트 정의를 각자의 파일로 분리하고, 역할과 정의를 맵 하나로 연결한 뒤, 로그인 시점에 사용자 역할에 해당하는 라우트만 라우터에 등록한다.
허용되지 않은 경로는 접근이 차단되는 것이 아니라 라우터에 존재하지 않는다. 다른 역할의 경로로 진입하면 권한 오류가 아니라 404가 나온다. 페이지마다 역할을 검사하는 코드가 사라지고, 라우트를 감싸는 컴포넌트 하나가 로그인 여부만 확인한다. 역할이 새로 생기면 라우트 정의 파일 하나를 만들고 맵에 한 줄을 추가하면 끝난다.
라우트 정의는 중첩 구조로 작성했다. 경로의 계층과 메뉴의 그룹이 정의에서 그대로 보이고, React Router의 라우트 작성 관례와 같은 모양이라 읽는 부담이 없다.
다만 당시 정의는 부모 라우트가 화면을 갖는 형태였다. 목록 페이지가 부모, 상세 페이지가 자식으로 선언되는 식이다. React Router는 자식 라우트를 부모 컴포넌트의 Outlet 안에 렌더링하는데, 목록 페이지는 Outlet을 가진 레이아웃이 아니라 완결된 화면이라 중첩된 그대로 등록하면 자식이 그려질 자리가 없다.
마침 평탄화 로직이 이미 있었다. 사이드바는 메뉴를 도메인 단위로 묶고 현재 위치를 표시해야 해서, 중첩 정의를 재귀로 순회해 전체 경로를 가진 평탄한 배열로 바꾸고 handle 있는 항목을 걸러 쓰고 있었다. 등록도 같은 배열로 풀었다. 모든 페이지를 전체 경로 문자열을 가진 형제로 바꿔 공통 레이아웃 아래에 등록한 것이다.
// 정의: 중첩
{ path: 'brand', children: [{ path: 'update', element: <BrandUpdatePage /> }] }
// 등록: 평탄화
{ path: 'brand/update', element: <BrandUpdatePage /> }이 단순화가 성립한 배경이 있다. 개발 단계까지 화면 그룹이 레이아웃을 공유해야 하는 요구가 없었다. 모든 화면이 어드민 공통 레이아웃 하나 아래의 형제여도 아무 문제가 없었던 것이다.
페이지 컴포넌트는 전부 lazy import로 선언해 라우트 단위로 코드를 분리했다. 어떤 역할로 로그인하든 그 역할이 쓸 화면의 코드만 내려받는다.
평탄화로 문제없이 동작하게끔 했지만 의문이 들었다. React Router에서 중첩 등록은 경로의 계층만이 아니라 레이아웃의 중첩을 함께 담당한다. 평탄화는 그 결합을 우회하는 방법이었는데, 이 방법에도 문제를 느꼈다. 정의는 중첩인데 등록은 평탄이라 둘의 모양이 달라지고, 화면들이 공유할 수 있는 레이아웃은 전체를 감싸는 어드민 레이아웃 하나로 고정된다. 설정 화면들이 탭 레이아웃을 공유하는 것 같은 요구는 어드민에서 언제든 생길 수 있는 종류이기 때문에, 요구가 생기기 전에 답을 준비해둘 필요가 있다고 생각했다.
React Router의 index 개념이 떠올랐다. element가 없는 부모 라우트는 Outlet만 렌더링하므로 자식들이 각자 독립적으로 그려지고, 부모 경로의 화면은 index 라우트가 맡는다. 목록과 상세를 형제로 두는 것이다. 이러면 중첩 정의를 변환 없이 그대로 등록할 수 있고, 레이아웃이 필요한 그룹만 부모에 element를 주면 된다. 정의의 형태를 바꾸면 평탄화 자체가 필요 없어진다.
이 방향이 실제로 성립하는지 별도 프로젝트로 검증했다.
→ admin-nested-routing-poc
역할별 중첩 정의가 변환 없이 useRoutes에 등록되고, 목록과 상세가 부모 중첩 없이 독립적으로 렌더링된다. 사이드바는 평탄 배열 대신 중첩 정의를 순회하는 파생 함수로 만들어, handle이 있는 노드의 누적 전체 경로를 계산한다. Outlet을 가진 레이아웃 그룹도 같은 정의 안에서 공존한다. 사이드바 표시에만 관여하던 disabled는 라우터 등록 제외까지로 정책을 일원화해, 준비 중인 메뉴는 URL로 직접 진입해도 404가 된다. 파생 함수들이 전부 순수 함수라 유닛 테스트로 고정했다.
결정해야 할 한 가지가 더 남아 있었다. 역할마다 로그인 후 도착해야 하는 첫 화면이 다르다는 요구, 즉 기본 진입 경로다. 기본 경로를 상수로 두면 '/products' 같은 문자열이 라우트 정의와 별도로 관리된다. 라우트 경로를 바꾸면 기본 경로가 조용히 404를 가리킬 수 있는 이중 관리다.
Claude Code는 두 가지를 제안했다. dev 모드에서 기본 경로가 실제 라우트에 존재하는지 검사하는 가드를 두거나, 경로 리터럴을 상수 파일로 빼서 라우트 정의와 기본 경로가 같은 상수를 참조하게 하거나. 둘 다 동작하지만 공통 전제가 있다. 기본 경로가 라우트 정의 바깥에 존재한다는 것이다. 가드는 이중 관리를 감시하고 상수는 이중 관리를 정돈할 뿐, 출처가 둘인 상태는 그대로다.
그래서 두 안을 기각하고 기본 경로를 정의 안으로 넣기로 했다. 라우트 노드에 isLanding을 선언하고, 트리를 순회해 그 노드의 누적 경로를 파생한다. 경로 세그먼트를 리네임해도 기본 경로가 자동으로 따라오고, 정의와 어긋나기가 어렵다. 역할당 landing이 정확히 하나라는 불변식은 테스트로 고정했다.
돌아보니 같은 판단의 반복이었다. 동기화를 잘하는 방법과 동기화할 대상을 없애는 방법 중에서, 이번에도 없애는 쪽을 골랐다.
이 구조가 통제하는 것은 화면 접근이다. 라우트 정의와 등록 로직은 모두 클라이언트 코드이고, 클라이언트 코드는 사용자 손에 있는 것이라 그 자체로 보안 경계가 될 수 없다. 실제 권한의 경계는 서버에 있다. API가 요청마다 토큰과 역할을 검증하므로, 화면이 라우터에 없다는 것과 무관하게 데이터 접근은 서버가 허용해야만 성립한다. 이 구조에서 404의 역할은 방어가 아니라 안내다.
그리고 재설계는 검증까지다. PoC는 인증이 목이라 실제 인증의 지속성과 서버 데이터 로딩을 다루지 않았고, 운영 트래픽 아래에서 검증된 구조는 평탄화 방식이다.
실무 구현에서는 페이지와 권한 추가가 단일 정의 수정으로 끝나게 됐고, 세 곳의 불일치라는 문제 자체가 성립하지 않게 됐다. 추가 절차는 체크리스트로 문서화해 팀원 누구나 같은 방식으로 확장했다. 초기 로드 JS는 48% 줄었다(gzip 713KB → 373KB). 라우트 단위 코드 스플리팅의 적용 유무를 동일 조건에서 비교 측정한 값이다.
재설계에서는 하나의 정의가 파생하는 것이 라우트, 메뉴, 권한에서 기본 진입 경로까지 넷으로 늘었다. 정의와 등록의 모양이 일치하고, 공유 레이아웃이 가능해졌으며, 파생 로직이 순수 함수로 남아 테스트로 고정된다.
