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

@@ -14,7 +14,7 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
// ============================================
// 타입 정의
@@ -83,17 +83,10 @@ export interface UpdateDepartmentRequest {
parentId?: number | null; // null이면 최상위로 이동
}
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// ============================================
// 헬퍼 함수
// ============================================
// API URL
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
/**
@@ -130,36 +123,18 @@ function transformApiToFrontend(apiData: ApiDepartment, depth: number = 0): Depa
*/
export async function getDepartmentTree(params?: {
withUsers?: boolean;
}): Promise<{ success: boolean; data?: DepartmentRecord[]; error?: string }> {
}): Promise<ActionResult<DepartmentRecord[]>> {
const queryParams = new URLSearchParams();
if (params?.withUsers) {
queryParams.append('with_users', '1');
}
const queryString = queryParams.toString();
const url = `${API_URL}/v1/departments/tree${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, error: error?.message || '부서 트리 조회에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment[]> = await response.json();
if (result.success && result.data) {
const transformed = result.data.map((dept) => transformApiToFrontend(dept, 0));
return {
success: true,
data: transformed,
};
}
return {
success: false,
error: result.message || '부서 트리 조회에 실패했습니다.',
};
return executeServerAction({
url: `${API_URL}/v1/departments/tree${queryString ? `?${queryString}` : ''}`,
transform: (data: ApiDepartment[]) => data.map((dept) => transformApiToFrontend(dept, 0)),
errorMessage: '부서 트리 조회에 실패했습니다.',
});
}
/**
@@ -168,26 +143,12 @@ export async function getDepartmentTree(params?: {
*/
export async function getDepartmentById(
id: number
): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> {
const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, { method: 'GET' });
if (error || !response) {
return { success: false, error: error?.message || '부서 조회에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
if (result.success && result.data) {
return {
success: true,
data: transformApiToFrontend(result.data),
};
}
return {
success: false,
error: result.message || '부서 조회에 실패했습니다.',
};
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 조회에 실패했습니다.',
});
}
/**
@@ -196,36 +157,21 @@ export async function getDepartmentById(
*/
export async function createDepartment(
data: CreateDepartmentRequest
): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> {
const { response, error } = await serverFetch(`${API_URL}/v1/departments`, {
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments`,
method: 'POST',
body: JSON.stringify({
body: {
parent_id: data.parentId,
code: data.code,
name: data.name,
description: data.description,
is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined,
sort_order: data.sortOrder,
}),
},
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 생성에 실패했습니다.',
});
if (error || !response) {
return { success: false, error: error?.message || '부서 생성에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
if (result.success && result.data) {
return {
success: true,
data: transformApiToFrontend(result.data),
};
}
return {
success: false,
error: result.message || '부서 생성에 실패했습니다.',
};
}
/**
@@ -235,36 +181,21 @@ export async function createDepartment(
export async function updateDepartment(
id: number,
data: UpdateDepartmentRequest
): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> {
const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, {
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
method: 'PATCH',
body: JSON.stringify({
parent_id: data.parentId === null ? 0 : data.parentId, // null이면 0(최상위)으로 전환
body: {
parent_id: data.parentId === null ? 0 : data.parentId,
code: data.code,
name: data.name,
description: data.description,
is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined,
sort_order: data.sortOrder,
}),
},
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 수정에 실패했습니다.',
});
if (error || !response) {
return { success: false, error: error?.message || '부서 수정에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
if (result.success && result.data) {
return {
success: true,
data: transformApiToFrontend(result.data),
};
}
return {
success: false,
error: result.message || '부서 수정에 실패했습니다.',
};
}
/**
@@ -273,23 +204,12 @@ export async function updateDepartment(
*/
export async function deleteDepartment(
id: number
): Promise<{ success: boolean; error?: string }> {
const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, { method: 'DELETE' });
if (error || !response) {
return { success: false, error: error?.message || '부서 삭제에 실패했습니다.' };
}
const result: ApiResponse<{ id: number; deleted_at: string }> = await response.json();
if (result.success) {
return { success: true };
}
return {
success: false,
error: result.message || '부서 삭제에 실패했습니다.',
};
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
method: 'DELETE',
errorMessage: '부서 삭제에 실패했습니다.',
});
}
/**