임동욱 임동욱 08-26
feat: 다른 곳에서 로그인되면 전역에서 로그아웃시킨다
백엔드가 중복 로그인 감지를 넣으면서, 내 토큰이 밀리면 이후 모든 요청이
403 + code 905(만료된 토큰입니다)로 떨어진다(JwtAuthenticationFilter).
어느 화면·어느 API에서 걸리든 같은 결말이어야 해서 backendFetch 한 곳에서
처리한다.

- 905/904를 받으면 /logout/revoked로 보낸다. 렌더 중에는 쿠키를 지울 수 없어
  라우트 핸들러에서 세션을 파기하고 /login으로 안내와 함께 되돌린다.
- redirect()는 예외로 던져지므로 Server Action의 catch가 삼키면 안 된다.
  저장소를 호출하는 catch에 unstable_rethrow를 넣어 통과시킨다.
- 그 경로는 쿠키가 이미 없어도 지나가야 안내가 유지돼 proxy 공개 경로에 넣는다.

Co-Authored-By: Claude Opus 5 
@f36ef317b4491c3df5c9358e40f5a6f9ae0f820c
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
@@ -48,6 +49,8 @@
     await write();
     return null;
   } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     if (error instanceof BackendRequestError) {
       return { status: 'error', message: error.message };
     }
@@ -166,6 +169,7 @@
       return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
     }
   } catch (error) {
+    unstable_rethrow(error);
     if (error instanceof BackendRequestError) {
       return { status: 'failed', message: error.message };
     }
app/(protected)/(basic)/boards/_actions.ts
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
@@ -80,6 +81,8 @@
     await write();
     return null;
   } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return {
       status: 'error',
       message: error instanceof Error ? error.message : fallback,
@@ -161,6 +164,7 @@
   try {
     attachmentId = await resolveAttachmentId(formData);
   } catch (error) {
+    unstable_rethrow(error);
     return {
       status: 'error',
       message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
@@ -208,6 +212,7 @@
   try {
     attachmentId = await resolveAttachmentId(formData);
   } catch (error) {
+    unstable_rethrow(error);
     return {
       status: 'error',
       message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
@@ -254,6 +259,7 @@
   try {
     attachmentId = await resolveAttachmentId(formData);
   } catch (error) {
+    unstable_rethrow(error);
     return {
       status: 'error',
       message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
@@ -312,7 +318,8 @@
       return { status: 'error', message: GONE_MESSAGE };
     }
     return { status: 'success', post };
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: LOAD_FAILED_MESSAGE };
   }
 }
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -1,7 +1,7 @@
 'use server';
 
 import { revalidatePath } from 'next/cache';
-import { redirect } from 'next/navigation';
+import { redirect, unstable_rethrow } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
 import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
@@ -122,6 +122,8 @@
   try {
     return await run();
   } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     // 진짜 원인은 여기에만 남는다 — 화면에는 사람이 고칠 수 있는 사유만 나간다.
     console.error('[contents] 저장 실패', error);
     return failure(error, SAVE_FAILED_MESSAGE);
@@ -448,6 +450,7 @@
       await deleteContent(kind, id);
     }
   } catch (error) {
+    unstable_rethrow(error);
     return failure(error, DELETE_FAILED_MESSAGE);
   }
 
app/(protected)/(basic)/decoration-items/_actions.ts
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import {
@@ -154,13 +155,16 @@
     try {
       imageFileId = await uploadDecorationItemImage(newFile);
     } catch (error) {
+      // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+      unstable_rethrow(error);
       return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
     }
   }
 
   try {
     await createDecorationItem({ ...validation.values, imageFileId });
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -204,13 +208,15 @@
     try {
       imageFileId = await uploadDecorationItemImage(newFile);
     } catch (error) {
+      unstable_rethrow(error);
       return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
     }
   }
 
   try {
     await updateDecorationItem(itemSn, { ...validation.values, imageFileId });
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -236,7 +242,8 @@
 
   try {
     await deleteDecorationItem(itemSn);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: DELETE_FAILED_MESSAGE };
   }
 
app/(protected)/(basic)/points/standards/_actions.ts
--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
@@ -60,7 +61,9 @@
 
   try {
     await createPointStandard(validation.values);
-  } catch {
+  } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -84,7 +87,8 @@
 
   try {
     await updatePointStandard(validation.values);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -99,7 +103,8 @@
 
   try {
     await deletePointStandard(id);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: DELETE_FAILED_MESSAGE };
   }
 
app/(protected)/(basic)/system/banned-words/_actions.ts
--- app/(protected)/(basic)/system/banned-words/_actions.ts
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
@@ -1,7 +1,7 @@
 'use server';
 
 import { revalidatePath } from 'next/cache';
-import { redirect } from 'next/navigation';
+import { redirect, unstable_rethrow } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
 import {
@@ -47,6 +47,8 @@
   try {
     result = await createBannedWord(validation.value);
   } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
   }
 
@@ -66,6 +68,7 @@
   try {
     await deleteBannedWord(id);
   } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
   }
 
