요약
- 기존 명령형 권한 체크 방식의 한계와 문제점을 분석합니다.
Error Boundary
를 활용한 선언적 권한 관리 구현 과정을 설명합니다.- 라우터 설정에서 권한을 선언하여 중복 코드를 제거하는 방법을 소개합니다.
글의 코드는 원본코드가 아닌, 최대한 비슷하게 재현한 코드들입니다.
고민의 배경
개발했던 시스템에서는 사용자 권한에 따라 접근 가능한 메뉴가 다르고, 메뉴에 접근하더라도 역할에 따라 허용되는 액션(읽기/ 쓰기•수정)이 달랐습니다.
예를 들어, 어떤 사용자는 특정 메뉴의 화면 접근 자체가 제한되기도 했고, 혹은 화면은 보이지만 읽기 권한만 부여되어 있어 쓰기나 수정이 불가한 경우도 있습니다.
이처럼 다양한 권한 조합을 고려해 UI와 기능을 동적으로 제어해야 했고, 그 분기 처리를 프론트엔드에서 담당하게 되었습니다.
tsxexport default function RegisterPage() { const { hasWriteAuthority } = useAuthorization() if (!hasWriteAuthority('menu1')) { return <Navigate to="/" /> } return <Form1 /> } export default function RegisterPage2() { const { hasWriteAuthority } = useAuthorization() if (!hasWriteAuthority('menu2')) { // 동일한 로직 반복 return <Navigate to="/" /> } return <Fomr2 /> }
처음 구현은 위와 같았습니다. 점차 메뉴가 많아지면서, 권한 체크 로직의 중복과 URL 직접 접근 시의 UX 문제를 해결할 수 있는 더 나은 아키텍처가 필요하다는 생각이 들었습니다.
- 각 컴포넌트마다 동일한 권한 체크 로직이 반복되어 코드 중복이 심각했습니다.
- 개발자가 권한 체크를 누락할 가능성이 높았습니다. (실제로 많이 누락을 했었기도 합니다..🫢)
이 글에서는 ErrorBoundary
를 활용하여 명령형 권한 체크를 선언적 시스템으로 전환한 과정과 그 기술적 구현 세부사항을 공유하려 합니다.
해결 과정: Error Boundary 기반 권한 시스템
해결해야할 문제 정리
우선 당시는 권한이 없을 때 홈으로 리다이렉트를 시켜줘서, 사용자가 의도하지 않은 flow를 제공하리 떄문에 UX적으로도 개선이 필요하다 생각했습니다.
따라서, 권한이 없을 때 보여줘야하는 fall back 페이지가 필요했고 상황을 설명한 뒤, 디자이너님께 디자인을 요청했습니다.
또한 개발적으로 해결해야할 문제는 권한 관리 코드의 통합이었습니다.
- 중복된 권한 체크 로직 제거
- 각 컴포넌트마다 반복되는 권한 체크 코드를 하나의 시스템으로 통합
- 권한 체크 누락 방지
- 개발자가 실수로 권한 체크를 빼먹을 가능성 차단
설계 아이디어
설계의 핵심 아이디어는 권한은 route 레벨에서 선언하고, 흩어져 있는 오류처리를 한곳에서 하기였습니다.
아래와 같은 결과를 목표로 했습니다.
- 선언적 설정
- 라우트 설정만 보고도 권한 구조를 파악 가능
- 중앙 집중식 관리
- 권한 로직이 한 곳에 집중
- 실수 방지
- 개발자가 권한 체크를 깜빡할 수 없는 구조
단계별 구현 과정
a) AuthorityError 클래스 정의
먼저 권한 오류를 나타내는 커스텀 에러 클래스를 정의했습니다.
tsexport default class AuthorityError extends Error { constructor(message?: string) { super(message) this.name = 'AuthorityError' this.message = '권한 없음' } }
이 클래스는 권한 관련 오류를 다른 일반적인 에러와 구분하기 위해 만들었습니다. instanceof
를 통해 권한 오류인지 확인할 수 있어, Error Boundary에서 적절한 UI를 렌더링 하기 위해 추가했습니다.
b) React Router 타입 확장으로 권한 정보 추가
기존의 명령형 방식에서 벗어나 라우터 설정 자체에 권한 정보를 선언하는 시스템을 설계, 구현했습니다. 부모 라우트에서 정의된 권한이 자식 라우트로 자동 전파되며, 자식에서 더 구체적인 권한(예: WRITE)을 정의할 수 있습니다.
이 방식의 핵심은 라우트 설정 자체에 권한 정보를 선언하여, 각 컴포넌트에서 권한 체크 코드를 반복 작성할 필요가 없습니다.
- React Router 타입 확장
타입 확장을 통해 React Router
의 기존 구조를 유지하면서 권한 정보를 추가적으로 받을 수 있게 했습니다.
ts// 기본 권한 정보를 담는 인터페이스 interface BaseRouteConfig { element?: React.ReactNode permission?: string // 어떤 메뉴에 대한 권한인지 (예시 코드라 단순 string으로 선언해뒀습니다.) type?: 'READ' | 'WRITE' // 읽기/쓰기 권한 구분 id?: string } // React Router의 IndexRouteObject와 유사하게 확장 interface IndexRouteConfig extends BaseRouteConfig { index: true } // React Router의 NonIndexRouteObject와 유사하게 확장 interface NonIndexRouteConfig extends BaseRouteConfig { path?: string index?: false children?: RouteConfig[] // 재귀적 구조로 중첩 라우트 지원 } // 유니온 타입으로 두 가지 라우트 타입 통합 export type RouteConfig = IndexRouteConfig | NonIndexRouteConfig
- 실제 사용 예시
tsconst routeConfig: RouteConfig = { path: 'users', permission: 'MENU1', // 부모 권한 children: [ { index: true, element: <UserListPage />, // type 미지정 시 기본적으로 READ 권한 }, { path: 'create', element: <UserCreatePage />, type: 'WRITE', // 명시적으로 쓰기 권한 요구 }, { path: ':userId/edit', element: <UserEditPage />, type: 'WRITE', // 수정도 쓰기 권한 필요 } ] };
c) 재귀적 라우트 생성과 권한 상속 메커니즘
tsxexport function createRoute( config: RouteConfig, parentPermission?: string ): RouteObject { // 1. 권한 상속: 현재 라우트의 권한 또는 부모에서 상속받은 권한 사용 const currentPermission = config.permission || parentPermission // 2. 현재 라우트 생성 const route: RouteObject = { path: config.path, element: config.element && withPermissionCheck(config.element, currentPermission, config.type), } // 3. 🔥 재귀적 자식 라우트 처리 - 핵심 부분 if (config.children) { route.children = config.children.map( (child) => createRoute(child, currentPermission) // ← 재귀 호출 + 권한 전파 ) } return route }
- 재귀 호출의 동작 과정
createRoute
함수가 각 자식 요소에 대해createRoute
를 다시 호출currentPermission
을 자식에게 계속 전달config.children
가 없거나 빈 배열일 때 자연스럽게 종료
d) AuthorizationWrapper 컴포넌트 구현
권한이 없으면 에러를 던져서 Error Boundary가 처리하도록 합니다.
이렇게 하면 컴포넌트는 권한 로직을 신경 쓰지 않고 비즈니스 로직에만 집중할 수 있습니다.
tsxconst AuthorizationWrapper = ({ permissionMenu, type = AuthorityType.READ, children, }: Props) => { const { account } = useContext(AppContext) const { hasAdminAuthority, hasWriteAuthority, hasReadAuthority } = useAuthorization() if (!account) { return } let hasPermission: boolean = false // 일부 코드 생략.. if (!hasPermission) { throw new AuthorityError() } return <>{children}</> }
key={location.pathname}
을 사용한 이유는 라우트가 변경될 때마다 Error Boundary를 리셋하여, 이전 페이지의 오류 상태가 다음 페이지에 영향을 주지 않도록 하기 위함입니다.
e) Error Boundary와 Fallback UI 구현
tsximport { FallbackProps } from 'react-error-boundary' import { Link } from 'react-router-dom' import AuthorityError from '../../models/authority-error' export default function FallbackPage({ error, resetErrorBoundary, }: FallbackProps) { const isAuthorityError: boolean = (error as Error) instanceof AuthorityError const theme = useTheme() if (isAuthorityError) { return ( // 권한 오류인 경우 // ui 생략 <Link to="/"> <Button variant="contained" size="medium" onClick={resetErrorBoundary} > 홈으로 이동하기 </Button> </Link> // ) } return ( // 일반 오류인 경우 // ui 생략 ) }
성과와 배운 점
정량적 개선사항
코드 중복 제거로 권한 체크 코드 40% 이상 감소하였다. 또한 새로운 페이지 추가시 권한 설정 시간이 단축되었다.
권한체크 누락으로 인한 버그가 감소하였다.
기술적 인사이트
명령형 코드를 선언적으로 전환하여 가독성과 유지보수성 향상시켰다.
권한 로직을 컴포넌트에서 분리하여 각각의 책임을 명확히 하여 관심사를 분리시켰다.
결론
Error Boundary를 활용한 선언적 권한 관리 시스템으로 전환하면서 다음과 같은 성과를 얻었습니다:
- 중복 제거와 관심사 분리로 유지보수성 대폭 개선
- 권한 설정의 단순화로 개발 생산성 향상
- 일관성 있는 권한 없음 페이지 제공하면서 사용자 경험 개선
Error Boundary
를 단순한 에러 처리를 넘어 시스템 아키텍처의 핵심 요소로 활용할 수 있다는 것을 배웠습니다.
명령형에서 선언적 패러다임으로의 전환이 코드베이스 전체에 미치는 영향을 경험할 수 있었던 의미 있는 개선이었습니다.