CLOVA Nursing AI Agent

인증 · 미들웨어

대표 글

토큰을 클라이언트에서 걷어내다 - HttpOnly 위임·단일 refresh·미들웨어 RBAC

병원에서 쓰는 헬스케어 서비스라 인증·권한이 유독 민감했다. 초기 구조를 들여다보다 세 가지가 한꺼번에 걸렸다 - 클라이언트가 JWT를 직접 쥐고 있었고, 토큰이 만료되는 순간 여러 요청이 제각각 /refresh를 쳐서 멀쩡한 사용자를 로그아웃시켰고, 환자·간호사·관리자 권한 분기가 페이지마다 흩어져 있었다. 세 문제는 뿌리가 하나였다 - 인증 판단을 여기저기서 따로 하고 있었다. 그래서 토큰은 백엔드에 위임하고, refresh는 하나로 묶고, 권한은 미들웨어 한 곳으로 모으는 방향으로 풀었다. 설계대로 한 번에 되진 않았다 - 중간에 페이지가 500으로 무너지는 삽질을 한 번 거쳤다.

출발 - 클라이언트가 토큰을 직접 쥐고 있었다

초기 구조는 서버에서 JWT를 응답 body로 받아, 클라이언트가 직접 쿠키를 set/delete하고 Authorization 헤더에 실어 보냈다. 문제는 둘이었다.

  • 보안: 토큰이 JS가 접근 가능한 곳에 있었다. XSS가 터지면 그대로 긁어갈 수 있고, 개발자 도구로도 토큰이 맨눈에 보였다. 병원 데이터를 다루는 서비스에서 이건 그냥 두면 안 되는 결함이었다.
  • 판단의 분산: "로그인됐나"를 클라이언트가 헤더를 세팅하며 판단하고, 미들웨어가 또 판단했다. 기준이 흩어지면 언젠가 서로 어긋난다.

원칙을 하나로 정하고 시작했다. 토큰은 클라이언트가 접근할 수 없어야 하고, 인증 판단은 한 곳에서만 한다.

토큰을 백엔드로 넘기다 - HttpOnly 쿠키

클라이언트의 토큰 set/delete를 전부 걷어내고, 토큰 발급·저장·갱신을 백엔드가 HttpOnly 쿠키로 관리하도록 위임했다. JS에서 쿠키에 손댈 수 없으니 XSS 토큰 탈취 표면 자체가 사라진다. 클라이언트는 더 이상 토큰을 "알" 필요가 없고, 요청을 보내면 브라우저가 쿠키를 알아서 실어 보낸다.

항목BeforeAfter
토큰 저장클라이언트가 set/delete백엔드가 HttpOnly 쿠키로 관리
토큰 전송JS가 Authorization 헤더에 주입브라우저가 쿠키 자동 전송
JS 접근가능 (개발자 도구·XSS 노출)불가능
인증 판단클라이언트 + 미들웨어미들웨어·백엔드로 일원화

쿠키 속성을 하나씩 따졌다

토큰을 쿠키로 위임하는 순간, 쿠키 속성이 곧 보안 경계가 된다. 그래서 하나씩 근거를 붙여 정했다.

  • HttpOnly: true - JS에서 쿠키 접근을 막는다. 이게 XSS 토큰 탈취를 막는 핵심이다.
  • Secure: true - HTTPS에서만 쿠키를 전송한다. 단 로컬 개발(localhost)은 Chrome 89+가 예외로 허용해줘 개발에 지장이 없었다.
  • SameSite: Lax - 사이트 이동 시엔 쿠키를 포함하되, iframe·JS 접근은 제한한다. CSRF 표면을 좁히면서도 정상적인 페이지 이동은 막지 않는 절충점이었다.
  • credentials: include 금지 - 이건 의도적으로 썼다. Access-Control-Allow-Origin: *에 준하는 보안 위험으로 분류돼, 사내 보안검수에서 막힐 수 있는 옵션이었다.

