Deallo

상태 · 데이터

여러 링크 프리뷰가 1개만 요청되던 문제 — fetch 소유권을 부모로

채팅방에 URL 메시지가 여러 개인데, OG 프리뷰 요청이 Network 탭에 딱 1건만 잡혔다. 처음엔 "프론트가 마지막 링크 하나만 요청하나?" 싶었다. 그런데 파고들수록 표면이 거짓이었다. fetch 소유권을 부모로 올렸는데도 증상이 그대로였고, 진짜 범인은 WhatsApp 코드가 아니라 공용 axios 인터셉터의 중복 요청 취소 키였다. 범인까지 가는 동안 가설을 두 번 버렸다.

증상 — 요청은 2개여야 하는데 1건만 보였다

요구사항은 두 줄로 나뉘었다.

  • 렌더 규칙: 메시지 버블 하나에 링크가 여러 개여도 첫 번째 링크만 프리뷰한다
  • 요청 규칙: 채팅방의 여러 메시지 버블은 각자 자기 첫 링크에 대해 OG 요청을 보낸다

재현 데이터는 이랬다. 두 메시지의 첫 URL이 서로 달라서, 고유 URL 기준 2개 요청이 나가야 했다.

{ id: 1933, message: 'https://youtu.be/gnbY...\nhttps://youtu.be/IeRUG...' }
{ id: 1932, message: 'https://youtu.be/IeRUG... 링크테스트입니다' }
// 1933 → 첫 URL gnbY... / 1932 → 첫 URL IeRUG... → 고유 2개

그런데 실제 Network 탭에는 og-metadata가 1건만 떴다. 최신 메시지 쪽 링크만 프리뷰가 뜨고, 나머지 버블은 skeleton에서 멈췄다. Network만 보면 "프론트가 마지막 링크 하나만 요청하는 것처럼" 보였다.

chat_rooms?page=1&size=100   200
messages?page=1&size=20      200
og-metadata                  200   ← 이것만 1건

가설 A — "버블이 lazy fetch라서 화면의 최신 메시지 하나만 가져온다"

초기 구조는 메시지 버블 컴포넌트가 직접 자기 첫 URL을 useOgPreview로 가져오는 방식이었다. 이 구조의 한계는 명확해 보였다.

  • fetch 소유권이 버블에 있다 → 방 전체 기준 병렬 요청을 부모가 통제 못 한다
  • 누가 언제 요청을 시작하는지가 버블의 렌더 타이밍에 의존한다

그래서 첫 수정 방향을 이렇게 잡았다 — fetch 소유권을 부모로 올린다.

가설 A (수정 전)1차 수정 (수정 후)
fetch 주체메시지 버블 각자부모 훅(useWhatsAppWorkspace)
요청 시점버블 렌더 타이밍 의존방 선택 시 한곳에서 통제
병렬 처리제어 불가useQueries 가변 개수 병렬

부모가 선택된 방의 메시지를 훑어 각 메시지의 첫 URL만 뽑고, useQueries로 병렬 요청한 뒤, 결과를 메시지별 ogPreview / isOgPreviewLoading으로 주입했다. 버블은 더 이상 fetch하지 않고 받은 상태만 소비했다.

const selectedRoomOgUrls = Array.from(
  new Set(
    selectedRoom.messages
      .map((message) => extractFirstUrl(message.text))
      .filter(Boolean),
  ),
)
 
const selectedRoomOgQueries = useOgPreviews(selectedRoomOgUrls)

방향 자체는 맞았다(부모 소유권은 끝까지 유지했다). 이론상 여기서 gnbY... 1개 + IeRUG... 1개, 총 2개 요청이 보장돼야 했다.

그런데 Network에는 여전히 1건만 보였다. 이 시점에서 "프론트가 첫 URL을 하나만 뽑는다"는 가설은 힘을 잃었다.

추측을 멈추고 런타임을 봤다

여기서부터는 머리로 추측하지 않고, 런타임에서 실제로 뭐가 만들어지는지 전역 디버그 값을 박아서 확인했다.

먼저 부모 훅이 URL을 몇 개나 요청 대상으로 보는지 찍었다.

globalThis.__WA_OG_QUERY_URLS__
// ['https://youtu.be/gnbY...', 'https://youtu.be/IeRUG...']  ← 2개

프론트는 첫 URL 추출을 잘 하고 있었고, 부모 훅은 실제로 2개 URL을 요청 대상으로 잡고 있었다. 그다음 useQueries가 만든 query 상태를 찍었다. 여기서 결정적인 게 나왔다.

{
  queries: [
    { url: 'https://youtu.be/gnbY...',  status: 'error',   hasData: false },
    { url: 'https://youtu.be/IeRUG...', status: 'success',  hasData: true  },
  ],
}

query는 2개가 생성되고 있었다. 하나는 success, 하나는 error. "최신 메시지 하나만 query가 만들어진다"는 가설 A는 완전히 틀렸다. 그런데 이상한 점이 남았다.

  • TanStack Query 레벨에서는 query가 2개 존재한다
  • Network 레벨에서는 성공한 XHR 1건만 눈에 띈다

