임동욱 임동욱 08-26
feat: 어드민 로그인을 mngr/auth API로 옮긴다
백엔드가 /api/v1/common/auth/admin/login을 지우고 /api/v1/mngr/auth/login으로
옮겼다(4b0f0ba). 구 경로는 실제로 404를 반환해 로그인이 전혀 되지 않는 상태였다.

- 로그인 POST /api/v1/mngr/auth/login, 로그아웃 POST /api/v1/mngr/auth/logout
- accessToken 클레임이 adminId/loginId/admRoleCd -> userId/userNm/roleId로 바뀌었다.
  loginId는 더 이상 토큰에 없어 입력값을 세션에 담는다. 이름 클레임(userNm)이
  생겨 그동안 loginId로 대체하던 표시 이름을 실제 이름으로 되돌린다.
- 중복 로그인: 살아 있는 세션이 있으면 200인데 토큰 대신 최근 접속 정보만 온다.
  FoxModal로 안내하고 [로그인]을 누르면 isForceLogin=true로 재요청한다. 재요청에
  아이디·비밀번호가 남아 있어야 해서 폼 리셋을 막고 액션을 직접 호출한다.
- 실패 문구는 백엔드 것을 그대로 쓴다(잠김·탈퇴·실패횟수 안내). 다만 404
  "로그인 계정이 없습니다"만 일반 문구로 바꿔 계정 존재 여부를 드러내지 않는다.

Co-Authored-By: Claude Opus 5 
@cf2ad9867b0786f85848ea6ea6875366576c44c5
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/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;
+}
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-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/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-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;
 };
Add a comment
List