feat(WEB): Server Actions 전용 API 클라이언트 구현
- ServerApiClient 클래스 추가 - 쿠키에서 access_token 자동 읽기 - X-API-KEY + Bearer 토큰 자동 포함 - 401 발생 시 토큰 자동 갱신 후 재시도 - 인증 실패 시 쿠키 자동 삭제
This commit is contained in:
@@ -20,12 +20,231 @@ export {
|
||||
type CommonCode,
|
||||
} from './common-codes';
|
||||
|
||||
// Server-side API 클라이언트 인스턴스
|
||||
// 서버 액션에서 사용
|
||||
import { ApiClient } from './client';
|
||||
// Server-side API 클라이언트
|
||||
// 서버 액션에서 쿠키 기반 Bearer 토큰 자동 포함
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { AUTH_CONFIG } from './auth/auth-config';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
mode: 'api-key',
|
||||
apiKey: process.env.API_KEY,
|
||||
});
|
||||
/**
|
||||
* Server Actions 전용 API 클라이언트
|
||||
*
|
||||
* 특징:
|
||||
* - 쿠키에서 access_token 자동 읽기
|
||||
* - X-API-KEY + Bearer 토큰 자동 포함
|
||||
* - 401 발생 시 토큰 자동 갱신 후 재시도
|
||||
*/
|
||||
class ServerApiClient {
|
||||
private baseURL: string;
|
||||
private apiKey: string;
|
||||
|
||||
constructor() {
|
||||
// API URL에 /api/v1 prefix 자동 추가
|
||||
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거
|
||||
this.baseURL = `${apiUrl}/api/v1`;
|
||||
this.apiKey = process.env.API_KEY || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키에서 인증 헤더 생성 (async)
|
||||
*/
|
||||
private async getAuthHeaders(token?: string): Promise<Record<string, string>> {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = token || cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': this.apiKey,
|
||||
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 쿠키 삭제 (인증 실패 시)
|
||||
*/
|
||||
private async clearTokenCookies() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('access_token');
|
||||
cookieStore.delete('refresh_token');
|
||||
cookieStore.delete('token_refreshed_at');
|
||||
cookieStore.delete('is_authenticated');
|
||||
console.log('🗑️ [ServerApiClient] 토큰 쿠키 삭제 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 토큰을 쿠키에 저장
|
||||
*/
|
||||
private async setNewTokenCookies(tokens: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (tokens.accessToken) {
|
||||
cookieStore.set('access_token', tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: tokens.expiresIn || 7200,
|
||||
});
|
||||
|
||||
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.refreshToken) {
|
||||
cookieStore.set('refresh_token', tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 604800,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 실행 (토큰 자동 갱신 포함)
|
||||
*/
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit & { skipAuthRetry?: boolean }
|
||||
): Promise<T> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||
const headers = await this.getAuthHeaders();
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
// 401 발생 시 토큰 갱신 후 재시도
|
||||
if (response.status === 401 && !options?.skipAuthRetry && refreshToken) {
|
||||
console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient');
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [ServerApiClient] 토큰 갱신 성공, 재시도...');
|
||||
await this.setNewTokenCookies(refreshResult);
|
||||
|
||||
const newHeaders = await this.getAuthHeaders(refreshResult.accessToken);
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...newHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn('🔴 [ServerApiClient] 재시도 실패, 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
} else {
|
||||
console.warn('🔴 [ServerApiClient] 토큰 갱신 실패, 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
} else if (response.status === 401 && !options?.skipAuthRetry) {
|
||||
console.warn('🔴 [ServerApiClient] 401 (refresh token 없음), 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw {
|
||||
status: response.status,
|
||||
message: errorData.message || 'An error occurred',
|
||||
errors: errorData.errors,
|
||||
code: errorData.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 요청
|
||||
*/
|
||||
async get<T>(endpoint: string, options?: { params?: Record<string, string> }): Promise<T> {
|
||||
let url = endpoint;
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams(options.params);
|
||||
url = `${endpoint}?${searchParams.toString()}`;
|
||||
}
|
||||
return this.request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 요청
|
||||
*/
|
||||
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청
|
||||
*/
|
||||
async delete<T>(endpoint: string, options?: { data?: unknown }): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
body: options?.data ? JSON.stringify(options.data) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 액션용 API 클라이언트 인스턴스
|
||||
export const apiClient = new ServerApiClient();
|
||||
Reference in New Issue
Block a user