refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
144
src/lib/api/execute-server-action.ts
Normal file
144
src/lib/api/execute-server-action.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Server Action 공통 실행 유틸리티
|
||||
*
|
||||
* 82개 action.ts 파일에서 반복되는 보일러플레이트를 제거합니다:
|
||||
* - try/catch + isNextRedirectError
|
||||
* - serverFetch 호출 + 에러 체크
|
||||
* - response.json() + success 검증
|
||||
* - 일관된 ActionResult 반환
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before: ~20줄
|
||||
* export async function getRanks() {
|
||||
* try {
|
||||
* const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
* if (error) return { success: false, error: error.message, __authError: error.__authError };
|
||||
* if (!response) return { success: false, error: '...' };
|
||||
* const result = await response.json();
|
||||
* if (!response.ok || !result.success) return { success: false, error: result.message };
|
||||
* return { success: true, data: result.data.map(transform) };
|
||||
* } catch (error) {
|
||||
* if (isNextRedirectError(error)) throw error;
|
||||
* return { success: false, error: '...' };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // After: ~5줄
|
||||
* export async function getRanks() {
|
||||
* return executeServerAction({
|
||||
* url: `${API_URL}/api/v1/positions?type=rank`,
|
||||
* transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
|
||||
* errorMessage: '직급 목록 조회에 실패했습니다.',
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { serverFetch } from './fetch-wrapper';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 공통 반환 타입 =====
|
||||
export interface ActionResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
// ===== 옵션 타입 =====
|
||||
interface ExecuteOptions<TApi = unknown, TResult = TApi> {
|
||||
/** API URL (전체 경로) */
|
||||
url: string;
|
||||
/** HTTP 메서드 (기본: GET) */
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
/** 요청 본문 (POST/PUT/PATCH) - FormData 또는 JSON-직렬화 가능 객체 */
|
||||
body?: unknown;
|
||||
/** API 응답 데이터 → 프론트엔드 타입 변환 함수 */
|
||||
transform?: (data: TApi) => TResult;
|
||||
/** 실패 시 기본 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
/** fetch 캐시 설정 (기본: no-store) */
|
||||
cache?: RequestCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action 실행 유틸리티
|
||||
*
|
||||
* serverFetch 호출 → 에러 처리 → JSON 파싱 → transform 적용 → ActionResult 반환
|
||||
*/
|
||||
export async function executeServerAction<TApi = unknown, TResult = TApi>(
|
||||
options: ExecuteOptions<TApi, TResult>
|
||||
): Promise<ActionResult<TResult>> {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
body,
|
||||
transform,
|
||||
errorMessage = '처리에 실패했습니다.',
|
||||
cache = 'no-store',
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit & { cache?: RequestCache } = {
|
||||
method,
|
||||
cache,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
fetchOptions.body = body instanceof FormData ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const { response, error } = await serverFetch(url, fetchOptions);
|
||||
|
||||
// serverFetch 에러 (네트워크, 403 등)
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 없음
|
||||
if (!response) {
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
// 204 No Content (DELETE 성공 등)
|
||||
if (response.status === 204) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// JSON 파싱
|
||||
const result = await response.json();
|
||||
|
||||
// API 실패 응답
|
||||
if (!response.ok || !result.success) {
|
||||
let errorMsg = result.message || errorMessage;
|
||||
// Laravel validation errors: { errors: { field: ['msg1', 'msg2'] } }
|
||||
if (result.errors && typeof result.errors === 'object') {
|
||||
const validationErrors = Object.values(result.errors).flat().join(', ');
|
||||
if (validationErrors) errorMsg = validationErrors;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
// 성공 + transform
|
||||
if (transform && result.data !== undefined) {
|
||||
return { success: true, data: transform(result.data) };
|
||||
}
|
||||
|
||||
// 성공 (data 없거나 transform 없음)
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
// Next.js redirect()는 다시 throw
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
|
||||
console.error(`[executeServerAction] ${method} ${url}:`, error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ class QuoteApiClient extends ApiClient {
|
||||
constructor() {
|
||||
super({
|
||||
mode: 'bearer',
|
||||
apiKey: process.env.NEXT_PUBLIC_API_KEY,
|
||||
apiKey: process.env.API_KEY,
|
||||
getToken: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('auth_token');
|
||||
|
||||
Reference in New Issue
Block a user