@@ -90,6 +93,7 @@
   try {
     result = await uploadBannedWordExcel(file);
   } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: failureMessage(error, UPLOAD_FAILED_MESSAGE) };
   }
 
app/(protected)/(basic)/system/codes/_actions.ts
--- app/(protected)/(basic)/system/codes/_actions.ts
+++ app/(protected)/(basic)/system/codes/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import {
@@ -68,7 +69,9 @@
 
   try {
     await createCodeGroup(validation.values);
-  } catch {
+  } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -99,7 +102,8 @@
 
   try {
     await updateCodeGroup(targetComCd, validation.values);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -124,7 +128,8 @@
 
   try {
     await deleteCodeGroup(comCd);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: DELETE_FAILED_MESSAGE };
   }
 
@@ -154,7 +159,8 @@
 
   try {
     await createCodeDetail(validation.values);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -188,7 +194,8 @@
 
   try {
     await updateCodeDetail(targetComCd, targetComDtlCd, validation.values);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
@@ -208,7 +215,8 @@
 
   try {
     await deleteCodeDetail(comCd, comDtlCd);
-  } catch {
+  } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: DELETE_FAILED_MESSAGE };
   }
 
app/(protected)/(basic)/system/menus/_actions.ts
--- app/(protected)/(basic)/system/menus/_actions.ts
+++ app/(protected)/(basic)/system/menus/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
@@ -80,7 +81,9 @@
   let values;
   try {
     values = await readValues(formData);
-  } catch {
+  } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return { status: 'error', message: UPLOAD_FAILED_MESSAGE };
   }
 
@@ -96,6 +99,7 @@
       await createSystemMenu(validation.values);
     }
   } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
   }
 
@@ -111,6 +115,7 @@
   try {
     await deleteSystemMenu(menuId);
   } catch (error) {
+    unstable_rethrow(error);
     return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
   }
 
app/(protected)/(basic)/system/merchants/_actions.ts
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
@@ -1,5 +1,6 @@
 'use server';
 
+import { unstable_rethrow } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
 import { createMerchant } from '@/lib/data/repositories/merchant-repository';
@@ -45,7 +46,9 @@
   // TODO(백엔드): 수정 API가 없다(`/api/v1/mngr/voc`는 목록·등록 둘뿐). 생기면 여기서 가른다.
   try {
     await createMerchant(validation.values);
-  } catch {
+  } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
 
app/(public)/login/_components/logout-notice.tsx
--- app/(public)/login/_components/logout-notice.tsx
+++ app/(public)/login/_components/logout-notice.tsx
@@ -7,10 +7,14 @@
 interface LogoutNoticeProps {
   /** 로그아웃 Server Action이 백엔드 호출 실패를 알리려고 실어 보낸 쿼리 파라미터가 있었는가. */
   showError: boolean;
+  /** 다른 곳에서 로그인되어 세션이 밀렸는가. */
+  showSessionRevoked: boolean;
 }
 
 const LOGOUT_ERROR_MESSAGE =
   '로그아웃 처리 중 일부 오류가 있었지만 로그아웃되었습니다.';
