
제가 작성한 middleware를 통해 데스크톱, 모바일 분리
이전에 진행했던 프로젝트에서는 middleware를 활용해서 모바일과 데스크톱을 분리한 경험이 있습니다. 그 경험을 통해 Next.js에서의 서버 기반 라우팅 처리에 익숙해졌고, 사용자 디바이스에 따라 리소스를 나눠 쓰며 성능도 개선할 수 있었죠.
이번에는 그 연장선에서, 유저의 권한(Role)에 따라 접근 가능한 경로를 분기하는 작업을 진행해봤습니다.
보통 Client-Side Rendering 기반의 React 프로젝트에서는 router.tsx 혹은 useEffect 안에서 localStorage에 저장된 토큰을 확인해서 라우팅을 제어하곤 하죠.
하지만 Next.js는 Server-Side Rendering을 기본으로 사용하는 프레임워크입니다.
여기서 중요한 포인트가 하나 있습니다.
❌ 서버 사이드에서는 localStorage를 쓸 수 없습니다.
✅ 서버는 클라이언트의 쿠키(cookie)만 읽을 수 있습니다.
즉, SSR 환경에서는 유저 권한이나 인증 정보를 localStorage가 아니라 쿠키에 저장해야만 미들웨어나 getServerSideProps 같은 서버 측 로직에서 접근할 수 있습니다.
그래서 저는 이번에 권한 분기를 처리할 때, 다음과 같은 진행을 하였습니다.
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value
const userRole = request.cookies.get('userRole')?.value
// Admin 관리
if (!(token && userRole === 'ADMIN')) {
// 토큰 및 userRole 검사
return NextResponse.redirect(new URL('/', request.url))
}
// 요청 계속 진행
return NextResponse.next()
}
export const config = {
matcher: ['/admin((?!/login|/_next|/api|/favicon.ico).*)'],
}
이 방식의 가장 큰 장점은 페이지가 렌더링되기도 전에 서버에서 권한을 검사할 수 있다는 점입니다.
🔁 CSR 방식: useEffect를 통한 검사
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/') // 로그인 안 되어 있으면 리디렉션
}
}, [])
CSR 방식 흐름
- 먼저 /admin 페이지가 렌더링
- 그 다음에 useEffect()가 실행
- 조건에 따라 리디렉션되는 구조예요.
이 방식의 문제는 관리자 페이지가 잠깐 보였다가 바로 홈으로 튕기는 "Flickering" 현상이 발생한다는 점입니다.
또한 이 과정에서 /admin 페이지의 JS 번들, 초기 리소스가 이미 다운로드된 이후이기 때문에 네트워크 낭비도 발생합니다.
SSR + middleware 방식 흐름
- 사용자가 /admin 경로로 접근 시도하면
- 서버는 쿠키에 담긴 token, userRole을 즉시 확인하고
- 조건에 부합하지 않으면 페이지를 렌더링하지 않고 곧바로 홈으로 리다이렉트합니다.
즉, 클라이언트는 /admin 페이지를 아예 다운로드하지도, 렌더링하지도 않습니다.
middleware.ts를 통해 쿠키에 담긴 token의 존재 여부와 userRole 값을 기반으로 1차 필터링을 수행할 수 있습니다.
하지만 이 방식은 쿠키가 클라이언트에 의해 조작될 수 있다는 점에서 완전히 안전하다고 보기 어렵습니다.
예를 들어, 개발자 도구를 통해 사용자가 userRole=ADMIN 같은 값을 임의로 삽입할 수도 있습니다.
그렇다면 이런 상황에서 어떻게 방어해야 할까요?
저는 이를 보완하기 위해, 서버 측 렌더링 단계에서 백엔드 API를 호출하여 토큰의 실제 유효성을 확인하는 "2차 검증"을 함께 적용했습니다
export async function getServerSideProps(context) {
const token = context.req.cookies.token
const res = await fetch('백엔드 API', {
headers: { Authorization: `Bearer ${token}` }
})
if (res.status !== 200) {
return {
redirect: {
destination: '/',
permanent: false,
},
}
}
return { props: {} }
}