임동욱 임동욱 08-21
merge: 백엔드 응답 로깅
@46bd00084fb2fc3fb0a40d2af5bc33aaff31dbf9
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -137,24 +137,102 @@
   };
 }
 
+/** 로그가 켜지는 환경. 운영에서는 요청·응답을 남기지 않는다. */
+function loggingEnabled(): boolean {
+  return process.env.NODE_ENV !== 'production';
+}
+
+/** 요청과 응답을 짝지어 읽기 위한 일련번호. 동시 호출이 섞여도 쌍을 찾을 수 있다. */
+let callSeq = 0;
+
+/** 로그에 남기면 안 되는 값. 키 이름으로 가린다 — 토큰이 콘솔·로그 파일에 남으면 그 자체가 유출이다. */
+const SECRET_KEY_PATTERN = /token|password|secret|authorization/i;
+
+/** 한 번에 남길 본문의 최대 길이. 넘으면 잘라내고 잘렸다고 적는다. */
+const LOG_BODY_LIMIT = 4000;
+
+function redactSecrets(value: unknown): unknown {
+  if (Array.isArray(value)) {
+    return value.map(redactSecrets);
+  }
+  if (value !== null && typeof value === 'object') {
+    return Object.fromEntries(
+      Object.entries(value as Record<string, unknown>).map(([key, item]) =>
+        SECRET_KEY_PATTERN.test(key) ? [key, '***'] : [key, redactSecrets(item)]
+      )
+    );
+  }
+  return value;
+}
+
+function clip(text: string): string {
+  return text.length > LOG_BODY_LIMIT
+    ? `${text.slice(0, LOG_BODY_LIMIT)}\n… (${text.length}자 중 앞부분만)`
+    : text;
+}
+
+/** 본문 문자열을 JSON이면 보기 좋게, 아니면 그대로 — 어느 쪽이든 민감값은 가리고 길면 자른다. */
+function formatBody(raw: string): string {
+  try {
+    return clip(JSON.stringify(redactSecrets(JSON.parse(raw)), null, 2));
+  } catch {
+    return clip(raw);
+  }
+}
+
+/**
+ * 백엔드에서 **받은** 것을 남긴다. 요청 로그와 같은 번호가 붙는다.
+ *
+ * 본문은 호출부가 이미 읽어 둔 글자를 받는다 — 여기서 `response.text()`를 부르면 스트림이
+ * 소비돼 호출부가 같은 본문을 다시 읽을 수 없다.
+ */
+function logResponse(
+  call: { id: number; startedAt: number },
+  status: number,
+  raw: string | null,
+  note?: string
+): void {
+  if (!loggingEnabled()) {
+    return;
+  }
+
+  const lines = [
+    `[backend-fetch] #${call.id} ← ${status} (${Date.now() - call.startedAt}ms)`,
+  ];
+  if (note) {
+    lines.push(note);
+  }
+  if (raw !== null) {
+    lines.push(raw.trim() === '' ? '(본문 없음)' : `response:\n${formatBody(raw)}`);
+  }
+
+  console.info(lines.join('\n'));
+}
+
 /**
  * 테스트 환경에서는 백엔드로 나가는 **모든** 요청을 서버 콘솔에 남긴다 — 브라우저 네트워크
  * 탭에는 이 호출이 뜨지 않아(BFF) 여기가 유일하게 보이는 자리다. 운영에서는 찍지 않는다.
  * JSON 본문은 Swagger Request body에 그대로 붙여 넣을 수 있는 모양으로 찍는다.
+ *
+ * 돌려주는 값을 `logResponse`에 넘기면 요청·응답이 같은 번호로 묶인다.
  */
 function logRequest(
   method: string,
   url: string,
   payload?: { body?: unknown; form?: Record<string, string | number | undefined>; multipart?: FormData; note?: string }
-): void {
-  if (process.env.NODE_ENV === 'production') {
-    return;
+): { id: number; startedAt: number } {
+  const call = { id: (callSeq += 1), startedAt: Date.now() };
+
+  if (!loggingEnabled()) {
+    return call;
   }
 
-  const lines = [`[backend-fetch] ${method} ${url}`];
+  const lines = [`[backend-fetch] #${call.id} → ${method} ${url}`];
 
   if (payload?.body !== undefined) {
-    lines.push(`body(JSON):\n${JSON.stringify(payload.body, null, 2)}`);
+    lines.push(
+      `body(JSON):\n${clip(JSON.stringify(redactSecrets(payload.body), null, 2))}`
+    );
   }
   if (payload?.form) {
     const params = new URLSearchParams();
@@ -178,6 +256,7 @@
   }
 
   console.info(lines.join('\n'));
+  return call;
 }
 
 function resolveUrl(
@@ -196,11 +275,9 @@
 }
 
 /** 봉투 파싱 실패를 예외로 만들지 않는다 — 실패 응답의 형태가 깨져 있어도 호출부는 계속 진행한다. */
-async function readEnvelopeSafely(
-  response: Response
-): Promise<Partial<BackendEnvelope<unknown>> | null> {
+function parseEnvelope(raw: string): Partial<BackendEnvelope<unknown>> | null {
   try {
-    const parsed: unknown = await response.json();
+    const parsed: unknown = JSON.parse(raw);
     return parsed !== null && typeof parsed === 'object'
       ? (parsed as Partial<BackendEnvelope<unknown>>)
       : null;
@@ -229,7 +306,7 @@
   init: BackendStreamRequestInit
 ): Promise<BackendResult<Response>> {
   const url = resolveUrl(path, init.query);
-  logRequest('GET', url, { note: '(파일 응답 스트림)' });
+  const call = logRequest('GET', url, { note: '(파일 응답 스트림)' });
 
   let response: Response;
   try {
@@ -244,11 +321,15 @@
       cache: 'no-store',
     });
   } catch (error) {
+    logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)');
     return communicationError('파일 요청 실패(네트워크·타임아웃)', error);
   }
 
   if (!response.ok) {
-    const envelope = await readEnvelopeSafely(response);
+    // 실패 응답은 JSON 봉투라 읽어도 스트림을 낭비하지 않는다.
+    const raw = await response.text().catch(() => '');
+    logResponse(call, response.status, raw);
+    const envelope = parseEnvelope(raw);
     if (envelope && typeof envelope.code === 'number') {
       return {
         ok: false,
@@ -265,6 +346,15 @@
     );
   }
 
+  // 성공 본문은 파일 스트림이다 — 읽으면 호출부가 흘려보낼 것이 사라지므로 머리말만 남긴다.
+  logResponse(
+    call,
+    response.status,
+    null,
+    `(파일 스트림 ${response.headers.get('content-type') ?? '형식 미상'}, ${
+      response.headers.get('content-length') ?? '길이 미상'
+    }바이트)`
+  );
   return { ok: true, data: response };
 }
 
@@ -310,7 +400,7 @@
   const { body, contentType } = buildRequestBody(init);
   const url = resolveUrl(path, init.query);
 
-  logRequest(init.method, url, {
+  const call = logRequest(init.method, url, {
     body: init.body,
     form: init.form,
     multipart: init.multipart,
@@ -332,14 +422,27 @@
       next: init.next,
     });
   } catch (error) {
+    logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)');
     return communicationError('요청 실패(네트워크·타임아웃)', error);
   }
+
+  // 본문은 여기서 딱 한 번 읽는다 — 로그와 파싱이 같은 글자를 쓰고, 스트림을 두 번 읽는 실수를
+  // 구조적으로 막는다. 아래 분기들은 모두 이 문자열만 본다.
+  let raw: string;
+  try {
+    raw = await response.text();
+  } catch (error) {
+    logResponse(call, response.status, null, '(본문 읽기 실패)');
+    return communicationError('응답 읽기 실패', error);
+  }
+
+  logResponse(call, response.status, raw);
 
   if (!response.ok) {
     // 인증 실패만은 HTTP status로도 온다 — 미인증·권한부족 모두 401 + `{success:true, auth:false,
     // code:401}`이다(실측). status만 보고 통신 오류로 뭉뚱그리면 "세션이 끊겼다"와 "서버가 죽었다"를
     // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다.
-    const envelope = await readEnvelopeSafely(response);
+    const envelope = parseEnvelope(raw);
     if (envelope && typeof envelope.code === 'number') {
       return {
         ok: false,
@@ -363,35 +466,22 @@
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`);
   }
 
-  // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이
-  // 파싱 오류를 던지므로 그 전에 갈라야 한다.
-  if (init.canHaveEmptyBody) {
-    let raw: string;
-    try {
-      raw = await response.text();
-    } catch (error) {
-      return communicationError('응답 읽기 실패', error);
-    }
-
-    if (raw.trim() === '') {
-      return { ok: true, data: null as T };
-    }
-
-    try {
-      const parsed = JSON.parse(raw) as BackendEnvelope<T>;
-      return parsed.success
-        ? { ok: true, data: parsed.data as T }
-        : { ok: false, code: parsed.code, message: parsed.message };
-    } catch (error) {
-      return communicationError('응답 파싱 실패', error);
-    }
+  // 본문 없는 2xx를 허용한 호출은 빈 본문을 정상으로 본다.
+  if (init.canHaveEmptyBody && raw.trim() === '') {
+    return { ok: true, data: null as T };
   }
 
   let envelope: BackendEnvelope<T>;
   try {
-    envelope = (await response.json()) as BackendEnvelope<T>;
+    envelope = JSON.parse(raw) as BackendEnvelope<T>;
   } catch (error) {
     return communicationError('응답 파싱 실패', error);
+  }
+
+  if (init.canHaveEmptyBody) {
+    return envelope.success
+      ? { ok: true, data: envelope.data as T }
+      : { ok: false, code: envelope.code, message: envelope.message };
   }
 
   if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) {
@@ -422,7 +512,9 @@
   }
 ): Promise<BackendResult<T>> {
   const url = resolveUrl(path, undefined);
-  logRequest('POST', url, { note: '(multipart 스트림 본문 — 내용 생략)' });
+  const call = logRequest('POST', url, {
+    note: '(multipart 스트림 본문 — 내용 생략)',
+  });
 
   let response: Response;
   try {
@@ -441,10 +533,14 @@
       cache: 'no-store',
     } as RequestInit);
   } catch (error) {
+    logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)');
     return communicationError('업로드 실패(네트워크·타임아웃)', error);
   }
 
-  const envelope = await readEnvelopeSafely(response);
+  const raw = await response.text().catch(() => '');
+  logResponse(call, response.status, raw);
+
+  const envelope = parseEnvelope(raw);
 
   if (!response.ok || !envelope || envelope.success !== true) {
     if (envelope && typeof envelope.code === 'number') {
Add a comment
List