7월 31일 밤, 「지금 보고 있는 것」 페이지가 7월 25일에 멈춰 있는 걸 발견했다.

에러 메시지는 없었다. 서버도 멀쩡하고, 페이지도 정상적으로 그려지고, 애니메이션 쪽은 여전히 갱신되고 있었다. 영화와 드라마 쪽만 멈춰 있었다.

수동으로 다시 로그인하려고 Trakt 사이트에 갔더니 더 이상했다. 분명 로그인은 성공하는데, 화면을 옮길 때마다 첫 페이지로 튕겨 나왔다.

반면 쪽은 완전히 정상으로 보였다. 내가 뭘 봤는지 그대로 잡아냈고, 매칭 결과도 보여주고 있었다.

어디에서도 에러가 나지 않는데 데이터만 흐르지 않는 장애. 나중에 알았지만, 그날 밤 전 세계의 적지 않은 사람들이 정확히 같은 지점에 걸려 있었다.

두 개의 문자열#

백엔드 로그 깊숙한 곳에 두 가지만 남아 있었다.

text
client not found
session not found

client not found의 뜻은 분명하다 — Trakt 쪽에서 내 OAuth 앱을 찾을 수 없다는 것. 시크릿 만료도, 토큰 실효도 아니다. 앱 자체가 존재하지 않는다.

Trakt의 API 설정 페이지를 확인했다. 비어 있었다. 몇 달 전에 만든 그 앱이 사라져 있었다.

누가 지웠나#

Trakt의 웹 프런트엔드는 오픈소스라서, 이 부분은 추측할 필요가 없다.

trakt/trakt-webPR #3057, 제목은 feat(settings): make api application creation vip-only. 작성자는 Trakt 자체 개발자 vladjerca이고, 2026년 7월 30일에 만들어져 같은 날 병합됐다.

추가된 인터페이스 문자열에는 이렇게 적혀 있다.

text
"Creating new apps requires Trakt VIP"
"VIP members can register unlimited OAuth applications."

그리고 생성 버튼을 <RenderFor audience="vip">로 감쌌다.

여기까지는 납득이 간다. 어떤 기능을 유료 회원 전용으로 만드는 건 회사의 판단이다.

그런데 이 PR이 한 일은 「생성을 막는 것」뿐이다. 설명에 분명히 적혀 있다. 무료 사용자에게는 애플리케이션 목록 위에 업그레이드 안내가 뜨지만, 그 목록 자체는 "(still readable)" — 여전히 읽을 수 있다고.

기존 앱을 삭제한다는 말은 처음부터 끝까지 한 글자도 없다.

그런데도 내 앱은 사라졌다. GitHub 이슈의 제목이 그대로 **"Can't see API apps"**다. 신고자의 앱은 v3 이전에 만든 것이고, 목록은 통째로 비었으며, 연동은 403을 받기 시작했다고 한다. 뒤이은 네 개의 댓글은 전부 같은 얘기였다.

my API app was deleted without warning. Not a VIP here either, but i feed them data and now get nothing in return. Is this intentional?

The lack of notice just plain sucks. Whoever took that decision needs to hire a comms team...

「공지가 없었다」는 건 그들 자신의 기록으로 증명할 수 있다#

이건 주관적인 감정이 아니라 확인 가능한 사실이다.

trakt/trakt-api 저장소에는 Announcements 카테고리가 있고, 그들은 평소에 실제로 그걸 쓴다.

번호날짜제목
#7752026-04-29‼️ Upcoming API Changes: Watched Endpoints Pagination & Extended Defaults
#6942026-01-29🔔 Upcoming Change: Seconds and Milliseconds Will Be Zeroed Out
#6812026-01-20📣 Upcoming API Changes: Pagination & Sorting Updates
#6552025-12-01⚠️ Reminder: Set a proper User-Agent header

「타임스탬프의 초를 0으로 만들겠다」는 수준의 변경에도 미리 한 건 올린다.

그리고 이 글을 쓰는 시점에, 그 카테고리의 가장 최근 공지는 여전히 6월 11일의 문서 업데이트다. 한 계층의 API 앱을 통째로 지운 건에 대해서는 한 건도 없다.

그러니 증명할 수 있는 건 체계는 있고, 평소에는 쓰고, 이번에는 쓰지 않았다는 것이다. 증명할 수 없는 건 의도다 — 내부 기록은 볼 수 없다. 다만 보이는 행동은 전부 같은 방향을 가리키고 있다.

나흘 뒤, 공식이 답했다#