+const SESSION_REVOKED_MESSAGE =
+  '다른 곳에서 로그인되어 로그아웃되었습니다. 다시 로그인해 주세요.';
 
 /**
  * 화면에 그릴 UI가 없는 알림 트리거 컴포넌트 — `app/(protected)/_actions.ts`의 `logout()`이
@@ -31,21 +35,29 @@
  * 그대로 true이기 때문이다. 프로덕션은 원래 1회지만, 사용자에게 실제로 보이는 부수효과(알림)는
  * Strict Mode 유무와 무관하게 항상 1회여야 한다.
  */
-export function LogoutNotice({ showError }: LogoutNoticeProps) {
+export function LogoutNotice({
+  showError,
+  showSessionRevoked,
+}: LogoutNoticeProps) {
   const router = useRouter();
   const { showToast } = useFeedback();
   const hasNotifiedRef = useRef(false);
 
   useEffect(() => {
-    if (!showError || hasNotifiedRef.current) {
+    if ((!showError && !showSessionRevoked) || hasNotifiedRef.current) {
       return;
     }
     hasNotifiedRef.current = true;
 
     // 내부 정보(백엔드 message 원문 등)는 노출하지 않는다 — 항상 이 고정 문구만 보여준다.
-    showToast({ variant: 'warning', message: LOGOUT_ERROR_MESSAGE });
+    showToast({
+      variant: 'warning',
+      message: showSessionRevoked
+        ? SESSION_REVOKED_MESSAGE
+        : LOGOUT_ERROR_MESSAGE,
+    });
     router.replace('/login');
-  }, [showError, showToast, router]);
+  }, [showError, showSessionRevoked, showToast, router]);
 
   return null;
 }
app/(public)/login/page.tsx
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
@@ -1,5 +1,8 @@
 import { getSessionAdmin } from '@/lib/auth/dal';
-import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
+import {
+  LOGOUT_ERROR_QUERY_PARAM,
+  SESSION_REVOKED_QUERY_PARAM,
+} from '@/lib/constants/logout';
 import { redirect } from 'next/navigation';
 import styles from './login.module.scss';
 import { LoginForm } from './_components/login-form';
@@ -22,10 +25,16 @@
   const resolvedSearchParams = await searchParams;
   const logoutError = resolvedSearchParams[LOGOUT_ERROR_QUERY_PARAM];
   const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0;
+  const sessionRevoked = resolvedSearchParams[SESSION_REVOKED_QUERY_PARAM];
+  const showSessionRevoked =
+    typeof sessionRevoked === 'string' && sessionRevoked.length > 0;
 
   return (
     <div className={styles.page}>
-      <LogoutNotice showError={showLogoutError} />
+      <LogoutNotice
+        showError={showLogoutError}
+        showSessionRevoked={showSessionRevoked}
+      />
       <div className={styles.card}>
         <h1 className={styles.title}>관리자 로그인</h1>
         <LoginForm />
 
app/logout/revoked/route.ts (added)
+++ app/logout/revoked/route.ts
@@ -0,0 +1,12 @@
+import { redirect } from 'next/navigation';
+import { deleteSession } from '@/lib/auth/session';
+import { SESSION_REVOKED_QUERY_PARAM } from '@/lib/constants/logout';
+
+/**
+ * 다른 곳에서 로그인되어 세션이 밀렸을 때 도착하는 곳 — 쿠키를 지우고 `/login`으로 보낸다.
+ * 렌더 중에는 쿠키를 지울 수 없어 라우트 핸들러로 분리했다(lib/auth/session-revoked.ts 참고).
+ */
+export async function GET(): Promise<never> {
+  await deleteSession();
+  redirect(`/login?${SESSION_REVOKED_QUERY_PARAM}=1`);
+}
 
lib/auth/session-revoked.ts (added)
+++ lib/auth/session-revoked.ts
@@ -0,0 +1,19 @@
+import 'server-only';
+import { redirect } from 'next/navigation';
+import { SESSION_REVOKED_PATH } from '@/lib/constants/logout';
+
+/**
+ * 다른 곳에서 로그인되면 백엔드가 저장한 refreshToken이 바뀌고, 그 뒤로 이 세션의 accessToken은
+ * 403 + XPIRED_TOKEN(905)으로 거절된다(edupay-backend JwtAuthenticationFilter). 904는 같은 뜻의
+ * 예비 코드다.
+ *
+ * 쿠키 삭제는 렌더 중에 할 수 없어(Next.js 제약) 라우트 핸들러로 넘긴다 — 거기서 세션을 지우고
+ * `/login`으로 안내와 함께 되돌린다.
+ */
+const SESSION_REVOKED_CODES: ReadonlySet<number> = new Set([904, 905]);
+
+export function redirectIfSessionRevoked(code: number): void {
+  if (SESSION_REVOKED_CODES.has(code)) {
+    redirect(SESSION_REVOKED_PATH);
+  }
+}
lib/constants/logout.ts
--- lib/constants/logout.ts
+++ lib/constants/logout.ts
@@ -11,3 +11,12 @@
  * 하는 설계, app/(protected)/_actions.ts 참고).
  */
 export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError';
