-
Notifications
You must be signed in to change notification settings - Fork 1
SSR 504 방지를 위한 BFF Timeout & Fallback 구조 도입
-
문제: upstream(백엔드) 무응답/지연 시 BFF
fetch()가 무기한 대기 → SSR 전체가 서버리스 실행 제한에 걸려 페이지 자체가 504로 실패 -
해결
-
withTimeout공통 유틸 도입 (AbortSignal 기반) - BFF 공통 wrapper
fetchBffUpstream도입 + 타임아웃/응답 표준화 - SSR에서
try/catch + FALLBACK_DATA + degraded적용 - 장기 작업 API는 30초 예외 적용
- 구조화 로그 태깅 추가 (
timeout,upstream_status,route,requestId)
-
- 백엔드 API가 응답하지 않거나 매우 느린 상황
- SSR(Server Component)이 BFF를 호출
- BFF는 내부에서
fetch(buildApiUrl(...))수행 - 기본 fetch는 타임아웃이 없으므로 무기한 대기
- 서버리스 환경(Lambda 등)에서 최대 실행 시간 초과 → SSR 자체가 504로 종료
- 사용자는 “데이터만 늦게 뜨는” 수준이 아니라 페이지가 아예 안 뜨는 장애를 경험
// (기존) 여러 BFF route가 이런 형태
const res = await fetch(buildApiUrl('/api/v1/resumes'), {
headers: { Authorization: `Bearer ${accessToken}` },
});- 라우트별로 fetch가 제각각
- 공통 timeout / 공통 에러 포맷 / 공통 로깅이 없음
// (기존) 실패해도 그냥 빈 배열로 처리
const recommendations = await apiFetch(url).then(r => r.recommendations).catch(() => []);- 사용자 입장: “추천이 원래 없는 건가?” vs “지금 장애인가?” 구분 불가
- 운영 입장: SSR fallback이 조용히 처리되면 장애 감지 지연
-
BFF upstream 호출에 공통 타임아웃 적용
-
타임아웃 시 무기한 대기 대신 즉시 표준 에러 반환
- HTTP
504 - 내부 코드
UPSTREAM_TIMEOUT -
degraded,requestId포함
- HTTP
-
SSR에서 fallback 데이터를 사용해 페이지 렌더 유지
-
장애 원인 추적 가능한 구조화 로그 추가
-
장기 작업(파싱/리포트 생성)은 30초 정책으로 성공률 보장
-
동일값은 단순하지만 API 성격이 다름
- 추천(비핵심) vs 리포트 생성(장기 작업)
-
같은 값이면 둘 중 하나는 항상 손해
- 너무 짧으면 장기 작업 실패
- 너무 길면 추천 같은 부가 기능이 SSR을 오래 잡아먹음
✅ 선택: 프로파일 기반 기본값 + 엔드포인트별 override
-
optional: 2s -
default: 3.5s -
critical: 5s - 장기 작업은 route/server에서
timeoutMs: 30000명시
- 라우트마다
AbortController를 넣으면 중복 + 실수 가능성 증가 - 공통 wrapper로 모으면 정책/로깅/표준화가 강제됨
✅ 선택: 공통 wrapper fetchBffUpstream 도입 후 라우트는 이것만 사용
- BFF에서만 처리하면 UI는 degraded를 “모름”
- SSR에서도 degraded 전달해야 사용자에게 맥락 제공 가능
✅ 선택: SSR server component에서 try/catch + FALLBACK_DATA + degraded 전달
파일: src/shared/api/server/withTimeout.server.ts
- 기본 fetch에 timeout이 없으므로 강제 타임아웃 구현 필요
- Node/Edge 환경에서
AbortSignal.timeout지원 여부가 다를 수 있음 - 외부 signal(취소/종료)과 timeout signal을 동시에 지원해야 함
- 지원되면
AbortSignal.timeout(ms)사용 - 미지원이면
AbortController + setTimeoutfallback - 외부 signal이 들어오면
AbortSignal.any()또는 수동 결합 - 타임아웃일 때만
RequestTimeoutError로 정규화
import 'server-only';
export class RequestTimeoutError extends Error {
readonly timeoutMs: number;
constructor(timeoutMs: number) {
super(`REQUEST_TIMEOUT_${timeoutMs}MS`);
this.name = 'RequestTimeoutError';
this.timeoutMs = timeoutMs;
}
}
export type WithTimeoutOptions = {
timeoutMs: number;
signal?: AbortSignal;
};
const hasAbortSignalAny = typeof AbortSignal !== 'undefined' && 'any' in AbortSignal;
const hasAbortSignalTimeout = typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal;
function combineSignals(signals: AbortSignal[]): AbortSignal {
if (signals.length === 1) return signals[0];
if (hasAbortSignalAny) {
const abortSignalAny = AbortSignal.any as (signals: AbortSignal[]) => AbortSignal;
return abortSignalAny(signals);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
for (const signal of signals) {
if (signal.aborted) {
controller.abort();
return controller.signal;
}
signal.addEventListener('abort', onAbort, { once: true });
}
return controller.signal;
}
function createTimeoutSignal(timeoutMs: number): AbortSignal {
if (hasAbortSignalTimeout) {
const abortSignalTimeout = AbortSignal.timeout as (ms: number) => AbortSignal;
return abortSignalTimeout(timeoutMs);
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error('TIMEOUT')), timeoutMs);
return controller.signal;
}
function isAbortError(error: unknown): boolean {
if (error instanceof DOMException) {
return error.name === 'AbortError' || error.name === 'TimeoutError';
}
if (error instanceof Error) {
return error.name === 'AbortError' || error.name === 'TimeoutError';
}
return false;
}
export async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
options: WithTimeoutOptions,
): Promise<T> {
const timeoutSignal = createTimeoutSignal(options.timeoutMs);
const signal = options.signal ? combineSignals([options.signal, timeoutSignal]) : timeoutSignal;
try {
return await operation(signal);
} catch (error) {
// “타임아웃으로 인해 abort된 케이스”만 표준화된 에러로 래핑
if (timeoutSignal.aborted && isAbortError(error)) {
throw new RequestTimeoutError(options.timeoutMs);
}
throw error;
}
}파일: src/shared/api/server/index.ts
export { withTimeout, RequestTimeoutError } from './withTimeout.server';파일: src/app/bff/_lib/fetchUpstream.ts
- upstream fetch에 공통 타임아웃 적용
- endpoint별 프로파일 적용
- timeout 발생 시 표준화된 504 응답 반환
- 로깅/태깅을 단일 지점에서 수행
type TimeoutProfile = 'optional' | 'default' | 'critical';
const TIMEOUT_BY_PROFILE: Record<TimeoutProfile, number> = {
optional: 2000,
default: 3500,
critical: 5000,
};function resolveTimeoutProfile(
upstreamPath: string,
method: string,
override?: TimeoutProfile,
): TimeoutProfile {
if (override) return override;
// optional: 홈 추천 영역 (부가 기능)
if (upstreamPath.startsWith('/api/v1/experts/recommendations')) return 'optional';
// critical: 이력서 작업/리포트 조회는 중요도가 높음
if (upstreamPath.startsWith('/api/v1/resumes/tasks')) return 'critical';
if (upstreamPath.startsWith('/api/v2/reports')) return method === 'GET' ? 'critical' : 'default';
return 'default';
}요구사항:
504 + UPSTREAM_TIMEOUT고정 +{ code, message, degraded, requestId }형태
function createTimeoutResponse(timeoutMs: number, requestId: string): Response {
return Response.json(
{
code: 'UPSTREAM_TIMEOUT',
message: 'UPSTREAM_TIMEOUT',
data: null,
degraded: true,
requestId,
timeoutMs,
},
{ status: 504 },
);
}import { withTimeout, RequestTimeoutError } from '@/shared/api/server';
type FetchBffUpstreamOptions = RequestInit & {
timeoutMs?: number; // 엔드포인트 별 override
timeoutProfile?: TimeoutProfile; // optional/default/critical override
bffPath?: string; // 로그 집계용 (라우트명)
};
function getTimeoutMs(timeoutMs?: number, profile: TimeoutProfile = 'default'): number {
if (typeof timeoutMs === 'number' && timeoutMs > 0) return timeoutMs;
return TIMEOUT_BY_PROFILE[profile];
}
function getUpstreamPath(input: RequestInfo | URL): string {
if (typeof input === 'string') {
try {
return new URL(input).pathname;
} catch {
return input;
}
}
if (input instanceof URL) return input.pathname;
return input.url;
}
export async function fetchBffUpstream(
input: RequestInfo | URL,
options?: FetchBffUpstreamOptions,
): Promise<Response> {
const { timeoutMs: timeoutMsOption, timeoutProfile, bffPath, ...init } = options ?? {};
const method = init.method ?? 'GET';
const upstreamPath = getUpstreamPath(input);
const resolvedTimeoutProfile = resolveTimeoutProfile(upstreamPath, method, timeoutProfile);
const timeoutMs = getTimeoutMs(timeoutMsOption, resolvedTimeoutProfile);
const startMs = Date.now();
const requestSignal = init.signal ?? undefined;
try {
const res = await withTimeout<Response>((signal) => fetch(input, { ...init, signal }), {
timeoutMs,
signal: requestSignal,
});
// 5xx면 장애로 태깅
if (res.status >= 500) {
console.error('[BFF_UPSTREAM_HTTP_ERROR]', {
event: 'bff_upstream_http_error',
bffPath: bffPath ?? upstreamPath,
upstreamPath,
status: res.status,
method,
timeoutProfile: resolvedTimeoutProfile,
timeoutMs,
durationMs: Date.now() - startMs,
});
}
return res;
} catch (error) {
// timeout만 504로 표준화
if (error instanceof RequestTimeoutError) {
const requestId = crypto.randomUUID();
console.error('[BFF_UPSTREAM_TIMEOUT]', {
event: 'bff_upstream_timeout',
bffPath: bffPath ?? upstreamPath,
upstreamPath,
method,
timeoutProfile: resolvedTimeoutProfile,
timeoutMs,
requestId,
});
return createTimeoutResponse(timeoutMs, requestId);
}
throw error;
}
}const res = await fetch(buildApiUrl('/api/v1/resumes'), {
headers: { Authorization: `Bearer ${accessToken}` },
});import { fetchBffUpstream } from '@/app/bff/_lib/fetchUpstream';
const res = await fetchBffUpstream(buildApiUrl('/api/v1/resumes'), {
headers: { Authorization: `Bearer ${accessToken}` },
});- reports
- resumes (+ tasks)
- users/me
- experts/recommendations
- onboarding/metadata
- email-verifications (+ public)
- uploads/presigned-url
- auth/logout
결과적으로 BFF의 upstream
fetch호출이 공통 레이어로 수렴되어 timeout/표준 응답/로깅 정책이 자동 적용됨.
홈 추천 영역: ExpertRecommendationsServer
파일: src/widgets/home/ui/ExpertRecommendationsServer.tsx
const FALLBACK_DATA: ExpertRecommendationsResponse = {
user_id: 0,
recommendations: [],
total_count: 0,
evaluation: {},
};let data = FALLBACK_DATA;
let degraded = false;
try {
data = await apiFetch<ExpertRecommendationsResponse>(url, {
method: 'GET',
cache: 'no-store',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
} catch (error) {
degraded = true;
console.error('[SSR_EXPERT_RECOMMENDATIONS_FALLBACK]', {
event: 'ssr_expert_recommendations_fallback',
degraded,
reason: error instanceof Error ? error.message : 'unknown',
path: '/bff/experts/recommendations',
});
}
return <ExpertRecommendations recommendations={data.recommendations} degraded={degraded} />;파일: src/widgets/home/ui/ExpertRecommendations.tsx
type ExpertRecommendationsProps = {
recommendations: ExpertRecommendation[];
degraded?: boolean;
};
export default function ExpertRecommendations({
recommendations,
degraded = false,
}: ExpertRecommendationsProps) {
return (
<section>
<p className="text-sm font-semibold text-neutral-900">현직자 추천</p>
{degraded ? (
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
추천 서비스 지연으로 일부 데이터가 누락될 수 있어요.
</div>
) : null}
{/* 나머지 렌더 */}
</section>
);
}- 백엔드가 느리거나 장애여도 페이지 렌더는 유지
- 추천 영역만 degraded 처리 (UI 안내 포함)
- 운영자는 SSR fallback 로그로 장애 감지 가능
파싱/리포트 생성 같은 장기 작업은 기본(2~5초)로는 실패율이 높으므로 예외 적용.
파일: src/app/bff/resumes/tasks/route.ts
const res = await fetchBffUpstream(buildApiUrl('/api/v1/resumes/tasks'), {
timeoutMs: 30000,
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});파일: src/features/chat/server/createChatFeedback.server.ts
const CHAT_FEEDBACK_TIMEOUT_MS = 30000;
return apiFetchWithRefresh<ChatFeedbackCreatedData>(
url,
{
method: 'POST',
signal: AbortSignal.timeout(CHAT_FEEDBACK_TIMEOUT_MS),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params.payload),
},
params.accessToken,
params.allowRefresh ?? true,
);파일: src/app/bff/chat/[chatId]/feedback/route.ts
(타임아웃 시 표준 응답 생성/로그 태깅)
if (isTimeoutError(error)) {
const requestId = crypto.randomUUID();
console.error('[BFF_UPSTREAM_TIMEOUT]', {
event: 'bff_upstream_timeout',
bffPath: '/bff/chat/[chatId]/feedback',
upstreamPath: '/api/v2/chats/{chatId}/feedback',
method: 'POST',
timeoutMs: 30000,
requestId,
});
return NextResponse.json(
{
code: 'UPSTREAM_TIMEOUT',
message: 'UPSTREAM_TIMEOUT',
data: null,
degraded: true,
requestId,
timeoutMs: 30000,
},
{ status: 504 },
);
}GET /bff/reportsGET /bff/reports/[reportId]DELETE /bff/reports/[reportId]GET /bff/resumesPOST /bff/resumesPOST /bff/resumes/tasksGET /bff/resumes/[resumeId]DELETE /bff/resumes/[resumeId]PATCH /bff/resumes/[resumeId]PATCH /bff/resumes/[resumeId]/titleGET /bff/users/mePATCH /bff/users/meDELETE /bff/users/meGET /bff/users/me/expert-statusDELETE /bff/users/me/profile-imageGET /bff/experts/recommendationsGET /bff/onboarding/metadataPOST /bff/email-verificationsPATCH /bff/email-verificationsPOST /bff/email-verifications/publicPATCH /bff/email-verifications/publicPOST /bff/uploads/presigned-urlPOST /bff/auth/logout
POST /bff/resumes/tasksPOST /bff/chat/[chatId]/feedback
- 무기한 대기 제거 → SSR 전체 504 위험 감소
- 장애 시에도 페이지 렌더 유지 (Graceful Degradation)
- 표준화된 응답/로그로 운영 가시성 향상
- 장기 작업은 예외적으로 30초로 성공률 확보
- 타임아웃이 너무 짧으면 정상 요청도 실패 처리 (조기 중단)
- 타임아웃 이후 사용자 재시도 → 중복 요청 증가 가능
- 엔드포인트별 정책 관리 복잡도 증가
- 장기 작업 30초는 서버리스 제한과 맞닿을 수 있어 운영 판단 필요
- 프론트 단독으로는 완전하지 않음
- 백엔드가 키 저장/중복판단/재반환을 지원해야 “진짜” 중복 방지 가능
- 이번 변경은 timeout/fallback 안정화가 1차 목표
- idempotency는 후속 과제로 남김