Query 상태와 Network 탭이 서로 다른 진실을 말하고 있었다. 이게 핵심 분기점이었다. 문제는 더 이상 OG 추출 로직이나 useQueries가 아니라, HTTP 요청이 실제로 어떻게 취소되고 있는지였다.

가설 B — 공용 axios 인터셉터가 둘 중 하나를 abort한다

shared/api/instance.ts에는 중복 요청을 막으려고 AbortController로 in-flight 요청을 취소하는 인터셉터가 있었다. 문제는 "같은 요청"을 판별하는 request key를 만드는 기준이었다.

기존 키는 사실상 url + params였다.

const url = config.url ?? ''
const params = JSON.stringify(config.params ?? {})
const key = `${url}-${params}`   // ← data(body)가 빠져 있다

OG 메타데이터 요청은 URL을 POST body로 보낸다. 그러니 아래 두 요청은 —

POST /api/og-metadata  body: { url: 'https://youtu.be/gnbY...' }
POST /api/og-metadata  body: { url: 'https://youtu.be/IeRUG...' }

URL이 둘 다 /api/og-metadata, params도 둘 다 없음 → key가 동일해진다. 그 결과 두 번째 요청이 들어오는 순간, 인터셉터가 첫 번째 요청의 controller를 abort해버렸다.

이게 증상을 정확히 설명했다.

  • React Query는 query 2개를 만든다
  • 하지만 공용 axios가 둘 중 먼저 뜬 하나를 abort한다 → 그게 status: error
  • 그래서 Network엔 살아남은 1건만 선명하게 보인다

도메인 코드(WhatsApp)가 아니라 shared 레이어의 dedupe key가 범인이었다.

해결 — request key에 body까지 포함

근본 수정은 단순했다. 키를 url + params가 아니라 url + params + data 기준으로 만든다. data는 string / URLSearchParams / FormData / 그 외 JSON으로 안전하게 직렬화한다.

function buildRequestKey(config: AxiosRequestConfig): string {
  const url = config.url ?? ''
  const params = JSON.stringify(config.params ?? {})
  const data = serializeRequestData(config.data)
  return `${url}-${params}-${data}`
}
 
function serializeRequestData(data: unknown): string {
  if (data == null) return ''
  if (typeof data === 'string') return data
  if (data instanceof URLSearchParams) return data.toString()
  if (typeof FormData !== 'undefined' && data instanceof FormData) {
    return JSON.stringify(Array.from(data.entries()))
  }
  try {
    return JSON.stringify(data)
  } catch {
    return String(data)
  }
}

이제 같은 endpoint·같은(없는) params라도 body가 다르면 서로 다른 key를 가진다 → 서로 abort하지 않는다. 채팅방에 서로 다른 첫 URL 2개가 있으면 og-metadata 요청도 2건 나가고, 같은 URL은 dedupe돼 한 번만 나간다. 각 버블은 자기 첫 링크에 대해 skeleton → preview로 전환됐다.

정리된 최종 구조 — 요청은 넓게, 렌더는 좁게

두 층의 수정이 합쳐져야 요구사항이 만족됐다.

  • 구조 개선: 방 단위 useQueries로 OG fetch 소유권을 부모로 올림 (요청 대상 = 방 전체 첫 링크들)
  • 근본 수정: axios 중복 요청 키에 data를 포함 (POST body가 다르면 다른 요청으로 취급)
// 부모: 방의 고유 첫 링크들을 가변 개수로 병렬
const results = useQueries({
  queries: firstLinks.map((url) => ({
    queryKey: ['og', url],
    queryFn: () => fetchOg(url),
  })),
})
// 버블: fetch 안 함. 받은 ogPreview만 렌더, 없고 로딩 중이면 skeleton

검증 포인트는 셋이었다 — 메시지별 첫 URL 추출이 맞는가 / 방 기준으로 각 첫 URL이 병렬 query 대상이 되는가 / 같은 POST /api/og-metadata라도 body가 다르면 서로 abort하지 않는가. 마지막 항목을 인터셉터 단위 테스트로 못 박았다.

두 번 버린 가설이 남긴 것

  • Network 탭만 보고 결론 내리면 안 된다. 요청이 "하나만" 보일 때, 진짜 하나만 보내는지 / 여러 개가 뭉개진 건지를 구분해야 한다. 이번엔 Query는 2개였고 Network는 1건이었다 — 둘이 다른 진실을 말할 때 shared 인터셉터를 의심해야 했다
  • "누가 fetch를 소유하는가"가 병렬성을 결정한다. 자식이 각자 fetch하면 렌더 타이밍에 휘둘린다. 가변 개수 병렬은 부모가 useQueries로 모아 쥐는 게 정석이다. 다만 이건 필요조건이었지 충분조건은 아니었다
  • 중복 요청 취소는 body까지 봐야 한다. 특히 POST endpoint는 같은 URL이라도 body가 다르면 의미가 완전히 다르다. URL만으로 dedupe하면 서로 다른 요청을 같은 요청으로 오인해 abort한다
  • 표면이 "WhatsApp OG 프리뷰 버그"처럼 보여도, root cause는 공용 HTTP 인프라의 request deduplication 버그일 수 있다. 가설 A(도메인 코드)에서 멈췄으면 영영 못 찾았을 자리였다