Files
Gnuboard7/resources/js/core/template-engine/networkResilience.ts
T
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00

476 lines
17 KiB
TypeScript

/**
* 네트워크 복원력 유틸리티
*
* 페이지 로드에 필요한 요청 1건이 일시적으로 실패했다고 앱 전체가 죽지 않도록,
* **네트워크 레벨 실패에만** 지수 백오프 재시도를 거는 fetch/script 로더를 제공한다.
*
* 판별의 핵심: `fetch` 는 4xx/5xx 로 reject 하지 않는다(`Response.ok=false` 로 resolve).
* 따라서 `TypeError: Failed to fetch` 는 **응답 자체가 없었다**는 뜻이다 — 요청 취소 또는
* 커넥션 유실. 서버가 에러를 응답한 것과는 근본이 다르며, 전자만 재시도 가치가 있다.
* HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기(401 재시도 등)를 보존한다.
*
* @since engine-v1.53.0
*/
import { createLogger } from '../utils/Logger';
const logger = createLogger('networkResilience');
/**
* 재시도 옵션
*
* @since engine-v1.53.0
*/
export interface RetryOptions {
/** 재시도 횟수 (기본 2 = 총 3시도) */
retries?: number;
/** 첫 백오프 대기 (기본 300ms, 시도마다 2배 + ±25% jitter) */
baseDelayMs?: number;
/** 백오프 상한 (기본 2000ms) */
maxDelayMs?: number;
/** 시도별 타임아웃 (기본 15000ms, 0 이면 비활성) */
timeoutMs?: number;
/** 로그 식별용 라벨 */
label?: string;
}
const DEFAULT_RETRIES = 2;
const DEFAULT_BASE_DELAY_MS = 300;
const DEFAULT_MAX_DELAY_MS = 2000;
const DEFAULT_TIMEOUT_MS = 15000;
/**
* 취소(AbortError) 여부를 판별합니다.
*
* `instanceof DOMException` 을 쓰지 않는 이유: jsdom 등 테스트 환경에서 DOMException 이
* 없거나 다른 realm 의 것일 수 있어 판정이 어긋난다. name 비교가 환경 독립적이다.
*
* @param error 검사할 에러
* @return bool 취소로 인한 에러이면 true
* @since engine-v1.53.0
*/
export function isAbortError(error: unknown): boolean {
return (error as { name?: string } | null)?.name === 'AbortError';
}
/**
* 네트워크 레벨 실패(응답 없음) 여부를 판별합니다.
*
* fetch 스펙상 네트워크 오류는 TypeError 로 reject 된다. 취소(AbortError)는
* 호출부의 의도이므로 재시도 대상이 아니다.
*
* axios 경로(로그인 등 ApiClient 를 쓰는 요청)는 TypeError 가 아니라 `AxiosError` 로
* reject 되며 종류는 `code` 에만 남는다. TypeError 만 보면 그 경로의 네트워크 실패가
* 판정에서 빠져 영문 원문(`Network Error`)이 그대로 화면에 노출된다.
*
* @param error 검사할 에러
* @return bool 재시도할 가치가 있는 네트워크 실패이면 true
* @since engine-v1.53.0
* @since engine-v1.65.0 axios 네트워크 오류 코드(ERR_NETWORK/ECONNABORTED) 인식
*/
export function isNetworkFailure(error: unknown): boolean {
if (isAbortError(error)) return false;
if (error instanceof TypeError) return true;
const code = (error as { code?: string } | null)?.code;
return code === 'ERR_NETWORK' || code === 'ECONNABORTED';
}
let unloading = false;
let unloadGuardInstalled = false;
/**
* 현재 문서가 이탈(새로고침/이동) 중인지 반환합니다.
*
* @return bool 이탈 중이면 true
* @since engine-v1.53.0
*/
export function isDocumentUnloading(): boolean {
return unloading;
}
/**
* 문서 이탈 감지 가드를 설치합니다 (중복 설치 무해).
*
* 이탈 중 발생한 요청 실패는 "버려지는 문서" 의 실패이므로 에러 화면을 띄우면 안 된다.
* 사용자가 이미 떠난 화면에 에러를 그리는 것은 그 자체가 결함이다.
*
* `visibilitychange`/`hidden` 은 의도적으로 쓰지 않는다 — 탭 전환·앱 백그라운드로도
* 발화하므로, 백그라운드에서 재시도가 모두 실패한 **정상적 초기화 실패**까지 삼키면
* 사용자가 돌아왔을 때 영구 빈 화면이 된다(현행보다 나쁨). 새로고침·이탈은 `pagehide`
* 가 반드시 발화한다.
*
* @return void
* @since engine-v1.53.0
*/
export function installUnloadGuard(): void {
if (typeof window === 'undefined' || unloadGuardInstalled) return;
unloadGuardInstalled = true;
window.addEventListener('pagehide', () => {
unloading = true;
});
// bfcache 복귀 시 해제 — 없으면 뒤로가기로 복귀한 문서가 영구히 "이탈 중" 으로 오판된다.
window.addEventListener('pageshow', (event) => {
if ((event as PageTransitionEvent).persisted) {
unloading = false;
}
});
// Safari 보강 (pagehide 미발화 케이스 대비)
window.addEventListener('beforeunload', () => {
unloading = true;
});
}
/**
* 테스트 전용 — unload 가드 상태를 초기화합니다.
*
* @return void
* @since engine-v1.53.0
*/
export function resetUnloadGuardForTesting(): void {
unloading = false;
unloadGuardInstalled = false;
}
/**
* 지수 백오프 대기 시간을 계산합니다 (2배 증가 + ±25% jitter).
*
* jitter 를 두는 이유: 병렬 요청이 동시에 실패하면(커넥션 유실은 대개 그렇다)
* 재시도가 한 시점에 몰려 스탬피드가 된다.
*
* @param attempt 완료된 시도 횟수 (0부터)
* @param baseDelayMs 기준 대기 시간
* @param maxDelayMs 상한
* @return number 대기할 밀리초
* @since engine-v1.53.0
*/
function computeBackoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
const exponential = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
return Math.max(0, Math.round(exponential + jitter));
}
/**
* 지정 시간만큼 대기합니다.
*
* @param ms 대기할 밀리초
* @return Promise<void>
* @since engine-v1.53.0
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 네트워크 레벨 실패에만 재시도하는 fetch 래퍼입니다.
*
* HTTP 응답(2xx/4xx/5xx 무관)은 **재시도하지 않고 그대로 반환**한다. `!response.ok`
* 검사와 throw 는 호출부 책임으로 남긴다 — 유틸이 `.ok` 를 보면 호출부의 상태코드
* 분기(예: LayoutLoader 의 401 재시도)와 이중 재시도가 겹친다.
*
* 외부 signal 에 의한 AbortError 는 즉시 rethrow 한다(호출부의 명시적 취소를 유틸이
* 되살리면 안 된다). 내부 timeout 이 발화시킨 abort 만 재시도 대상이다.
*
* @param url 요청 URL
* @param options 재시도 옵션 + fetch init
* @return Promise<Response> HTTP 응답 (4xx/5xx 포함)
* @throws TypeError 모든 시도가 네트워크 실패로 끝난 경우
* @since engine-v1.53.0
*/
export async function fetchWithRetry(
url: string,
options: RetryOptions & { init?: RequestInit } = {}
): Promise<Response> {
const {
retries = DEFAULT_RETRIES,
baseDelayMs = DEFAULT_BASE_DELAY_MS,
maxDelayMs = DEFAULT_MAX_DELAY_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
label = url,
init,
} = options;
const maxAttempts = retries + 1;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// 내부 timeout 이 발화시킨 abort 와 외부 signal 의 abort 를 구분하기 위한 플래그.
// 이 구분이 없으면 호출부가 명시적으로 취소한 요청을 유틸이 되살려 재발사한다.
let timedOut = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const controller = timeoutMs > 0 ? new AbortController() : null;
try {
const requestInit: RequestInit = { ...init };
if (controller) {
// 외부 signal 이 있으면 그 취소를 내부 controller 로 전파한다.
const externalSignal = init?.signal;
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
requestInit.signal = controller.signal;
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
}
const response = await fetch(url, requestInit);
// HTTP 응답이 왔다 = 재시도 대상 아님. 상태코드 판단은 호출부 몫.
return response;
} catch (error) {
lastError = error;
// 외부 취소는 즉시 rethrow (유틸이 되살리지 않는다)
if (isAbortError(error) && !timedOut) {
throw error;
}
// 내부 timeout 이 발화시킨 abort 는 네트워크 실패와 동급으로 취급해 재시도한다.
const retryable = timedOut || isNetworkFailure(error);
if (!retryable) {
throw error;
}
// 이탈 중인 문서는 남은 재시도를 포기한다 (버려질 문서에 대한 낭비)
if (isDocumentUnloading()) {
logger.warn(`Document unloading, aborting retries: ${label}`);
throw error;
}
const isLastAttempt = attempt === maxAttempts - 1;
if (isLastAttempt) {
logger.warn(
`All ${maxAttempts} attempts failed: ${label}`,
error
);
throw error;
}
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
logger.warn(
`Network failure (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
);
await delay(waitMs);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
// 도달 불가 (루프가 반드시 return 또는 throw 한다) — 타입 완결용
throw lastError;
}
/**
* `<script src>` 로드를 재시도하는 로더입니다.
*
* 최종 실패 시 **반드시 reject** 한다. `onerror` 에서 `resolve()` 하면 실패가 성공으로
* 위장되어 상위 try/catch 가 무력화되고, 앱은 한참 뒤 엉뚱한 곳(미등록 핸들러 등)에서
* 죽는다 — 그것이 이 유틸이 도입된 계기다.
*
* `<script>` 의 `onerror` 는 실패 사유를 주지 않으므로(404 인지 네트워크 유실인지 구분
* 불가) **모든 실패를 재시도 대상**으로 본다. 404 라면 3회 시도 후 reject 되어 명시적
* 에러로 끝나므로 안전하다.
*
* 재시도 전 기존 element 를 제거하고 새로 만든다 — 남겨두면 IIFE 번들이 두 번 실행되어
* 핸들러가 중복 등록된다.
*
* @param url 스크립트 URL
* @param attrs script element 에 부여할 속성 (id 등)
* @param options 재시도 옵션
* @return Promise<void> 로드 성공 시 resolve
* @throws Error 모든 시도가 실패한 경우
* @since engine-v1.53.0
*/
export async function loadScriptWithRetry(
url: string,
attrs: Record<string, string> = {},
options: RetryOptions = {}
): Promise<void> {
const {
retries = DEFAULT_RETRIES,
baseDelayMs = DEFAULT_BASE_DELAY_MS,
maxDelayMs = DEFAULT_MAX_DELAY_MS,
label = url,
} = options;
const maxAttempts = retries + 1;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await loadScriptOnce(url, attrs);
return;
} catch (error) {
// 실패한 element 를 제거한다 (IIFE 중복 실행 방지 + 다음 시도의 id 충돌 방지)
if (attrs.id) {
document.getElementById(attrs.id)?.remove();
}
if (isDocumentUnloading()) {
logger.warn(`Document unloading, aborting script retries: ${label}`);
throw error;
}
const isLastAttempt = attempt === maxAttempts - 1;
if (isLastAttempt) {
logger.warn(`All ${maxAttempts} script attempts failed: ${label}`);
throw error;
}
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
logger.warn(
`Script load failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
);
await delay(waitMs);
}
}
}
/**
* `<script>` 1회 로드를 시도합니다 (재시도 없음).
*
* @param url 스크립트 URL
* @param attrs script element 속성
* @return Promise<void> 로드 성공 시 resolve, 실패 시 reject
* @since engine-v1.53.0
*/
function loadScriptOnce(url: string, attrs: Record<string, string>): Promise<void> {
return new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.async = false; // 번들 내부 물리 순서로 실행 보장
for (const [key, value] of Object.entries(attrs)) {
if (key === 'id') {
script.id = value;
} else {
script.setAttribute(key, value);
}
}
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
document.head.appendChild(script);
});
}
/**
* `<link rel="stylesheet">` 로드를 재시도하는 로더입니다.
*
* `loadScriptWithRetry` 와 동형이다 — 3시도(기본) · 지수 백오프 · **최종 실패 시 reject**.
* CSS 경로만 이 계층이 없어 실패가 통째로 무음이었다: `onerror` 를 아예 걸지 않거나
* `resolve()` 로 삼켜, 스타일이 붙지 않은 화면(아이콘 소실·본문 서식 붕괴)이 오류 없이
* 남았다. 아이콘만으로 조작하는 버튼이 있는 화면에서는 그것이 곧 조작 불능이다.
*
* `<link>` 의 `onerror` 도 사유를 주지 않으므로 모든 실패를 재시도 대상으로 본다.
* 재시도 전 기존 element 를 제거한다 — 남겨두면 실패한 `<link>` 가 누적되고, id 를
* 지정한 경우 다음 시도와 충돌한다.
*
* @param url 스타일시트 URL
* @param attrs link element 에 부여할 속성 (id, media, crossorigin 등)
* @param options 재시도 옵션
* @return Promise<void> 로드 성공 시 resolve
* @throws Error 모든 시도가 실패한 경우
* @since engine-v1.62.0
*/
export async function loadStylesheetWithRetry(
url: string,
attrs: Record<string, string> = {},
options: RetryOptions = {}
): Promise<void> {
const {
retries = DEFAULT_RETRIES,
baseDelayMs = DEFAULT_BASE_DELAY_MS,
maxDelayMs = DEFAULT_MAX_DELAY_MS,
label = url,
} = options;
const maxAttempts = retries + 1;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let link: HTMLLinkElement | null = null;
try {
link = createStylesheetElement(url, attrs);
await loadStylesheetOnce(link);
return;
} catch (error) {
// 실패한 element 를 제거한다 (누적 방지 + 다음 시도의 id 충돌 방지)
link?.remove();
if (attrs.id) {
document.getElementById(attrs.id)?.remove();
}
if (isDocumentUnloading()) {
logger.warn(`Document unloading, aborting stylesheet retries: ${label}`);
throw error;
}
const isLastAttempt = attempt === maxAttempts - 1;
if (isLastAttempt) {
logger.warn(`All ${maxAttempts} stylesheet attempts failed: ${label}`);
throw error;
}
const waitMs = computeBackoffDelay(attempt, baseDelayMs, maxDelayMs);
logger.warn(
`Stylesheet load failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${waitMs}ms: ${label}`
);
await delay(waitMs);
}
}
}
/**
* `<link rel="stylesheet">` element 를 생성해 head 에 추가합니다.
*
* @param url 스타일시트 URL
* @param attrs link element 속성
* @return HTMLLinkElement 생성된 element
* @since engine-v1.62.0
*/
function createStylesheetElement(url: string, attrs: Record<string, string>): HTMLLinkElement {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
for (const [key, value] of Object.entries(attrs)) {
if (key === 'id') {
link.id = value;
} else {
link.setAttribute(key, value);
}
}
document.head.appendChild(link);
return link;
}
/**
* `<link>` 1회 로드를 시도합니다 (재시도 없음).
*
* @param link head 에 이미 추가된 link element
* @return Promise<void> 로드 성공 시 resolve, 실패 시 reject
* @since engine-v1.62.0
*/
function loadStylesheetOnce(link: HTMLLinkElement): Promise<void> {
return new Promise<void>((resolve, reject) => {
link.onload = () => resolve();
link.onerror = () => reject(new Error(`Failed to load stylesheet: ${link.href}`));
});
}