임동욱 임동욱 08-11
feat: 로그아웃 백엔드 API 연동
로그아웃 Server Action이 GET /api/v1/common/auth/logout을 best-effort로 호출하도록
Repository(requestAdminLogout)를 추가하고 연동한다. 백엔드 호출이 실패해도 로컬 세션
파기·리다이렉트는 항상 진행된다.

이 엔드포인트의 성공 응답은 data가 항상 null(ApiResponseVO.success(null))인데
backendFetch의 기존 규약은 data===null을 실패로 간주해 정상 성공을 오판하므로,
backend-fetch.ts에 opt-in 플래그 canHaveNullData를 추가해 이 호출에만 적용한다(기존
호출부는 동작 변화 없음).

백엔드 호출 실패 시에는 /login?logoutError=1로 리다이렉트해 실패 신호를 남긴다(다음
커밋의 알림 UI가 소비).

Co-Authored-By: Claude Opus 5 
@b82634af9d8b4c66eefe86146afbbe77899daf42
app/(protected)/_actions.ts
--- app/(protected)/_actions.ts
+++ app/(protected)/_actions.ts
@@ -3,10 +3,25 @@
 import { redirect } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
 import { deleteSession, readSessionToken } from '@/lib/auth/session';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { requestAdminLogout } from '@/lib/data/repositories/auth-repository';
+import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
 
 /**
  * 로그아웃 Server Action — 자기 세션을 파기하는 동작이라 `verifySession()`의 전제(관리자 조회)가
  * 성립하지 않는다. 토큰 존재 여부만 확인해 이미 로그아웃된 상태의 중복 호출도 안전하게 처리한다.
+ *
+ * 백엔드 로그아웃 호출(GET .../auth/logout)은 best-effort다 — 실패해도(백엔드 다운,
+ * accessToken 만료 등) 로컬 세션은 반드시 파기한다. 사용자가 로그아웃 자체를 못 하게 되는
+ * 상황을 절대 만들지 않기 위함이다. accessToken이 애초에 없으면(getSessionAccessToken()이
+ * 형식·만료·유저일치 검사에서 이미 null을 반환한 경우) 호출 자체를 건너뛴다 — 보낼 유효한
+ * 토큰이 없는 것은 "실패"가 아니라 "애초에 알릴 대상이 없음"이므로 실패로 취급하지 않는다.
+ *
+ * 실패 알림은 이 액션이 직접 띄우지 않는다 — Server Action에서 redirect()는 즉시 던져지므로
+ * (NEXT_REDIRECT) 클라이언트가 반환값을 돌려받아 토스트를 그릴 기회 자체가 없다(redirect와
+ * 클라이언트 상태 갱신이 같은 턴에서 경쟁하게 됨). 대신 `/login`에 쿼리 파라미터를 실어 보내고,
+ * 그 화면의 클라이언트 컴포넌트(LogoutNotice)가 마운트된 뒤(=redirect가 완전히 끝난 뒤) 토스트를
+ * 띄운다 — 시점이 분리되어 있어 경쟁이 원천적으로 없다.
  */
 export async function logout(): Promise<void> {
   const token = await readSessionToken();
@@ -14,7 +29,14 @@
     redirect('/login');
   }
 
+  const accessToken = await getSessionAccessToken();
+  const isBackendLogoutSuccessful = accessToken
+    ? await requestAdminLogout(accessToken)
+    : true;
+
   await deleteSession();
   revalidatePath('/', 'layout');
-  redirect('/login');
+  redirect(
+    isBackendLogoutSuccessful ? '/login' : `/login?${LOGOUT_ERROR_QUERY_PARAM}=1`
+  );
 }
 
