로그아웃은 멱등해야 한다 — BFF 예외처리 구현기

작성일: 2026년 4월 21일 오전 03:49(마지막 수정: 2026년 4월 21일 오전 03:51)
조회수: 6

로그아웃은 멱등해야 한다 — BFF 예외처리 구현기

문제 상황

이미 로그아웃된 사용자가 다시 로그아웃을 시도했을 때 앱이 에러 토스트를 띄우는 버그가 있었습니다.

흐름을 추적하면 이렇습니다.

  1. 사용자가 로그아웃 → refreshToken 쿠키 삭제
  2. 같은 탭 또는 다른 탭에서 다시 로그아웃 버튼 클릭
  3. BFF(/api/auth/logout)가 쿠키 없이 백엔드에 요청 → 401 Unauthorized
  4. 클라이언트가 onError 경로로 빠지며 "로그아웃 중 오류가 발생했지만 로그아웃되었습니다" 토스트 노출

원하는 상태(로그아웃)는 이미 달성된 상태인데 에러를 내고 있었습니다.


멱등성(Idempotency)이란

동일한 요청을 여러 번 실행해도 결과가 처음 한 번 실행한 것과 동일한 성질

HTTP 메서드 관점에서 GET, PUT, DELETE는 멱등하도록 설계합니다. POST는 원칙적으로 멱등하지 않지만, 의도한 결과 상태가 이미 충족된 경우 성공을 반환하는 것이 best practice입니다.

로그아웃의 목표는 "세션을 무효화하고 인증되지 않은 상태로 만드는 것"입니다. 이미 그 상태라면 요청은 성공입니다.

OAuth 2.0 / OpenID Connect 관점

OpenID Connect Session ManagementRFC 7009 (OAuth 2.0 Token Revocation)은 다음을 명시합니다.

RFC 7009 §2.2: The authorization server responds with HTTP status code 200 if the token has been revoked successfully or if the client submitted an invalid token.

이미 만료되었거나 존재하지 않는 토큰을 revoke 요청해도 200을 반환하라는 것입니다. 로그아웃도 같은 원칙이 적용됩니다.


구조: BFF 패턴

이 프로젝트는 인증을 BFF(Backend for Frontend) 레이어를 통해 처리합니다.

클라이언트 (브라우저)
    ↓  fetch /api/auth/logout
BFF (Next.js Route Handler)
    ↓  serverFetch /auth/logout  +  Cookie: refreshToken=...
백엔드 API

refreshToken은 httpOnly 쿠키로 관리되어 브라우저 JS에서 직접 접근할 수 없습니다. BFF가 쿠키를 추출해 백엔드로 전달하는 역할을 합니다.


기존 코드의 문제

ts
// app/api/auth/logout/route.ts (수정 전)
export async function POST(request: Request) {
  const refreshToken = getRefreshCookieValue(request.headers.get("cookie"));
  const headers: Record<string, string> = {};
  if (refreshToken) headers.Cookie = `${REFRESH_COOKIE_NAME}=${refreshToken}`;

  const res = await serverFetch("/auth/logout", { method: "POST", headers });
  const data = await res.json();

  // 백엔드가 401을 반환하면 그대로 클라이언트에 401 전달
  const nextRes = NextResponse.json(data, { status: res.status });
  nextRes.cookies.set(REFRESH_COOKIE_NAME, "", { path: "/", maxAge: 0 });
  return nextRes;
}

두 가지 케이스를 처리하지 않습니다.

  • refreshToken 쿠키가 없을 때: 백엔드에 빈 요청을 보내고 401을 받아 그대로 전달
  • 백엔드가 401/403을 반환할 때: 세션 만료임에도 에러로 처리

수정: 멱등성 처리 추가

ts
// app/api/auth/logout/route.ts (수정 후)
export async function POST(request: Request) {
  const refreshToken = getRefreshCookieValue(request.headers.get("cookie"));

  // Case 1: 쿠키 없음 → 이미 로그아웃 상태, 즉시 성공 반환
  if (!refreshToken) {
    return NextResponse.json({ success: true }, { status: 200 });
  }

  const authHeader = request.headers.get("authorization") ?? undefined;
  const headers: Record<string, string> = {
    Cookie: `${REFRESH_COOKIE_NAME}=${refreshToken}`,
  };
  if (authHeader) headers.Authorization = authHeader;

  try {
    const res = await serverFetch("/auth/logout", { method: "POST", headers });

    // Case 2: 백엔드 401/403 → 세션 이미 만료, 성공으로 처리
    if (res.status === 401 || res.status === 403) {
      const nextRes = NextResponse.json({ success: true }, { status: 200 });
      nextRes.cookies.set(REFRESH_COOKIE_NAME, "", { path: "/", maxAge: 0 });
      return nextRes;
    }

    const data = await res.json();
    const nextRes = NextResponse.json(data, { status: res.status });
    nextRes.cookies.set(REFRESH_COOKIE_NAME, "", { path: "/", maxAge: 0 });
    return nextRes;
  } catch (e: unknown) {
    const message = e instanceof Error ? e.message : "로그아웃 요청에 실패했습니다.";
    return NextResponse.json({ success: false, message }, { status: 500 });
  }
}

판단 기준

상황처리
refreshToken 쿠키 없음200 즉시 반환 (백엔드 호출 불필요)
백엔드 401200 반환 + 쿠키 삭제 (토큰 만료)
백엔드 403200 반환 + 쿠키 삭제 (승인 거부 상태)
백엔드 5xx500 에러 (실제 서버 장애)

네트워크 오류나 서버 장애(catch 블록)는 여전히 에러로 처리합니다. 멱등성 처리는 "이미 원하는 상태"인 경우에만 적용합니다.


클라이언트 변경 없음

클라이언트(useLogout 훅)는 변경하지 않았습니다. BFF 수정만으로 클라이언트가 onSuccess 경로를 타게 됩니다.

ts
// src/features/auth-logout/model/use-logout.ts
onSuccess: async () => {
  queryClient.clear();
  await deregisterPushToken(); // clearAuth 이전에 호출해야 401 방지
  clearAuth();
  toast.success("로그아웃 성공"); // 이제 이미 로그아웃 상태에서도 이 경로를 탄다
  router.push("/login");
},

테스트

ts
it("이미 로그아웃 상태에서 BFF가 성공을 반환하면 onSuccess 경로로 처리된다", async () => {
  vi.mocked(logoutViewerMock).mockResolvedValueOnce({ success: true });

  const store = useViewerStore();
  const { result } = renderHook(() => useLogout(), { wrapper: createWrapper() });

  await act(async () => {
    await result.current.mutateAsync();
  });

  expect(store.clearAuth).toHaveBeenCalled();
  expect(vi.mocked(toast.success)).toHaveBeenCalledWith("로그아웃 성공");
  expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});

정리

로그아웃 예외처리의 핵심은 "원하는 상태가 이미 달성됐다면 성공이다" 는 멱등성 원칙입니다. 에러를 줄이는 것이 아니라 의미 있는 에러만 에러로 취급하는 것입니다.

BFF 레이어가 있을 때 이 원칙을 적용하면 클라이언트 코드를 건드리지 않고도 UX를 개선할 수 있습니다.


참고 자료

0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!