feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화

- API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가
- CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장
- 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선
- 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫)
- 레이아웃: Sidebar/AuthenticatedLayout 수정
- 작업자화면: WorkerScreen 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-30 14:16:17 +09:00
parent a486977b80
commit 3ef9570f3b
27 changed files with 554 additions and 451 deletions

View File

@@ -0,0 +1,95 @@
/**
* Authenticated Fetch Gateway
*
* 인증이 필요한 백엔드 API 호출의 유일한 게이트웨이.
* 401 감지 → refresh (globalThis 캐시) → retry 를 한 곳에서 처리.
*
* 이 모듈이 담당하는 것:
* - 요청 실행
* - 401 감지 → refreshAccessToken (globalThis 캐시로 프로세스 내 중복 방지)
* - 새 토큰으로 재시도
*
* 이 모듈이 담당하지 않는 것 (호출자가 처리):
* - 쿠키 읽기 (PROXY: request.cookies / Server Actions: cookies() API)
* - 쿠키 설정 (PROXY: Set-Cookie 헤더 / Server Actions: cookies() API)
* - 리다이렉트 (Server Actions: redirect('/login'))
* - 헤더 구성 (각 호출자가 자기 방식으로)
*/
import { refreshAccessToken, type RefreshResult } from './refresh-token';
export type AuthenticatedFetchResult = {
/** 백엔드 응답 (성공이든 실패든) */
response: Response;
/** refresh 성공 시 새 토큰 (호출자가 쿠키에 저장) */
newTokens?: RefreshResult;
/** true면 인증 실패 (호출자가 쿠키 삭제 + 리다이렉트 처리) */
authFailed?: boolean;
};
/**
* 인증된 백엔드 요청 실행
*
* 반환값 상태:
* - { response } → 정상 (401 아님)
* - { response, newTokens } → 401 → refresh 성공 → 재시도 성공
* - { response, authFailed: true } → 인증 실패 (refresh 불가/실패/재시도 실패)
*
* @param url 백엔드 API URL
* @param options fetch 옵션 (호출자가 Authorization 등 헤더 포함)
* @param refreshToken refresh_token (없으면 401 시 바로 실패 반환)
* @param caller 호출자 이름 (로그용: 'PROXY' | 'serverFetch' | 'ServerApiClient')
*/
export async function authenticatedFetch(
url: string,
options: RequestInit,
refreshToken: string | undefined,
caller: string = 'unknown'
): Promise<AuthenticatedFetchResult> {
// 1. 요청 실행 (호출자가 이미 모든 헤더 설정)
const response = await fetch(url, options);
// 2. 401이 아니면 그대로 반환
if (response.status !== 401) {
return { response };
}
// 3. 401이지만 refresh_token 없음 → 인증 실패
if (!refreshToken) {
console.warn(`🔴 [${caller}] 401 (no refresh token)`);
return { response, authFailed: true };
}
// 4. 401 + refresh_token 있음 → 갱신 시도 (globalThis 캐시로 중복 방지)
console.log(`🔄 [${caller}] Got 401, attempting token refresh...`);
const refreshResult = await refreshAccessToken(refreshToken, caller);
if (!refreshResult.success || !refreshResult.accessToken) {
console.warn(`🔴 [${caller}] Token refresh failed`);
return { response, authFailed: true };
}
// 5. 새 토큰으로 재시도
console.log(`✅ [${caller}] Token refreshed, retrying...`);
const retryHeaders = new Headers(options.headers || {});
retryHeaders.set('Authorization', `Bearer ${refreshResult.accessToken}`);
const retryResponse = await fetch(url, {
...options,
headers: retryHeaders,
});
console.log(`🔵 [${caller}] Retry status: ${retryResponse.status}`);
// 6. 재시도도 401 → 인증 실패
if (retryResponse.status === 401) {
console.warn(`🔴 [${caller}] Retry still 401, auth failed`);
return { response: retryResponse, authFailed: true };
}
// 7. 재시도 성공 → 새 토큰과 함께 반환 (호출자가 쿠키 설정)
return {
response: retryResponse,
newTokens: refreshResult,
};
}

