이 글의 코드 쇼핑몰 백오피스라는 가상의 도메인으로 재구성한 예제이다.
관리자 화면 전반을 맡으면서 가장 신경 쓴 부분은 권한 제어였다.
상품 담당자에게는 상품 관리 메뉴만 보여주고, 매출 담당자에게는 매출 관리 페이지만 보여주는 요구사항은 어느 서비스에나 흔히 있을 것이다.
처음에는 권한에 따라 메뉴를 숨기고, 권한 없는 페이지에 접근하면 다른 화면으로 보내는 정도면 충분하다고 생각했다. 하지만 그 후 권한이 없는 사용자에게도 화면이 잠깐 보였다가 사라지는 문제가 눈에 들어왔다.
이 글에서는 부족하지만 이 문제를 고민하고 해결해 나갔는지 기록해보려고 한다!

권한은 서버가 만들고, 프론트는 받아 쓴다
현재 우리 서비스는 어떤 권한이 존재하고 누가 어떤 권한을 갖는지는 서버에서 관리했고, 프론트는 로그인한 사용자가 가진 권한 목록을 배열로 전달받았다.
["PRODUCT_MANAGE", "SALES_MANAGE"];
이 구조에서는 서버는 PRODUCT_MANAGE라는 권한이 무엇을 의미하는지 알고 있지만, 이 권한이 프론트의 /admin/products 경로와 연결된다는 사실은 프론트가 알고 있어야 했다.
- 백엔드가 사용자가 어떤 권한을 가지고 있는지 결정한다.
- 프론트는 해당 권한으로 어떤 화면을 보여줄지 결정한다.
- 백엔드가 사용자가 실제 데이터에 접근할 권한이 있는지 최종 검증한다.
이 구분은 이후 권한 제어 구조를 설계하는 기준이 됐다.
클라이언트 훅과 UI만으로 막아볼까?
처음에는 권한 목록을 조회하는 클라이언트 훅을 만들었다.
훅의 결과에 따라 권한이 없는 사용자는 페이지에서 내보내고, 사이드 메뉴에서 접근할 수 없는 항목을 숨겼다.
{ label: "상품 관리", hidden: !permissions?.includes(Permission.PRODUCT_MANAGE), }
이렇게 메뉴와 페이지, 두 군데에서만 처리하면 충분하다고 생각했다. 권한이 없는 사용자가 직접 URL을 입력하더라도 API에서 권한 없음 응답을 반환하므로 실제 데이터는 전달되지 않기 때문이다.
그런데 권한이 없는 계정으로 관리자 URL에 직접 접근해보니 문제가 있었다.
데이터는 표시되지 않았지만 관리자 레이아웃이 먼저 그려졌다. API에서 권한 조회가 끝난 뒤에야 다른 화면으로 튕겼던 것이다.
화면이 다 보인 다음 내보내는 것을 정말 접근을 막았다고 할 수 있을까?
메뉴와 페이지 훅은 모두 클라이언트 단에서 실행되었고, 브라우저가 권한 조회 API의 응답을 받기 전까지는 사용자의 권한을 알 수 없기 때문에 표시되지 않지만 그동안 페이지 렌더링은 이미 시작되어버린다.
화면이 그려지기 전에 접근을 끊으려면 판정 시점을 브라우저 렌더링 이전으로 옮겨야 했다.
Middleware에서 페이지 접근 전에 막아보자
Next.js Middleware는 요청이 페이지 렌더링으로 넘어가기 전에 실행된다. 요청을 먼저 가로채 권한을 확인하고, 접근할 수 없다면 리다이렉트하면 된다. 이 방식이라면 권한이 없는 화면의 렌더링 자체를 시작하지 않을 수 있었다.
그런데 Middleware에서 권한 API를 호출하려면 인증 쿠키가 필요했다. 백엔드가 발급한 세션 쿠키를 읽어보려 했지만, 로컬에서는 계속 undefined가 나왔다.
console.log(request.cookies.get(sessionKey)); // undefined
처음에는 Middleware가 프론트 서버에서 실행되기 때문에 백엔드가 발급한 쿠키를 읽지 못하는 것이라고 생각했다.
당시 로컬 개발 환경은 다음과 같았다.
프론트: http://localhost:3000 API: https://api.example.com
확인해보니 세션 쿠키는 example.com 도메인에서 사용할 수 있도록 발급돼 있었다. 브라우저는 이 쿠키를 api.example.com 요청에는 포함했지만, 전혀 다른 주소인 localhost 요청에는 보내지 않았다. Middleware가 읽을 쿠키가 없었던 이유였다.
이것이 이유라 확신이 든 이후, 로컬이 아닌 배포 환경에서는 세션 쿠키를 가져올수 있으리라 생각했는데, 배포 환경은 다음과 같은 구조였기 때문이다.
프론트: https://app.example.com API: https://api.example.com
세션 쿠키가 example.com의 하위 도메인에서 함께 사용되도록 설정돼 있었기 때문에, 브라우저는 app.example.com으로 요청할 때도 쿠키를 포함 할것이었다. 노심초사 개발 환경에 테스트 해 본 결과 예상대로 Middleware에서 세션 쿠키를 읽을 수 있었다!
하지만 이 로직대로 반영해버리면 개발자가 로컬 환경에서 관리자 페이지에 접근할 때마다 로그인 화면으로 이동하게 된것이 뻔했다.
따라서 개발 환경에서는 해당 검증을 건너뛰고, 그 외 환경에서는 세션과 경로 권한을 차례대로 확인하도록 구성했다.
export async function middleware(request: NextRequest) { // 1. 로컬일 경우 검증을 건너뛰자 if (isLocalDevelopment()) { return NextResponse.next(); } // 2. 너 세션이 있니 const session = readSession(request); if (!session) { return redirect("/login"); } // 3. admin이니? 어느 페이지로 접근 가능하니? if (isAdminPath(request) && !(await canAccess(request, session))) { return redirectToFirstAccessiblePage(session); } return NextResponse.next(); }
너 어느 페이지로 접근 가능하니?
경로별 권한 판정에는 하나의 매핑 객체를 사용하기로 했다. 각 경로에는 접근을 허용할 권한 목록이 연결되어 있고, 사용자가 그중 하나라도 가지고 있다면 통과한다.
const PATH_PERMISSIONS: Record<string, Permission[]> = { "/admin/products": [Permission.SUPER_ADMIN, Permission.PRODUCT_MANAGE], "/admin/sales": [Permission.SUPER_ADMIN, Permission.SALES_MANAGE], "/admin/members": [Permission.SUPER_ADMIN, Permission.MEMBER_MANAGE], };
반대로 /admin 아래의 경로인데 권한 매핑에 등록되어 있지 않다면 접근을 차단했다. 새로운 관리자 화면이 권한 설정 없이 노출되는 상황을 피하려면 등록되지 않은 경로를 허용하기보다 차단하는 편이 안전하다고 판단했다.
다음 문제는 권한이 없는 사용자를 어디로 보낼지였다. 선택지는 두 가지였다.
- 403 권한 오류 화면을 보여준다.
- 사용자가 접근할 수 있는 다른 관리자 화면으로 보낸다.
관리자는 필수적으로 하나 이상의 관리 권한을 가지고 있었고,
매출 담당자가 상품 관리 URL을 잘못 열었을 때 필요한 것은 권한이 없습니다라는 막다른 화면보다 본인이 사용할 수 있는 매출 관리 화면이라고 판단했다.
그래서 권한 목록을 전달하면 접근 가능한 화면 중 우선순위가 가장 높은 경로를 반환하는 함수를 만들었다.
export const getFirstAccessiblePath = (permissions: Permission[]) => { const accessiblePage = pages.find((page) => permissions.includes(page.requiredPermission), ); return accessiblePage?.path ?? "/"; };
pages는 접근 우선순위 순서로 정렬되어있도록 하였다.
사용자의 권한과 처음 일치하는 페이지를 반환하고, 관리자 권한이 하나도 없다면 일반 사용자 화면으로 이동시켰다.
그외 고민들..
1. Middleware가 있는데 화면 훅은 왜 다시 필요할까?
Middleware에서 이미 접근을 막는다면 화면 권한 훅은 중복처럼 보일 수 있다. 하지만 두 코드는 실행 시점과 역할이 달르다고 생각했다.
Middleware는 새로운 요청이 들어올 때, 렌더링을 시작해도 되는지 판정한다. 반면 화면 훅은 사용자가 이미 화면에 머무르는 동안의 권한 상태를 다룬다.
예를 들어 화면을 열어둔 상태에서 권한이 변경되면 클라이언트가 다시 조회한 권한 목록이 이전과 다를 수 있고, 하나의 페이지 안에서도 권한에 따라 일부 버튼이나 기능만 숨겨야 하는 경우가 있었다.
2. 매번 권한을 조회하는 것은 낭비가 아닐까?
Middleware는 사용자가 관리자 경로에 접근할 때마다 서버에 현재 권한 목록을 요청했다.
캐싱을 적용하면 호출 횟수를 줄일 수 있지만, 권한이 변경되거나 회수됐을 때 캐시가 만료되기 전까지 사용자가 이전 권한으로 접근할 수 있다는 문제가 생긴다.
- 권한 조회 호출을 줄이는 것?
- 권한 변경을 즉시 반영하는 것?
관리자 권한은 담당자 변경이나 역할 이동에 따라 즉시 회수되어야 할 수 있고, 관리자 페이지의 트래픽은 일반 사용자 페이지보다 적다.
그래서 후자를 택했다. 성능을 고려하지 않은 것이 아니라, 권한 회수의 즉시성을 더 높은 우선순위에 둔 결정이었다.
프론트 세 곳이나 보안을 검증하지만, 그게 다이면 안된다!
최종적으로 프론트에서는 세 곳에서 권한을 확인하는 구조가 되었다.
- Middleware는 렌더링이 시작되기 전에 잘못된 진입을 차단한다.
- 사이드 메뉴는 접근할 수 없는 메뉴를 노출하지 않는다.
- 화면 권한 훅은 이미 열린 화면과 화면 내부 기능을 제어한다.
확인하는 시점과 역할은 달랐지만, 세 곳 모두 서버에서 내려준 동일한 권한 목록을 판단 기준으로 사용했다. 각 지점이 서로 다른 권한 규칙을 가지면 정책이 바뀔 때마다 세 구현을 따로 수정해야 하기 때문이다.
다만 프론트에서 권한을 여러 번 확인한다고 해서 데이터까지 안전해지는 것은 아니라 생각한다.
Middleware와 메뉴, 화면 훅은 렌더링 전 접근 차단과 UI 노출 제어를 담당한다. 실제 데이터를 지키는 최종 보안 경계는 API를 가진 백엔드다.
사용자가 프론트의 제한을 우회해 API에 직접 요청하더라도, 백엔드는 권한을 다시 확인하고 권한이 없다면 데이터를 반환하지 않아야 한다. Next.js 공식 문서도 Middleware의 인증 검사를 빠른 낙관적 검사로 설명하고, 실제 권한 검증은 데이터에 가까운 위치에서 수행하라고 안내한다.
종종 프론트에서 모두 처리해달라는 요청을 받곤 하는데... 화면상에 안보이는게 다가 아니라 생각한다.
입력값 검증이나 잘못된 요청 차단, 에러 처리도 프론트와 백엔드 양쪽에서 필요한 경우가 많다. 프론트는 사용자가 잘못된 요청을 보내지 않도록 안내하고 즉각적인 피드백을 제공하며, 백엔드는 프론트를 거치지 않은 요청까지 포함해 실제 데이터와 시스템을 보호해야 한다.
결국 프론트의 검증은 사용자 경험을 위한 것이고, 백엔드의 검증은 시스템의 신뢰성을 지키기 위한 것이 아닐까!
Reference
- Next.js 공식 가이드 — Authentication
Middleware의 빠른 검증과 데이터 계층의 실제 권한 검증에 관한 설명