localStorage에서 Cookie 기반 인증 시스템으로 전환하기


웹 애플리케이션에서 사용자 인증은 가장 기본적이면서도 중요한 요소 중 하나입니다. 또한 프론트엔드 개발자에게는 서버에서 생성한 JWT 토큰을 클라이언트에서 어떻게 관리하느냐가 중요한 요소입니다. 클라이언트에도 여러 저장소가 있지만, 그 중 localStorage는 개발 난이도가 쉽고 구현하기 간편하다는 장점이 있습니다.

저희 프로젝트는 Vue.js에서 Next.js로 전환되었는데, 기존 Vue.js로 된 프로젝트에서 localStorage로 토큰을 관리하고 있었기에 Next.js로 전환할 때에도 이 로직을 그대로 사용하게 되었습니다.

하지만 서비스를 운영하면서 localStorage 기반 인증 시스템으로 인한 여러 문제점들이 드러나기 시작했습니다. 결국 이번에 Cookie 기반 인증 시스템으로 전환을 진행했고, 그 이유와 과정에서 얻은 경험을 공유해보려고 합니다.

localStorage에서 토큰을 관리할 때의 문제점들

서버 컴포넌트에서 인증 상태 활용 불가

localStorage 기반 인증의 가장 큰 문제는 서버 사이드 렌더링(SSR) 시 인증 상태를 알 수 없다는 점이었습니다. localStorage는 브라우저에서만 접근 가능하기 때문에, 서버에서는 사용자가 로그인했는지 알 수 없었습니다.

