관리자 사이트를 개발하다 보면, 사용자 권한에 따라 버튼이나 페이지를 숨기거나 "권한이 없습니다."와 같은 안내 메시지를 표시하는 기능이 거의 필수적이다. 이러한 기능은 사용자 경험을 개선하고 보안을 강화하는 데 중요한 역할을 한다.
이러한 권한 관리 기능을 보다 효율적으로 처리하기 위해, 선언적으로 사용할 수 있는 라이브러리를 개발해 보았다.
구현
권한 정보를 React의 Context API를 활용하여 제공하고, AccessGuard 컴포넌트에서 해당 권한을 조회하도록 구현했다. 권한이 있을 경우에는 children을 렌더링하고, 권한이 없을 경우에는 fallback이나 null을 렌더링하도록 설정했다.
AccessProvider
// types.ts
export type Access = string
export interface AccessMap {
[entityCode: string]: Array<Access>
}
// access-context.ts
export const AccessContext = createContext<AccessMap | undefined>(undefined);
// access-provider.tsx
function AccessProvider({
children,
accessMap,
}: {
children: ReactNode;
accessMap: AccessMap;
}) {
return (
<AccessContext.Provider value={accessMap}>
{children}
</AccessContext.Provider>
);
}
권한(access)을 어떻게 저장할지에 대해 많은 고민을 했다. 실제 권한은 계층 구조를 가지는 경우가 많기 때문에 트리 형태로 저장하는 방안도 고려했으나, 커버해야 할 경우의 수가 너무 많아지고 구현과 사용의 복잡도가 크게 증가할 것 같았다. 그래서 객체 형태로, 엔티티의 고유 코드를 키로 하고 권한의 고유 이름 배열을 값으로 갖도록 설계했다.
데이터를 제공하는 방식에 대해서도 고민이 많았다. 전역 상태 관리 라이브러리를 도입할지, 아니면 React의 Context API를 사용할지 고민했다. 전역 상태 관리 라이브러리를 사용할 경우, 번들에 포함되면 번들이 너무 커질 수 있고, 포함하지 않고 peerDependency로 설정하면 특정 상태 관리 라이브러리에 의존적이 되어버린다. 결국, React의 Context API를 차용하기로 결정했다.
AccessGuard
// use-access.ts
function useAccess({entityCode}: {entityCode: string}) {
const accessMap = useContext(AccessContext)
if (!accessMap && process.env.NODE_ENV !== "production") {
throw new Error(
"[react-access-guard-error]: `<AccessGuard />` must be wrapped in a <AccessProvider />"
)
}
return accessMap?.[entityCode]
}
// access-guard.tsx
function AccessGuard({
entityCode,
children,
access,
fallback,
}: {
entityCode: string;
children: ReactNode;
access: Access[];
fallback?: ReactNode;
}) {
const userAccess = useAccess({ entityCode });
const hasAccess =
userAccess?.some((_userAccess) => access.includes(_userAccess)) ?? false;
if (!hasAccess) return <>{fallback ?? null}</>;
return <>{children}</>;
}
권한을 Context에서 조회하는 useAccess 훅을 별도로 구현하여 사용했다. 개발자가 상위를 Provider로 감싸는 것을 잊을 수 있기 때문에, 이 경우에는 오류를 throw하여 쉽게 인지할 수 있도록 했다.
AccessGuard 컴포넌트는 사용자 권한을 조회하여 prop으로 받은 사용 권한을 가지고 있는지 검사한다. 권한이 있을 경우에는 children을 렌더링하고, 권한이 없을 경우에는 fallback이나 null을 반환하도록 구현했다.
테스트
라이브러리는 앱의 동작에 영향을 미치지 않아야 하므로, 기본 동작을 검증하는 테스트 코드를 추가했다.
function TestTemplate() {
return (
<>
<AccessGuard entityCode={"TEST"} access={["CREATE"]}>
<p>CREATE</p>
</AccessGuard>
<AccessGuard entityCode={"TEST"} access={["READ"]}>
<p>READ</p>
</AccessGuard>
<AccessGuard entityCode={"TEST"} access={["UPDATE"]}>
<p>UPDATE</p>
</AccessGuard>
<AccessGuard
entityCode={"TEST"}
access={["DELETE"]}
fallback={"fallback"}
>
<p>DELETE</p>
</AccessGuard>
</>
);
}
function renderWithAccessProvider(accessMap: AccessMap) {
render(
<AccessProvider accessMap={accessMap}>
<TestTemplate />
</AccessProvider>
);
}
test("When the user has permission, <p>READ</p> wrapped in AccessGuard is visible.", async () => {
renderWithAccessProvider({ TEST: ["READ"] });
const readParagraph = screen.getByText("READ");
expect(readParagraph).toBeInTheDocument();
});
test("When the user does not have permission, <p>READ</p> wrapped in AccessGuard is not visible.", async () => {
renderWithAccessProvider({ TEST: ["CREATE"] });
const readParagraph = screen.queryByText("READ");
expect(readParagraph).not.toBeInTheDocument();
});
test("When the user does not have permission, <p>fallback</p> provided as fallback in AccessGuard is visible.", async () => {
renderWithAccessProvider({ TEST: ["CREATE"] });
const fallbackParagraph = screen.queryByText("fallback");
expect(fallbackParagraph).toBeInTheDocument();
});
테스트에 사용할 컴포넌트를 상단에 선언하고, 해당 컴포넌트를 권한과 함께 렌더링하는 커스텀 렌더 함수 renderWithAccessProvider
를 만들었다. 공통되는 부분을 같은 파일의 상단에 선언함으로써 문맥 파악에 도움이 되도록 했다.
테스트는 다음과 같이 세 가지 경우로 나누어 진행했다:
- 권한이 있을 경우: 사용자가 필요한 권한을 가지고 있는지 확인.
- 권한이 없을 경우: 권한이 없는 사용자가 접근할 때의 동작 확인.
- Fallback을 준 경우: 권한이 없을 때 fallback이 제대로 작동하는지 확인.
이러한 방식으로 테스트를 구성하여 각 상황에 대한 명확한 검증을 수행했다.
개발자를 위한 안내
라이브러리의 사용자는 개발자이다. 개발자가 쉽게 사용할 수 있도록 상세한 사용 예시를 README에 추가했으며, 각 컴포넌트에는 JSDoc을 통해 설명을 달아놓았다.
// README.md
## Usage
To use the react-access-guard library, you need to wrap your root component or the component where you want to manage access with the AccessProvider.
This provider will receive an accessMap prop containing the permission information.
### Step 1: Wrap with AccessProvider
```jsx
import React from 'react';
import { AccessProvider, AccessMap } from 'react-access-guard';
// Generally, you will fetch permission information from the server and then transform it to match the type of AccessMap for use.
const accessMap: AccessMap = {
myEntity: ['CREATE', 'UPDATE'],
};
const App = () => {
return (
<AccessProvider accessMap={accessMap}>
<MyComponent />
</AccessProvider>
);
};
```
### Step 2: Protect Components with AccessGuard
// 생략
// access-provider.tsx
/**
* Context Provider that enables the use of AccessGuard.
*
* @param {Object} props - The component props.
* @param {ReactNode} props.children - The child components to be rendered within the provider.
* @param {AccessMap} props.accessMap - A hash map containing access information.
*
* @returns {JSX.Element} The rendered AccessProvider component.
*/
function AccessProvider() {/* 생략 */}
// access-guard.tsx
/**
* The AccessGuard component checks the user's access permissions and
* renders a fallback component if the user does not have the required permissions.
*
* @param {Object} props - The props for the component
* @param {string} props.entityCode - The entity code for which to check access permissions
* @param {ReactNode} props.children - The child component to render if access is granted
* @param {Access[]} props.access - An array of allowed access permissions
* @param {ReactNode} [props.fallback] - (optional) The fallback component to render if access is denied
* @returns {ReactNode} - Returns the child component or fallback component based on access permissions
*/
function AccessGuard() {/* 생략 */}
빌드 및 배포
https://ko.vite.dev/guide/build#library-mode
Vite의 build.lib 옵션을 활용하여 라이브러리 빌드 설정을 했다.
formats
를 es
와 cjs
로 지정하여 ESM과 CJS로 각각 빌드되도록 했다. 이렇게 설정한 이유는 ESM으로만 빌드할 경우 CJS 기반의 테스트 도구인 Jest 등에서 import 시 에러가 발생하기 때문이다. (예외 설정을 통해 해결이 가능하긴 하지만 개발자가 일일이 해야한다) 이를 방지하기 위해 두 가지 포맷으로 빌드했다.
또한, vite-plugin-dts를 활용하여 빌드 시 index.d.ts 파일을 생성하도록 했다. 이 파일이 없으면 TypeScript 프로젝트에서 이 라이브러리를 사용하기 위해 별도로 index.d.ts를 만들어 타입 선언을 해주어야 하므로 필수적이다.
마지막으로, rollupOptions를 사용하여 번들에서 react와 react-dom을 제외했다. 리액트는 프로젝트에 이미 설치되어 있기 때문에 라이브러리에 다시 포함하면 번들이 불필요하게 커지므로 제외했다.
// vite.config.ts
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [react(), tsconfigPaths(), dts({
rollupTypes: true,
tsconfigPath: "./tsconfig.app.json",
entryRoot: 'src',
include: ['src'],
exclude: ['node_modules', 'dist', '**/*.test.ts*', './src/vite-env.d.ts'],
})],
build: {
lib: {
entry: resolve(__dirname, 'src/lib/main.ts'),
name: 'react-access-guard',
formats: ['es', 'cjs']
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
}
})
main.ts
에서 라이브러리에서 노출할 컴포넌트들만 re-export 했다.
import AccessProvider from "./access-provider";
import AccessGuard from "./access-guard";
export {AccessGuard, AccessProvider}
package.json
에서 라이브러리의 각종 메타데이터를 설정하고, exports를 통해 빌드한 결과물들을 명시했다.
peerDependency
로 react
와 react-dom
의 버전을 명시하여 라이브러리 사용 시 해당 패키지의 버전 제한을 두었다.
exports
에 types
를 명시하여 TypeScript 프로젝트에서 활용할 때 오류가 발생하지 않도록 했다.
{
// 생략
"main": "./dist/react-access-guard.cjs",
"module": "./dist/react-access-guard.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/react-access-guard.js",
"require": "./dist/react-access-guard.cjs"
}
},
// 생략
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
}
package.json
의 version
을 1.0.0으로 설정하고 npm publish
명령어를 활용하여 npm에 배포했다.
Cannot read properties of undefined (reading 'ReactCurrentDispatcher') 오류 해결
배포 후 데모 앱을 하나 만들어 라이브러리를 설치하고 테스트를 진행했다.
테스트 결과, 브라우저에서 Cannot read properties of undefined (reading 'ReactCurrentDispatcher')
오류가 발생하며 react-access-guard
가 아예 렌더링되지 않았다.
이 문제를 조사해보니, React v19 버전부터 Rollup으로 React를 빌드할 때 external
로 react
와 react-dom
을 지정해도 몇몇 하위 모듈들이 번들에 포함되는 버그가 있다고 한다. 실제로 번들을 확인해보니, react/jsx-runtime
를 require하지 않고 관련 코드가 빌드된 소스코드에 포함되어 있었다. 어쩐지 번들이 20KB로 너무 컸다.
이 문제를 해결하기 위해 정규식을 활용하여 external
옵션에 React와 ReactDOM 관련 모듈을 제외했다.
{
rollupOptions: {
external: ["react", /^react\/.*/, "react-dom", /react-dom\/.*/],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
}
}
변경 후 빌드한 코드를 확인해보니 react/jsx-runtime
관련 코드가 없어졌다. 번들 크기도 3.5kb로 많이 줄었다.
배포한 후 데모 사이트에서 React 버전 v18과 v19에서 각각 테스트해본 결과, 정상적으로 동작했다.
개선할 점
- AccessMap Type 변경하기
AccessProvider
의accessMap
에 커서를 올리면interface AccessMap
으로만 타입이 표시되어, 상세한 타입을 확인하기 위해서는 Ctrl+클릭을 해야 한다.AccessMap
대신Record<string, string[]>
로 타입을 지정하면 개발자들이 더 쉽게 확인할 수 있을 것 같다.
- CI/CD 파이프라인 구성하기
- Pull Request 시 테스트 통과 여부를 표시하는 기능을 GitHub Actions로 구성하면, 추후 유지보수에 도움이 될 것이다.
- 현재
npm publish
명령어로 수동으로 릴리스를 진행하고 있는데, GitHub에 태깅하고 자동 배포가 이루어지도록 구성하면 좋을 것 같다.
- 모노레포로 구성 변경
- 모노레포로 구성 변경 후, 데모 앱을 설정하여 패키지의 react-access-guard를 import하고 테스트할 수 있도록 하면, 별도로 데모 앱을 생성하는 수고를 줄일 수 있을 것 같다
'프론트엔드 > React' 카테고리의 다른 글
리액트 EventBus 활용 (0) | 2024.07.21 |
---|---|
웹 접근성 (0) | 2024.03.17 |
리액트 프로젝트 폴더 구조 (0) | 2023.04.08 |
useIntersectionObserverRef 커스텀 훅 만들기 (1) | 2023.02.16 |
Portal (0) | 2022.07.06 |