링크 하나 분석하는 데 17초가 걸려서 요청 응답 방식을 버렸다

지난 글에서 유튜브 영상의 자막까지 읽어서 여행지를 뽑아내는 이야기를 썼다. 기능은 원하는 대로 나왔는데, 붙여놓고 보니 다른 문제가 생겼다. 너무 오래 걸린다.

생각해보면 당연했다. 유튜브에서 메타 정보를 긁고, yt-dlp로 자막을 받고, AI를 호출하고, 나온 이름들을 공공데이터랑 맞춰보고, 못 찾은 건 카카오 로컬 검색까지 돌린다. 외부 서비스만 네 군데다. 자막 수집 하나에 타임아웃을 45초로 잡아뒀으니 최악의 경우 요청 하나가 그만큼 살아 있는다는 뜻이다.

그래서 그 요청을 어떻게 쪼갰는지, 그리고 기다리는 시간 자체는 어떻게 줄였는지 순서대로 적어본다.

한 번에 기다리는 방식의 한계

처음 만든 API는 평범했다. POST로 링크를 받아서 다 끝나면 결과를 돌려준다.

router.post('/analyze', asyncHandler(async (req, res) => {
  const url = String(req.body?.url ?? '').trim();
  // ... 수집 -> 추출 -> 확인 -> 좌표 ...
  res.json({ content, places });   // 여기까지 오는 데 십몇 초
}));

실제로 재보니 유튜브 영상 하나에 17.5초가 걸렸다. 오래 걸린다는 것 자체도 문제지만, 더 큰 문제는 그 시간 동안 아무것도 할 수 없다는 것이다.

사용자는 흰 화면에 도는 아이콘만 보면서 기다려야 하고, 다른 화면으로 넘어가면 요청이 끊긴다. 앞에 Apache 리버스 프록시가 있으니 프록시 타임아웃도 신경 써야 하고, 모바일에서 잠깐 화면이 꺼지면 그것대로 위험하다.

무엇보다 사용자 입장에서 지금 어디까지 됐는지를 알 방법이 없다. 로딩 아이콘은 3초가 걸리든 30초가 걸리든 똑같이 돈다.

요청을 작업으로 바꿨다

그래서 흔히 쓰는 방식으로 갔다. 시작 요청은 작업 번호만 받고 바로 응답하고, 진행 상태는 그 번호로 따로 물어보게 하는 것이다.

const JOB_TTL_MS = 10 * 60 * 1000;
const jobs = new Map();              // jobId -> { stage, result, error, at }
const runningByContent = new Map();  // contentId -> jobId (같은 링크는 한 작업에 합류)

// POST /api/sns/analyze/start -> { result } (이미 분석됨) 또는 { jobId, stage }
// GET  /api/sns/analyze/jobs/:id -> { stage } 또는 { result }

여기서 중요한 건 두 번째 Map이다. 같은 영상 링크를 여러 명이 동시에 붙여넣으면 똑같은 분석을 여러 번 돌릴 이유가 없다. 그래서 콘텐츠 id로 이미 도는 작업이 있으면 새로 시작하지 않고 그 작업에 합류시킨다. 유튜브랑 AI 호출을 아끼는 효과가 꽤 크다.

작업 결과는 메모리 Map에 10분만 들고 있는다. 어차피 분석이 끝나면 DB에 저장되고, 같은 링크를 다시 넣으면 DB에서 바로 꺼내 쓴다. 굳이 오래 들고 있을 이유가 없었다.

진행 단계를 진짜 진행에 맞췄다

작업으로 쪼개면서 같이 한 게 진행 단계 표시다. 흔히 보는 가짜 진행바 말고, 실제로 그 단계에 들어갔을 때 알려주는 방식으로 만들었다.

/**
 * 실제 분석: 수집 -> 추출 -> 저장. onStage로 진행 단계를 알린다
 *  fetch(콘텐츠, 자막 수집) -> extract(장소 찾기) -> verify(공공데이터 확인)
 *  -> geocode(좌표 채우기) -> done
 */
async function runAnalysis(url, platform, id, onStage = () => {}) {
  onStage('fetch');
  const meta = platform === 'youtube' ? await fetchYoutubeMeta(url) : await fetchWebMeta(url);
  const { placeIds, signals } = await extractPlaces(meta, { onStage });
  ...
  onStage('done');
}

화면에는 이렇게 나온다.

