feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현

- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션
- 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts)
- ApiErrorContext 추가로 전역 에러 처리 개선
- HR EmployeeForm 컴포넌트 개선
- 참조함(ReferenceBox) 기능 수정
- juil 테스트 URL 페이지 추가
- claudedocs 문서 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-30 17:00:18 +09:00
parent 0e5307f7a3
commit d38b1242d7
82 changed files with 7434 additions and 4775 deletions

View File

@@ -12,7 +12,7 @@
'use server';
import { cookies } from 'next/headers';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Board, BoardApiData, BoardFormData } from './types';
// API 응답 타입
@@ -22,21 +22,6 @@ interface ApiResponse<T> {
message: string;
}
/**
* API 헤더 생성
*/
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
}
/**
* API 데이터 → 프론트엔드 타입 변환
*/
@@ -89,10 +74,8 @@ function transformFrontendToApi(data: BoardFormData & { boardCode?: string; desc
export async function getBoards(filters?: {
board_type?: string;
search?: string;
}): Promise<{ success: boolean; data?: Board[]; error?: string }> {
}): Promise<{ success: boolean; data?: Board[]; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const params = new URLSearchParams();
if (filters?.board_type) params.append('board_type', filters.board_type);
if (filters?.search) params.append('search', filters.search);
@@ -101,14 +84,16 @@ export async function getBoards(filters?: {
// 테넌트 게시판만 조회 (시스템 게시판은 mng에서 관리)
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/tenant${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
const { response, error } = await serverFetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
console.error('[BoardActions] GET boards error:', response.status);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 목록 조회에 실패했습니다.' };
}
@@ -120,7 +105,7 @@ export async function getBoards(filters?: {
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!result.success) {
if (!response.ok || !result.success) {
return { success: false, error: result.message || '게시판 목록 조회에 실패했습니다.' };
}
@@ -143,10 +128,8 @@ export async function getBoards(filters?: {
export async function getTenantBoards(filters?: {
board_type?: string;
search?: string;
}): Promise<{ success: boolean; data?: Board[]; error?: string }> {
}): Promise<{ success: boolean; data?: Board[]; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const params = new URLSearchParams();
if (filters?.board_type) params.append('board_type', filters.board_type);
if (filters?.search) params.append('search', filters.search);
@@ -154,20 +137,28 @@ export async function getTenantBoards(filters?: {
const queryString = params.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/tenant${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
const { response, error } = await serverFetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
console.error('[BoardActions] GET tenant boards error:', response.status);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '테넌트 게시판 목록 조회에 실패했습니다.' };
}
const result: ApiResponse<BoardApiData[]> = await response.json();
let result: ApiResponse<BoardApiData[]>;
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!result.success) {
if (!response.ok || !result.success) {
return { success: false, error: result.message || '테넌트 게시판 목록 조회에 실패했습니다.' };
}
@@ -182,27 +173,32 @@ export async function getTenantBoards(filters?: {
/**
* 게시판 상세 조회 (코드 기반)
*/
export async function getBoardByCode(code: string): Promise<{ success: boolean; data?: Board; error?: string }> {
export async function getBoardByCode(code: string): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${code}`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${code}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (!response.ok) {
console.error('[BoardActions] GET board error:', response.status);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 조회에 실패했습니다.' };
}
const result: ApiResponse<BoardApiData> = await response.json();
let result: ApiResponse<BoardApiData>;
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!result.success || !result.data) {
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '게시판을 찾을 수 없습니다.' };
}
@@ -216,27 +212,32 @@ export async function getBoardByCode(code: string): Promise<{ success: boolean;
/**
* 게시판 상세 조회 (ID 기반)
*/
export async function getBoardById(id: string): Promise<{ success: boolean; data?: Board; error?: string }> {
export async function getBoardById(id: string): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (!response.ok) {
console.error('[BoardActions] GET board by id error:', response.status);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 조회에 실패했습니다.' };
}
const result: ApiResponse<BoardApiData> = await response.json();
let result: ApiResponse<BoardApiData>;
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!result.success || !result.data) {
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '게시판을 찾을 수 없습니다.' };
}
@@ -252,21 +253,31 @@ export async function getBoardById(id: string): Promise<{ success: boolean; data
*/
export async function createBoard(
data: BoardFormData & { boardCode: string; description?: string }
): Promise<{ success: boolean; data?: Board; error?: string }> {
): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards`,
{
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify(apiData),
});
const result = await response.json();
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 생성에 실패했습니다.' };
}
let result: ApiResponse<BoardApiData>;
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!response.ok || !result.success) {
return {
@@ -291,21 +302,31 @@ export async function createBoard(
export async function updateBoard(
id: string,
data: BoardFormData & { boardCode?: string; description?: string }
): Promise<{ success: boolean; data?: Board; error?: string }> {
): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data, true); // isUpdate=true
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify(apiData),
});
const result = await response.json();
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 수정에 실패했습니다.' };
}
let result: ApiResponse<BoardApiData>;
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!response.ok || !result.success) {
return {
@@ -327,19 +348,29 @@ export async function updateBoard(
/**
* 게시판 삭제
*/
export async function deleteBoard(id: string): Promise<{ success: boolean; error?: string }> {
export async function deleteBoard(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`,
{
method: 'DELETE',
headers,
}
);
const { response, error } = await serverFetch(url, {
method: 'DELETE',
});
const result = await response.json();
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시판 삭제에 실패했습니다.' };
}
let result: { success: boolean; message?: string };
try {
result = await response.json();
} catch {
console.error('[BoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!response.ok || !result.success) {
return {
@@ -358,10 +389,15 @@ export async function deleteBoard(id: string): Promise<{ success: boolean; error
/**
* 게시판 일괄 삭제
*/
export async function deleteBoardsBulk(ids: string[]): Promise<{ success: boolean; error?: string }> {
export async function deleteBoardsBulk(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const results = await Promise.all(ids.map(id => deleteBoard(id)));
const failed = results.filter(r => !r.success);
const hasAuthError = results.some(r => r.__authError);
if (hasAuthError) {
return { success: false, error: '인증이 만료되었습니다.', __authError: true };
}
if (failed.length > 0) {
return {