8월 3일, 드디어 Trakt 쪽 사람이 나타났다. 내용보다 답이 도착한 방식이 더 기록할 만하다 — 같은 1분 안에 세 가지 일이 일어났다.

08:53:05 · 이관

이슈가 trakt-web에서 trakt-api로 옮겨졌다.

08:53:29 · 토론으로 전환하고 잠금

같은 초에 이슈는 Discussion으로 바뀌었고 원래 스레드는 잠겼다. 버그 추적에서 Q&A로 — 고쳐야 하는 것에서 답변하면 되는 것으로.

08:54:37 · 그 다음에야 댓글

1분 뒤, 공식 계정이 세 문장을 남겼다.

This is just a temporary measure to prevent abuse. Our intention is to keep the API accessible for developers. We'll have more to announce soon.

다음 댓글은 App Store에 앱을 올려둔 다른 개발자의 것이었다.

Why don't you communicate about it BEFORE deleting API apps then? Give us some notice and some delay before doing such moves, you shouldn't cut access like that, some of us depend on those for production apps. ... You've lost my trust as well.

그 "temporary"에 대해 말하자면, 오늘까지 PR #3057은 되돌려지지 않았고, 공식 공지도 여전히 없으며, 그 사이에 새 VIP 기능은 나왔다.

이 일은 세 곳에서 논의됐는데, 각각의 현재 상태가 그 플랫폼의 주인이 누구인지와 정확히 일치한다.

플랫폼누구의 영역인가현재
GitHub그들의 저장소이관·토론 전환·잠금
공식 포럼그들의 것스레드 통째로 404
Reddit그들의 것이 아님살아 있음. 102표, 60개 댓글

포럼 쪽은 주소를 잘못 쓴 게 아닌지 확인했다. 같은 서버의 검색 API는 정상적으로 응답하고, 그 주제만 존재하지 않는다. 누가 지웠는지는 증거가 없으니 쓰지 않겠다.

Reddit 스레드에는 오해하기 쉬운 부분이 하나 있다 — 원 게시물이 [deleted]로 표시되지만 그건 작성자 본인이 지운 것이고, 페이지에 그렇게 명시돼 있다. 댓글은 전부 그대로 남아 있다. 나도 하마터면 「또 하나 사라진 논의」로 셀 뻔했다.

그 60개 댓글 중 가장 많은 표를 받은 한 줄.

Trakt is speedrunning the dumbest path to monetization.

그리고 또 하나, 그곳 사람들에게 이게 단발성 사건이 아니라는 걸 깨닫게 해준 댓글.

they redesigned their UI which everyone hates, got rid of the option to use the old version of the site, increased pricing for VIP, and now cut off their API for free users. I'm sure there's more that I'm forgetting, but it's all anti-user.

나는 Trakt를 두 달밖에 쓰지 않았으니 그 이전 일들을 평가할 입장은 아니다. 다만 그 스레드의 온도가 「API 하나 망가진 것」치고 왜 그렇게 높은지는 설명이 된다.

먼저, 나는 정확히 무엇을 잃었나#

대안을 찾기 전에 더 기본적인 걸 확인했다. 애초에 내 데이터는 어떻게 들어오고 있었나?

생각과 달랐다.

text
netflix      788건   전부 2026-05-28 같은 초에 기록   ← 일회성 과거 이력 가져오기
trakt        155건   영화는 7/25까지, 드라마는 5/29에서 멈춤
letterboxd     1건   2026-05-28, 일회성

Netflix의 788건은 내가 직접 CSV를 내려받아 넣은 것이다. 한 번뿐이었고, 이후로 전혀 움직이지 않았다. Trakt는 겨우 155건이라 하찮아 보이지만 —

그게 유일하게 아직 흐르고 있던 공급원이었다. 비중이 작은 건 두 달밖에 돌지 않았기 때문일 뿐이다.

즉 Trakt가 끊긴 순간, 영화와 드라마의 자동 공급원은 완전히 사라졌다. (애니메이션은 내가 직접 만든 동기화를 타므로 영향이 없다.)

덤으로, 그때까지 몰랐던 문제도 찾았다. 드라마 151건은 watched_date가 전부 NULL이었다. Trakt는 회차 단위 시청 시각을 한 번도 준 적이 없었던 것이다. 이게 나중 판단에서 결정적으로 작용한다.

될 법해 보이던 세 가지 방안, 전부 탈락#

기존 구조에 끼워 넣을 수 있는 걸 찾았다. 기준은 하나뿐이다. 내 백엔드가 데이터를 가져올 수 있어야 한다.

