flowoodz.
← blog
8분 읽기글 flowoodz

틱톡 썸네일이 이틀 뒤 전부 깨졌습니다: CDN 서명 만료와 서버 프록시로 고친 기록

개인 프로젝트 Reelpool에서 틱톡 썸네일이 수집 이틀 뒤 전부 403이 되는 문제를 겪고, CDN 서명 만료 구조를 확인한 뒤 서버 프록시로 바이트를 캐싱해 고친 기록입니다.

목차
  1. 이틀 지나면 타일이 빈칸이 됩니다
  2. 원인: URL 서명에 만료 시각이 박혀 있습니다
  3. 복구 경로: 틱톡 공개 oEmbed
  4. 미결: 인스타그램은 같은 방법이 안 통합니다
  5. 알아둘 것

Reelpool은 틱톡·유튜브·인스타그램의 짧은 영상을 한 화면에 모아 보여주는 개인 프로젝트입니다. 카테고리 하나(댄스)로 좁혀 공개 피드를 돌리고, 인스타그램은 사용자가 직접 계정을 연결해야 데이터가 붙는 구조였습니다.

지금은 잠시 손을 놓은 상태입니다. 다만 운영하던 중에 겪은 CDN 썸네일 장애는 따로 기록해 둘 만합니다.

이틀 지나면 타일이 빈칸이 됩니다#

reelpool.flowoodz.com의 공개 피드를 확인하다가 타일 156개 중 23개가 빈칸인 걸 봤습니다. 깨진 타일을 하나씩 열어보니 정확히 틱톡 영상만 걸려 있었습니다. 유튜브(i.ytimg.com)와 인스타그램은 멀쩡했습니다.

수집 당시 받아둔 썸네일 URL을 <img src>에 그대로 걸어두는 구조였는데, 시간이 지나면 죽는다는 건 그때 처음 알았습니다.

원인: URL 서명에 만료 시각이 박혀 있습니다#

깨진 URL 하나를 열어보면 이런 모양입니다.

https://p16-common-sign.tiktokcdn.com/.../~tplv-tiktokx-origin.image?x-expires=...&x-signature=...

x-expires 값은 초 단위 유닉스 타임스탬프이고, 실측으로는 발급 시점부터 약 48시간 뒤였습니다. 그 시각이 지나면 같은 URL을 그대로 요청해도 Akamai가 403 Access Denied를 돌려줍니다.

여기서 한 번 삽질했습니다. 서명이 깨진 거라고 생각해서 쿼리스트링에 캐시버스터 파라미터를 붙이거나, 파일명의 ~tplv-... 템플릿 이름을 다른 값으로 바꿔봤습니다. 둘 다 403이었습니다.

x-signature는 URL 전체(경로 포함)에 대해 계산됩니다. 한 글자만 바뀌어도 서명 불일치로 막힙니다. 만료 여부는 손대지 않은 원본 URL에서 x-expires만 읽어 판정해야 합니다.

복구 경로: 틱톡 공개 oEmbed#

다행히 틱톡은 인증 없이 열리는 공개 oEmbed 엔드포인트가 있습니다.

https://www.tiktok.com/oembed?url=<영상 URL>

여기 응답의 thumbnail_url에 새로 서명된 URL이 담겨 있고, 유효기간은 마찬가지로 48시간입니다. 영상 URL 자체는 만료되지 않으므로, 이미 깨진 과거 타일도 영상 URL만 있으면 새 썸네일 URL을 다시 받을 수 있습니다.

이 구조를 서버 라우트 하나로 정리했습니다.

app.get('/t/:id', async (req, reply) => {
  const { rows: [v] } = await pool.query(
    'SELECT id, url, platform, thumb, thumb_blob, thumb_type FROM videos WHERE id = $1', [req.params.id]);
  if (!v) return reply.code(404).send();
  const serve = (type, buf) => reply.type(type || 'image/jpeg')
    .header('Cache-Control', 'public, max-age=604800').send(buf);
  if (v.thumb_blob) return serve(v.thumb_type, v.thumb_blob);
 
  const get = async (u) => { try { const r = await fetch(u); return r.ok ? r : null; } catch { return null; } };
  let res = v.thumb ? await get(v.thumb) : null;
  if (!res && v.platform === 'tiktok') {
    const j = await (await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(v.url)}`))
      .json().catch(() => null);
    if (j?.thumbnail_url) {
      res = await get(j.thumbnail_url);
      if (res) await pool.query('UPDATE videos SET thumb = $2 WHERE id = $1', [v.id, j.thumbnail_url]);
    }
  }
  if (!res) return reply.code(404).send();
  const buf = Buffer.from(await res.arrayBuffer());
  const type = res.headers.get('content-type') || 'image/jpeg';
  await pool.query('UPDATE videos SET thumb_blob = $2, thumb_type = $3 WHERE id = $1', [v.id, buf, type]);
  return serve(type, buf);
});

동작 순서는 이렇습니다.

  1. DB에 캐시된 바이트(thumb_blob)가 있으면 그대로 서빙합니다.
  2. 없으면 원본 URL을 받아봅니다. 200이면 바이트를 DB에 저장하고 서빙합니다.
  3. 403이고 틱톡이면 oEmbed로 새 URL을 받아 다시 시도하고, 성공하면 thumb 컬럼도 새 URL로 갱신합니다.

프론트에서는 플랫폼별로 이미지 소스를 나눴습니다.

// 유튜브 썸네일(i.ytimg.com)은 서명이 없어 오래가므로 그대로 건다.
// 틱톡·인스타는 서명이 만료되니 서버의 /t/:id를 거친다.
const thumbSrc = (it) => (it.platform === 'youtube' ? it.thumb : `/t/${it.id}`);

배포하고 다시 확인하니 156개 중 깨진 타일이 0개가 됐습니다. 이미 서명이 만료된 과거 행까지 oEmbed 재조회로 되살아난 겁니다.

틱톡 썸네일 프록시 흐름도: 깨진 타일 23개가 0개로 복구되는 과정

미결: 인스타그램은 같은 방법이 안 통합니다#

인스타그램 Graph API의 media_url, thumbnail_url도 똑같이 서명 만료 URL입니다. 그런데 틱톡의 oEmbed 같은, 인증 없이 새 서명 URL을 다시 받아올 수 있는 공개 재발급 경로가 없습니다.

그래서 인스타그램은 /t/:id로 프록시해서 캐시 자체는 됩니다. 다만 캐싱 전에 원본 URL이 먼저 만료되면 복구할 방법이 없습니다. 한 번 놓치면 그 타일은 영구히 빈칸입니다.

수집 파이프라인이 새 영상을 넣는 즉시 첫 요청을 한 번 트리거해서 만료 전에 선점하는 방법을 생각은 해봤습니다. 이 프로젝트는 거기서 손을 놓아서 실제로 넣지는 못했습니다.

알아둘 것#

CDN이 서명된 URL을 내려준다면, 그 URL을 그대로 저장해 두고 나중에 다시 쓰는 구조는 언젠가 깨집니다. x-expires 같은 필드가 있는지 먼저 확인하고, 있으면 처음 요청 시점에 바이트 자체를 받아 직접 저장해 두는 편이 안전합니다.

플랫폼이 공개 재발급 경로(oEmbed 같은)를 제공하는지도 미리 확인해 두면 좋습니다. 이미 만료된 데이터까지 복구할 수 있는지 여부가 거기서 갈립니다.