+
+/**
+ * 다른 곳에서 로그인되어 이 세션이 밀렸을 때 `/login` 화면에 알릴 쿼리 파라미터 이름.
+ * LOGOUT_ERROR_QUERY_PARAM과 같은 이유로 상수로 둔다.
+ */
+export const SESSION_REVOKED_QUERY_PARAM = 'sessionRevoked';
+
+/** 세션이 밀린 것을 감지했을 때 보내는 경로 — 쿠키를 지우고 `/login`으로 넘긴다. */
+export const SESSION_REVOKED_PATH = '/logout/revoked';
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -1,5 +1,6 @@
 import 'server-only';
 import { getApiBaseUrl } from '@/lib/env';
+import { redirectIfSessionRevoked } from '@/lib/auth/session-revoked';
 
 /**
  * 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더,
@@ -331,6 +332,7 @@
     logResponse(call, response.status, raw);
     const envelope = parseEnvelope(raw);
     if (envelope && typeof envelope.code === 'number') {
+      redirectIfSessionRevoked(envelope.code);
       return {
         ok: false,
         code: envelope.code,
@@ -444,6 +446,7 @@
     // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다.
     const envelope = parseEnvelope(raw);
     if (envelope && typeof envelope.code === 'number') {
+      redirectIfSessionRevoked(envelope.code);
       return {
         ok: false,
         code: envelope.code,
@@ -544,6 +547,7 @@
 
   if (!response.ok || !envelope || envelope.success !== true) {
     if (envelope && typeof envelope.code === 'number') {
+      redirectIfSessionRevoked(envelope.code);
       return {
         ok: false,
         code: envelope.code,
proxy.ts
--- proxy.ts
+++ proxy.ts
@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server';
 import type { NextRequest } from 'next/server';
 import { SESSION_COOKIE_NAME } from '@/lib/auth/session-cookie';
+import { SESSION_REVOKED_PATH } from '@/lib/constants/logout';
 
 /**
  * 낙관적 체크 전용 — 세션 쿠키의 "존재 여부"만 확인한다. 최종 판단(서명·만료 검증)은
@@ -16,7 +17,7 @@
  */
 // 이슈: `/dev-test/design`은 모든 환경에서 세션 없이 열린다(사용자 요청). 실데이터를 그리지
 // 않고 @fox 컴포넌트만 보여주는 화면이지만, 운영에도 노출되는 경로다.
-const PUBLIC_PATHS = ['/login', '/dev-test/design'];
+const PUBLIC_PATHS = ['/login', '/dev-test/design', SESSION_REVOKED_PATH];
 
 function isPublicPath(pathname: string): boolean {
   return PUBLIC_PATHS.includes(pathname);
Add a comment
List