둘 다 셀프호스팅 시청 기록 서비스이고 GitHub에서도 활발하다. 언뜻 보면 둘 다 Trakt 자리에 들어갈 것 같다.

README만 읽지 않고 소스를 직접 검색했다.

text
Yamtrack   rest_framework / api/urls  → 0건
Floppy     rest_framework / APIView   → 0건

둘 다 제3자가 데이터를 가져갈 REST API가 없다. 이들의 정체성은 「사람이 보는 기록 사이트」이지 다른 서비스의 데이터 소스가 아니다.

게다가 Floppy의 Trakt 가져오기는 직접 Trakt API 앱을 만들어야 한다 — 나에게는 방금 막힌 길이다.

판단 기준은 이미 구조 안에 적혀 있었다#

한 바퀴 돌고 나서 깨달았다. 이 기능을 다시 설계할 필요가 없다. 요구사항은 이미 내가 지어놓은 것 안에 적혀 있다.

바꿀 건 가운데 하나뿐. 그래서 기준이 분명해졌다.

가져올 수 있는 공개 API가 있을 것

없으면 내 백엔드에 데이터가 닿지 않는다.

시청 기록을 밀어 넣어 줄 무언가가 있을 것

가능하면 공식이 직접 유지보수하는 확장이 좋다. 끊길 지점이 하나 줄어든다.

무료 등급이 미끼가 아닐 것

이건 방금 몸으로 배웠다.

왜 Simkl인가#

Simkl이 통과한 첫 시험은 단순했다. 공개 엔드포인트가 아무 인증 없이 데이터를 돌려준다.

bash
curl https://api.simkl.com/movies/trending   # 그냥 응답이 온다

Trakt는 trending에조차 api-key를 요구한다. API를 어떻게 대하는 회사인지가 드러나는 지점이다.

그리고 구조가 거의 일대일로 대응한다는 걸 발견했다.

Trakt(망가지기 전)Simkl
Netflix 수집UTS(서드파티 확장)공식 확장, Netflix + Crunchyroll 기본 지원
인가 흐름OAuth + redirect_uriPIN 흐름도 있어 서버 콜백 불필요
회차별 시청 시각내 쪽은 전부 NULLepisode_watched_at=yes로 실제 타임스탬프
증분 동기화없음. 매번 페이징으로 전량date_from을 기본 제공
공개 엔드포인트api-key 필요인증 정보 불필요

특히 PIN 흐름(디바이스 플로우)이 나에게 가치가 컸다. 토큰은 /oauth/pin/{USER_CODE}를 폴링해서 받으므로, 내 서버로 콜백이 오는 일이 전혀 없다. Trakt의 redirect 때문에 열어둔 외부 엔드포인트를 닫을 수 있다.

다만 분명히 해둘 게 있다.

WARNING

Simkl도 무료 제3자 서비스이고, 원리상 똑같은 일이 벌어질 수 있다.

내가 확인한 「개발자 페이지에 VIP 문구가 없고 API가 무료로 열려 있다」는 오늘의 사실이지 약속이 아니다. 7월 30일 이전의 Trakt도 이렇게 보였다.

진짜로 면역이 되는 건 「데이터가 내 기계 위에 있는 것」뿐이다. 이번 이행은 그 문제를 해결하지 못했다. 지금은 더 개방적으로 보이는 상대로 바꾼 것일 뿐이다.

코드를 쓰기 전에 불확실성부터 없앤다#

한 번 데인 이상 또 걸고 싶지 않았다. 그래서 백엔드 코드에 한 줄도 손대기 전에 최소한의 검증을 했다.

무료 계정으로 앱을 만든다

바로 이 관문에서 Trakt가 무너졌다. Simkl도 막는다면 그 자리에서 계획은 폐기다.

만들어졌다. 첫 번째 불확실성이 사라졌다.

PIN 흐름으로 토큰을 받는다

8번 폴링, 약 40초. 그리고 토큰 응답에 뜻밖의 희소식이 있었다.

json
{ "access_token": "…", "token_type": "bearer", "scope": "public" }

expires_inrefresh_token도 없다. Simkl의 토큰은 만료되지 않는다.

즉 Trakt의 토큰 갱신을 위해 썼던 뮤텍스, 원자적 파일 쓰기, 이중 확인 — 전부 필요 없어진다. 그리고 바로 그 기구가 애초에 내 Trakt 인가를 태워버린 장본인이었다.

실제 API를 때려 데이터 모양을 본다

Simkl에서 영화 한 편을 「봤음」으로 표시하고, 그걸 가져와 JSON의 실물을 본다.