여기서 한 가지 의문이 남는다. credentials: include도 없이 로컬(localhost:3000)과 배포된 백엔드가 어떻게 쿠키를 주고받았을까. 답은 Next.js 프록시였다. 프록시가 중개자로 서서 프론트·백엔드 도메인 차이를 가려주니, 브라우저 입장에선 같은 도메인 요청처럼 처리돼 쿠키가 자연스럽게 실렸다. 크로스 오리진 완화 옵션을 켜지 않고도 쿠키가 오갈 수 있었던 이유다.

동시 요청이 로그인을 풀었다 - 단일 refresh

토큰을 숨기고 나니 다음 문제가 드러났다. Access 토큰이 만료된 바로 그 순간 여러 요청이 동시에 나가면, 각 요청이 401을 받고 제각각 /auth/refresh를 호출했다. 백엔드의 Redis whitelist 로직과 충돌해, 멀쩡히 로그인돼 있던 사용자가 로그아웃되는 일이 생겼다.

처방은 single-flight였다. 첫 401에만 refresh Promise를 하나 만들고, 이후 동시 요청은 새로 refresh를 치는 게 아니라 그 하나의 Promise를 await하도록 묶었다. Promise가 끝나면 변수를 다시 비운다.

// src/shared/api/instance.ts (요지)
let refreshTokenPromise: Promise<unknown> | null = null;
 
// afterResponse 훅
async (request, options, response) => {
  if (response.ok || response.url.includes('/auth') || response.status !== 401) {
    return response;
  }
 
  // 첫 401만 refresh Promise를 만들고, 나머지는 이 Promise를 공유한다
  if (!refreshTokenPromise) {
    refreshTokenPromise = ky
      .post(`${BASE_URL}/auth/refresh`, {
        headers: { 'Content-Type': 'application/json' },
      })
      .finally(() => {
        refreshTokenPromise = null;
      });
  }
 
  await refreshTokenPromise; // 동시 요청은 여기서 하나의 refresh를 기다린다
  // ...refresh 후 원 요청 재시도
}

포인트는 if (!refreshTokenPromise) 가드다. refresh가 진행 중이면 새로 만들지 않고 진행 중인 Promise를 그대로 쓴다. N개의 요청이 401을 받아도 /refresh는 딱 한 번만 나간다. .finally로 Promise를 비우니 다음 만료 때 다시 정상 동작한다. /auth 경로 자체와 401이 아닌 응답은 훅 초입에서 바로 통과시켜, refresh 로직이 로그인·리프레시 요청 자신을 물고 늘어지지 않게 했다.

중복 로그인이 페이지를 500으로 무너뜨렸다

여기서 삽질을 하나 했다. 같은 계정으로 다른 브라우저에서 로그인하면(중복 로그인), 기존 브라우저의 토큰이 무효화된다. 그 상태로 기존 브라우저가 요청을 보내자 401이 뜨고 이어서 페이지 로드가 500으로 터졌다. 쿠키를 손으로 지우기 전까지 화면의 에러가 사라지지 않았다.

원인은 미들웨어에 있었다. 미들웨어가 /users/me로 사용자 정보를 읽어 권한을 판단하는데, 그 usersApi.getUsersMe 호출을 try-catch 없이 그냥 부르고 있었다.

중복 로그인 시 미들웨어 getUsersMe의 401이 예외로 튀어 페이지가 Server Error(500)로 무너진 화면
중복 로그인 시 실제 화면 - src/middleware.tsgetUsersMe가 받은 401이 예외로 튀어 페이지가 Server Error로 무너졌다. 쿠키도 지워지지 않아 새로고침 전까지 에러가 남았다.

유효하지 않은 토큰에 대한 401이 정상 처리되지 못하고 그대로 예외로 튀어, 페이지 렌더링까지 무너진 것이다. 게다가 예외를 잡더라도 당시 코드는 /로 리다이렉트하고 있어서, 다시 미들웨어를 태우는 꼬리물기의 소지가 있었다.

