본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 21. 02:43

SongBhaav v3: 중복 제거, 실시간 처리, 그리고 자가 치유 파이프라인 구축

요약

SongBhaav v3 업데이트를 통해 백엔드 아키텍처를 개선한 사례를 다룹니다. 동기식 가사 스크래핑을 통한 빠른 피드백 제공, 작업 중복 제거를 통한 리소스 최적화, 그리고 실시간 처리 및 자가 치유 시스템 구축 과정을 설명합니다.

핵심 포인트

  • 가사 스크래핑을 동기식으로 처리하여 사용자에게 빠른 실패 피드백 제공
  • 작업 중복 제거(Deduplication)를 통해 불필요한 API 비용 및 리소스 낭비 방지
  • 로딩 UI를 단계별로 세분화하여 사용자 경험(UX) 개선
  • 외부 정책 변경에 대응하기 위한 자가 치유(Self-healing) 파이프라인 구축

Part 1에서 저는 SongBhaav의 백엔드가 출시 이틀 만에 어떻게 무너졌는지, 그리고 v2 재작성(rewrite)이 비동기 작업 큐(async job queue)를 통해 어떻게 서버리스 타임아웃(serverless timeout) 문제를 해결했는지 다루었습니다.

그 아키텍처는 잘 버텨주었습니다. 하지만 몇 가지 문제가 여전히 저를 괴롭혔습니다. 일부는 UX(사용자 경험) 측면의 미흡한 부분이었고, 하나는 이메일을 통해 알게 된 시한폭탄 같은 문제였습니다.

이 포스트에서는 v3를 구성하는 세 가지 변경 사항을 다룹니다: 더 빠른 실패 피드백를 위한 동기식 가사 스크래핑(synchronous lyrics scraping), 작업 중복 제거(job deduplication), 폴링(polling)에서 실시간(Realtime)으로의 전환, 그리고 저의 일일 동기화 파이프라인을 망가뜨릴 뻔했던 Spotify 정책 변경에 대응하기 위한 자가 치유 시스템(self-healing system)입니다.

문제 1: "가사를 찾을 수 없음"을 알려주는 데 너무 오래 걸림

v2에서의 흐름은 다음과 같았습니다: 백그라운드 작업(background job) 시작 → 대기 → 가사가 존재하는지 여부를 결국 확인.

이는 만약 어떤 노래의 가사를 LRCLIB, Genius 등 그 어디에서도 정말로 찾을 수 없는 경우, 사용자가 전체 비동기 전달(async handoff) 과정을 거치고 로딩 상태를 지켜본 후에야 보여줄 것이 없다는 안내를 받게 된다는 것을 의미했습니다. 실패 모드(failure mode)가 정작 즉각적이어야 할 상황에서 느리게 작동한 것입니다.

v3에서의 해결책은 가사 가져오기(fetching)를 요청의 동기적(synchronous) 부분으로 다시 옮기는 것이었습니다. 단, 가져오기 작업만 해당하며 AI 처리(AI processing)는 제외됩니다.

사용자(User) → POST /api/start-job
         ├─ processed_songs 확인 (캐시 히트(cache hit)? 즉시 반환)
         ├─ background_jobs 확인 (작업이 이미 진행 중인가? 기존 job_id 반환)
...

가사가 정말로 존재하지 않는다면, 사용자는 전체 비동기 우회 과정을 먼저 거치는 대신 세 개의 가사 제공업체를 쿼리하는 데 걸리는 시간(몇 초) 내에 그 답변을 받게 됩니다. 느리고 예측 불가능한 부분(Gemini 분석)만 비동기(async)로 유지됩니다. 빠르고 결정론적인 부분(이 가사가 어디에라도 존재하는가)은 앞단에서 처리됩니다.

또한 이를 통해 로딩 UI를 하나의 일반적인 스피너(spinner) 대신 두 개의 명확한 단계로 나눌 수 있었습니다. 스크래핑(scraping)이 실행되는 동안에는 주황색의 "가사 찾는 중(hunting down the lyrics)" 단계를 보여주고, AI 프로세싱(AI processing)이 시작되면 보라색의 "마디 분석 중(breaking down the bars)" 단계를 보여줍니다. 작은 차이지만, 대기 시간이 블랙박스(black box)처럼 느껴지는 대신 진행 중인 과정처럼 느껴지게 합니다.

문제 2: 두 명의 사용자, 동일한 곡, 두 배의 작업량

이 문제는 동시 트래픽(concurrent traffic) 규모가 작을 때는 나타나지 않지만, 실제적인 버그입니다. 만약 두 사람이 몇 초 간격으로 캐시되지 않은 동일한 곡을 검색한다면, 두 요청 모두 독립적으로 캐시를 확인하고, 둘 다 캐시 미스(miss)가 발생하며, 둘 다 별도의 QStash 작업을 생성하게 됩니다. 즉, 정확히 동일한 곡에 대해 중복된 가사 가져오기, 중복된 Gemini 호출, 중복된 API 비용 지출이 발생하게 됩니다.

해결책은 새로운 작업을 생성하기 전에 실행 중인 작업(in-flight)을 확인하는 간단한 체크 로직을 추가하는 것입니다:

이 spotify_id에 대한 background_jobs 확인 중:
  - status = 'pending' 또는 'processing'이 이미 존재하는가?
      → 해당 기존 job_id를 새 요청에 반환
...

