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
... | ... | @@ -3,10 +3,25 @@ |
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { revalidatePath } from 'next/cache';
|
| 5 | 5 |
import { deleteSession, readSessionToken } from '@/lib/auth/session';
|
| 6 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 7 |
+import { requestAdminLogout } from '@/lib/data/repositories/auth-repository';
|
|
| 8 |
+import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 6 | 9 |
|
| 7 | 10 |
/** |
| 8 | 11 |
* 로그아웃 Server Action — 자기 세션을 파기하는 동작이라 `verifySession()`의 전제(관리자 조회)가 |
| 9 | 12 |
* 성립하지 않는다. 토큰 존재 여부만 확인해 이미 로그아웃된 상태의 중복 호출도 안전하게 처리한다. |
| 13 |
+ * |
|
| 14 |
+ * 백엔드 로그아웃 호출(GET .../auth/logout)은 best-effort다 — 실패해도(백엔드 다운, |
|
| 15 |
+ * accessToken 만료 등) 로컬 세션은 반드시 파기한다. 사용자가 로그아웃 자체를 못 하게 되는 |
|
| 16 |
+ * 상황을 절대 만들지 않기 위함이다. accessToken이 애초에 없으면(getSessionAccessToken()이 |
|
| 17 |
+ * 형식·만료·유저일치 검사에서 이미 null을 반환한 경우) 호출 자체를 건너뛴다 — 보낼 유효한 |
|
| 18 |
+ * 토큰이 없는 것은 "실패"가 아니라 "애초에 알릴 대상이 없음"이므로 실패로 취급하지 않는다. |
|
| 19 |
+ * |
|
| 20 |
+ * 실패 알림은 이 액션이 직접 띄우지 않는다 — Server Action에서 redirect()는 즉시 던져지므로 |
|
| 21 |
+ * (NEXT_REDIRECT) 클라이언트가 반환값을 돌려받아 토스트를 그릴 기회 자체가 없다(redirect와 |
|
| 22 |
+ * 클라이언트 상태 갱신이 같은 턴에서 경쟁하게 됨). 대신 `/login`에 쿼리 파라미터를 실어 보내고, |
|
| 23 |
+ * 그 화면의 클라이언트 컴포넌트(LogoutNotice)가 마운트된 뒤(=redirect가 완전히 끝난 뒤) 토스트를 |
|
| 24 |
+ * 띄운다 — 시점이 분리되어 있어 경쟁이 원천적으로 없다. |
|
| 10 | 25 |
*/ |
| 11 | 26 |
export async function logout(): Promise<void> {
|
| 12 | 27 |
const token = await readSessionToken(); |
... | ... | @@ -14,7 +29,14 @@ |
| 14 | 29 |
redirect('/login');
|
| 15 | 30 |
} |
| 16 | 31 |
|
| 32 |
+ const accessToken = await getSessionAccessToken(); |
|
| 33 |
+ const isBackendLogoutSuccessful = accessToken |
|
| 34 |
+ ? await requestAdminLogout(accessToken) |
|
| 35 |
+ : true; |
|
| 36 |
+ |
|
| 17 | 37 |
await deleteSession(); |
| 18 | 38 |
revalidatePath('/', 'layout');
|
| 19 |
- redirect('/login');
|
|
| 39 |
+ redirect( |
|
| 40 |
+ isBackendLogoutSuccessful ? '/login' : `/login?${LOGOUT_ERROR_QUERY_PARAM}=1`
|
|
| 41 |
+ ); |
|
| 20 | 42 |
} |
+++ lib/constants/logout.ts
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +/** | |
| 2 | + * 로그아웃 Server Action이 백엔드 호출 실패를 `/login` 화면에 알릴 때 쓰는 쿼리 파라미터 이름. | |
| 3 | + * | |
| 4 | + * `app/(protected)/_actions.ts`(로그아웃 액션 — redirect URL에 값을 싣는다)와 | |
| 5 | + * `app/(public)/login/page.tsx`(읽어서 LogoutNotice에 boolean prop으로 넘긴다) 양쪽이 같은 키를 | |
| 6 | + * 참조해야 하므로, 리터럴 문자열이 두 파일에 중복되어 드리프트하는 것을 막기 위해 상수로 뺀다 | |
| 7 | + * (session-cookie.ts의 SESSION_COOKIE_NAME과 동일한 목적). | |
| 8 | + * | |
| 9 | + * redirect()는 Server Action에서 즉시 던져지므로 클라이언트가 반환값을 읽을 기회가 없다 — 그래서 | |
| 10 | + * 실패 신호를 반환값이 아니라 이동할 URL 자체에 싣는다(§ redirect와 토스트 표시가 경쟁하지 않게 | |
| 11 | + * 하는 설계, app/(protected)/_actions.ts 참고). | |
| 12 | + */ | |
| 13 | +export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError'; |
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
... | ... | @@ -29,6 +29,8 @@ |
| 29 | 29 |
accessTokenExpiresAt: number; |
| 30 | 30 |
}; |
| 31 | 31 |
|
| 32 |
+const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout'; |
|
| 33 |
+ |
|
| 32 | 34 |
/** 백엔드 accessToken(JWT)의 payload 클레임. */ |
| 33 | 35 |
type AdminAccessTokenClaims = {
|
| 34 | 36 |
sub: string; |
... | ... | @@ -99,3 +101,32 @@ |
| 99 | 101 |
accessTokenExpiresAt: claims.exp, |
| 100 | 102 |
}; |
| 101 | 103 |
} |
| 104 |
+ |
|
| 105 |
+/** |
|
| 106 |
+ * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에 |
|
| 107 |
+ * 없음을 확인함: edupay-backend SecurityConfig.java). |
|
| 108 |
+ * |
|
| 109 |
+ * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지 |
|
| 110 |
+ * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend |
|
| 111 |
+ * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`, |
|
| 112 |
+ * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다. |
|
| 113 |
+ * |
|
| 114 |
+ * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로 |
|
| 115 |
+ * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고). |
|
| 116 |
+ * |
|
| 117 |
+ * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은 |
|
| 118 |
+ * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch |
|
| 119 |
+ * 없이 결과만 보고 흐름을 결정할 수 있게 한다. |
|
| 120 |
+ * |
|
| 121 |
+ * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다. |
|
| 122 |
+ */ |
|
| 123 |
+export async function requestAdminLogout(accessToken: string): Promise<boolean> {
|
|
| 124 |
+ const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
|
|
| 125 |
+ method: 'GET', |
|
| 126 |
+ accessToken, |
|
| 127 |
+ cache: 'no-store', |
|
| 128 |
+ canHaveNullData: true, |
|
| 129 |
+ }); |
|
| 130 |
+ |
|
| 131 |
+ return result.ok; |
|
| 132 |
+} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -63,6 +63,17 @@ |
| 63 | 63 |
/** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ |
| 64 | 64 |
cache?: RequestCache; |
| 65 | 65 |
next?: { revalidate?: number | false; tags?: string[] };
|
| 66 |
+ /** |
|
| 67 |
+ * 이 호출은 성공 응답의 `data`가 정상적으로 `null`일 수 있다(예: 로그아웃처럼 돌려줄 데이터가 |
|
| 68 |
+ * 없는 변경 API). 기본값 false는 기존 계약을 그대로 유지한다 — 지금까지의 모든 호출(로그인· |
|
| 69 |
+ * 목록 조회)은 성공 시 항상 실질 데이터를 반환했으므로 `data === null`은 통신 오류·예상 밖 |
|
| 70 |
+ * 응답의 신호로 다뤄 왔다. true로 지정하지 않으면 `success:true`인데 `data:null`인 정상 성공 |
|
| 71 |
+ * 응답까지 실패로 오판된다 — edupay-backend가 실제로 이 형태를 반환한다(확인: |
|
| 72 |
+ * ApiResponseVO.success(null) → `{success:true, code:200, data:null}`,
|
|
| 73 |
+ * ApiResponseVO.java에 `@JsonInclude(NON_NULL)`이 없어 data 키가 생략되지 않고 명시적으로 |
|
| 74 |
+ * null이 직렬화됨). |
|
| 75 |
+ */ |
|
| 76 |
+ canHaveNullData?: boolean; |
|
| 66 | 77 |
}; |
| 67 | 78 |
|
| 68 | 79 |
function communicationError(reason: string, detail: unknown): BackendResult<never> {
|
... | ... | @@ -211,9 +222,13 @@ |
| 211 | 222 |
return communicationError('응답 파싱 실패', error);
|
| 212 | 223 |
} |
| 213 | 224 |
|
| 214 |
- if (!envelope.success || envelope.data === null) {
|
|
| 225 |
+ if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) {
|
|
| 215 | 226 |
return { ok: false, code: envelope.code, message: envelope.message };
|
| 216 | 227 |
} |
| 217 | 228 |
|
| 218 |
- return { ok: true, data: envelope.data };
|
|
| 229 |
+ // `canHaveNullData`가 true인 호출은 data가 null이어도 위에서 걸러지지 않으므로, 여기서 |
|
| 230 |
+ // TS는 `envelope.data`를 `T | null` 그대로 본다(narrowing 불가 — null 여부와 무관한 |
|
| 231 |
+ // canHaveNullData 플래그가 조건에 섞여 있어서다). 그 null은 호출부가 명시적으로 허용한 |
|
| 232 |
+ // 값이므로(그리고 그런 호출부는 보통 `backendFetch<null>(...)`로 T=null을 지정한다) 단언한다. |
|
| 233 |
+ return { ok: true, data: envelope.data as T };
|
|
| 219 | 234 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?