링크 하나 분석하는 데 17초가 걸려서 요청 응답 방식을 버렸다
오래 걸리는 API를 작업으로 쪼개고 진행 단계를 보여주기까지
#백엔드 #Node #Express #API #성능 #트러블슈팅
링크 하나 분석하는 데 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'); } 화면에는 이렇게 나온다. 네 단계가 실제 진행에 따라 하나씩 켜진다. 여기서 은근 중요한 게 아래쪽 버튼이다. "둘러보는 동안 계속 분석할게요"를 누르면 다른 화면으로 넘어가도 분석은 서버에서 계속 돌고, 끝나면 알림으로 알려준다. 요청 응답 방식이었으면 애초에 불가능한 동작이다. 그리고 "긴 영상은 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로 레이트리밋 한 줄이면 끝나는데, 이걸 모르면 "왜 나만 안 되지"가 아니라 "왜 다 같이 막히지"가 되기 때문에 원인 찾기가 꽤 헷갈린다. 기다리는 시간 자체도 줄였다 작업으로 쪼갠 건 기다리는 경험을 고친 거지 속도를 고친 건 아니다. 그래서 안쪽도 같이 손봤다. 분석 과정을 뜯어보니 순서대로 할 이유가 없는 것들이 있었다. 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라면 이 정도만 해도 체감이 꽤 달라진다. 정리하면 오래 걸리는 작업을 API로 만들 때 배운 건, 시간을 줄이는 것과 기다리는 경험을 고치는 게 서로 다른 문제 라는 점이다. 병렬로 돌려서 4초를 1초로 만드는 건 시간을 줄이는 일이다. 하지만 외부 서비스를 네 군데나 기다려야 하는 이상 어느 선 아래로는 안 내려간다. 그 지점부터는 시작 요청과 상태 조회를 나누고, 지금 어느 단계인지 보여주고, 화면을 떠나도 계속 돌게 만드는 쪽이 훨씬 효과가 컸다. 그리고 상태를 물어보는 구조로 바꿀 거면 레이트리밋을 꼭 같이 확인해보길 추천한다. 잘 돌던 기능이 폴링을 붙이는 순간 자기가 만든 제한에 걸리는 건 생각보다 자주 있는 일이다.