임동욱 임동욱 08-26
Merge branch 'claude/confident-bell-efa28b' into hub
@ff064bced3ad39992f343d01e6a568d9d95abc41
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/_actions.ts
--- app/(public)/login/_actions.ts
+++ app/(public)/login/_actions.ts
@@ -2,11 +2,17 @@
 
 import { redirect } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
-import { requestAdminLogin } from '@/lib/data/repositories/auth-repository';
+import {
+  requestAdminLogin,
+  type DuplicateLoginNotice,
+} from '@/lib/data/repositories/auth-repository';
 import { createSession } from '@/lib/auth/session';
+import { FORCE_LOGIN_FIELD } from '@/lib/constants/login';
 
 export type LoginFormState = {
   error?: string;
+  /** 다른 곳에 살아 있는 세션이 있어 확인이 필요하다. 화면이 안내 후 강제 로그인으로 재요청한다. */
+  duplicate?: DuplicateLoginNotice;
 };
 
 const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.';
@@ -25,8 +31,6 @@
 
 /**
  * 로그인 Server Action — 인증의 진입점이라 사전 인증 확인은 성립하지 않는다(예외로 명시).
- * 입력 검증 → 자격 검증 → 실패 시 계정 존재 여부를 드러내지 않는 일반화 메시지 →
- * 성공 시 세션 생성 → 클라이언트 라우터 캐시 무효화 → 리다이렉트.
  */
 export async function login(
   _prevState: LoginFormState,
@@ -44,18 +48,28 @@
     return { error: GENERIC_LOGIN_ERROR };
   }
 