링크 하나 분석하는 데 17초가 걸려서 요청 응답 방식을 버렸다 이미지 1

네 단계가 실제 진행에 따라 하나씩 켜진다.

여기서 은근 중요한 게 아래쪽 버튼이다. "둘러보는 동안 계속 분석할게요"를 누르면 다른 화면으로 넘어가도 분석은 서버에서 계속 돌고, 끝나면 알림으로 알려준다. 요청 응답 방식이었으면 애초에 불가능한 동작이다.

그리고 "긴 영상은 1분 넘게 걸릴 수 있어요"라고 미리 적어뒀다. 실제로 오래 걸리는 걸 감출 수 없다면 미리 말해두는 게 낫다고 봤다.

레이트리밋이 폴링을 죽일 뻔했다

상태를 물어보는 방식으로 바꾸고 나서 어이없는 걸로 한 번 걸렸다. 외부 API 쿼터를 한 사용자가 다 써버리지 못하게 요청 제한을 걸어뒀는데, 그게 상태 조회까지 잡아버린 것이다.

분석은 1분에 20번으로 제한해뒀다. 그런데 상태 조회를 2초마다 하면 1분에 30번이다. 내가 만든 폴링이 내가 건 제한에 걸린다. 그래서 제한은 시작 요청에만 걸고 상태 조회는 뺐다.

const limiter = (max, windowMs, message) => rateLimit({
  windowMs, max, standardHeaders: true, legacyHeaders: false, message: { error: message },
});

app.use('/api/', limiter(600, 60 * 1000, '요청이 너무 많아요. 잠시 후 다시 시도해 주세요.'));
app.use('/api/sns/analyze', limiter(20, 60 * 1000, '분석 요청이 너무 잦아요. 1분 뒤 다시 시도해 주세요.'));
app.use('/api/itinerary/generate', limiter(15, 60 * 1000, '일정 생성 요청이 너무 잦아요.'));
app.use('/api/auth/', limiter(30, 60 * 1000, '로그인 시도가 너무 많아요.'));

그리고 이거랑 같이 걸린 함정이 하나 더 있다. 이 서버는 Apache 리버스 프록시 뒤에 있어서, 그냥 두면 모든 요청이 프록시 IP 하나에서 온 걸로 보인다. 그 상태로 제한을 걸면 접속자 전체가 한 사람 취급을 받아서 몇 명만 써도 전원이 막힌다.

app.set('trust proxy', 1); // Apache 뒤에서 실제 클라이언트 IP로 레이트리밋

한 줄이면 끝나는데, 이걸 모르면 "왜 나만 안 되지"가 아니라 "왜 다 같이 막히지"가 되기 때문에 원인 찾기가 꽤 헷갈린다. 레이트리밋을 걸기 전에 req.ip 에 뭐가 찍히는지 한 번 보고 시작하는 게 낫다.

기다리는 시간 자체도 줄였다

작업으로 쪼갠 건 기다리는 경험을 고친 거지 속도를 고친 건 아니다. 17초는 여전히 17초다. 그래서 안쪽도 같이 손봤다.

분석 과정을 뜯어보니 순서대로 할 이유가 없는 것들이 있었다. AI 호출은 몇 초가 걸리는데 그동안 서버는 그냥 놀고 있고, 규칙으로 후보를 뽑는 건 AI 결과랑 아무 상관이 없다. 그래서 둘을 동시에 돌리고 나중에 합치는 걸로 바꿨다.

// AI 호출(수 초)과 규칙 후보 계산은 서로 독립이라 동시에 진행한다
const aiPromise = aiEnabled() ? aiCandidates(meta) : Promise.resolve({ cands: [], method: 'rules' });

const basePromise = (async () => {
  const r = await matchCandidates(baseCands, hints);
  return { matched: r.matched, extra: await geocodeUnmatched(r.unmatched, hints, 'rules') };
})();

const [ai, base] = await Promise.all([aiPromise, basePromise]);

카카오 로컬 검색도 마찬가지였다. 후보 하나씩 순서대로 검색하고 있었는데, 후보들은 서로 아무 관계가 없으니 같이 보내면 된다.

// 후보별 카카오 검색은 서로 독립이라 동시에 5개씩 처리 (순차 16회 약 4초 -> 1초 안팎)
const results = [];
for (let i = 0; i < targets.length; i += 5) {
  results.push(...(await Promise.all(targets.slice(i, i + 5).map(lookup))));
}

