merge: 관리자 로그인 연동(hub)과 통합
학생 회원 목록 조회를 로그인 구현이 세운 인증·통신 기반 위로 옮긴다. - 백엔드 HTTP 클라이언트를 lib/http/backend-fetch.ts 하나로 통일한다. 목록 연동에서 임시로 두었던 lib/data/api-client.ts는 삭제하고, 거기에만 있던 세 가지를 backendFetch로 옮겼다: 쿼리스트링 조립, Authorization 헤더(accessToken), 그리고 비2xx 응답의 봉투 code 보존. 마지막 항목이 없으면 백엔드가 HTTP 401로 주는 인증 실패가 통신 오류(code -1)로 뭉개져 "세션이 끊겼다"와 "서버가 죽었다"를 호출부가 구분할 수 없다. - 세션에 보관된 accessToken을 꺼내는 getSessionAccessToken()을 DAL에 추가한다. 화면 DTO (AdminUser)는 토큰을 담지 않으므로 서버 전용 경로를 따로 둔다. - 개발용 토큰 우회로(getDevApiAccessToken / EDUPAY_API_ACCESS_TOKEN)를 제거한다. 실제 로그인이 토큰을 공급하므로 존재 이유가 사라졌다. - lib/env.ts 충돌은 hub 쪽 getApiBaseUrl()을 채택해 해소했다(같은 환경변수 키, 같은 동작).
@5df6b01247d97b4ea11e73b3d417171fa89249e2
--- app/(public)/login/_actions.ts
+++ app/(public)/login/_actions.ts
... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { revalidatePath } from 'next/cache';
|
| 5 |
-import { verifyAdminCredentials } from '@/lib/data/repositories/auth-repository';
|
|
| 5 |
+import { requestAdminLogin } from '@/lib/data/repositories/auth-repository';
|
|
| 6 | 6 |
import { createSession } from '@/lib/auth/session';
|
| 7 | 7 |
|
| 8 | 8 |
export type LoginFormState = {
|
... | ... | @@ -44,12 +44,19 @@ |
| 44 | 44 |
return { error: GENERIC_LOGIN_ERROR };
|
| 45 | 45 |
} |
| 46 | 46 |
|
| 47 |
- const admin = await verifyAdminCredentials({ loginId, password });
|
|
| 48 |
- if (!admin) {
|
|
| 47 |
+ const result = await requestAdminLogin({ loginId, password });
|
|
| 48 |
+ if (!result) {
|
|
| 49 | 49 |
return { error: GENERIC_LOGIN_ERROR };
|
| 50 | 50 |
} |
| 51 | 51 |
|
| 52 |
- await createSession(admin.id); |
|
| 52 |
+ await createSession({
|
|
| 53 |
+ adminId: result.admin.id, |
|
| 54 |
+ loginId: result.admin.loginId, |
|
| 55 |
+ admRoleCd: result.admin.roleCode, |
|
| 56 |
+ accessToken: result.accessToken, |
|
| 57 |
+ refreshToken: result.refreshToken, |
|
| 58 |
+ exp: result.accessTokenExpiresAt, |
|
| 59 |
+ }); |
|
| 53 | 60 |
revalidatePath('/', 'layout');
|
| 54 | 61 |
redirect('/');
|
| 55 | 62 |
} |
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
... | ... | @@ -3,7 +3,6 @@ |
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { readSessionToken } from '@/lib/auth/session';
|
| 5 | 5 |
import { verifySessionToken } from '@/lib/auth/session-token';
|
| 6 |
-import { fetchAdminById } from '@/lib/data/repositories/auth-repository';
|
|
| 7 | 6 |
import type { AdminUser } from '@/lib/domain/admin-user';
|
| 8 | 7 |
|
| 9 | 8 |
/** |
... | ... | @@ -13,6 +12,9 @@ |
| 13 | 12 |
* 이후 모든 mutation(Server Action)은 Repository를 직접 호출하기 전에 반드시 |
| 14 | 13 |
* `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고 |
| 15 | 14 |
* 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다. |
| 15 |
+ * |
|
| 16 |
+ * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 클레임 |
|
| 17 |
+ * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다. |
|
| 16 | 18 |
*/ |
| 17 | 19 |
|
| 18 | 20 |
/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ |
... | ... | @@ -22,12 +24,34 @@ |
| 22 | 24 |
return null; |
| 23 | 25 |
} |
| 24 | 26 |
|
| 25 |
- const verified = verifySessionToken(token); |
|
| 26 |
- if (!verified) {
|
|
| 27 |
+ const session = verifySessionToken(token); |
|
| 28 |
+ if (!session) {
|
|
| 27 | 29 |
return null; |
| 28 | 30 |
} |
| 29 | 31 |
|
| 30 |
- return fetchAdminById(verified.adminId); |
|
| 32 |
+ return {
|
|
| 33 |
+ id: session.adminId, |
|
| 34 |
+ // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항). |
|
| 35 |
+ name: session.loginId, |
|
| 36 |
+ loginId: session.loginId, |
|
| 37 |
+ roleCode: session.admRoleCd, |
|
| 38 |
+ }; |
|
| 39 |
+}); |
|
| 40 |
+ |
|
| 41 |
+/** |
|
| 42 |
+ * 백엔드 호출에 부착할 accessToken을 세션에서 꺼낸다 — **서버 전용, 화면으로 내려보내지 않는다.** |
|
| 43 |
+ * |
|
| 44 |
+ * `getSessionAdmin()`이 반환하는 `AdminUser`에는 토큰이 없다(민감 값이라 화면 DTO에서 의도적으로 |
|
| 45 |
+ * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 만료·위조면 |
|
| 46 |
+ * null이며, 그 경우 호출부는 Authorization 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed). |
|
| 47 |
+ */ |
|
| 48 |
+export const getSessionAccessToken = cache(async (): Promise<string | null> => {
|
|
| 49 |
+ const token = await readSessionToken(); |
|
| 50 |
+ if (!token) {
|
|
| 51 |
+ return null; |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ return verifySessionToken(token)?.accessToken ?? null; |
|
| 31 | 55 |
}); |
| 32 | 56 |
|
| 33 | 57 |
/** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */ |
+++ lib/auth/decode-jwt-payload.ts
... | ... | @@ -0,0 +1,28 @@ |
| 1 | +import 'server-only'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * JWT의 payload(두 번째 세그먼트)만 파싱한다 — 서명 검증은 하지 않는다. | |
| 5 | + * | |
| 6 | + * 백엔드(edupay-backend)가 발급한 accessToken/refreshToken은 백엔드의 서명 시크릿으로 | |
| 7 | + * 서명되어 있고 우리는 그 시크릿을 갖고 있지 않아 서명 검증 자체가 불가능하다. 따라서 이 | |
| 8 | + * 함수가 반환하는 값은 "신뢰된 인가 판단"에 직접 쓰지 않는다 — 여기서 읽은 클레임은 우리 | |
| 9 | + * 자체 HMAC 세션 토큰(lib/auth/session-token.ts)에 실어 서명한 뒤에만 위조 불가능한 형태로 | |
| 10 | + * 라우트 가드에 쓰인다. 이 함수는 백엔드가 방금 응답한 토큰에서 표시용 정보(로그인 아이디, | |
| 11 | + * 역할 코드 등)를 꺼내는 매핑 용도로만 사용한다. | |
| 12 | + * | |
| 13 | + * 형식 오류·JSON 파싱 실패 등 어떤 이유로든 실패하면 예외를 던지지 않고 null을 반환한다 | |
| 14 | + * (fail-closed) — 호출부가 매번 try/catch를 두지 않아도 되게 한다. | |
| 15 | + */ | |
| 16 | +export function decodeJwtPayload<T>(token: string): T | null { | |
| 17 | + const segments = token.split('.'); | |
| 18 | + if (segments.length !== 3) { | |
| 19 | + return null; | |
| 20 | + } | |
| 21 | + | |
| 22 | + try { | |
| 23 | + const json = Buffer.from(segments[1], 'base64url').toString('utf8'); | |
| 24 | + return JSON.parse(json) as T; | |
| 25 | + } catch { | |
| 26 | + return null; | |
| 27 | + } | |
| 28 | +} |
--- lib/auth/session-token.ts
+++ lib/auth/session-token.ts
... | ... | @@ -6,13 +6,38 @@ |
| 6 | 6 |
* 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰. |
| 7 | 7 |
* 형식: `base64url(JSON payload).base64url(signature)`. |
| 8 | 8 |
* 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed). |
| 9 |
+ * |
|
| 10 |
+ * 세션 payload는 백엔드(edupay-backend) 로그인 응답의 accessToken/refreshToken을 그대로 |
|
| 11 |
+ * 담는다 — 백엔드 토큰을 우리가 서명 검증할 수 없는 채로 쿠키에 그대로 두면(우리는 백엔드 |
|
| 12 |
+ * 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이 |
|
| 13 |
+ * HMAC 서명이 그 위조를 막는 경계다. |
|
| 14 |
+ * |
|
| 15 |
+ * 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의 |
|
| 16 |
+ * `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국 |
|
| 17 |
+ * accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래 |
|
| 18 |
+ * 살면 이미 만료된 accessToken을 유효한 세션이 계속 들고 있다가 실제 API 호출 시점에야 실패가 |
|
| 19 |
+ * 드러나고, (2) 세션이 accessToken보다 먼저 끊기면 아직 쓸 수 있는 토큰을 조기 폐기하게 된다. |
|
| 20 |
+ * 과거의 고정 12시간 TTL(mock 인증 시절 자체 발급 토큰 기준)은 이제 의미가 없다 — 백엔드가 |
|
| 21 |
+ * accessToken 수명(현재 24시간)의 유일한 소유자이므로 그 값을 그대로 따른다. |
|
| 9 | 22 |
*/ |
| 10 | 23 |
|
| 11 |
-const TOKEN_TTL_SECONDS = 60 * 60 * 12; // 12시간 — 보안 우선으로 짧게 설정 |
|
| 12 |
- |
|
| 13 |
-type SessionTokenPayload = {
|
|
| 24 |
+export type SessionTokenPayload = {
|
|
| 14 | 25 |
adminId: string; |
| 26 |
+ loginId: string; |
|
| 27 |
+ admRoleCd: string; |
|
| 28 |
+ accessToken: string; |
|
| 29 |
+ refreshToken: string; |
|
| 15 | 30 |
iat: number; |
| 31 |
+ exp: number; |
|
| 32 |
+}; |
|
| 33 |
+ |
|
| 34 |
+export type CreateSessionTokenInput = {
|
|
| 35 |
+ adminId: string; |
|
| 36 |
+ loginId: string; |
|
| 37 |
+ admRoleCd: string; |
|
| 38 |
+ accessToken: string; |
|
| 39 |
+ refreshToken: string; |
|
| 40 |
+ /** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */ |
|
| 16 | 41 |
exp: number; |
| 17 | 42 |
}; |
| 18 | 43 |
|
... | ... | @@ -30,12 +55,16 @@ |
| 30 | 55 |
.digest('base64url');
|
| 31 | 56 |
} |
| 32 | 57 |
|
| 33 |
-export function createSessionToken(adminId: string): string {
|
|
| 58 |
+export function createSessionToken(input: CreateSessionTokenInput): string {
|
|
| 34 | 59 |
const issuedAt = Math.floor(Date.now() / 1000); |
| 35 | 60 |
const payload: SessionTokenPayload = {
|
| 36 |
- adminId, |
|
| 61 |
+ adminId: input.adminId, |
|
| 62 |
+ loginId: input.loginId, |
|
| 63 |
+ admRoleCd: input.admRoleCd, |
|
| 64 |
+ accessToken: input.accessToken, |
|
| 65 |
+ refreshToken: input.refreshToken, |
|
| 37 | 66 |
iat: issuedAt, |
| 38 |
- exp: issuedAt + TOKEN_TTL_SECONDS, |
|
| 67 |
+ exp: input.exp, |
|
| 39 | 68 |
}; |
| 40 | 69 |
|
| 41 | 70 |
const encodedPayload = base64UrlEncode(JSON.stringify(payload)); |
... | ... | @@ -43,7 +72,7 @@ |
| 43 | 72 |
return `${encodedPayload}.${signature}`;
|
| 44 | 73 |
} |
| 45 | 74 |
|
| 46 |
-export function verifySessionToken(token: string): { adminId: string } | null {
|
|
| 75 |
+export function verifySessionToken(token: string): SessionTokenPayload | null {
|
|
| 47 | 76 |
const [encodedPayload, signature] = token.split('.');
|
| 48 | 77 |
if (!encodedPayload || !signature) {
|
| 49 | 78 |
return null; |
... | ... | @@ -67,7 +96,18 @@ |
| 67 | 96 |
return null; |
| 68 | 97 |
} |
| 69 | 98 |
|
| 70 |
- if (typeof payload.adminId !== 'string' || !payload.adminId) {
|
|
| 99 |
+ if ( |
|
| 100 |
+ typeof payload.adminId !== 'string' || |
|
| 101 |
+ !payload.adminId || |
|
| 102 |
+ typeof payload.loginId !== 'string' || |
|
| 103 |
+ !payload.loginId || |
|
| 104 |
+ typeof payload.admRoleCd !== 'string' || |
|
| 105 |
+ !payload.admRoleCd || |
|
| 106 |
+ typeof payload.accessToken !== 'string' || |
|
| 107 |
+ !payload.accessToken || |
|
| 108 |
+ typeof payload.refreshToken !== 'string' || |
|
| 109 |
+ !payload.refreshToken |
|
| 110 |
+ ) {
|
|
| 71 | 111 |
return null; |
| 72 | 112 |
} |
| 73 | 113 |
|
... | ... | @@ -76,5 +116,5 @@ |
| 76 | 116 |
return null; |
| 77 | 117 |
} |
| 78 | 118 |
|
| 79 |
- return { adminId: payload.adminId };
|
|
| 119 |
+ return payload; |
|
| 80 | 120 |
} |
--- lib/auth/session.ts
+++ lib/auth/session.ts
... | ... | @@ -4,12 +4,17 @@ |
| 4 | 4 |
SESSION_COOKIE_NAME, |
| 5 | 5 |
SESSION_COOKIE_OPTIONS, |
| 6 | 6 |
} from '@/lib/auth/session-cookie'; |
| 7 |
-import { createSessionToken } from '@/lib/auth/session-token';
|
|
| 7 |
+import {
|
|
| 8 |
+ createSessionToken, |
|
| 9 |
+ type CreateSessionTokenInput, |
|
| 10 |
+} from '@/lib/auth/session-token'; |
|
| 8 | 11 |
|
| 9 | 12 |
/** 세션 쿠키 조작 — Server Action에서만 set/delete 가능 (Next.js cookies() 제약). */ |
| 10 | 13 |
|
| 11 |
-export async function createSession(adminId: string): Promise<void> {
|
|
| 12 |
- const token = createSessionToken(adminId); |
|
| 14 |
+export async function createSession( |
|
| 15 |
+ input: CreateSessionTokenInput |
|
| 16 |
+): Promise<void> {
|
|
| 17 |
+ const token = createSessionToken(input); |
|
| 13 | 18 |
const cookieStore = await cookies(); |
| 14 | 19 |
cookieStore.set(SESSION_COOKIE_NAME, token, SESSION_COOKIE_OPTIONS); |
| 15 | 20 |
} |
--- lib/data/api-client.ts
... | ... | @@ -1,175 +0,0 @@ |
| 1 | -import 'server-only'; | |
| 2 | -import { getApiBaseUrl, getDevApiAccessToken } from '@/lib/env'; | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * 백엔드(edupay-backend) 통신 규약의 단일 지점 (설계서 §4 `server/api-client`). | |
| 6 | - * | |
| 7 | - * 이 파일이 아는 것은 "백엔드와 말하는 법"뿐이다 — URL 조립, 인증 헤더 부착, 공통 응답 봉투 | |
| 8 | - * 해석, 실패 정규화. 어떤 화면이 무엇을 조회하는지(엔드포인트·파라미터·필드 매핑)는 각 | |
| 9 | - * Repository의 책임이라 여기로 들어오지 않는다. 반환 타입이 `unknown`인 것도 그 때문이다 — | |
| 10 | - * 응답 shape 검증은 그 shape을 아는 Repository가 한다. | |
| 11 | - * | |
| 12 | - * 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). `server-only`로 클라이언트 | |
| 13 | - * 번들 유입 시 빌드가 실패하게 막아 이 전제를 도구로 강제한다. | |
| 14 | - */ | |
| 15 | - | |
| 16 | -/** 백엔드 공통 응답 봉투. 성공·실패 모두 이 형태로 온다. */ | |
| 17 | -type ApiEnvelope = { | |
| 18 | - success?: unknown; | |
| 19 | - code?: unknown; | |
| 20 | - message?: unknown; | |
| 21 | - data?: unknown; | |
| 22 | -}; | |
| 23 | - | |
| 24 | -export type ApiErrorKind = | |
| 25 | - /** 401 — 토큰이 없거나 만료·무효다. */ | |
| 26 | - | 'unauthorized' | |
| 27 | - /** 백엔드가 입력값을 거절했다(예: code 900 입력값 무결성 오류). */ | |
| 28 | - | 'validation' | |
| 29 | - /** 백엔드가 그 외 실패를 반환했다. */ | |
| 30 | - | 'server' | |
| 31 | - /** 백엔드에 닿지 못했다(DNS·타임아웃·TLS 등). */ | |
| 32 | - | 'network' | |
| 33 | - /** 닿았지만 약속된 형태가 아니다(JSON 파싱 실패, 필드 누락 등). */ | |
| 34 | - | 'contract'; | |
| 35 | - | |
| 36 | -/** | |
| 37 | - * 백엔드 호출 실패의 정규화된 표현 (설계서 §8.1 6층 "실패 안전"). | |
| 38 | - * 화면에는 이 메시지를 그대로 내보내지 않는다 — 호출부가 일반화된 문구로 바꿔 보여준다. | |
| 39 | - */ | |
| 40 | -export class ApiError extends Error { | |
| 41 | - readonly kind: ApiErrorKind; | |
| 42 | - /** 백엔드가 준 code. HTTP 레벨에서 끊긴 경우엔 HTTP status, 그마저 없으면 null. */ | |
| 43 | - readonly code: number | null; | |
| 44 | - | |
| 45 | - constructor( | |
| 46 | - kind: ApiErrorKind, | |
| 47 | - message: string, | |
| 48 | - code: number | null = null, | |
| 49 | - options?: { cause?: unknown } | |
| 50 | - ) { | |
| 51 | - super(message, options); | |
| 52 | - this.name = 'ApiError'; | |
| 53 | - this.kind = kind; | |
| 54 | - this.code = code; | |
| 55 | - } | |
| 56 | -} | |
| 57 | - | |
| 58 | -/** | |
| 59 | - * 요청에 붙일 백엔드 accessToken을 얻는다 — **인증 연동의 유일한 이식 지점**이다. | |
| 60 | - * | |
| 61 | - * 현재 세션 토큰(`lib/auth/session-token.ts`)은 mock 관리자의 `adminId`만 담고 있어 부착할 | |
| 62 | - * 백엔드 토큰이 없다. 관리자 로그인 연동(별도 작업)이 들어오면 이 함수 하나만 | |
| 63 | - * "세션에서 accessToken을 읽어 반환"하도록 바꾸면 되고, 개별 Repository는 손대지 않는다. | |
| 64 | - * 그때 개발용 env 폴백(`getDevApiAccessToken`)은 함께 제거한다. | |
| 65 | - */ | |
| 66 | -async function resolveAccessToken(): Promise<string | null> { | |
| 67 | - return getDevApiAccessToken(); | |
| 68 | -} | |
| 69 | - | |
| 70 | -/** base URL과 경로를 합친다. base 끝의 `/`·경로 앞의 `/` 유무와 무관하게 같은 URL을 만든다. */ | |
| 71 | -function buildRequestUrl( | |
| 72 | - path: string, | |
| 73 | - params: Record<string, string | number> | |
| 74 | -): string { | |
| 75 | - const base = getApiBaseUrl().replace(/\/+$/, ''); | |
| 76 | - const normalizedPath = path.startsWith('/') ? path : `/${path}`; | |
| 77 | - const url = new URL(`${base}${normalizedPath}`); | |
| 78 | - | |
| 79 | - for (const [key, value] of Object.entries(params)) { | |
| 80 | - url.searchParams.set(key, String(value)); | |
| 81 | - } | |
| 82 | - | |
| 83 | - return url.toString(); | |
| 84 | -} | |
| 85 | - | |
| 86 | -/** 실패 응답에서 code/message를 최대한 건져낸다 — 형태가 깨져 있어도 throw하지 않는다. */ | |
| 87 | -function readEnvelopeCode(envelope: ApiEnvelope): number | null { | |
| 88 | - return typeof envelope.code === 'number' ? envelope.code : null; | |
| 89 | -} | |
| 90 | - | |
| 91 | -function readEnvelopeMessage(envelope: ApiEnvelope, fallback: string): string { | |
| 92 | - return typeof envelope.message === 'string' && envelope.message | |
| 93 | - ? envelope.message | |
| 94 | - : fallback; | |
| 95 | -} | |
| 96 | - | |
| 97 | -function classifyFailure(code: number | null): ApiErrorKind { | |
| 98 | - if (code === 401 || code === 403) { | |
| 99 | - return 'unauthorized'; | |
| 100 | - } | |
| 101 | - // 900 = 입력값 무결성 오류(백엔드 통지 계약). | |
| 102 | - if (code === 900) { | |
| 103 | - return 'validation'; | |
| 104 | - } | |
| 105 | - return 'server'; | |
| 106 | -} | |
| 107 | - | |
| 108 | -async function readEnvelope(response: Response): Promise<ApiEnvelope> { | |
| 109 | - try { | |
| 110 | - const parsed: unknown = await response.json(); | |
| 111 | - return parsed !== null && typeof parsed === 'object' | |
| 112 | - ? (parsed as ApiEnvelope) | |
| 113 | - : {}; | |
| 114 | - } catch { | |
| 115 | - return {}; | |
| 116 | - } | |
| 117 | -} | |
| 118 | - | |
| 119 | -/** | |
| 120 | - * 백엔드 GET 조회. 성공 시 봉투의 `data`만 돌려주고, 실패는 전부 `ApiError`로 정규화한다. | |
| 121 | - * | |
| 122 | - * 캐시: `no-store` 고정. 어드민은 데이터 신선도가 우선이고(설계서 §7.1), 조회 대상이 | |
| 123 | - * 개인정보이며 검색 조건이 매 요청 달라져 재사용 캐시를 둘 이유가 없다. | |
| 124 | - * | |
| 125 | - * 성공 판정에 `success` 플래그를 단독으로 믿지 않는다 — 실측 결과 401 응답이 | |
| 126 | - * `{"success":true,"auth":false,"code":401,...}`로 와서 `success`만 보면 실패를 성공으로 | |
| 127 | - * 읽는다. HTTP 상태와 `code`를 함께 확인한다. | |
| 128 | - */ | |
| 129 | -export async function apiGet( | |
| 130 | - path: string, | |
| 131 | - params: Record<string, string | number> = {} | |
| 132 | -): Promise<unknown> { | |
| 133 | - const url = buildRequestUrl(path, params); | |
| 134 | - const accessToken = await resolveAccessToken(); | |
| 135 | - | |
| 136 | - let response: Response; | |
| 137 | - try { | |
| 138 | - response = await fetch(url, { | |
| 139 | - method: 'GET', | |
| 140 | - headers: { | |
| 141 | - Accept: 'application/json', | |
| 142 | - ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), | |
| 143 | - }, | |
| 144 | - cache: 'no-store', | |
| 145 | - }); | |
| 146 | - } catch (cause) { | |
| 147 | - throw new ApiError( | |
| 148 | - 'network', | |
| 149 | - `백엔드에 연결하지 못했습니다: ${path}`, | |
| 150 | - null, | |
| 151 | - { cause } | |
| 152 | - ); | |
| 153 | - } | |
| 154 | - | |
| 155 | - const envelope = await readEnvelope(response); | |
| 156 | - const code = readEnvelopeCode(envelope) ?? response.status; | |
| 157 | - | |
| 158 | - if (!response.ok) { | |
| 159 | - throw new ApiError( | |
| 160 | - classifyFailure(code), | |
| 161 | - readEnvelopeMessage(envelope, `백엔드 응답 실패(HTTP ${response.status})`), | |
| 162 | - code | |
| 163 | - ); | |
| 164 | - } | |
| 165 | - | |
| 166 | - if (envelope.success !== true || code !== 200) { | |
| 167 | - throw new ApiError( | |
| 168 | - classifyFailure(code), | |
| 169 | - readEnvelopeMessage(envelope, '백엔드 응답 실패'), | |
| 170 | - code | |
| 171 | - ); | |
| 172 | - } | |
| 173 | - | |
| 174 | - return envelope.data; | |
| 175 | -} |
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
... | ... | @@ -1,56 +1,101 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
-import { createHash, timingSafeEqual } from 'node:crypto';
|
|
| 3 |
-import { getMockAdminCredentials } from '@/lib/env';
|
|
| 2 |
+import { backendFetch } from '@/lib/http/backend-fetch';
|
|
| 3 |
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
|
|
| 4 | 4 |
import type { AdminUser } from '@/lib/domain/admin-user';
|
| 5 | 5 |
|
| 6 | 6 |
/** |
| 7 |
- * mock 인증 Repository. |
|
| 7 |
+ * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동. |
|
| 8 | 8 |
* |
| 9 |
- * 백엔드(edupay-backend)에 관리자 인증 API가 아직 없어 mock으로 구현한다. |
|
| 10 |
- * 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 — 연동 시점에는 |
|
| 11 |
- * 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략(`cache: 'no-store'`)을 |
|
| 12 |
- * 명시적으로 추가해야 한다. 인증·개인 데이터는 요청 간 캐시 잔존이 금지되기 때문이다. |
|
| 9 |
+ * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()). |
|
| 10 |
+ * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로). |
|
| 11 |
+ * |
|
| 12 |
+ * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은 |
|
| 13 |
+ * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다 |
|
| 14 |
+ * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후 |
|
| 15 |
+ * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다 |
|
| 16 |
+ * (사용자 확정 사항). |
|
| 13 | 17 |
*/ |
| 14 |
- |
|
| 15 |
-const MOCK_ADMIN_ID = 'mock-admin-1'; |
|
| 16 | 18 |
|
| 17 | 19 |
export type AdminCredentials = {
|
| 18 | 20 |
loginId: string; |
| 19 | 21 |
password: string; |
| 20 | 22 |
}; |
| 21 | 23 |
|
| 22 |
-/** SHA-256 다이제스트 후 timingSafeEqual 비교 — 길이·타이밍 정보 누출을 방지한다. */ |
|
| 23 |
-function digestsMatch(a: string, b: string): boolean {
|
|
| 24 |
- const digestA = createHash('sha256').update(a, 'utf8').digest();
|
|
| 25 |
- const digestB = createHash('sha256').update(b, 'utf8').digest();
|
|
| 26 |
- return timingSafeEqual(digestA, digestB); |
|
| 27 |
-} |
|
| 24 |
+export type AdminLoginResult = {
|
|
| 25 |
+ admin: AdminUser; |
|
| 26 |
+ accessToken: string; |
|
| 27 |
+ refreshToken: string; |
|
| 28 |
+ /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */ |
|
| 29 |
+ accessTokenExpiresAt: number; |
|
| 30 |
+}; |
|
| 28 | 31 |
|
| 29 |
-export async function verifyAdminCredentials( |
|
| 32 |
+/** 백엔드 accessToken(JWT)의 payload 클레임. */ |
|
| 33 |
+type AdminAccessTokenClaims = {
|
|
| 34 |
+ sub: string; |
|
| 35 |
+ loginId: string; |
|
| 36 |
+ admRoleCd: string; |
|
| 37 |
+ adminId: string; |
|
| 38 |
+ userType: string; |
|
| 39 |
+ userId: string; |
|
| 40 |
+ iat: number; |
|
| 41 |
+ exp: number; |
|
| 42 |
+}; |
|
| 43 |
+ |
|
| 44 |
+type AdminLoginResponse = {
|
|
| 45 |
+ accessToken: string; |
|
| 46 |
+ refreshToken: string; |
|
| 47 |
+}; |
|
| 48 |
+ |
|
| 49 |
+/** |
|
| 50 |
+ * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라 |
|
| 51 |
+ * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가 |
|
| 52 |
+ * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 — |
|
| 53 |
+ * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다). |
|
| 54 |
+ * |
|
| 55 |
+ * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함). |
|
| 56 |
+ */ |
|
| 57 |
+export async function requestAdminLogin( |
|
| 30 | 58 |
credentials: AdminCredentials |
| 31 |
-): Promise<AdminUser | null> {
|
|
| 32 |
- const seed = getMockAdminCredentials(); |
|
| 33 |
- if (!seed) {
|
|
| 59 |
+): Promise<AdminLoginResult | null> {
|
|
| 60 |
+ const result = await backendFetch<AdminLoginResponse>( |
|
| 61 |
+ '/api/v1/common/auth/admin/login', |
|
| 62 |
+ {
|
|
| 63 |
+ method: 'POST', |
|
| 64 |
+ body: { loginId: credentials.loginId, loginPw: credentials.password },
|
|
| 65 |
+ cache: 'no-store', |
|
| 66 |
+ } |
|
| 67 |
+ ); |
|
| 68 |
+ |
|
| 69 |
+ if (!result.ok) {
|
|
| 34 | 70 |
return null; |
| 35 | 71 |
} |
| 36 | 72 |
|
| 37 |
- const loginIdMatches = digestsMatch(credentials.loginId, seed.loginId); |
|
| 38 |
- const passwordMatches = digestsMatch(credentials.password, seed.password); |
|
| 39 |
- |
|
| 40 |
- if (!loginIdMatches || !passwordMatches) {
|
|
| 73 |
+ const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken); |
|
| 74 |
+ if ( |
|
| 75 |
+ !claims || |
|
| 76 |
+ typeof claims.adminId !== 'string' || |
|
| 77 |
+ !claims.adminId || |
|
| 78 |
+ typeof claims.loginId !== 'string' || |
|
| 79 |
+ !claims.loginId || |
|
| 80 |
+ typeof claims.admRoleCd !== 'string' || |
|
| 81 |
+ !claims.admRoleCd || |
|
| 82 |
+ typeof claims.exp !== 'number' |
|
| 83 |
+ ) {
|
|
| 41 | 84 |
return null; |
| 42 | 85 |
} |
| 43 | 86 |
|
| 44 |
- return { id: MOCK_ADMIN_ID, name: '관리자' };
|
|
| 45 |
-} |
|
| 87 |
+ const admin: AdminUser = {
|
|
| 88 |
+ id: claims.adminId, |
|
| 89 |
+ // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항). |
|
| 90 |
+ name: claims.loginId, |
|
| 91 |
+ loginId: claims.loginId, |
|
| 92 |
+ roleCode: claims.admRoleCd, |
|
| 93 |
+ }; |
|
| 46 | 94 |
|
| 47 |
-export async function fetchAdminById( |
|
| 48 |
- adminId: string |
|
| 49 |
-): Promise<AdminUser | null> {
|
|
| 50 |
- const seed = getMockAdminCredentials(); |
|
| 51 |
- if (!seed || adminId !== MOCK_ADMIN_ID) {
|
|
| 52 |
- return null; |
|
| 53 |
- } |
|
| 54 |
- |
|
| 55 |
- return { id: MOCK_ADMIN_ID, name: '관리자' };
|
|
| 95 |
+ return {
|
|
| 96 |
+ admin, |
|
| 97 |
+ accessToken: result.data.accessToken, |
|
| 98 |
+ refreshToken: result.data.refreshToken, |
|
| 99 |
+ accessTokenExpiresAt: claims.exp, |
|
| 100 |
+ }; |
|
| 56 | 101 |
} |
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
-import { ApiError, apiGet } from '@/lib/data/api-client';
|
|
| 2 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 3 |
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 3 | 4 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 4 | 5 |
import type {
|
| 5 | 6 |
StudentMemberQuery, |
... | ... | @@ -8,8 +9,8 @@ |
| 8 | 9 |
|
| 9 | 10 |
/** |
| 10 | 11 |
* 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터· |
| 11 |
- * 응답 매핑). 백엔드와 말하는 공통 규약(URL·인증 헤더·응답 봉투·에러 정규화)은 |
|
| 12 |
- * `lib/data/api-client.ts`가 소유하므로 여기에 들어오지 않는다. |
|
| 12 |
+ * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은 |
|
| 13 |
+ * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
|
| 13 | 14 |
* |
| 14 | 15 |
* GET /api/v1/mngr/user/pagination (ROLE_ADMIN 전용) |
| 15 | 16 |
* → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
|
... | ... | @@ -27,7 +28,11 @@ |
| 27 | 28 |
* teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다. |
| 28 | 29 |
* 화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다. |
| 29 | 30 |
* |
| 30 |
- * 캐시: `apiGet`이 `no-store`를 고정한다 — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
|
| 31 |
+ * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서 |
|
| 32 |
+ * 꺼내 Bearer로 붙인다 — 관리자 로그인 응답의 토큰이 그대로 통한다(JWT의 `userType`=ADMIN 클레임을 |
|
| 33 |
+ * 백엔드가 ROLE_ADMIN 권한으로 변환한다). |
|
| 34 |
+ * |
|
| 35 |
+ * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다. |
|
| 31 | 36 |
*/ |
| 32 | 37 |
|
| 33 | 38 |
const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination'; |
... | ... | @@ -98,11 +103,7 @@ |
| 98 | 103 |
): string {
|
| 99 | 104 |
const value = source[key]; |
| 100 | 105 |
if (typeof value !== 'string' || value.length === 0) {
|
| 101 |
- throw new ApiError( |
|
| 102 |
- 'contract', |
|
| 103 |
- `학생 회원 응답에 ${key}가 없습니다.`,
|
|
| 104 |
- null |
|
| 105 |
- ); |
|
| 106 |
+ throw new Error(`학생 회원 응답에 ${key}가 없습니다.`);
|
|
| 106 | 107 |
} |
| 107 | 108 |
return value; |
| 108 | 109 |
} |
... | ... | @@ -119,7 +120,7 @@ |
| 119 | 120 |
*/ |
| 120 | 121 |
function toStudentMember(raw: unknown): StudentMember {
|
| 121 | 122 |
if (!isRecord(raw)) {
|
| 122 |
- throw new ApiError('contract', '학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 123 |
+ throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 123 | 124 |
} |
| 124 | 125 |
|
| 125 | 126 |
const userId = readRequiredString(raw, 'userId'); |
... | ... | @@ -174,13 +175,25 @@ |
| 174 | 175 |
export async function fetchStudentMembers( |
| 175 | 176 |
query: StudentMemberQuery |
| 176 | 177 |
): Promise<StudentMemberPage> {
|
| 177 |
- const data = await apiGet(STUDENT_MEMBER_PAGINATION_PATH, {
|
|
| 178 |
- ...buildSearchParams(query), |
|
| 179 |
- ...buildPaginationParams(query), |
|
| 178 |
+ const accessToken = await getSessionAccessToken(); |
|
| 179 |
+ |
|
| 180 |
+ const result = await backendFetch<unknown>(STUDENT_MEMBER_PAGINATION_PATH, {
|
|
| 181 |
+ method: 'GET', |
|
| 182 |
+ query: {
|
|
| 183 |
+ ...buildSearchParams(query), |
|
| 184 |
+ ...buildPaginationParams(query), |
|
| 185 |
+ }, |
|
| 186 |
+ accessToken: accessToken ?? undefined, |
|
| 187 |
+ cache: 'no-store', |
|
| 180 | 188 |
}); |
| 181 | 189 |
|
| 190 |
+ if (!result.ok) {
|
|
| 191 |
+ throw new BackendRequestError(result); |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ const data = result.data; |
|
| 182 | 195 |
if (!isRecord(data) || !Array.isArray(data.list)) {
|
| 183 |
- throw new ApiError('contract', '학생 회원 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 196 |
+ throw new Error('학생 회원 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 184 | 197 |
} |
| 185 | 198 |
|
| 186 | 199 |
const items = data.list.map(toStudentMember); |
--- lib/domain/admin-user.ts
+++ lib/domain/admin-user.ts
... | ... | @@ -1,8 +1,17 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 관리자 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. |
| 3 |
- * 화면에는 최소 DTO만 노출한다 (비밀번호 등 민감 필드는 여기 포함하지 않는다). |
|
| 3 |
+ * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다 — 토큰은 |
|
| 4 |
+ * 세션 payload에만 보관하고 화면까지 내려주지 않는다). |
|
| 4 | 5 |
*/ |
| 5 | 6 |
export type AdminUser = {
|
| 6 | 7 |
id: string; |
| 8 |
+ /** |
|
| 9 |
+ * 백엔드 accessToken(JWT)에 관리자 "이름" 클레임이 없어 loginId를 표시용 이름으로 그대로 |
|
| 10 |
+ * 대체한다(사용자 확정 사항, 별도 이름 조회 API 없음). 이름 클레임/API가 추가되면 이 필드를 |
|
| 11 |
+ * 실제 이름으로 교체한다. |
|
| 12 |
+ */ |
|
| 7 | 13 |
name: string; |
| 14 |
+ loginId: string; |
|
| 15 |
+ /** 백엔드 accessToken의 admRoleCd 클레임 (예: "ROLE_SYSTEM"). */ |
|
| 16 |
+ roleCode: string; |
|
| 8 | 17 |
}; |
--- lib/env.ts
+++ lib/env.ts
... | ... | @@ -24,43 +24,11 @@ |
| 24 | 24 |
return readRequiredEnv('SESSION_SECRET');
|
| 25 | 25 |
} |
| 26 | 26 |
|
| 27 |
-/** 백엔드(edupay-backend) API 오리진. 누락 시 즉시 throw(fail-fast, 기본값 폴백 없음). */ |
|
| 27 |
+/** |
|
| 28 |
+ * 백엔드(edupay-backend) API의 base URL. 서버 전용 — `NEXT_PUBLIC_` 접두를 붙이지 않는다. |
|
| 29 |
+ * 클라이언트는 백엔드에 직접 요청하지 않고 항상 Server Action → Repository(lib/http/backend-fetch.ts) |
|
| 30 |
+ * 를 경유하므로 이 값이 브라우저에 노출될 필요가 없다. |
|
| 31 |
+ */ |
|
| 28 | 32 |
export function getApiBaseUrl(): string {
|
| 29 | 33 |
return readRequiredEnv('EDUPAY_API_BASE_URL');
|
| 30 |
-} |
|
| 31 |
- |
|
| 32 |
-/** |
|
| 33 |
- * 개발용 백엔드 accessToken. |
|
| 34 |
- * |
|
| 35 |
- * 관리자 로그인 연동(별도 작업)이 끝나면 토큰은 세션에서 나오고 이 함수는 삭제 대상이다. |
|
| 36 |
- * 그 전까지 보호된 백엔드 API를 실제 데이터로 확인할 수 있게 하는 임시 우회로이며, |
|
| 37 |
- * 운영에서는 키가 있더라도 읽지 않는다(fail-closed) — 개발용 토큰이 배포에 섞여 들어가도 |
|
| 38 |
- * 운영 트래픽이 그 토큰으로 백엔드를 호출하는 일이 없어야 하기 때문이다. |
|
| 39 |
- */ |
|
| 40 |
-export function getDevApiAccessToken(): string | null {
|
|
| 41 |
- if (process.env.NODE_ENV === 'production') {
|
|
| 42 |
- return null; |
|
| 43 |
- } |
|
| 44 |
- |
|
| 45 |
- return process.env.EDUPAY_API_ACCESS_TOKEN || null; |
|
| 46 |
-} |
|
| 47 |
- |
|
| 48 |
-export type MockAdminCredentials = {
|
|
| 49 |
- loginId: string; |
|
| 50 |
- password: string; |
|
| 51 |
-}; |
|
| 52 |
- |
|
| 53 |
-/** |
|
| 54 |
- * mock 관리자 시드 계정. 운영 환경(.env.production)에는 키를 두지 않아 |
|
| 55 |
- * 누락 시 null을 반환 — mock 로그인이 항상 실패하도록(fail-closed) 한다. |
|
| 56 |
- */ |
|
| 57 |
-export function getMockAdminCredentials(): MockAdminCredentials | null {
|
|
| 58 |
- const loginId = process.env.MOCK_ADMIN_LOGIN_ID; |
|
| 59 |
- const password = process.env.MOCK_ADMIN_PASSWORD; |
|
| 60 |
- |
|
| 61 |
- if (!loginId || !password) {
|
|
| 62 |
- return null; |
|
| 63 |
- } |
|
| 64 |
- |
|
| 65 |
- return { loginId, password };
|
|
| 66 | 34 |
} |
+++ lib/http/backend-fetch.ts
... | ... | @@ -0,0 +1,162 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getApiBaseUrl } from '@/lib/env'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더, | |
| 6 | + * 공통 응답 봉투 `{ success, code, message, data }` 파싱을 여기서만 다룬다. Repository는 이 | |
| 7 | + * 함수를 통해서만 백엔드를 호출하고, 직접 `fetch()`를 호출하지 않는다. | |
| 8 | + * | |
| 9 | + * ⚠️ 백엔드는 비즈니스 실패(예: 로그인 실패)도 HTTP 200으로 응답하고 응답 봉투의 `success` | |
| 10 | + * 필드로만 성공/실패를 구분한다(실제 호출로 확인됨: POST .../admin/login에 잘못된 계정을 | |
| 11 | + * 넣어도 status 200 + `{success:false, code:300, ...}`가 온다). 그래서 이 모듈은 HTTP status가 | |
| 12 | + * 아니라 `success` 필드를 성공/실패의 유일한 판정 기준으로 삼는다. status가 2xx가 아닌 경우 | |
| 13 | + * (5xx 등 인프라 오류)와 네트워크 실패·타임아웃·JSON 파싱 실패는 모두 "통신 오류"로 뭉뚱그려 | |
| 14 | + * 반환한다 — 상세 사유는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달해 | |
| 15 | + * 내부 정보 노출을 막는다. | |
| 16 | + * | |
| 17 | + * 캐시 전략은 이 모듈이 강제하지 않는다 — 호출부(Repository)가 §2.6에 따라 매 호출마다 | |
| 18 | + * `cache`/`next` 옵션을 명시해 전달해야 한다. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +const REQUEST_TIMEOUT_MS = 10_000; | |
| 22 | +const COMMUNICATION_ERROR_CODE = -1; | |
| 23 | +const COMMUNICATION_ERROR_MESSAGE = | |
| 24 | + '서버와 통신할 수 없습니다. 잠시 후 다시 시도해 주세요.'; | |
| 25 | + | |
| 26 | +type BackendEnvelope<T> = { | |
| 27 | + success: boolean; | |
| 28 | + code: number; | |
| 29 | + message: string; | |
| 30 | + data: T | null; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export type BackendResult<T> = | |
| 34 | + | { ok: true; data: T } | |
| 35 | + | { ok: false; code: number; message: string }; | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * 실패를 예외로 다루는 호출부(조회 Repository 등)가 `BackendResult`를 그대로 던질 때 쓰는 타입. | |
| 39 | + * 로그인처럼 실패가 정상 흐름인 호출부는 이걸 쓰지 않고 `ok: false`를 값으로 다룬다. | |
| 40 | + * 화면에는 이 메시지를 그대로 노출하지 않는다 — 에러 경계가 일반화된 문구로 바꿔 보여준다. | |
| 41 | + */ | |
| 42 | +export class BackendRequestError extends Error { | |
| 43 | + readonly code: number; | |
| 44 | + | |
| 45 | + constructor(result: { code: number; message: string }) { | |
| 46 | + super(result.message); | |
| 47 | + this.name = 'BackendRequestError'; | |
| 48 | + this.code = result.code; | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +type BackendRequestInit = { | |
| 53 | + method: 'GET' | 'POST'; | |
| 54 | + body?: unknown; | |
| 55 | + /** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */ | |
| 56 | + query?: Record<string, string | number>; | |
| 57 | + /** | |
| 58 | + * 백엔드 accessToken. 인증이 필요한 엔드포인트(`/api/v1/mngr/**`는 ROLE_ADMIN 전용)를 부를 때 | |
| 59 | + * 호출부가 세션에서 꺼내 전달한다 — 이 모듈이 세션을 직접 읽지 않는 이유는, 토큰을 어디에 | |
| 60 | + * 보관하고 언제 유효로 볼지가 인증 정책(lib/auth)의 책임이고 통신 규약의 책임이 아니기 때문이다. | |
| 61 | + */ | |
| 62 | + accessToken?: string; | |
| 63 | + /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ | |
| 64 | + cache?: RequestCache; | |
| 65 | + next?: { revalidate?: number | false; tags?: string[] }; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +function communicationError(reason: string, detail: unknown): BackendResult<never> { | |
| 69 | + // 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다. | |
| 70 | + console.error(`[backend-fetch] ${reason}`, detail); | |
| 71 | + return { | |
| 72 | + ok: false, | |
| 73 | + code: COMMUNICATION_ERROR_CODE, | |
| 74 | + message: COMMUNICATION_ERROR_MESSAGE, | |
| 75 | + }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +function resolveUrl( | |
| 79 | + path: string, | |
| 80 | + query: Record<string, string | number> | undefined | |
| 81 | +): string { | |
| 82 | + const base = getApiBaseUrl().replace(/\/+$/, ''); | |
| 83 | + const suffix = path.startsWith('/') ? path : `/${path}`; | |
| 84 | + const url = new URL(`${base}${suffix}`); | |
| 85 | + | |
| 86 | + for (const [key, value] of Object.entries(query ?? {})) { | |
| 87 | + url.searchParams.set(key, String(value)); | |
| 88 | + } | |
| 89 | + | |
| 90 | + return url.toString(); | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** 봉투 파싱 실패를 예외로 만들지 않는다 — 실패 응답의 형태가 깨져 있어도 호출부는 계속 진행한다. */ | |
| 94 | +async function readEnvelopeSafely( | |
| 95 | + response: Response | |
| 96 | +): Promise<Partial<BackendEnvelope<unknown>> | null> { | |
| 97 | + try { | |
| 98 | + const parsed: unknown = await response.json(); | |
| 99 | + return parsed !== null && typeof parsed === 'object' | |
| 100 | + ? (parsed as Partial<BackendEnvelope<unknown>>) | |
| 101 | + : null; | |
| 102 | + } catch { | |
| 103 | + return null; | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +/** 백엔드 REST 호출 단일 진입점. */ | |
| 108 | +export async function backendFetch<T>( | |
| 109 | + path: string, | |
| 110 | + init: BackendRequestInit | |
| 111 | +): Promise<BackendResult<T>> { | |
| 112 | + let response: Response; | |
| 113 | + try { | |
| 114 | + response = await fetch(resolveUrl(path, init.query), { | |
| 115 | + method: init.method, | |
| 116 | + headers: { | |
| 117 | + 'Content-Type': 'application/json', | |
| 118 | + ...(init.accessToken | |
| 119 | + ? { Authorization: `Bearer ${init.accessToken}` } | |
| 120 | + : {}), | |
| 121 | + }, | |
| 122 | + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, | |
| 123 | + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | |
| 124 | + cache: init.cache, | |
| 125 | + next: init.next, | |
| 126 | + }); | |
| 127 | + } catch (error) { | |
| 128 | + return communicationError('요청 실패(네트워크·타임아웃)', error); | |
| 129 | + } | |
| 130 | + | |
| 131 | + if (!response.ok) { | |
| 132 | + // 인증 실패만은 HTTP status로도 온다 — 미인증·권한부족 모두 401 + `{success:true, auth:false, | |
| 133 | + // code:401}`이다(실측). status만 보고 통신 오류로 뭉뚱그리면 "세션이 끊겼다"와 "서버가 죽었다"를 | |
| 134 | + // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다. | |
| 135 | + const envelope = await readEnvelopeSafely(response); | |
| 136 | + if (envelope && typeof envelope.code === 'number') { | |
| 137 | + return { | |
| 138 | + ok: false, | |
| 139 | + code: envelope.code, | |
| 140 | + message: | |
| 141 | + typeof envelope.message === 'string' && envelope.message | |
| 142 | + ? envelope.message | |
| 143 | + : COMMUNICATION_ERROR_MESSAGE, | |
| 144 | + }; | |
| 145 | + } | |
| 146 | + | |
| 147 | + return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined); | |
| 148 | + } | |
| 149 | + | |
| 150 | + let envelope: BackendEnvelope<T>; | |
| 151 | + try { | |
| 152 | + envelope = (await response.json()) as BackendEnvelope<T>; | |
| 153 | + } catch (error) { | |
| 154 | + return communicationError('응답 파싱 실패', error); | |
| 155 | + } | |
| 156 | + | |
| 157 | + if (!envelope.success || envelope.data === null) { | |
| 158 | + return { ok: false, code: envelope.code, message: envelope.message }; | |
| 159 | + } | |
| 160 | + | |
| 161 | + return { ok: true, data: envelope.data }; | |
| 162 | +} |
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?