lib/constants/logout.ts (added)
+++ lib/constants/logout.ts
@@ -0,0 +1,13 @@
+/**
+ * 로그아웃 Server Action이 백엔드 호출 실패를 `/login` 화면에 알릴 때 쓰는 쿼리 파라미터 이름.
+ *
+ * `app/(protected)/_actions.ts`(로그아웃 액션 — redirect URL에 값을 싣는다)와
+ * `app/(public)/login/page.tsx`(읽어서 LogoutNotice에 boolean prop으로 넘긴다) 양쪽이 같은 키를
+ * 참조해야 하므로, 리터럴 문자열이 두 파일에 중복되어 드리프트하는 것을 막기 위해 상수로 뺀다
+ * (session-cookie.ts의 SESSION_COOKIE_NAME과 동일한 목적).
+ *
+ * redirect()는 Server Action에서 즉시 던져지므로 클라이언트가 반환값을 읽을 기회가 없다 — 그래서
+ * 실패 신호를 반환값이 아니라 이동할 URL 자체에 싣는다(§ redirect와 토스트 표시가 경쟁하지 않게
+ * 하는 설계, app/(protected)/_actions.ts 참고).
+ */
+export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError';
lib/data/repositories/auth-repository.ts
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
@@ -29,6 +29,8 @@
   accessTokenExpiresAt: number;
 };
 
+const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout';
+
 /** 백엔드 accessToken(JWT)의 payload 클레임. */
 type AdminAccessTokenClaims = {
   sub: string;
@@ -99,3 +101,32 @@
     accessTokenExpiresAt: claims.exp,
   };
 }
+
+/**
+ * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에
+ * 없음을 확인함: edupay-backend SecurityConfig.java).
+ *
+ * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지
+ * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend
+ * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`,
+ * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다.
+ *
+ * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로
+ * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고).
+ *
+ * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은
+ * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch
+ * 없이 결과만 보고 흐름을 결정할 수 있게 한다.
+ *
+ * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다.
+ */
+export async function requestAdminLogout(accessToken: string): Promise<boolean> {
+  const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
+    method: 'GET',
+    accessToken,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  return result.ok;
+}
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -63,6 +63,17 @@
   /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */
   cache?: RequestCache;
   next?: { revalidate?: number | false; tags?: string[] };
+  /**
+   * 이 호출은 성공 응답의 `data`가 정상적으로 `null`일 수 있다(예: 로그아웃처럼 돌려줄 데이터가
+   * 없는 변경 API). 기본값 false는 기존 계약을 그대로 유지한다 — 지금까지의 모든 호출(로그인·
+   * 목록 조회)은 성공 시 항상 실질 데이터를 반환했으므로 `data === null`은 통신 오류·예상 밖
+   * 응답의 신호로 다뤄 왔다. true로 지정하지 않으면 `success:true`인데 `data:null`인 정상 성공
+   * 응답까지 실패로 오판된다 — edupay-backend가 실제로 이 형태를 반환한다(확인:
+   * ApiResponseVO.success(null) → `{success:true, code:200, data:null}`,
+   * ApiResponseVO.java에 `@JsonInclude(NON_NULL)`이 없어 data 키가 생략되지 않고 명시적으로
+   * null이 직렬화됨).
+   */
+  canHaveNullData?: boolean;
 };
 
 function communicationError(reason: string, detail: unknown): BackendResult<never> {
@@ -211,9 +222,13 @@
     return communicationError('응답 파싱 실패', error);
   }
 
-  if (!envelope.success || envelope.data === null) {
+  if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) {
     return { ok: false, code: envelope.code, message: envelope.message };
   }
 
-  return { ok: true, data: envelope.data };
+  // `canHaveNullData`가 true인 호출은 data가 null이어도 위에서 걸러지지 않으므로, 여기서
+  // TS는 `envelope.data`를 `T | null` 그대로 본다(narrowing 불가 — null 여부와 무관한
+  // canHaveNullData 플래그가 조건에 섞여 있어서다). 그 null은 호출부가 명시적으로 허용한
+  // 값이므로(그리고 그런 호출부는 보통 `backendFetch<null>(...)`로 T=null을 지정한다) 단언한다.
+  return { ok: true, data: envelope.data as T };
 }
Add a comment
List