전부 한꺼번에 보내지 않고 5개씩 끊는 이유는 상대 API 쪽 사정 때문이다. 한 번에 수십 개를 던지면 거부당하기 쉽다.

단계별로 시간을 찍어보면 지금은 이렇게 나온다.

===== AI 켬 : 4,647ms
  단계: extract+0ms -> verify+3,594ms -> geocode+3,640ms

===== 규칙만 : 1,522ms
  단계: extract+1ms -> verify+1,449ms

AI를 쓰면 전체가 4.6초쯤이고, 그중 3.6초가 후보를 공공데이터랑 맞춰보고 좌표를 채우는 구간이다. 앞에서 말한 17.5초는 여기에 자막 수집이 붙은 값이다. 결국 남은 시간의 대부분은 외부 서비스를 기다리는 시간이라, 더 줄이려면 그쪽을 손대야 한다.

덤으로 첫 화면도 캐시했다

분석이랑 별개로 앱 첫 화면이 느린 것도 같이 봤다. 첫 로딩 때 장소 3,000곳이랑 권역, 축제 정보를 한 번에 내려주는 API가 있는데, 이 데이터는 하루 한 번 자정에 크론으로만 바뀐다. 매번 DB에서 새로 만들 이유가 없었다.

const TTL_MS = Number(process.env.BOOTSTRAP_CACHE_MINUTES || 10) * 60 * 1000;
let cache = { body: '', etag: '', at: 0 };

router.get('/', asyncHandler(async (req, res) => {
  const fresh = Date.now() - cache.at < TTL_MS ? cache : await buildBootstrap();
  res.setHeader('ETag', fresh.etag);
  res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
  ...
}));

서버 메모리에 완성된 JSON 문자열을 10분간 들고 있고, 브라우저에는 ETag를 준다. 내용이 그대로면 304로 끝나서 본문을 아예 안 보낸다. 데이터가 자주 안 바뀌는 목록성 API라면 이 정도만 해도 체감이 꽤 달라진다.

폴링으로 바꾸고 나서 생긴 것

상태를 물어보는 구조로 바꾸니까 요청 수가 확 늘었다. 분석 하나에 상태 조회가 수십 번 붙는다.

그리고 그 다음 날 커밋에 이런 게 있다.

08-27  fix(auth): 로그인 만료 처리와 축제 적재 안전장치

폴링이 도는 동안 토큰이 만료되면 401이 계속 쌓이는데, 화면에서는 그냥 "분석 중"이 멈춰 있는 것처럼 보였다. 실패했는데 실패한 티가 안 나는 게 제일 나쁘다는 걸 그때 알았다.

지금 폴링 코드는 이렇게 정리돼 있다. 0.9초마다 상태를 물어보되, 시작하고 4분이 지나면 "분석이 너무 오래 걸려요"로 끊고, 서버가 404를 주면 작업이 만료된 걸로 보고 다시 시도하라고 알린다. 그 밖의 일시적인 네트워크 오류는 그냥 넘기고 다음 폴링에서 다시 물어본다.

const POLL_MS = 900
const MAX_WAIT_MS = 4 * 60 * 1000   // 이 시간이 지나면 포기하고 알린다

if (Date.now() - job.startedAt > MAX_WAIT_MS) {
  setError(job.url, { status: 408, message: '분석이 너무 오래 걸려요. 잠시 후 다시 시도해 주세요.' })
  continue
}
try {
  const s = await api.getSnsAnalysisJob(job.jobId)
  if (s.result) complete(job.url, s.result)
  else if (s.error) setError(job.url, s.error)
  else if (s.stage !== job.stage) setStage(job.url, s.stage)
} catch (e) {
  if (toApiError(e).status === 404) setError(job.url, { status: 404, message: '분석 작업이 만료됐어요. 다시 시도해 주세요.' })
  // 그 밖의 일시 오류는 다음 폴링에서 재시도
}

4분이라는 숫자는 자막 타임아웃 45초에 AI 호출과 검색을 더해도 남는 여유로 잡은 것이지, 실측해서 나온 값은 아니다. 연속으로 몇 번 실패했는지 세는 방식도 생각해봤는데, 지하철에서 잠깐 끊기는 것까지 실패로 세면 멀쩡히 돌고 있는 분석을 화면에서 먼저 포기해버릴 것 같아서 시간 제한 하나로 뒀다. 이게 맞는 기준인지는 더 써봐야 안다.