View File

@@ -239,10 +239,33 @@ function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint
return checkPoints;
}
/**
* 변동률 → changeRate/changeDirection 변환 헬퍼
*/
function toChangeFields(rate?: number): { changeRate?: string; changeDirection?: 'up' | 'down' } {
if (rate === undefined || rate === null) return {};
const direction = rate >= 0 ? 'up' as const : 'down' as const;
const sign = rate >= 0 ? '+' : '';
return {
changeRate: `${sign}${rate.toFixed(1)}%`,
changeDirection: direction,
};
}
/**
* DailyReport API 응답 → Frontend 타입 변환
*/
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
const change = api.daily_change;
// TODO: 백엔드 daily_change 필드 제공 시 더미값 제거
const FALLBACK_CHANGES = {
cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const },
foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const },
income: { changeRate: '+12.0%', changeDirection: 'up' as const },
expense: { changeRate: '-8.0%', changeDirection: 'down' as const },
};
return {
date: formatDate(api.date, api.day_of_week),
cards: [
@@ -250,22 +273,34 @@ export function transformDailyReportResponse(api: DailyReportApiResponse): Daily
id: 'dr1',
label: '현금성 자산 합계',
amount: api.cash_asset_total,
...(change?.cash_asset_change_rate !== undefined
? toChangeFields(change.cash_asset_change_rate)
: FALLBACK_CHANGES.cash_asset),
},
{
id: 'dr2',
label: '외국환(USD) 합계',
amount: api.foreign_currency_total,
currency: 'USD',
...(change?.foreign_currency_change_rate !== undefined
? toChangeFields(change.foreign_currency_change_rate)
: FALLBACK_CHANGES.foreign_currency),
},
{
id: 'dr3',
label: '입금 합계',
amount: api.krw_totals.income,
...(change?.income_change_rate !== undefined
? toChangeFields(change.income_change_rate)
: FALLBACK_CHANGES.income),
},
{
id: 'dr4',
label: '출금 합계',
amount: api.krw_totals.expense,
...(change?.expense_change_rate !== undefined
? toChangeFields(change.expense_change_rate)
: FALLBACK_CHANGES.expense),
},
],
checkPoints: generateDailyReportCheckPoints(api),

View File

