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:
유병철
2026-02-09 16:14:06 +09:00
parent d014227e9c
commit 55e0791e16
85 changed files with 7211 additions and 17638 deletions

View 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 };
}
}

View File

@@ -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');