이로 인해 커뮤니티 프로필 같은 개인화 콘텐츠를 렌더링할 때 다음과 같은 문제가 발생했습니다.

  • 초기 렌더링 - 서버에서 로그아웃 상태로 렌더링
  • 클라이언트 하이드레이션 - localStorage 확인 후 해당 토큰으로 API 호출하여 로그인 상태로 업데이트
  • 결과 - 로그아웃 → 로그인 UI로 변경되는 깜빡임 현상
  • 위와 같은 상황으로 사용자 입장에서는 페이지가 로딩될 때마다 잠깐 로그아웃 화면이 보이다가 로그인 화면으로 바뀌는 불편함을 겪어야 했습니다.

    또한 서버 컴포넌트에서는 localStorage에 접근할 수 없기 때문에, 인증이 필요한 API를 호출할 수 없었습니다. 모든 개인화된 데이터 페칭을 클라이언트 컴포넌트에서 해야 했습니다.

    이로 인해 서버 컴포넌트의 장점(성능, SEO 등)을 살릴 수 있는 상황에서 서버 컴포넌트를 활용하지 못하는 경우가 생겼습니다. 개발자 입장에서 개인화된 콘텐츠가 포함되면 클라이언트 컴포넌트만을 사용해야하는 제약사항이 생긴 것입니다.

    매번 토큰 유효성 검증을 위한 API 호출

    저희 프로젝트는 Next.js 그룹 라우팅을 활용해 (auth)와 (unAuth) 세그먼트를 분리하고, 각각의 template.tsx에서 checkAuth 함수를 실행하여 로그인 상태를 확인하는 구조였습니다. layout.tsxtemplate.tsx 의 차이점은 Next.js 공식 문서 내용에서 확인할 수 있습니다.

    // (auth)/template.tsx
    useEffect(() => {
    checkAuth();
    }, [])

    이 구조에서 localStorage 기반 인증을 사용하면, 페이지를 이동할 때마다 그리고 인증이 필요한 액션을 할 때마다 토큰 유효성을 검증하기 위해 API를 호출해야 했습니다.

    일일 평균 약 45,691회(3개월 로그를 분석한 수치)의 토큰 검증 API 호출이 발생하고 있었고, 이는 불필요한 서버 부하와 네트워크 트래픽을 야기했습니다.

    보안상의 취약점

    localStorage는 JavaScript를 통해 쉽게 접근할 수 있어 XSS 공격 시 토큰 탈취 위험이 높습니다. 악성 스크립트가 document.localStorage로 토큰에 직접 접근이 가능합니다.

    Cookie 기반 인증 시스템으로 전환

    이러한 문제들을 해결하기 위해 Cookie 기반 인증 시스템으로 전환하기로 결정했습니다.

    httpOnly 쿠키로 안전한 토큰 관리

    가장 중요한 변화는 토큰을 httpOnly 쿠키에 저장하는 것이었습니다. Next.js의 서버 액션을 활용해 쿠키에 토큰을 세팅하고 가져오는 함수들을 구현했습니다.

    'use server';
    import { cookies } from 'next/headers';
    export async function setTokenToCookie({
    accessToken,
    refreshToken,
    accessTokenMaxAge = 60 * 60 * 24,
    }: {
    accessToken?: string;
    refreshToken?: string;
    accessTokenMaxAge?: number;
    }) {
    try {
    if (accessToken) {
    cookies().set({
    name: 'mockAccessTokenName',
    value: accessToken,
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: accessTokenMaxAge,
    });
    }
    if (refreshToken) {
    cookies().set({
    name: 'mockRefreshTokenName',
    value: refreshToken,
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 90,
    });
    }
    } catch (error) {
    console.error(error);
    }
    }
    export async function getAccessTokenFromCookie(): Promise<string | null> {
    try {
    const accessToken = cookies().get('mockAccessTokenName')?.value;
    return accessToken || null;
    } catch (error) {
    console.error(error);
    return null;
    }
    }
    export async function removeTokenFromCookie() {
    try {
    cookies().delete('mockAccessTokenName');
    cookies().delete('mockRefreshTokenName');
    } catch (error) {
    console.error(error);
    }
    }

    핵심 보안 설정들

  • httpOnly: true - 클라이언트에서 document.cookie로 접근 불가
  • secure: true - HTTPS 연결에서만 전송
  • sameSite: 'lax' - CSRF 공격 방지
  • maxAge - 토큰 자동 만료 (accessToken: 1일, refreshToken: 90일)
  • checkAuth 함수 개선

    기존 checkAuth 함수를 쿠키 기반으로 동작하도록 수정했습니다. 핵심은 쿠키에 저장된 토큰을 우선적으로 확인하여 불필요한 API 호출을 줄이는 것이었습니다.

    const checkAuth = async ({
    onSuccess,
    onError,
    }: {
    onSuccess?: VoidFunction;
    onError?: VoidFunction;
    } = {}) => {
    const cookieAccessToken = await getAccessTokenFromCookie();
    const cookieRefreshToken = await getRefreshTokenFromCookie();
    // 1. accessToken이 쿠키에 있으면 API 호출 없이 바로 인증 완료 (24시간 이내)
    if (cookieAccessToken && cookieRefreshToken) {
    updateIsValidTokenTrue();
    onSuccess && onSuccess();
    return;
    }
    // 2. refreshToken만 있으면 한 번만 검증 후 새 accessToken 발급 (90일 이내)
    if (cookieRefreshToken) {
    verifyTokenMutate(cookieRefreshToken, {
    onSuccess: ({ data: { accessToken, refreshToken }, meta: { expiresIn } }) => {
    onSuccessAuthorize({
    accessToken,
    refreshToken,
    accessTokenMaxAge: expiresIn,
    });
    onSuccess && onSuccess();
    },
    onError: () => {
    // 3. refreshToken이 만료된 경우 로그아웃 처리 (90일 이후)
    updateToUnAuth();
    onError && onError();
    },
    });
    return;
    }
    // 4. 토큰이 없으면 로그아웃 처리
    updateToUnAuth();
    onError && onError();
    };

    핵심 개선 사항

  • 쿠키 우선 확인 - cookieAccessToken이 존재하면 API 호출 없이 즉시 인증 완료
  • 조건부 토큰 갱신 - refreshToken만 있을 때 검증 API 호출
  • 자동 만료 관리 - 쿠키의 maxAge 설정으로 브라우저가 토큰 유효성 자동 관리
  • 개선된 점들

    API 호출 88% 감소

    가장 눈에 띄는 개선 효과는 API 호출 수의 극적인 감소였습니다. 수치 측정은 서버 로그를 참고했습니다.

  • Before - 일일 평균 45,691회의 토큰 검증 API 호출 (개선 전 3개월 평균)
  • After - 일일 평균 5,647회의 토큰 검증 API 호출 (개선 후 1개월 평균)
  • 개선율 - 88% 감소
  • 이는 쿠키의 maxAge 설정을 통해 토큰 유효성을 브라우저 차원에서 관리할 수 있게 되었기 때문입니다. 토큰이 만료되지 않은 상태라면 굳이 서버에 검증 요청을 보낼 필요가 없어졌습니다.

    서버 컴포넌트에서 개인화 콘텐츠 렌더링 가능

    Cookie 기반으로 전환하면서 서버 컴포넌트에서도 인증 상태를 확인할 수 있게 되었습니다.

    // 서버 컴포넌트에서 인증 토큰 사용 예시 코드

    이로 인해 다음과 같은 개선 효과를 얻었습니다.

  • 깜빡임 현상 완전 해결 - 서버에서 올바른 상태로 렌더링
  • 초기 로딩 속도 향상 - 서버에서 데이터 페칭 완료 후 전송
  • SEO 개선 - 개인화된 콘텐츠도 서버에서 렌더링되어 검색 엔진이 인덱싱 가능
  • 향상된 보안

    localStorage에서 Cookie로 전환하면서 여러 보안 이점을 얻었습니다.

    XSS 공격 방어 강화

  • httpOnly: true 설정으로 클라이언트 스크립트에서 토큰 접근 불가
  • 악성 스크립트가 주입되어도 토큰을 탈취할 수 없음
  • 자동 토큰 만료 관리

  • maxAge 설정을 통한 토큰 수명 관리
  • 별도의 만료 로직 구현 없이 브라우저에서 자동 처리
  • 마치며

    결국 localStorage 기반 토큰 관리는 저희에게 하나의 레거시였습니다. 앞서 언급한 여러 단점들이 있었지만 그래도 그동안 서비스를 안정적으로 운영하였고, 실제로 잘 동작했다는 점에서 충분히 역할을 다했다고 생각합니다. 그래서 이러한 레거시를 무조건적으로 미워할 필요는 없다고 생각합니다.

    하지만 레거시를 개선하는 리소스와 개선으로 인해 얻을 수 있는 이점을 비교했을 때, 개선으로 얻을 수 있는 것이 더 많다면 이렇게 개선을 시도해보는 것이 좋다고 생각합니다. 물론 이런 개선 작업은 팀의 공감이 있어야 가능한 일이기도 합니다.

    코드는 계속해서 변화하는 것이니 제가 개선한 코드도 언젠가는 legacy의 말 뜻 그대로 과거의 유산으로 여겨지고, 사라지게 되지 않을까 싶습니다. 그래도 제가 고민했던 흔적들이 히스토리로 남는다고 생각하니 늘 책임감을 가지고 더 좋은 코드를 남기려고 노력하는 것 같습니다.


    참고 자료:

  • Next.js Cookies 문서
  • MDN HTTP Cookies