-  const result = await requestAdminLogin({ loginId, password });
-  if (!result) {
-    return { error: GENERIC_LOGIN_ERROR };
+  const outcome = await requestAdminLogin({
+    loginId,
+    password,
+    forceLogin: formData.get(FORCE_LOGIN_FIELD) === '1',
+  });
+
+  if (outcome.status === 'failure') {
+    return { error: outcome.message };
+  }
+
+  if (outcome.status === 'duplicate') {
+    return { duplicate: outcome.notice };
   }
 
   await createSession({
-    adminId: result.admin.id,
-    loginId: result.admin.loginId,
-    admRoleCd: result.admin.roleCode,
-    accessToken: result.accessToken,
-    refreshToken: result.refreshToken,
-    exp: result.accessTokenExpiresAt,
+    userId: outcome.admin.id,
+    loginId: outcome.admin.loginId,
+    userNm: outcome.admin.name,
+    roleId: outcome.admin.roleCode,
+    accessToken: outcome.accessToken,
+    refreshToken: outcome.refreshToken,
+    exp: outcome.accessTokenExpiresAt,
   });
   revalidatePath('/', 'layout');
   redirect('/');
 
app/(public)/login/_components/duplicate-login-modal.tsx (added)
+++ app/(public)/login/_components/duplicate-login-modal.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import type { DuplicateLoginNotice } from '@/lib/data/repositories/auth-repository';
+import styles from '../login.module.scss';
+
+interface DuplicateLoginModalProps {
+  notice: DuplicateLoginNotice;
+  isPending: boolean;
+  onCancel: () => void;
+  onConfirm: () => void;
+}
+
+const EMPTY_VALUE = '-';
+
+/** 백엔드가 DB DATETIME을 그대로 문자열로 내려준다 — 소수점 이하와 T 구분자만 다듬는다. */
+function formatLastLoginAt(value: string | null): string {
+  if (!value) {
+    return EMPTY_VALUE;
+  }
+  return value.replace('T', ' ').replace(/\.\d+$/, '');
+}
+
+export function DuplicateLoginModal({
+  notice,
+  isPending,
+  onCancel,
+  onConfirm,
+}: DuplicateLoginModalProps) {
+  return (
+    <FoxModal
+      open
+      onClose={onCancel}
+      size="sm"
+      title="이미 로그인된 접속이 있습니다"
+      actions={
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={onCancel} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="로그인"
+            loading={isPending}
+            onAction={onConfirm}
+          />
+        </FoxButtonGroup>
+      }
+    >
+      <div className={styles.duplicate}>
+        <FoxDescriptionList
+          items={[
+            {
+              key: 'lastLoginAt',
+              term: '최근 접속 일시',
+              description: formatLastLoginAt(notice.lastLoginAt),
+            },
+            {
+              key: 'lastLoginIp',
+              term: '최근 접속 IP',
+              description: notice.lastLoginIp ?? EMPTY_VALUE,
+            },
+            {
+              key: 'currentIp',
+              term: '현재 접속 IP',
+              description: notice.currentIp ?? EMPTY_VALUE,
+            },
+          ]}
+        />
+        <p className={styles.duplicateNotice}>
+          계속 로그인하면 기존 접속은 로그아웃됩니다.
+        </p>
+      </div>
+    </FoxModal>
+  );
+}
app/(public)/login/_components/login-form.tsx
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
@@ -1,11 +1,12 @@
 'use client';
 
-import { useActionState, useId, useState } from 'react';
+import { startTransition, useActionState, useId, useRef, useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxInput } from '@fox/core/components/fox-input';
+import { FORCE_LOGIN_FIELD } from '@/lib/constants/login';
 import { login, type LoginFormState } from '../_actions';
 import styles from '../login.module.scss';
-import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { DuplicateLoginModal } from './duplicate-login-modal';
 
 const initialState: LoginFormState = {};
 
@@ -32,10 +33,35 @@
 export function LoginForm() {
   const [state, formAction, isPending] = useActionState(login, initialState);
   const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+  const [isDuplicateDismissed, setIsDuplicateDismissed] = useState(false);
+  const formRef = useRef<HTMLFormElement>(null);
   const passwordId = useId();
 
+  // 이슈: `<form action={...}>`은 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.
+  // 중복 로그인 안내에서 [확인]을 눌러 재요청할 때 아이디·비밀번호가 남아 있어야 하므로
+  // 액션을 직접 transition으로 부른다(app/_hooks/submit-form-action.ts와 같은 이유).
+  function submit(force: boolean) {
+    const form = formRef.current;
+    if (!form) {
+      return;
+    }
+    const formData = new FormData(form);
+    if (force) {
+      formData.set(FORCE_LOGIN_FIELD, '1');
+    }
+    setIsDuplicateDismissed(false);
+    startTransition(() => formAction(formData));
+  }
+
   return (
-    <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
+    <form
+      ref={formRef}
+      onSubmit={(event) => {
+        event.preventDefault();
+        submit(false);
+      }}
+      className={styles.fields}
+    >
       <FoxInput
         size="lg"
         label="ID"
@@ -88,6 +114,15 @@
         disabled={isPending}
         className={styles.submit}
       />
+
+      {state.duplicate && !isDuplicateDismissed && (
+        <DuplicateLoginModal
+          notice={state.duplicate}
+          isPending={isPending}
+          onCancel={() => setIsDuplicateDismissed(true)}
+          onConfirm={() => submit(true)}
+        />
+      )}
     </form>
   );
 }
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/login.module.scss
--- app/(public)/login/login.module.scss
+++ app/(public)/login/login.module.scss
@@ -105,3 +105,19 @@
 .submit {
   inline-size: 100%;
 }
+
+// 중복 로그인 안내 팝업 — 조회 목록과 경고 문구 사이 간격만 준다.
+.duplicate {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(4);
+}
+
+.duplicateNotice {
+  margin: 0;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
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/dal.ts
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
@@ -14,15 +14,14 @@
  * `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고
  * 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다.
  *
- * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 클레임
- * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다.
+ * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 값
+ * (userId/loginId/userNm/roleId)만으로 AdminUser를 구성한다 — Repository 왕복이 없다.
  */
 
 /** 백엔드 accessToken(JWT) payload에서 신선도 검사에 필요한 클레임만 최소로 본다. */
 type AccessTokenFreshnessClaims = {
   exp: unknown;
-  adminId: unknown;
-  loginId: unknown;
+  userId: unknown;
 };
 
 /**
@@ -39,9 +38,9 @@
  *      (createSession이 백엔드 accessToken의 exp를 그대로 세션 exp로 쓴다) 그 사실이 이 검사의
  *      존재 이유를 없애지 않는다: 세션 HMAC의 exp 검사는 "우리 세션 자체가 안 끊겼는지"만
  *      보장하고, 이 검사는 "그 세션이 담고 있는 백엔드 토큰이 여전히 유효한지"를 재확인한다.
- *   3. 유저 일치 — 토큰 클레임의 adminId·loginId가 세션 payload에 저장된 값(로그인 시점에 같은
- *      토큰에서 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가.
- *      다르면 토큰 바꿔치기·세션 오염 신호이므로 무효로 취급한다.
+ *   3. 유저 일치 — 토큰 클레임의 userId가 세션 payload에 저장된 값(로그인 시점에 같은 토큰에서
+ *      읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가. 다르면 토큰
+ *      바꿔치기·세션 오염 신호이므로 무효로 취급한다. loginId는 토큰에 없어 비교 대상이 아니다.
  *
  * clock skew 허용치는 두지 않는다 — verifySessionToken()의 기존 exp 검사도 허용치 없이
  * `exp <= now`로 엄격하게 판정하고(session-token.ts), 이 서버가 백엔드와 시계를 맞출 별도
@@ -62,7 +61,7 @@
     return false;
   }
 
-  return claims.adminId === session.adminId && claims.loginId === session.loginId;
+  return claims.userId === session.userId;
 }
 
 /**
@@ -95,11 +94,10 @@
   }
 
   return {
-    id: session.adminId,
-    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
-    name: session.loginId,
+    id: session.userId,
+    name: session.userNm || session.loginId,
     loginId: session.loginId,
-    roleCode: session.admRoleCd,
+    roleCode: session.roleId,
   };
 });
 
 
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/auth/session-token.ts
--- lib/auth/session-token.ts
+++ lib/auth/session-token.ts
@@ -12,6 +12,9 @@
  * 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이
  * HMAC 서명이 그 위조를 막는 경계다.
  *
+ * loginId는 accessToken 클레임에 없어(백엔드가 싣지 않는다) 로그인 입력값을 그대로 담는다 —
+ * 이 서명 안에 들어가므로 이후 위조는 불가능하다.
+ *
  * 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의
  * `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국
  * accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래
@@ -22,9 +25,10 @@
  */
 
 export type SessionTokenPayload = {
-  adminId: string;
+  userId: string;
   loginId: string;
-  admRoleCd: string;
+  userNm: string;
+  roleId: string;
   accessToken: string;
   refreshToken: string;
   iat: number;
@@ -32,9 +36,10 @@
 };
 
 export type CreateSessionTokenInput = {
-  adminId: string;
+  userId: string;
   loginId: string;
-  admRoleCd: string;
+  userNm: string;
+  roleId: string;
   accessToken: string;
   refreshToken: string;
   /** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */
@@ -58,9 +63,10 @@
 export function createSessionToken(input: CreateSessionTokenInput): string {
   const issuedAt = Math.floor(Date.now() / 1000);
   const payload: SessionTokenPayload = {
-    adminId: input.adminId,
+    userId: input.userId,
     loginId: input.loginId,
-    admRoleCd: input.admRoleCd,
+    userNm: input.userNm,
+    roleId: input.roleId,
     accessToken: input.accessToken,
     refreshToken: input.refreshToken,
     iat: issuedAt,
@@ -96,13 +102,14 @@
     return null;
   }
 
+  // roleId·userNm은 빈 문자열일 수 있다 — 백엔드 TB_ADM_USER에 값이 없으면 클레임도 비어 온다.
   if (
-    typeof payload.adminId !== 'string' ||
-    !payload.adminId ||
+    typeof payload.userId !== 'string' ||
+    !payload.userId ||
     typeof payload.loginId !== 'string' ||
     !payload.loginId ||
-    typeof payload.admRoleCd !== 'string' ||
-    !payload.admRoleCd ||
+    typeof payload.userNm !== 'string' ||
+    typeof payload.roleId !== 'string' ||
     typeof payload.accessToken !== 'string' ||
     !payload.accessToken ||
     typeof payload.refreshToken !== 'string' ||
 
lib/constants/login.ts (added)
+++ lib/constants/login.ts
@@ -0,0 +1,7 @@
+/**
+ * 중복 로그인 안내에서 [확인]을 눌러 재요청할 때 폼에 싣는 필드 이름.
+ *
+ * `'use server'` 파일은 async 함수만 export할 수 있어 로그인 액션 쪽에 둘 수 없다 —
+ * 액션과 폼이 같은 키를 참조해야 하므로 여기 한 곳에 둔다(logout.ts와 같은 목적).
+ */
+export const FORCE_LOGIN_FIELD = 'forceLogin';
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/data/repositories/admin-member-repository.ts
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
@@ -51,12 +51,12 @@
   const loginFailCnt = raw.loginFailCnt;
 
   return {
-    id: readRequiredString(raw, 'admUserId'),
-    name: readRequiredString(raw, 'admNm'),
+    id: readRequiredString(raw, 'userId'),
+    name: readRequiredString(raw, 'userNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: readOptionalString(raw, 'admTelNo'),
-    email: readOptionalString(raw, 'admEmlAddr'),
-    roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
+    phoneNumber: readOptionalString(raw, 'userTelNo'),
+    email: readOptionalString(raw, 'userEmlAddr'),
+    roleCode: readOptionalString(raw, 'roleId') ?? '',
     createdAt: readOptionalString(raw, 'frstRegDtStr'),
     isLocked: parseYesNo(raw.acctLockYn),
     isActive: parseYesNo(raw.useYn),
@@ -208,12 +208,12 @@
     method: 'POST',
     // 이슈: 등록은 form(@ParameterObject), 수정은 JSON(@RequestBody)로 갈려 있다.
     form: {
-      admNm: input.name,
+      userNm: input.name,
       loginId: input.loginId,
       loginPw: input.password,
-      admEmlAddr: input.email,
-      admTelNo: input.phoneNumber,
-      admRoleCd: input.roleCode,
+      userEmlAddr: input.email,
+      userTelNo: input.phoneNumber,
+      roleId: input.roleCode,
     },
     accessToken: accessToken ?? undefined,
     canHaveNullData: true,
@@ -236,9 +236,9 @@
       method: 'PUT',
       body: {
         loginPw: input.password,
-        admEmlAddr: input.email,
-        admTelNo: input.phoneNumber,
-        admRoleCd: input.roleCode,
+        userEmlAddr: input.email,
+        userTelNo: input.phoneNumber,
+        roleId: input.roleCode,
         acctLockYn: toYesNo(input.isLocked),
       },
       accessToken: accessToken ?? undefined,
lib/data/repositories/auth-repository.ts
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
@@ -4,125 +4,152 @@
 import type { AdminUser } from '@/lib/domain/admin-user';
 
 /**
- * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동.
+ * 관리자 인증 Repository — `POST /api/v1/mngr/auth/login`, `POST /api/v1/mngr/auth/logout`.
  *
- * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()).
- * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로).
+ * 이슈: 로그인은 성공(200)이면서도 토큰 없이 최근 접속 정보만 돌아올 수 있다 — 다른 곳에
+ *   살아 있는 세션이 있는데 아직 [확인]을 누르지 않은 경우다. 이때 화면이 중복 로그인 안내를
+ *   띄우고 `forceLogin`으로 재요청해야 실제 토큰이 발급된다(백엔드 MngrAuthServiceImpl).
  *
- * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은
- * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다
- * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후
- * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다
- * (사용자 확정 사항).
+ * 이슈: accessToken 클레임에 `loginId`가 없다(EgovJwtTokenUtil#generateAccessToken은 userId·
+ *   userNm·userType·roleId·tokenVersion만 싣는다). 그래서 loginId는 입력값을 그대로 세션에
+ *   보관한다 — 백엔드가 그 아이디로 계정을 찾아 인증에 성공한 값이라 신뢰할 수 있다.
  */
+
+const LOGIN_PATH = '/api/v1/mngr/auth/login';
+const LOGOUT_PATH = '/api/v1/mngr/auth/logout';
+
+/** 백엔드 ResponseCode.NOT_FOUND — "로그인 계정이 없습니다". */
+const ACCOUNT_NOT_FOUND_CODE = 404;
+
+const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.';
 
 export type AdminCredentials = {
   loginId: string;
   password: string;
+  /** 중복 로그인 안내에서 [확인]을 누른 재시도인가. */
+  forceLogin?: boolean;
 };
 
-export type AdminLoginResult = {
+export type AdminLoginSuccess = {
+  status: 'success';
   admin: AdminUser;
   accessToken: string;
   refreshToken: string;
-  /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */
+  /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시킨다. */
   accessTokenExpiresAt: number;
 };
 
-const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout';
+/** 다른 곳에 살아 있는 세션이 있을 때 화면이 보여줄 최근 접속 정보. */
+export type DuplicateLoginNotice = {
+  lastLoginAt: string | null;
+  lastLoginIp: string | null;
+  currentIp: string | null;
+};
 
-/** 백엔드 accessToken(JWT)의 payload 클레임. */
+export type AdminLoginOutcome =
+  | AdminLoginSuccess
+  | { status: 'duplicate'; notice: DuplicateLoginNotice }
+  | { status: 'failure'; message: string };
+
 type AdminAccessTokenClaims = {
-  sub: string;
-  loginId: string;
-  admRoleCd: string;
-  adminId: string;
-  userType: string;
   userId: string;
-  iat: number;
+  userNm: string;
+  userType: string;
+  roleId: string;
   exp: number;
 };
 
 type AdminLoginResponse = {
-  accessToken: string;
-  refreshToken: string;
+  accessToken?: string | null;
+  refreshToken?: string | null;
+  lastLgnDt?: string | null;
+  lastLgnIpAddr?: string | null;
+  currentIpAddr?: string | null;
 };
 
-/**
- * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라
- * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가
- * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 —
- * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다).
- *
- * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함).
- */
+function readNullableString(value: unknown): string | null {
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
 export async function requestAdminLogin(
   credentials: AdminCredentials
-): Promise<AdminLoginResult | null> {
-  const result = await backendFetch<AdminLoginResponse>(
-    '/api/v1/common/auth/admin/login',
-    {
-      method: 'POST',
-      body: { loginId: credentials.loginId, loginPw: credentials.password },
-      cache: 'no-store',
-    }
-  );
+): Promise<AdminLoginOutcome> {
+  const result = await backendFetch<AdminLoginResponse>(LOGIN_PATH, {
+    method: 'POST',
+    body: {
+      loginId: credentials.loginId,
+      loginPw: credentials.password,
+      isForceLogin: credentials.forceLogin === true,
+    },
+    cache: 'no-store',
+  });
 
   if (!result.ok) {
-    return null;
+    // 계정 존재 여부는 드러내지 않는다. 잠김·탈퇴·실패횟수 안내는 백엔드 문구를 그대로 쓴다.
+    return {
+      status: 'failure',
+      message:
+        result.code === ACCOUNT_NOT_FOUND_CODE || !result.message
+          ? GENERIC_LOGIN_ERROR
+          : result.message,
+    };
   }
 
-  const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken);
+  const accessToken = readNullableString(result.data.accessToken);
+  const refreshToken = readNullableString(result.data.refreshToken);
+
+  if (!accessToken || !refreshToken) {
+    return {
+      status: 'duplicate',
+      notice: {
+        lastLoginAt: readNullableString(result.data.lastLgnDt),
+        lastLoginIp: readNullableString(result.data.lastLgnIpAddr),
+        currentIp: readNullableString(result.data.currentIpAddr),
+      },
+    };
+  }
+
+  const claims = decodeJwtPayload<AdminAccessTokenClaims>(accessToken);
   if (
     !claims ||
-    typeof claims.adminId !== 'string' ||
-    !claims.adminId ||
-    typeof claims.loginId !== 'string' ||
-    !claims.loginId ||
-    typeof claims.admRoleCd !== 'string' ||
-    !claims.admRoleCd ||
+    typeof claims.userId !== 'string' ||
+    !claims.userId ||
     typeof claims.exp !== 'number'
   ) {
-    return null;
+    return { status: 'failure', message: GENERIC_LOGIN_ERROR };
   }
 
   const admin: AdminUser = {
-    id: claims.adminId,
-    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
-    name: claims.loginId,
-    loginId: claims.loginId,
-    roleCode: claims.admRoleCd,
+    id: claims.userId,
+    name:
+      typeof claims.userNm === 'string' && claims.userNm
+        ? claims.userNm
+        : credentials.loginId,
+    loginId: credentials.loginId,
+    roleCode: typeof claims.roleId === 'string' ? claims.roleId : '',
   };
 
   return {
+    status: 'success',
     admin,
-    accessToken: result.data.accessToken,
-    refreshToken: result.data.refreshToken,
+    accessToken,
+    refreshToken,
     accessTokenExpiresAt: claims.exp,
   };
 }
 
 /**
- * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에
- * 없음을 확인함: edupay-backend SecurityConfig.java).
+ * 로그아웃 — 백엔드가 저장된 refreshToken을 NULL로 만든다.
  *
- * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지
- * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend
- * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`,
- * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다.
+ * 이슈: 이 호출이 accessToken 자체를 무효화하지는 않는다. JwtAuthenticationFilter의 중복 로그인
+ *   판정이 `dbRefreshToken != null && !equals(clientTokenVersion)`이라, NULL이 된 뒤에는 기존
+ *   accessToken이 만료(24h)까지 계속 통과한다(edupay-backend 61d8197 확인). 백엔드 보완 필요.
  *
- * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로
- * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고).
- *
- * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은
- * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch
- * 없이 결과만 보고 흐름을 결정할 수 있게 한다.
- *
- * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다.
+ * 실패해도 로컬 세션은 반드시 파기해야 하므로 예외 대신 boolean을 돌려준다.
  */
 export async function requestAdminLogout(accessToken: string): Promise<boolean> {
-  const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
-    method: 'GET',
+  const result = await backendFetch<null>(LOGOUT_PATH, {
+    method: 'POST',
     accessToken,
     cache: 'no-store',
     canHaveNullData: true,
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -111,7 +111,7 @@
 /**
  * 권한에 맞는 사이드바 메뉴를 가져온다.
  *
- * @param roleCode 세션의 `admRoleCd`(예: `ROLE_SYSTEM`). 지금은 쓰이지 않지만 API가 붙으면
+ * @param roleCode 세션의 `roleId`(예: `ROLE_SYSTEM`). 지금은 쓰이지 않지만 API가 붙으면
  *   이 값으로 조회한다 — 인자를 미리 받아 두어야 호출부를 나중에 고치지 않는다.
  */
 export const fetchSidebarMenu = cache(
lib/domain/admin-member.ts
--- lib/domain/admin-member.ts
+++ lib/domain/admin-member.ts
@@ -4,22 +4,16 @@
  * 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/admin/pagination`이며,
  * `lib/data/repositories/admin-member-repository.ts`가 응답을 이 타입으로 매핑한다.
  *
- * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 목록 응답 VO(MngrAdminVo)가 담는 값은
- * 식별자·이름·역할·계정상태 계열뿐이라 시안(ADM_ADM_101)의 휴대전화번호·이메일·생성일은 전부
- * `null`로 채워지고 화면에서 `-`로 표시된다. 화면 컬럼은 그대로 유지한다 — 백엔드가 필드를
- * 추가하면 Repository의 매핑만 늘리면 값이 그대로 채워진다.
- *
- * 등록/수정/삭제 API가 아직 없어 그 경로는 mock으로 동작한다(`lib/data/mock/admin-member-store.ts`).
- * mock이 만들어 낸 행만 휴대전화번호·이메일·메뉴 권한 값을 실제로 갖는다.
+ * 값이 없는 항목은 `null`로 채워지고 화면에서 `-`로 표시된다.
  */
 
-/** 역할 코드 — 백엔드 `admRoleCd`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */
+/** 역할 코드 — 백엔드 `roleId`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */
 export type AdminRoleCode = string;
 
 export type AdminMember = {
-  /** 내부 식별자 — 백엔드 `admUserId`. 목록 행의 key이자 수정/삭제의 입력값이다. */
+  /** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 수정/삭제의 입력값이다. */
   id: string;
-  /** 백엔드 `admNm`. */
+  /** 백엔드 `userNm`. */
   name: string;
   /** 백엔드 `loginId`. 등록 후에는 변경할 수 없다(시안 ADM_ADM_103_p ①). */
   loginId: string;
@@ -27,7 +21,7 @@
   phoneNumber: string | null;
   /** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */
   email: string | null;
-  /** 백엔드 `admRoleCd`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */
+  /** 백엔드 `roleId`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */
   roleCode: AdminRoleCode;
   /** ISO 형식(YYYY-MM-DD) 문자열. 백엔드 목록 응답에 없다(정렬에만 쓰이고 select되지 않는다). */
   createdAt: string | null;
@@ -53,7 +47,7 @@
  * **백엔드에 역할 코드 목록을 주는 API도, 코드 상수도 없다** — `TB_ADM_USER.ADM_ROLE_CD`를
  * 그대로 내려줄 뿐이고 코드값의 정의는 어디에도 없다(백엔드 저장소 전체 검색으로 확인).
  * 시안(ADM_ADM_101)이 요구하는 표기는 "최고관리자/일반관리자" 둘이고, 실제로 관측된 코드는
- * 로그인 토큰의 `admRoleCd` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`).
+ * 로그인 토큰의 `roleId` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`).
  *
  * 그래서 아래 표는 **잠정 매핑**이며, 표에 없는 코드는 임의의 이름을 지어내지 않고 코드를 그대로
  * 노출한다 — 잘못된 역할 이름을 보여주는 것보다 낯선 코드를 보여주는 편이 낫다(권한 화면이라
lib/domain/admin-user.ts
--- lib/domain/admin-user.ts
+++ lib/domain/admin-user.ts
@@ -1,17 +1,17 @@
 /**
  * 관리자 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
- * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다 — 토큰은
- * 세션 payload에만 보관하고 화면까지 내려주지 않는다).
+ * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다).
  */
 export type AdminUser = {
+  /** 백엔드 accessToken의 `userId` 클레임. */
   id: string;
-  /**
-   * 백엔드 accessToken(JWT)에 관리자 "이름" 클레임이 없어 loginId를 표시용 이름으로 그대로
-   * 대체한다(사용자 확정 사항, 별도 이름 조회 API 없음). 이름 클레임/API가 추가되면 이 필드를
-   * 실제 이름으로 교체한다.
-   */
+  /** 백엔드 accessToken의 `userNm` 클레임. 비어 있으면 loginId로 대체한다. */
   name: string;
+  /**
+   * 로그인 시 입력한 아이디. accessToken 클레임에는 없어(EgovJwtTokenUtil#generateAccessToken)
+   * 세션에 따로 보관한다.
+   */
   loginId: string;
-  /** 백엔드 accessToken의 admRoleCd 클레임 (예: "ROLE_SYSTEM"). */
+  /** 백엔드 accessToken의 `roleId` 클레임 (예: "ROLE_SYSTEM"). */
   roleCode: string;
 };
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