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 { CompanyFormData } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// API 응답 타입
interface TenantApiData {
id: number;
@@ -35,138 +36,61 @@ interface TenantApiData {
updated_at?: string;
}
/**
* 테넌트 정보 조회
*/
export async function getCompanyInfo(): Promise<{
success: boolean;
data?: CompanyFormData & { tenantId: number };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
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 = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '회사 정보 조회에 실패했습니다.' };
}
const apiData: TenantApiData = result.data;
const formData = transformApiToFrontend(apiData);
return { success: true, data: formData };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
// ===== 테넌트 정보 조회 =====
export async function getCompanyInfo(): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants`,
transform: (data: TenantApiData) => transformApiToFrontend(data),
errorMessage: '회사 정보 조회에 실패했습니다.',
});
}
/**
* 테넌트 정보 수정
*/
// ===== 테넌트 정보 수정 =====
export async function updateCompanyInfo(
tenantId: number,
data: Partial<CompanyFormData>
): Promise<{
success: boolean;
data?: CompanyFormData;
error?: string;
__authError?: boolean;
}> {
try {
const apiData = transformFrontendToApi(tenantId, data);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
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 || '회사 정보 수정에 실패했습니다.' };
}
const updatedData = transformApiToFrontend(result.data);
return { success: true, data: updatedData };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants`,
method: 'PUT',
body: transformFrontendToApi(tenantId, data),
transform: (d: TenantApiData) => transformApiToFrontend(d),
errorMessage: '회사 정보 수정에 실패했습니다.',
});
}
/**
* 상대 경로를 절대 URL로 변환
* /storage/... → https://api.example.com/storage/...
*/
// ===== 회사 로고 업로드 =====
export async function uploadCompanyLogo(formData: FormData): Promise<ActionResult<{ logoUrl: string }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants/logo`,
method: 'POST',
body: formData,
transform: (data: { logo_url?: string }) => ({
logoUrl: toAbsoluteUrl(data?.logo_url) || '',
}),
errorMessage: '로고 업로드에 실패했습니다.',
});
}
// ===== 유틸리티 =====
function toAbsoluteUrl(path: string | undefined): string | undefined {
if (!path) return undefined;
// 이미 절대 URL이면 그대로 반환
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로면 API URL 붙이기
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}${path}`;
}
/**
* API 응답 → Frontend 변환
*
* 기본 필드: company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에서 읽어옴
*/
function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { tenantId: number } {
const opts = apiData.options || {};
return {
// tenantId (API 응답의 id 필드)
tenantId: apiData.id,
// 기본 필드
companyName: apiData.company_name || '',
representativeName: apiData.ceo_name || '',
email: apiData.email || '',
managerPhone: apiData.phone || '',
businessNumber: apiData.business_num || '',
address: apiData.address || '',
// 로고 URL (상대 경로 → 절대 URL 변환)
companyLogo: toAbsoluteUrl(apiData.logo),
businessType: opts.business_type || '',
businessCategory: opts.business_category || '',
@@ -182,25 +106,12 @@ function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { ten
};
}
/**
* Frontend → API 변환
*
* 기본 필드: tenant_id, company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에 저장 (businessType, businessCategory, taxInvoiceEmail 등)
*/
function transformFrontendToApi(
tenantId: number,
data: Partial<CompanyFormData>
): Record<string, unknown> {
// 로고 URL에서 상대 경로 추출 (API는 상대 경로 기대)
function transformFrontendToApi(tenantId: number, data: Partial<CompanyFormData>): Record<string, unknown> {
let logoPath: string | null = null;
if (data.companyLogo && typeof data.companyLogo === 'string') {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
logoPath = data.companyLogo.startsWith(apiUrl)
? data.companyLogo.replace(apiUrl, '')
: data.companyLogo;
logoPath = data.companyLogo.startsWith(apiUrl) ? data.companyLogo.replace(apiUrl, '') : data.companyLogo;
}
return {
tenant_id: tenantId,
company_name: data.companyName,
@@ -208,75 +119,14 @@ function transformFrontendToApi(
email: data.email,
phone: data.managerPhone,
business_num: data.businessNumber,
// 로고 (삭제 시 null)
logo: logoPath,
// address: 우편번호 + 주소 + 상세주소 결합
address: [data.zipCode, data.address, data.addressDetail]
.filter(Boolean)
.join(' '),
// 확장 필드 (options JSON)
address: [data.zipCode, data.address, data.addressDetail].filter(Boolean).join(' '),
options: {
business_type: data.businessType,
business_category: data.businessCategory,
zip_code: data.zipCode,
address_detail: data.addressDetail,
tax_invoice_email: data.taxInvoiceEmail,
manager_name: data.managerName,
payment_bank: data.paymentBank,
payment_account: data.paymentAccount,
payment_account_holder: data.paymentAccountHolder,
payment_day: data.paymentDay,
business_type: data.businessType, business_category: data.businessCategory,
zip_code: data.zipCode, address_detail: data.addressDetail,
tax_invoice_email: data.taxInvoiceEmail, manager_name: data.managerName,
payment_bank: data.paymentBank, payment_account: data.paymentAccount,
payment_account_holder: data.paymentAccountHolder, payment_day: data.paymentDay,
},
};
}
/**
* 회사 로고 업로드
* @param formData - FormData (클라이언트에서 생성, 'logo' 키로 파일 포함)
*/
export async function uploadCompanyLogo(formData: FormData): Promise<{
success: boolean;
data?: { logoUrl: string };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/logo`,
{
method: 'POST',
body: formData,
// FormData는 Content-Type을 자동으로 설정하므로 headers 제거
}
);
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,
data: {
logoUrl: toAbsoluteUrl(result.data?.logo_url) || '',
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[uploadCompanyLogo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}