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

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { Rank } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -17,12 +18,6 @@ interface PositionApiData {
updated_at?: string;
}
interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Rank {
return {
@@ -39,55 +34,21 @@ function transformApiToFrontend(apiData: PositionApiData): Rank {
export async function getRanks(params?: {
is_active?: boolean;
q?: string;
}): Promise<{
success: boolean;
data?: Rank[];
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
searchParams.set('type', 'rank');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 목록 조회에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData[]> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 목록 조회에 실패했습니다.' };
}
const ranks = result.data.map(transformApiToFrontend);
return { success: true, data: ranks };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getRanks] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}): Promise<ActionResult<Rank[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'rank');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직급 목록 조회에 실패했습니다.',
});
}
// ===== 직급 생성 =====
@@ -95,51 +56,19 @@ export async function createRank(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<{
success: boolean;
data?: Rank;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions`,
{
method: 'POST',
body: JSON.stringify({
type: 'rank',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
}),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 생성에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 생성에 실패했습니다.' };
}
const rank = transformApiToFrontend(result.data);
return { success: true, data: rank };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[createRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'rank',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직급 생성에 실패했습니다.',
});
}
// ===== 직급 수정 =====
@@ -150,127 +79,33 @@ export async function updateRank(
sort_order?: number;
is_active?: boolean;
}
): Promise<{
success: boolean;
data?: Rank;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 수정에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 수정에 실패했습니다.' };
}
const rank = transformApiToFrontend(result.data);
return { success: true, data: rank };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직급 수정에 실패했습니다.',
});
}
// ===== 직급 삭제 =====
export async function deleteRank(id: number): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
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 };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[deleteRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function deleteRank(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직급 삭제에 실패했습니다.',
});
}
// ===== 직급 순서 변경 =====
export async function reorderRanks(
items: { id: number; sort_order: number }[]
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/reorder`,
{
method: 'PUT',
body: JSON.stringify({ items }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
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 };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[reorderRanks] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}