네트워킹 서비스 Next.js & 로그인 유지 구조 구축
Next.js 기반 중앙대학교 동문회 네트워킹 웹 서비스 Causw 의 Front-end 개발 프로젝트 설계를 수행하였다.
SSR과 CSR 환경을 동시에 고려한 인증 구조를 설계하고, 서비스 전반에 공통 적용되는 레이아웃 및 상태 관리 구조를 구축하였다.
1. App Structure 및 Layout 설계
인증 상태를 기준으로 /auth(로그인 이전)와 /causw(로그인 이후) 영역을 분리하고, Navigation Bar와 Side Bar를 layout 단에서 통합 구성하여 페이지 개발 시 UI 간섭을 제거하였다.
2. SSR,CSR 통합 인증/인가 시스템 구축
SSR과 CSR 환경에서 모두 동작하도록 토큰 저장 및 사용 구조를 분리 설계하였다.
3. API 에러 처리 구조 구축
API 에러를 error code 기준으로 분류하고, SSR과 CSR 환경에서 일관된 방식으로 처리할 수 있도록 구조를 구성하였다.
렌더링 환경에 따라 인증 상태가 달라지고 동일 세션이 유지되지 않는 문제가 발생했다.
인증 토큰 저장 방식이 localStorage 및 sessionStorage에 의존하도록 설계되어 SSR 환경에서는 토큰 접근 자체가 불가능한 구조였다.
access token은 메모리, refresh token은 브라우저 저장소에 분산되어 있었고 SSR에서는 해당 저장소에 접근할 수 없어 동일한 토큰 세트를 공유하지 못했다.
결과적으로 CSR과 SSR 간 토큰 소스가 달라지면서 인증 상태 동기화가 불가능한 구조였다.
API 호출 구조가 axios 기반 CSR Service 계층 하나로만 구성되어 있었고 SSR 환경을 위한 fetch 기반 계층이 존재하지 않았다. 이로 인해 인증 헤더 주입 및 에러 처리 흐름이 환경 별로 분리되지 못했다.
SSR 요청 시 사용할 수 있는 공통 저장소(cookies)가 없었기 때문에 최초 렌더링 시 인증 정보를 복원할 수 없었고 매 요청이 비인증 상태로 처리되었다.
이 구조에서는 새로고침 또는 서버 렌더링 시 세션 지속성이 유지될 수 없다.
CSR과 SSR 각각의 실행 환경에서 사용할 수 있는 저장소와 API 계층을 분리하고 동일한 인증 데이터를 두 환경에서 모두 사용할 수 있도록 구조를 재설계했다.
1.1. 기존 구조
1.2. 변경된 구조
SSR에서는 cookies를 사용하여 토큰을 저장하고 fetch 요청 시 해당 값을 header로 구성하도록 구현했다.
또한 인증 헤더를 구성하는 공통 함수로 SSR API 요청 시 동일한 방식으로 토큰을 주입하도록 처리했다.
1.3. 구조 변화 요약
로그인 시 access token과 refresh token을 CSR 저장소와 SSR cookies에 동시에 저장하도록 변경했다.
이 구조를 통해 CSR과 SSR 모두 동일한 인증 데이터를 기반으로 요청을 수행할 수 있도록 만들었다.
CSR에서는 기존 방식대로 axios header와 localStorage를 사용하고 SSR에서는 cookies 기반으로 동일 토큰을 참조하도록 구성했다.
SSR 환경에서는 fetch 요청 시마다 cookies에서 access token을 읽어 header에 주입하는 방식으로 인증 흐름을 구성했다.
이 구조를 통해 서버 렌더링 시에도 인증이 필요한 API 요청이 정상적으로 수행되도록 보장했다.
또한 refresh token 기반 세션 유지 로직을 SSR에서도 동작할 수 있도록 구조를 통합했다.
CSR과 SSR 환경 모두에서 동일한 인증 상태를 유지할 수 있는 구조가 확보되었고 서버 렌더링 시에도 인증 기반 데이터 요청이 정상적으로 수행되도록 개선되었다.
새로고침이나 재접속 시에도 cookies에 저장된 refresh token을 기반으로 인증 상태를 유지할 수 있게 되었고 사용자 세션 단절 문제가 제거되었다.
API 호출 계층이 실행 환경 기준으로 분리되면서 인증 처리 로직의 중복이 제거되고 유지보수성이 개선되었다.
이 개선의 핵심은 실행 환경에 따라 인증 저장소와 API 계층을 분리하고 동일한 토큰을 양쪽 환경에서 동시에 사용할 수 있도록 구조를 재설계한 데 있다.
CSR 중심 인증 구조에서 SSR까지 확장 가능한 이중 저장 및 이원화된 API 계층 구조로 변경하였다
결과적으로 토큰 만료 상황에서 요청이 자동으로 복구되지 않고 사용자 흐름이 끊기는 문제가 발생했다.
SSR은 API 에러 발생 시 throw를 통해 error boundary로 전달되어 화면 단위로 처리되고, CSR은 axios 요청이 reject되면서 요청 단위에서 종료되는 구조였다.
동일한 에러 코드라도 처리 진입 지점이 달라 공통된 복구 로직을 실행할 수 없는 구조였다.
Access token 만료 시 refresh token으로 재발급 후 기존 요청을 재실행하는 흐름이 정의되지 않았다.
CSR에서는 interceptor가 없어 요청 실패 후 재시도 경로가 없었고, SSR에서는 error.tsx에서 렌더링만 수행되고 기존 API 호출을 다시 실행하지 않는 구조였다.
토큰 만료, refresh token 만료, 권한 없음 등 인증 관련 에러가 존재했지만 이를 하나의 기준으로 분기하는 구조가 없었다.
각 API 또는 화면에서 개별적으로 처리되면서 동일한 에러 코드에 대해 일관된 대응이 이루어지지 않았다.
에러 발생 시 “복구”가 아니라 “중단”을 기준으로 처리되도록 설계되어 토큰 만료 상황을 정상 흐름으로 다루지 못했다.
이로 인해 인증 문제 발생 시 재발급 → 재요청으로 이어지는 연속 흐름이 아니라 단절된 상태로 처리되었다.
에러 처리 기준을 error code로 통일하고 SSR과 CSR 각각에서 동일한 기준으로 동작하도록 구조를 재설계했다.
1.1. 기존 구조
1.2. 변경된 구조
1.3. 구조 변화 요약
에러 처리 기준을 실행 환경이 아닌 error code로 통일하고 SSR과 CSR에서 동일한 분기 로직을 수행하도록 변경했다.
Access token 만료 시 refresh token을 활용하여 재발급 후 기존 요청을 다시 실행하도록 흐름을 구성했다.
CSR에서는 interceptor 내부에서 요청을 가로채고 토큰 갱신 후 기존 config로 재요청을 수행하도록 구현했다.
SSR에서는 error.tsx 내부에서 updateAccess 실행 후 reset을 호출하여 동일 요청을 다시 수행하도록 구성했다.
이 구조를 통해 토큰 갱신과 요청 재시도를 하나의 흐름으로 연결했다.
Refresh token이 만료된 경우에는 복구가 불가능하므로 모든 토큰을 제거하고 로그인 페이지로 이동하도록 처리했다.
권한이 없는 경우에는 no-permission 페이지로 이동하도록 분기 처리했다.
SSR과 CSR 모두 동일한 조건으로 분기하도록 구성하여 처리 결과를 통일했다.
또한 처리 가능한 에러 코드에 대해서는 LoadingComponent를 반환하여 사용자에게 중간 상태를 노출하고 비동기 복구 흐름을 유지했다.
이 개선의 핵심은 토큰 에러를 단순 실패가 아닌 복구 가능한 상태로 정의하고 요청 흐름 안에서 처리하도록 구조를 변경한 것에 있다.
요청 실패 후 종료되는 구조에서 토큰 갱신 후 동일 요청을 복구하는 구조로 변경
eexport const setRscToken = async (access: string, refresh: string | false) => {
cookies().set(storageAccessKey, access);
if (refresh) cookies().set(storageRefreshKey, refresh);
};export const setRscHeader = async (): Promise<{ Authorization: string }> => {
const token = await getRscAccess();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return { Authorization: "" };
};const headers = await setRscHeader();
const response = await fetch(URI, { headers });export const noAccessTokenCode = ["4105", "4110"];
export const noRefreshTokenCode = ["4000", "4102", "4103"];
export const noPermissionCode = ["4107"];if (noAccessTokenCode.includes(errorCode)) {
const refresh = getRccRefresh();
const accessToken = await updateAccess(refresh);
config.headers["Authorization"] = `Bearer ${accessToken}`;
return API.request(config);
}await updateAccess(refresh);
setTimeout(() => {
reset();
}, 1000);const handleNoRefresh = async () => {
await signout();
location.href = "auth/signin";
};