문제 3: 폴링(Polling)도 작동했지만, 여기서는 실시간(Realtime)이 훨씬 낫습니다

v2에서는 프론트엔드가 상태를 확인하기 위해 몇 초마다 /api/check-job을 폴링(polling)했습니다. 이는 잘 작동했으며, 당시에는 왜 WebSockets 대신 폴링이 합리적인 선택이었는지(더 단순하고, 지속적인 연결 오버헤드가 없음)에 대해 작성하기도 했습니다.

v3에서는 대신 Supabase Realtime으로 전환했습니다. 다음과 같은 몇 가지 이유로 이 결정을 내렸습니다:

  • 폴링은 과도한 페칭(over-fetching, 너무 자주 확인하여 요청을 낭비함) 또는 과소 페칭(under-fetching, 너무 드물게 확인하여 지연을 느낌) 중 하나를 의미합니다. 실시간(Realtime) 방식은 이러한 트레이드오프(tradeoff)를 완전히 피할 수 있습니다. 행(row)이 변경되는 즉시 업데이트가 도착하므로, 간격 타이밍을 추측할 필요가 없습니다.

  • 실제 구현 오버헤드는 예상보다 작았습니다. job_id에 대해 필터링된 채널(filtered channel)을 구독하는 것은 몇 줄의 코드에 불과하며, Supabase가 연결 생명주기(connection lifecycle)를 관리합니다.

따라서 이것은 "폴링이 틀렸다"는 사례가 아닙니다. v2의 제약 조건 하에서는 올바른 선택이었습니다. 다만, 작업 공유(job-sharing)가 동시적으로 발생하는 상황이 등장하면서 더 이상 최선의 트레이드오프가 아니게 된 것입니다.

문제 4: 아직 고장 나지 않은 무언가에 대한 Spotify의 이메일

SongBhaav는 GitHub Actions를 통해 Spotify에서 최근 재생한 트랙을 가져오는 일일 동기화(daily sync)를 실행하므로, 누군가 검색하기 전에 노래들이 미리 전처리됩니다. 이는 Spotify 리프레시 토큰(refresh token)이 무기한 유효하다는 전제하에 작동합니다.

최근 Spotify는 모든 개발자에게 정책 변경에 관한 이메일을 보냈습니다. 2026년 7월 20일부터 리프레시 토큰이 6개월 후에 만료된다는 내용이었습니다. 토큰이 만료되면 리프레시를 시도할 때마다 invalid_grant 에러가 반환되며, 유일한 해결 방법은 사용자를 다시 로그인 흐름(sign-in flow)으로 보내는 것뿐입니다.

이것은 아직 아무것도 고장 내지 않았습니다. 하지만 특정 날짜에, 그 이메일 외에는 아무런 예고 없이 고장이 날 예정이었습니다. 이는 몇 달 후 일일 동기화가 소리 없이 작동을 멈추고 왜 그런지 전혀 알 수 없게 될 때까지 잊어버리기 쉬운, 바로 그런 종류의 장애입니다.

그래서 저는 문제가 발생하기를 기다리는 대신, 미리 복구 흐름(recovery flow)을 구축했습니다:

일일 동기화 스크립트 실행
  → system_credentials 테이블(환경 변수가 아닌 DB)에서 refresh_token을 읽음
  → Spotify에 새로운 액세스 토큰(access token) 요청
...

여기에는 몇 가지 의도적인 선택이 포함되어 있습니다:

자격 증명(Credentials)은 환경 변수가 아닌 데이터베이스에 저장됩니다. 이는 재인증(re-authorizing)을 위해 재배포(redeploy)를 할 필요가 없음을 의미합니다. 다음 예정된 실행 시 DB에서 새 토큰을 자동으로 가져오기 때문입니다.

티켓은 단회용이며 시간 제한이 있습니다. 설령 Discord 웹훅(webhook) URL이 유출되더라도, 이미 만료되었거나 이미 사용된 티켓은 다시 재생(replay)할 수 없습니다.

스크립트는 invalid_grant 발생 시 재시도하는 대신 깔끔하게 종료됩니다. 만료된 토큰으로 재시도하는 것은 호출 횟수만 낭비하고 로그를 동일한 에러로 반복해서 채울 뿐입니다. 한 번 명확하게 실패를 알리고, 인간 참여형(human-in-the-loop) 수정을 기다리는 것이 더 낫습니다.

실제 메커니즘은 간단합니다. 웹훅을 통해 전송되는 매직 링크(magic link)입니다. 이 작업을 구축할 가치가 있었던 이유는 정책 이메일을 미리 읽고, "결국 실패할 것"이라는 상황을 "현재 실패 중"인 상황과 동일한 긴급함으로 다루었기 때문입니다.

요약: 무엇이 바뀌었나

관심 사항v2v3
가사 미검색 (Lyrics-not-found) 피드백비동기 (Async), 느림동기 (Synchronous), 즉각적
...

이 중 그 어느 것도 긴급한 상황은 아니었습니다. 그것이 바로 핵심입니다. v2가 고장 난 것은 아니었으며, 단지 개선할 여지가 있었을 뿐입니다. 또한, 아직 피해를 입히지는 않았지만 결국에는 문제를 일으켰을 한 가지 임박한 정책 변경 사항이 있었습니다.

SongBhaav를 직접 체험해보고 싶다면, 검색창에 아무 노래나 입력해 보세요: song-bhaav.vercel.app

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0