기존 테이블과 맞춰보니 모든 열에 대응이 있었다. 게다가 ids는 imdb / tmdb / tvdb / letterboxd를 한 번에 준다.

여기까지 오도록 코드는 아직 한 줄도 쓰지 않았다.

구현 방향을 직접 결정하는 규칙이 하나 있다#

Simkl 문서에는 경고가 있고, 굵은 글씨는 그쪽이 붙인 것이다.

Never run unconditional background polling timers without active user interaction. Ensure you always use date_from to avoid overloading the API server. If you don't follow these rules, your client_id will be suspended.

내 Trakt 쪽은 6시간마다 무조건 전량을 가져왔다. 그대로 옮기면 client_id가 정지된다 — 내 손으로 Trakt를 재연하는 셈이다.

올바른 모양은 문서에 있는 3단계다.

text
N시간마다:
  1. GET /sync/activities        ← 매우 가볍다. 타임스탬프만 돌아온다
  2. 저장한 커서와 비교. 같으면 종료  ← 대부분의 폴링은 여기서 끝난다
  3. 바뀌었을 때만 GET /sync/all-items/?date_from=<지난번 타임스탬프>

Trakt의 페이징 전수 조회보다 훨씬 깔끔하고, 대부분의 경우 두 번째 요청이 아예 발생하지 않는다.

실제 이행#

새 백엔드 모듈은 약 300줄로, 기존 450줄의 Trakt 로직을 대체했다. 차이는 줄 수만이 아니다.

Trakt(구)Simkl(신)
토큰7일, 갱신 필요만료 없음
동시성 보호뮤텍스 + 원자적 쓰기 + 이중 확인불필요
토큰 저장 위치JSON 파일환경 변수
커서없음. 매번 페이징으로 전량테이블의 한 행, 증분
폴링무조건 전량먼저 activities, 변화 없으면 종료

커서 저장에 파일을 일부러 쓰지 않은 건, Trakt의 토큰 파일이 바로 아픈 지점이었기 때문이다. 다만 기존 카운터 열은 정수라 ISO 타임스탬프를 담을 수 없어서, 아주 작은 sync_state 테이블을 하나 더 만들었다.

밟은 세 개의 함정#

① docker-compose의 environment가 env_file을 덮어쓴다

인증 정보는 .env.backend에 있는데도 worker가 client id가 없다고 했다.

원인은 docker-compose.ymlenvironment 아래에 내가 더 쓴 한 줄이었다.

yaml
- SIMKL_CLIENT_ID=${SIMKL_CLIENT_ID:-}

${VAR:-}가 읽는 건 **compose 자신의 .env**이고 거기엔 그 키가 없다 → 빈 문자열로 전개된다 → 그리고 environmentenv_file보다 우선하므로, 빈 문자열이 진짜 값을 덮어썼다.

그 줄을 지우면 해결된다. 인증 정보는 원래 env_file에서 통째로 읽히므로 environment에 하나씩 나열할 이유가 처음부터 없었다.

② 문서에 적힌 엔드포인트가 내 상황에서는 0건을 돌려줬다

Simkl 문서의 1단계에는 종류별로 /sync/movies를 호출하라고 돼 있다. 실측:

text
/sync/movies            → 0건
/sync/all-items/movies  → 1건

같은 계정, 같은 영화. /sync/all-items/{type}이어야 가져올 수 있다.

먼저 「실제 API를 때려 모양을 본다」를 하지 않았다면 백엔드를 다 연결한 뒤에 발견했을 테고, 첫 생각은 틀림없이 「내가 잘못 짰다」였을 것이다.

③ 내가 이미지 보완 경로를 막아서 포스터가 흐릿해졌다

이게 제일 재미있다. 하나를 고치는 김에 다른 하나를 망가뜨렸기 때문이다.

동기화가 통한 뒤, 상단 배너 이미지가 눈에 띄게 흐릿했다. 첫 가설인 「Simkl에 고해상도 이미지가 없다」는 틀렸다.

실제로는 이렇다. 내 백엔드에는 원래 포스터 열이 빈 행에 대해 TMDb로 이미지를 가지러 가는 로직이 있고, 가져오는 건 포스터만이 아니라 원본 크기의 가로형 스틸도 포함한다.

Trakt 동기화는 제목·날짜·TMDb ID만 쓰기 때문에 그 길을 우연히 열어두고 있었다. 그런데 Simkl 쪽을 쓸 때 나는 친절하게도 Simkl CDN의 작은 이미지를 포스터 열에 채워 넣었다 → 보완 판정이 「이미 포스터가 있다」로 보고 → 건너뛰고 → 가로형 스틸은 영원히 비어 있게 됐다.