// 변경 전 - getUsersMe가 catch 밖에 있어 401이 예외로 튐
const data = await usersApi.getUsersMe(kyInstance);
// ...
} catch (error) {
  return NextResponse.redirect(new URL('/', request.url)); // 다시 미들웨어로
}
// 변경 후 - getUsersMe를 try 안으로 넣고, 실패 시 요청을 그대로 흘려보냄
try {
  const data = await usersApi.getUsersMe(kyInstance);
 
  if (pathname === '/' && refreshToken) {
    const redirectUrl = match(data.role)
      .with('환자', () => '/patient')
      // ...역할별 분기
      .otherwise(() => Promise.resolve(response));
  }
} catch (error) {
  return NextResponse.next(); // 리다이렉트 대신 통과 → 500·꼬리물기 제거
}

getUsersMetry 안으로 옮기고, 실패하면 /로 되던지지 않고 NextResponse.next()로 요청을 그대로 흘려보냈다. 결과는 명확했다 - 중복 로그인 시 기존 브라우저는 500 대신 자동 로그아웃 처리 후 해당 역할의 로그인 페이지로 리다이렉트됐다. 401은 "예외"가 아니라 "만료됐다는 정상적인 신호"로 다뤄야 한다는 걸 여기서 배웠다.

미들웨어에도 같은 방어를 줬다

클라이언트의 kyInstance에는 앞서 만든 401→refresh 로직이 있었지만, 미들웨어를 통한 페이지 접근에는 그 로직이 없었다. 그래서 페이지 진입 시점에 토큰이 만료돼 있으면, API 호출과 달리 미들웨어는 갱신 없이 막아버렸다. 미들웨어의 ky 인스턴스에도 동일한 afterResponse 훅을 붙여 주기로 했다 - 클라이언트에서 쓰던 single-flight 코드를 그대로 재사용했다.

// src/middleware.ts (요지)
let refreshTokenPromise: Promise<unknown> | null = null;
 
const kyInstance = ky.create({
  prefixUrl: getPrefixUrl(request),
  headers: { 'Content-Type': 'application/json', Cookie: cookies().toString() },
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        if (response.ok || response.url.includes('/auth') || response.status !== 401) {
          return response;
        }
 
        if (!refreshTokenPromise) {
          refreshTokenPromise = ky
            .post(`${BASE_URL}/auth/refresh`, { headers: { 'Content-Type': 'application/json' } })
            .finally(() => { refreshTokenPromise = null; });
        }
 
        try {
          await refreshTokenPromise;
          const url = request.url.startsWith(options.prefixUrl)
            ? request.url.replace(options.prefixUrl, '')
            : request.url;
          // 재시도할 땐 afterResponse를 비워 무한 재귀를 막는다
          return await ky(url, { ...options, prefixUrl: undefined, hooks: { afterResponse: undefined } });
        } catch (e) {
          redirectToLogin();
          return response;
        }
      },
    ],
  },
});

두 가지를 신경 썼다. 하나는 재시도 요청의 afterResponseundefined로 비운 것 - 재시도가 또 401이면 훅이 다시 돌아 무한 재귀에 빠진다. 하나는 refresh 실패 시 redirectToLogin() - 갱신조차 안 되면 살릴 수 없으니 로그인으로 보낸다. 곁들여 401 판별 조건문도 정리했다.

BeforeAfter
if (response.ok) return response;
if (response.url.includes('/auth')) return response;if (response.ok || response.url.includes('/auth') || response.status !== 401) {
if (response.status !== 401) return response; return response; }

세 줄로 흩어진 early-return을 한 조건으로 합쳐, "통과시킬 응답"의 정의를 한눈에 보이게 했다.

권한을 한 곳으로 - fetch 기반 RBAC

마지막은 RBAC였다. 환자·간호사·관리자 권한 분기가 페이지마다 흩어져 있어, 같은 판단을 여러 군데서 반복하고 있었다. 이걸 미들웨어 한 곳으로 모았다.

