Skip to content

SSR 504 방지를 위한 BFF Timeout & Fallback 구조 도입

FE_juncci edited this page Feb 22, 2026 · 1 revision

📌 요약 (TL;DR)

  • 문제: upstream(백엔드) 무응답/지연 시 BFF fetch()무기한 대기 → SSR 전체가 서버리스 실행 제한에 걸려 페이지 자체가 504로 실패

  • 해결

    1. withTimeout 공통 유틸 도입 (AbortSignal 기반)
    2. BFF 공통 wrapper fetchBffUpstream 도입 + 타임아웃/응답 표준화
    3. SSR에서 try/catch + FALLBACK_DATA + degraded 적용
    4. 장기 작업 API는 30초 예외 적용
    5. 구조화 로그 태깅 추가 (timeout, upstream_status, route, requestId)

1) 📌 문제 배경

✅ 실제 장애 시나리오 (재현 가능한 흐름)

  • 백엔드 API가 응답하지 않거나 매우 느린 상황
  • SSR(Server Component)이 BFF를 호출
  • BFF는 내부에서 fetch(buildApiUrl(...)) 수행
  • 기본 fetch는 타임아웃이 없으므로 무기한 대기
  • 서버리스 환경(Lambda 등)에서 최대 실행 시간 초과 → SSR 자체가 504로 종료
  • 사용자는 “데이터만 늦게 뜨는” 수준이 아니라 페이지가 아예 안 뜨는 장애를 경험

⚠️ 기존 코드의 구조적 한계

1) BFF 라우트 대부분이 직접 fetch

// (기존) 여러 BFF route가 이런 형태
const res = await fetch(buildApiUrl('/api/v1/resumes'), {
  headers: { Authorization: `Bearer ${accessToken}` },
});
  • 라우트별로 fetch가 제각각
  • 공통 timeout / 공통 에러 포맷 / 공통 로깅이 없음

2) SSR 일부는 실패를 흡수하지만 “상태”를 전달하지 않음

// (기존) 실패해도 그냥 빈 배열로 처리
const recommendations = await apiFetch(url).then(r => r.recommendations).catch(() => []);
  • 사용자 입장: “추천이 원래 없는 건가?” vs “지금 장애인가?” 구분 불가
  • 운영 입장: SSR fallback이 조용히 처리되면 장애 감지 지연

2) 🎯 목표

  • BFF upstream 호출에 공통 타임아웃 적용

  • 타임아웃 시 무기한 대기 대신 즉시 표준 에러 반환

    • HTTP 504
    • 내부 코드 UPSTREAM_TIMEOUT
    • degraded, requestId 포함
  • SSR에서 fallback 데이터를 사용해 페이지 렌더 유지

  • 장애 원인 추적 가능한 구조화 로그 추가

  • 장기 작업(파싱/리포트 생성)은 30초 정책으로 성공률 보장


3) 🤔 설계 고민과 선택

고민 A) 모든 API에 동일 타임아웃을 줄 것인가?

  • 동일값은 단순하지만 API 성격이 다름

    • 추천(비핵심) vs 리포트 생성(장기 작업)
  • 같은 값이면 둘 중 하나는 항상 손해

    • 너무 짧으면 장기 작업 실패
    • 너무 길면 추천 같은 부가 기능이 SSR을 오래 잡아먹음

선택: 프로파일 기반 기본값 + 엔드포인트별 override

  • optional: 2s
  • default: 3.5s
  • critical: 5s
  • 장기 작업은 route/server에서 timeoutMs: 30000 명시

고민 B) 타임아웃 처리 위치를 어디에 둘 것인가?

  • 라우트마다 AbortController를 넣으면 중복 + 실수 가능성 증가
  • 공통 wrapper로 모으면 정책/로깅/표준화가 강제됨

선택: 공통 wrapper fetchBffUpstream 도입 후 라우트는 이것만 사용


고민 C) SSR fallback은 어디서 할 것인가?

  • BFF에서만 처리하면 UI는 degraded를 “모름”
  • SSR에서도 degraded 전달해야 사용자에게 맥락 제공 가능

선택: SSR server component에서 try/catch + FALLBACK_DATA + degraded 전달


4) 🛠 구현 상세


4-1) 공통 타임아웃 유틸 추가

파일: src/shared/api/server/withTimeout.server.ts

✅ 요구사항

  • 기본 fetch에 timeout이 없으므로 강제 타임아웃 구현 필요
  • Node/Edge 환경에서 AbortSignal.timeout 지원 여부가 다를 수 있음
  • 외부 signal(취소/종료)과 timeout signal을 동시에 지원해야 함