그리고 배너는 「가로형 스틸이 있으면 쓰고 없으면 포스터로 대체」한다 — 스틸이 없으니 세로형 포스터를 880px까지 늘린다. 흐릿한 게 당연하다.

해결은 멱등한 마이그레이션으로 그 행의 포스터 열을 비워, 보완 로직이 다시 넘겨받게 하는 것.

김에 fork도 만들었다#

Simkl의 공식 확장은 Netflix와 Crunchyroll만 다룬다. 반면 UTS는 29개 서비스를 지원하고, Disney+도 HBO Max도 거기 들어 있다.

중계를 바꾸겠다고 그것들을 버리고 싶지는 않아서, UTS를 Simkl로 돌리는 데 드는 품을 재봤다. 예상보다 좋았다.

text
고쳐 써야 할 Trakt 관련    약 1,396줄(8개 파일)
29개 서비스 추출기         전혀 손대지 않음
  disneyplus  Trakt 언급 0회
  hbo-max     Trakt 언급 0회

추출기와 Trakt는 깔끔하게 분리돼 있다. 각 서비스의 페이지 구조가 아무리 까다로워도 그 고통은 업스트림이 이미 감당했고, 그 부분은 보내는 곳이 누구인지에 의존하지 않는다.

그래서 이 fork의 실체는 「백엔드를 갈아끼우고 29개 추출기를 물려받는 것」이지 재작성이 아니다. 업스트림은 MIT이고, 코드를 복사하는 대신 GitHub의 fork를 썼다. 커밋 이력과 원작자의 공로가 남고 라이선스 관계도 명확해지기 때문이다. 링크는 맨 아래에 뒀다.

과정에서 가장 배울 게 있었던 건, 이름 바꾸기로 타입 검사는 전부 통과했는데 빌드가 터진 것이다.

이름 바꾸기 스크립트는 코드가 참조하는 아이콘 파일명을 바꿨지만, 실제 파일 이름은 그대로였다. 타입 검사는 에셋 경로를 보지 못하므로 번들링 단계에서야 터진다. 그래서 CI에 build를 추가했다. 그전까지는 타입 검사와 lint뿐이었고, 이 부류의 문제는 둘 다 잡지 못한다.

또 하나, 빌드가 터지는 것보다 훨씬 위험하다고 보는 게 조용히 틀리는 것이다. 업스트림에는 커뮤니티 데이터베이스에서 「매칭 수정」 제안을 가져오는 기능이 있는데, 그 내용은 전부 Trakt의 ID다. Simkl에 적용하면 전혀 무관한 작품으로 매칭되고, 에러는 하나도 나지 않는다. 통째로 비활성화했다.

현재 상태#

8월 4일, Netflix에서 영화를 한 편 다 봤다. 그 외에는 아무것도 하지 않았다.

스케줄이 돌아왔을 때 백엔드는 먼저 activities를 한 번 물었고, 타임스탬프가 움직인 걸 보고 그 증분만 가져와 데이터베이스에 쓰고 이미지를 보완했다. 내가 확인해볼 생각을 했을 때는 이미 페이지에 올라와 있었다.

관은 이어져 있다.

다만 끝내지 못한 게 하나 있고, 전부 해결된 것처럼 쓰고 싶지는 않다.

「지금 보는 중」이라는 실시간 상태는 애니메이션 쪽만 살아 있다. Trakt의 실시간 상태는 그 377줄과 함께 사라졌고, Simkl 쪽은 알아본 뒤 잇지 않기로 했다. 확장 프로그램이 API를 때리는 건 재생·일시정지·정지의 세 시점뿐이라, 영화를 통으로 보는 동안 Simkl에는 아무것도 도착하지 않기 때문이다. 그 「일시정지 중」 데이터를 「지금 보는 중」으로 내보내면 이틀 전에 1%에서 멈춘 게 표시된다.

올바른 방법은 Simkl을 거치지 않고 확장에서 내 서버로 직접 하트비트를 보내는 것이다 — 애니메이션 쪽이 바로 그렇게 하고 있다. 하지만 그러려면 누구나 내려받을 수 있게 공개 배포한 확장 안에 내 서버의 인증 정보를 넣어야 한다. 이건 아직 풀지 못한 설계 문제이지, 그대로 베껴 끝낼 일이 아니다.

이 페이지를 애초에 어떻게 만들었는지는 여기에 썼다.