미들웨어가 /users/me를 fetch해 사용자 Role을 읽고, ts-patternmatch(data.role)로 역할에 맞는 경로로 보낸다 - 환자면 /patient, 그 외 역할도 각자의 도메인으로. 로그인 페이지도 역할별로 갈라져 있어(/admin/login, /patient/login), 이 역할→도메인 대응이 인증의 뼈대였다.

문제는 이 판단에 쓰이는 Loader 함수들의 위치였다. adminPageLoader·patientPageLoader· superuserPageLoader가 각각 src/app/admin/(home)/loader.ts 처럼 도메인 디렉토리 하위에 흩어져 있었다. 도메인 전반에 영향을 주는 함수가 특정 페이지 폴더에 묶여 있으니, 미들웨어에서 쓰기에 위치가 어색했다. FSD 규칙에 맞춰 이들을 features/loader로 모으고, 미들웨어가 한 번에 import하도록 정리했다.

// 변경 전 - 도메인별로 흩어진 import
import { adminPageLoader } from '@/app/admin/(home)/loader';
import { patientPageLoader } from '@/app/patient/(home)/loader';
import { superuserPageLoader } from '@/app/superuser/(home)/loader';
// 변경 후 - features/loader 한 곳에서
import {
  adminPageLoader,
  patientPageLoader,
  superuserPageLoader,
  getPrefixUrl,
} from '@/features/loader';

권한 로직을 한 곳으로 모으니, "누가 어디에 접근할 수 있는가"의 답이 미들웨어 하나에만 있게 됐다. 페이지는 자기 화면 그리기에만 집중하고, 접근 판단은 진입 지점에서 끝난다.

검증

바꾼 것이 인증이라, 눈으로 짚어가며 확인했다. Loader·미들웨어 리팩토링 때 쓰던 체크리스트를 다시 한 번 태워 통과를 확인했다.

  • 토큰이 HttpOnly 쿠키에 담기고, 개발자 도구·document.cookie에서 토큰이 안 보임
  • 토큰 만료 시점에 동시 요청을 던져도 /auth/refresh한 번만 호출
  • 중복 로그인 시 기존 브라우저가 500 없이 자동 로그아웃 → 역할별 로그인 페이지로 리다이렉트
  • 역할(환자·간호사·관리자)별 접근 제어가 미들웨어 한 곳에서 일관되게 동작
간호사·환자·비로그인 각 역할별 로그인·리다이렉트·접근 차단을 점검한 검증 체크리스트
역할별(간호사·환자·비로그인) 로그인·리다이렉트·접근 차단을 한 항목씩 점검한 검증 체크리스트.

배운 점

  • 인증은 "누가 판단하는가"부터 정해야 한다. 클라이언트·미들웨어·백엔드가 제각각 판단하면 언젠가 서로 어긋난다. 토큰을 HttpOnly로 백엔드에 위임한 건 보안인 동시에, 판단의 출처를 한 곳으로 모으는 일이었다.
  • 401은 예외가 아니라 신호다. getUsersMetry-catch 밖에 두고 만료 토큰을 예외로 튕겼더니 페이지가 통째로 500으로 무너졌다. 만료를 "정상적으로 갱신하거나 로그아웃할 사건"으로 다루자 흐름이 잡혔다.
  • 동시성은 단일 요청이 아니라 요청 무리에서 터진다. 토큰 만료 순간의 동시 401이 제각각 refresh를 치며 로그인을 풀었다. single-flight Promise 하나로 묶어 "N개의 401 → 1개의 refresh"로 만든 게 해법이었다.
  • 같은 방어는 같은 지점 전부에 있어야 한다. API에는 refresh가 있고 미들웨어에는 없던 비대칭이 페이지 접근을 막았다. 클라이언트의 훅을 미들웨어에 그대로 재사용해 대칭을 맞췄다.
  • 흩어진 로직을 모으는 건 리팩토링이 아니라 설계다. 역할별 Loader를 features/loader로, 권한 분기를 미들웨어로 모으니 "누가 어디에 접근하나"의 답이 한 파일에만 남았다. 위치가 곧 책임이었다.