@@ -19,6 +19,14 @@ export interface CurrencyTotals {
/** 운영자금 안정성 상태 */
export type OperatingStability = 'stable' | 'caution' | 'warning' | 'unknown';
/** 어제 대비 변동률 */
export interface DailyChangeRate {
cash_asset_change_rate?: number; // 현금성 자산 변동률 (%)
foreign_currency_change_rate?: number; // 외국환 변동률 (%)
income_change_rate?: number; // 입금 변동률 (%)
expense_change_rate?: number; // 출금 변동률 (%)
}
/** GET /api/proxy/daily-report/summary 응답 */
export interface DailyReportApiResponse {
date: string; // "2026-01-20"
@@ -32,6 +40,8 @@ export interface DailyReportApiResponse {
monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균)
operating_months: number | null; // 운영 가능 개월 수
operating_stability: OperatingStability; // 안정성 상태
// 어제 대비 변동률 (optional - 백엔드에서 제공 시)
daily_change?: DailyChangeRate;
}
// ============================================

View File

@@ -2,16 +2,16 @@
* 전역 Fetch Wrapper
*
* 모든 Server Actions에서 사용할 공통 fetch 함수
* - 401 에러 자동 감지 및 토큰 자동 갱신
* - 401 에러 자동 감지 및 토큰 자동 갱신 (authenticatedFetch 게이트웨이 위임)
* - 일관된 에러 처리
* - 헤더 자동 설정
*/
import { cookies, headers } from 'next/headers';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createErrorResponse, type ApiErrorResponse } from './errors';
import { refreshAccessToken } from './refresh-token';
import { authenticatedFetch } from './authenticated-fetch';
/**
* 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
@@ -22,13 +22,12 @@ import { refreshAccessToken } from './refresh-token';
async function clearTokenCookies() {
const cookieStore = await cookies();
// 토큰 쿠키 삭제
cookieStore.delete('access_token');
cookieStore.delete('refresh_token');
cookieStore.delete('token_refreshed_at');
cookieStore.delete('is_authenticated');
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료');
}
/**
@@ -51,8 +50,7 @@ async function setNewTokenCookies(tokens: {
maxAge: tokens.expiresIn || 7200,
});
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
// HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함
// 토큰 갱신 신호 쿠키 (클라이언트 useMenuPolling 감지용)
cookieStore.set('token_refreshed_at', Date.now().toString(), {
httpOnly: false,
secure: isProduction,
@@ -76,24 +74,10 @@ async function setNewTokenCookies(tokens: {
/**
* API 헤더 생성 (Server Side)
*
* 🆕 미들웨어에서 전달한 새 토큰 우선 사용
* - 미들웨어 pre-refresh 성공 시 request headers에 'x-refreshed-access-token' 설정
* - Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음
* - 따라서 request headers를 먼저 확인
*/
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
// 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
const headerStore = await headers();
const refreshedAccessToken = headerStore.get('x-refreshed-access-token');
const cookieStore = await cookies();
const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value;
// 디버깅: 어떤 토큰을 사용하는지 로그
if (refreshedAccessToken) {
console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers');
}
const accessToken = token || cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
@@ -106,16 +90,13 @@ export async function getServerApiHeaders(token?: string): Promise<HeadersInit>
/**
* Server Action용 Fetch Wrapper
*
* 🔄 토큰 갱신 로직:
* 1. 현재 access_token으로 요청
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
* 3. 새 토큰으로 원래 요청 재시도
* 4. 재시도도 실패하면 → 로그인 페이지로 리다이렉트
* 401 감지 → refresh → retry 는 authenticatedFetch 게이트웨이에 위임.
* 이 함수는 쿠키 읽기/설정/삭제, 리다이렉트만 담당.
*
* @example
* ```typescript
* const { response, error } = await serverFetch(url, { method: 'GET' });
* if (error) return error; // 에러 응답 반환 (클라이언트에서 처리)
* if (error) return error;
* // response 사용...
* ```
*/
@@ -126,18 +107,14 @@ export async function serverFetch(
}
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
try {
// 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인
const headerStore = await headers();
const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token');
const cookieStore = await cookies();
const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value;
const refreshToken = cookieStore.get('refresh_token')?.value;
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const isFormData = options?.body instanceof FormData;
const requestHeaders: HeadersInit = isFormData
const requestHeaders: Record<string, string> = isFormData
? {
Accept: baseHeaders.Accept,
Authorization: baseHeaders.Authorization,
@@ -145,64 +122,30 @@ export async function serverFetch(
}
: baseHeaders;
let response = await fetch(url, {
...options,
headers: {
...requestHeaders,
...options?.headers,
// authenticatedFetch 게이트웨이로 요청 실행
// skipAuthCheck=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
const { response, newTokens, authFailed } = await authenticatedFetch(
url,
{
...options,
headers: {
...requestHeaders,
...options?.headers,
},
cache: options?.cache || 'no-store',
},
cache: options?.cache || 'no-store',
});
options?.skipAuthCheck ? undefined : refreshToken,
'serverFetch'
);
// 🔄 401 응답 시 토큰 갱신 후 재시도
if (response.status === 401 && !options?.skipAuthCheck && refreshToken) {
console.log('🔄 [serverFetch] Got 401, attempting token refresh...');
// 새 토큰 → 쿠키 저장
if (newTokens) {
await setNewTokenCookies(newTokens);
}
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
if (refreshResult.success && refreshResult.accessToken) {
console.log('✅ [serverFetch] Token refreshed, retrying original request...');
// 새 토큰을 쿠키에 저장
await setNewTokenCookies(refreshResult);
// 새 토큰으로 원래 요청 재시도
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
const newRequestHeaders: HeadersInit = isFormData
? {
Accept: newBaseHeaders.Accept,
Authorization: newBaseHeaders.Authorization,
'X-API-KEY': newBaseHeaders['X-API-KEY'],
}
: newBaseHeaders;
response = await fetch(url, {
...options,
headers: {
...newRequestHeaders,
...options?.headers,
},
cache: options?.cache || 'no-store',
});
console.log('🔵 [serverFetch] Retry response status:', response.status);
// 재시도도 401이면 로그인으로
if (response.status === 401) {
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else {
// 리프레시 실패 → 로그인 페이지로
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else if (response.status === 401 && !options?.skipAuthCheck) {
// refresh_token이 없는 경우
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
// 인증 실패 → 쿠키 삭제 + 로그인 리다이렉트
if (authFailed) {
await clearTokenCookies();
redirect('/login');
}
@@ -217,7 +160,7 @@ export async function serverFetch(
return { response, error: null };
} catch (error) {
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw
if (isNextRedirectError(error)) throw error;
console.error(`[serverFetch] Network error: ${url}`, error);
return {
@@ -278,4 +221,4 @@ export async function serverApiCall<T>(
const data = await parseJsonResponse<T>(response);
return { data, error: null };
}
}

View File

@@ -25,7 +25,7 @@ export {
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { AUTH_CONFIG } from './auth/auth-config';
import { refreshAccessToken } from './refresh-token';
import { authenticatedFetch } from './authenticated-fetch';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
/**
@@ -34,15 +34,14 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
* 특징:
* - 쿠키에서 access_token 자동 읽기
* - X-API-KEY + Bearer 토큰 자동 포함
* - 401 발생 시 토큰 자동 갱신 후 재시도
* - 401 발생 시 authenticatedFetch 게이트웨이를 통한 자동 갱신
*/
class ServerApiClient {
private baseURL: string;
private apiKey: string;
constructor() {
// API URL에 /api/v1 prefix 자동 추가
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, '');
this.baseURL = `${apiUrl}/api/v1`;
this.apiKey = process.env.API_KEY || '';
}
@@ -115,7 +114,7 @@ class ServerApiClient {
}
/**
* HTTP 요청 실행 (토큰 자동 갱신 포함)
* HTTP 요청 실행 (authenticatedFetch 게이트웨이를 통한 자동 갱신)
*/
private async request<T>(
endpoint: string,
@@ -127,47 +126,30 @@ class ServerApiClient {
const headers = await this.getAuthHeaders();
const url = `${this.baseURL}${endpoint}`;
let response = await fetch(url, {
...options,
headers: {
...headers,
...options?.headers,
// authenticatedFetch 게이트웨이로 요청 실행
// skipAuthRetry=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함
const { response, newTokens, authFailed } = await authenticatedFetch(
url,
{
...options,
headers: {
...headers,
...options?.headers,
},
cache: 'no-store',
},
cache: 'no-store',
});
options?.skipAuthRetry ? undefined : refreshToken,
'ServerApiClient'
);
// 401 발생 시 토큰 갱신 후 재시도
if (response.status === 401 && !options?.skipAuthRetry && refreshToken) {
console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...');
// 새 토큰 → 쿠키 저장
if (newTokens) {
await this.setNewTokenCookies(newTokens);
}
const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient');
if (refreshResult.success && refreshResult.accessToken) {
console.log('✅ [ServerApiClient] 토큰 갱신 성공, 재시도...');
await this.setNewTokenCookies(refreshResult);
const newHeaders = await this.getAuthHeaders(refreshResult.accessToken);
response = await fetch(url, {
...options,
headers: {
...newHeaders,
...options?.headers,
},
cache: 'no-store',
});
if (response.status === 401) {
console.warn('🔴 [ServerApiClient] 재시도 실패, 로그인 리다이렉트');
await this.clearTokenCookies();
redirect('/login');
}
} else {
console.warn('🔴 [ServerApiClient] 토큰 갱신 실패, 로그인 리다이렉트');
await this.clearTokenCookies();
redirect('/login');
}
} else if (response.status === 401 && !options?.skipAuthRetry) {
console.warn('🔴 [ServerApiClient] 401 (refresh token 없음), 로그인 리다이렉트');
// 인증 실패 → 쿠키 삭제 + 리다이렉트
if (authFailed && !options?.skipAuthRetry) {
await this.clearTokenCookies();
redirect('/login');
}
@@ -247,4 +229,4 @@ class ServerApiClient {
}
// 서버 액션용 API 클라이언트 인스턴스
export const apiClient = new ServerApiClient();
export const apiClient = new ServerApiClient();

View File

@@ -20,17 +20,29 @@ export type RefreshResult = {
expiresIn?: number;
};
// 캐시 상태 (모듈 레벨에서 공유)
let refreshCache: {
// 캐시 상태 (globalThis 레벨에서 공유)
// ⚠️ 모듈 레벨 변수는 Next.js가 API route와 Server Actions를 별도 모듈로 컴파일하면
// 각각 다른 인스턴스를 가져서 캐시가 공유되지 않음
// → globalThis를 사용하여 같은 Node.js 프로세스 내 모든 모듈에서 동일한 캐시 참조
type RefreshCache = {
promise: Promise<RefreshResult> | null;
timestamp: number;
result: RefreshResult | null;
} = {
promise: null,
timestamp: 0,
result: null,
};
const GLOBAL_CACHE_KEY = '__sam_refresh_token_cache__';
function getRefreshCache(): RefreshCache {
if (!(globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY]) {
(globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY] = {
promise: null,
timestamp: 0,
result: null,
};
}
return (globalThis as Record<string, unknown>)[GLOBAL_CACHE_KEY] as RefreshCache;
}
const REFRESH_CACHE_TTL = 5000; // 5초
/**
@@ -97,47 +109,47 @@ export async function refreshAccessToken(
refreshToken: string,
caller: string = 'unknown'
): Promise<RefreshResult> {
const cache = getRefreshCache();
const now = Date.now();
// 1. 캐시된 성공 결과가 유효하면 즉시 반환
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - refreshCache.timestamp}ms)`);
return refreshCache.result;
if (cache.result && cache.result.success && now - cache.timestamp < REFRESH_CACHE_TTL) {
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - cache.timestamp}ms)`);
return cache.result;
}
// 2. 진행 중인 refresh가 있고, 아직 결과가 없으면 기다림 (실패한 결과는 캐시 안 함)
if (refreshCache.promise && !refreshCache.result && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
if (cache.promise && !cache.result && now - cache.timestamp < REFRESH_CACHE_TTL) {
console.log(`🔵 [${caller}] Waiting for ongoing refresh...`);
return refreshCache.promise;
return cache.promise;
}
// 2-1. 이전 refresh가 실패했으면 캐시 초기화
if (refreshCache.result && !refreshCache.result.success) {
if (cache.result && !cache.result.success) {
console.log(`🔄 [${caller}] Previous refresh failed, clearing cache...`);
refreshCache.promise = null;
refreshCache.result = null;
cache.promise = null;
cache.result = null;
}
// 3. 새 refresh 시작
console.log(`🔄 [${caller}] Starting new refresh request...`);
refreshCache.timestamp = now;
refreshCache.result = null;
cache.timestamp = now;
cache.result = null;
refreshCache.promise = doRefreshToken(refreshToken).then(result => {
refreshCache.result = result;
cache.promise = doRefreshToken(refreshToken).then(result => {
cache.result = result;
return result;
});
return refreshCache.promise;
return cache.promise;
}
/**
* 캐시 초기화 (테스트용)
*/
export function clearRefreshCache(): void {
refreshCache = {
promise: null,
timestamp: 0,
result: null,
};
const cache = getRefreshCache();
cache.promise = null;
cache.timestamp = 0;
cache.result = null;
}