✅ 핵심 구현 포인트

  • 지원되면 AbortSignal.timeout(ms) 사용
  • 미지원이면 AbortController + setTimeout fallback
  • 외부 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;
  }
}

✅ FSD 규칙 준수 (public export)

파일: src/shared/api/server/index.ts

export { withTimeout, RequestTimeoutError } from './withTimeout.server';

4-2) BFF 공통 fetch 래퍼 도입

파일: src/app/bff/_lib/fetchUpstream.ts

✅ 역할

  • upstream fetch에 공통 타임아웃 적용
  • endpoint별 프로파일 적용
  • timeout 발생 시 표준화된 504 응답 반환
  • 로깅/태깅을 단일 지점에서 수행

✅ Timeout Profile 정의

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';
}

✅ timeout 시 표준 응답 생성

요구사항: 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 },
  );
}

✅ fetch wrapper 본체

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;
  }
}

4-3) BFF route 전환 (직접 fetch → 공통 wrapper)

✅ 변경 전

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/표준 응답/로깅 정책이 자동 적용됨.


4-4) SSR fallback/degraded 구조 도입

✅ 대상

홈 추천 영역: ExpertRecommendationsServer

파일: src/widgets/home/ui/ExpertRecommendationsServer.tsx


✅ FALLBACK_DATA 상수화

const FALLBACK_DATA: ExpertRecommendationsResponse = {
  user_id: 0,
  recommendations: [],
  total_count: 0,
  evaluation: {},
};

✅ try/catch + degraded 전달 + 구조화 로그

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} />;

✅ UI에서 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>
  );
}

✅ SSR 변경 결과

  • 백엔드가 느리거나 장애여도 페이지 렌더는 유지
  • 추천 영역만 degraded 처리 (UI 안내 포함)
  • 운영자는 SSR fallback 로그로 장애 감지 가능

4-5) 장기 작업 30초 정책 적용

파싱/리포트 생성 같은 장기 작업은 기본(2~5초)로는 실패율이 높으므로 예외 적용.


✅ 이력서 자동 파싱 (30s)

파일: 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),
});

✅ 채팅 피드백/리포트성 요청 (30s)

파일: 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 },
  );
}

5) 📌 적용 API 목록

✅ BFF 공통 wrapper 적용

  • GET /bff/reports
  • GET /bff/reports/[reportId]
  • DELETE /bff/reports/[reportId]
  • GET /bff/resumes
  • POST /bff/resumes
  • POST /bff/resumes/tasks
  • GET /bff/resumes/[resumeId]
  • DELETE /bff/resumes/[resumeId]
  • PATCH /bff/resumes/[resumeId]
  • PATCH /bff/resumes/[resumeId]/title
  • GET /bff/users/me
  • PATCH /bff/users/me
  • DELETE /bff/users/me
  • GET /bff/users/me/expert-status
  • DELETE /bff/users/me/profile-image
  • GET /bff/experts/recommendations
  • GET /bff/onboarding/metadata
  • POST /bff/email-verifications
  • PATCH /bff/email-verifications
  • POST /bff/email-verifications/public
  • PATCH /bff/email-verifications/public
  • POST /bff/uploads/presigned-url
  • POST /bff/auth/logout

✅ 30초 예외 적용

  • POST /bff/resumes/tasks
  • POST /bff/chat/[chatId]/feedback

6) ⚖️ 트레이드오프 정리

✅ 장점

  • 무기한 대기 제거 → SSR 전체 504 위험 감소
  • 장애 시에도 페이지 렌더 유지 (Graceful Degradation)
  • 표준화된 응답/로그로 운영 가시성 향상
  • 장기 작업은 예외적으로 30초로 성공률 확보

⚠️ 비용/리스크

  • 타임아웃이 너무 짧으면 정상 요청도 실패 처리 (조기 중단)
  • 타임아웃 이후 사용자 재시도 → 중복 요청 증가 가능
  • 엔드포인트별 정책 관리 복잡도 증가
  • 장기 작업 30초는 서버리스 제한과 맞닿을 수 있어 운영 판단 필요

7) 🧷 idempotency key 관련 메모

  • 프론트 단독으로는 완전하지 않음
  • 백엔드가 키 저장/중복판단/재반환을 지원해야 “진짜” 중복 방지 가능
  • 이번 변경은 timeout/fallback 안정화가 1차 목표
  • idempotency는 후속 과제로 남김

Clone this wiki locally