전체 글

Threads API 액세스 토큰 발급 — 테스터 등록, 60일 만료와 갱신 조건

xXfacebook페이스북

이 블로그의 스레드 발행은 브라우저 자동화가 아니라 Meta 의 공식 API 를 씁니다. 그래서 매일 09시에 도는 발행 잡은 결국 액세스 토큰 하나에 매달려 있습니다. 그런데 이 토큰은 처음 받으면 1시간짜리입니다. 교환을 한 번 해야 60일이 되고 그 60일이 지나 만료되면 갱신 API 자체가 안 먹혀서 앱 대시보드에서 처음부터 다시 받아야 합니다.

이 글은 그 발급 절차를 한 번 통과시키고 남긴 기록입니다. 코드가 무엇을 하고 사람이 무엇을 하는지, 우리 구현이 문서나 자기 주석과 어긋나는 곳까지 적었습니다. 미리 밝혀 둘 것이 둘 있습니다. 하나는 App Review 를 실제로 통과시켜 본 적이 없다는 것입니다 — 본인 계정에만 올리면 되는 상황이라 테스터 경로만 써 봤습니다. 다른 하나는 갱신 자동화가 아직 없다는 것입니다.

Threads 앱 생성부터 테스터 등록, 권한 두 개, 단기 토큰을 장기로 교환하는 다섯 단계를 순서대로 늘어놓고 심사와 만료 때문에 막히는 두 지점을 표시한 그림

다섯 단계 중 사람이 막히는 곳은 둘 — 심사와 1시간.

앱은 만들었는데 심사가 걸린다 — 테스터 등록이 우회로다

절차는 다섯 단계입니다. 앱을 만들고(용도는 Threads), 본인을 Threads 테스터로 추가하고 권한 두 개를 붙이고 단기 토큰을 장기 토큰으로 교환하고 THREADS_USER_ID 를 확인합니다. 끝나면 bun run threads whoami 로 토큰이 살아 있는지 봅니다.

필요한 권한은 threads_basic 과 threads_content_publish 두 개입니다. 앞의 것은 모든 엔드포인트에 필요한 기본 권한이고 뒤의 것이 발행에 쓰입니다.

권한을 붙이려는 순간 App Review 이야기가 나옵니다. 여기서 표현을 조심해야 합니다. Meta 문서에 "테스터는 심사가 면제된다"고 적힌 문장은 없습니다. 문서가 말하는 조건은 이렇습니다.

In order for app users without a role on your app to be able to grant your app these permissions, each permission must first be approved through the App Review process…

심사 요건이 걸리는 대상은 앱에 역할이 없는 사용자입니다. 테스터는 역할이 있는 쪽이라 이 요건에 해당하지 않습니다. 우리 pipeline/sources.md 는 이걸 "테스터는 예외"라고 단정해 적어 뒀는데, 문서의 조건은 예외 목록이 아니라 역할 유무입니다. 본인 계정에만 올리는 용도로는 심사 없이 동작했지만 근거는 "역할이 있어서"라고 적는 편이 정확합니다.

테스터 등록은 한쪽만 해서는 끝나지 않습니다. 문서는 초대와 수락을 함께 요구합니다.

In order to test your app with a Threads user, you must first send an invitation to the Threads user's profile and accept the invitation.

초대는 앱 대시보드의 App roles > Roles > Add People > Threads Tester 에서 보내고 수락은 그 계정이 Threads 의 Account Settings > Website permissions 에서 합니다. 같은 사람이 양쪽을 다 하더라도 화면이 둘이라 한쪽을 빼먹기 쉽습니다.

마지막 단계에도 어긋난 곳이 있습니다. 절차는 .env.local 에 THREADS_ACCESS_TOKEN 과 THREADS_USER_ID 를 넣으라고 안내하는데, 뒤의 값은 지금 발행 경로에서 쓰이지 않습니다. useAccount() 가 USER_ID = "me" 로 덮어쓰고 실제 호출도 /me/threads 로 나갑니다. 안내만 옛 상태로 남은 셈입니다.

발급된 토큰은 1시간짜리다 — 교환 한 번을 빼먹으면

앱 대시보드에서 받은 토큰을 그대로 .env.local 에 넣으면 그날 저녁에 멈춥니다. 문서에 기본값이 적혀 있습니다.

By default, Threads user access tokens are short-lived and are valid for one hour.

한 시간짜리 토큰으로는 무인 실행을 할 수 없습니다. 2026년 8월 3일 발행기 커밋(bf72f66)도 "장기 토큰(60일)이 필요하고 단기 토큰은 1시간이라 무인 실행에 못 쓴다"로 닫았습니다. 그래서 단기 토큰을 받은 직후 장기 토큰으로 바꾸는 호출을 한 번 해야 합니다.

GET https://graph.threads.net/v1.0/access_token
    ?grant_type=th_exchange_token&client_secret=<앱시크릿>&access_token=<단기토큰>

파라미터 세 개가 전부이고 grant_type 값은 고정입니다. <앱시크릿> 과 <단기토큰> 은 실제 값이 들어가는 자리라 여기에는 자리표시자만 적습니다.

여기에 조건이 하나 붙습니다. 문서 표현으로는 "Expired short-lived tokens cannot be exchanged for long-lived tokens." — 만료된 단기 토큰은 교환 자체가 안 됩니다. 토큰을 복사해 두고 하루 뒤에 이어서 하려고 하면 교환은 실패하고 단기 토큰부터 다시 받게 됩니다.

그리고 이 교환은 코드가 하지 않습니다. pipeline/threads.ts 전체를 뒤져도 th_exchange_token 문자열은 없습니다. 있는 것은 뒤에서 볼 th_refresh_token 뿐입니다. 최초 한 번은 사람이 하고 결과를 손으로 .env.local 에 붙여 넣습니다.

60일 뒤엔 또 막힌다 — 갱신엔 조건이 있다

장기 토큰의 유효기간은 60일이고 갱신에는 앞뒤로 창이 있습니다.

Long-lived tokens are valid for 60 days and can be refreshed as long as they are at least 24 hours old but have not expired.

발급 후 24시간이 지나야 하고 동시에 만료 전이어야 합니다. 갱신된 토큰은 그날부터 다시 60일입니다. 반대쪽 문장이 더 중요합니다 — "Tokens that have not been refreshed in 60 days will expire and can no longer be refreshed." 한 번 만료되면 갱신으로는 되살릴 수 없고 단기 토큰부터 다시 받아 교환까지 되풀이하게 됩니다.

장기 토큰의 60일 수명을 가로 막대로 그리고 발급 직후 24시간의 갱신 불가 구간, 그 뒤의 갱신 가능 구간, 만료 이후의 복구 불가 구간을 색으로 나눠 표시한 타임라인

갱신이 되는 구간은 24시간 뒤부터 만료 전까지. 만료 후는 갱신이 아니라 재발급이다.

우리 구현은 bun run threads refresh 한 줄이고 실행하면 이 호출이 나갑니다.

const url = `${API}/refresh_access_token?grant_type=th_refresh_token&access_token=${TOKEN}`;

API 는 https://graph.threads.net/v1.0 이고 응답의 expires_in 은 초 단위라 86400 으로 나눠 일 단위로 출력합니다.

코드에는 조건 검사 로직이 없습니다. "24시간이 지났는가", "만료 전인가"를 미리 따지지 않고 호출한 뒤 응답으로 판정합니다. 조건은 주석과 CLI 도움말에 문장으로만 있습니다. 실패하면 refresh() 는 예외 대신 { ok: false, message } 를 돌려주고 CLI 가 종료 코드 1 로 끝냅니다.

만료는 따로 알아봅니다. 발행 중 API 오류가 나면 에러 코드 190 을 분리해 처리합니다 — "다른 오류와 대응이 다르므로 구분해서 알려준다"는 주석이 붙어 있습니다.

토큰이 만료되었거나 무효합니다.
장기 토큰은 60일이고 만료되면 갱신도 안 됩니다.
앱 대시보드에서 다시 발급받아 .env.local 을 교체하세요.
(만료 전이라면: bun run threads refresh)

갱신에 성공해도 새 토큰이 파일에 자동으로 들어가지는 않습니다. 게으름이 아니라 결정이고 주석에 적혀 있습니다 — ".env.local 은 사람 것이고, 스크립트가 조용히 덮어쓰면 무엇이 언제 바뀌었는지 추적이 안 된다." 그래서 명령은 THREADS_ACCESS_TOKEN 을 교체하세요: 라는 안내로 끝납니다.

여기서 문서와 코드가 어긋난 곳을 밝혀 둡니다. refresh() 의 JSDoc 에는 "매 발행마다 남은 기간을 확인하고, 임박하면 갱신한다"고 적혀 있는데 실제 publish() 는 refresh() 를 호출하지 않습니다. 부르는 곳은 CLI 의 refresh 분기뿐이고 매일 도는 pipeline/threads-daily.sh 에도 없습니다. TODO.md 의 "월 1회 알림 크론"도 미완료입니다. 지금 갱신은 사람이 기억해서 치는 명령입니다. 60일 만료가 무인 실행에서 가장 조용한 고장이라는 점을 생각하면 이 불일치가 그대로 위험입니다.

계정을 사이트마다 나누려면 토큰도 나눠야 한다

발행기가 들어온 것이 2026년 8월 3일, 계정 분리가 8월 20일입니다. 그 17일 사이에 스레드로 내보낼 사이트가 둘이 될 가능성이 생겼습니다. 커밋 8c2452b 에 이유가 적혀 있습니다 — "계정이 둘이 되는 순간 dev 글이 money 계정(aichive_app)으로 나간다. 지워도 본 사람은 본 것이라 되돌릴 수 없다."

사이트 config 의 threads 설정에서 출발해 threadsAccount 가 계정과 토큰 환경변수를 고르고, 발행 직전 me 호출로 받은 username 과 대조해 불일치면 멈추는 흐름을, money 는 활성 dev 는 비활성으로 나란히 대비해 보여주는 그림

config 가 계정을 적고 토큰이 그 계정 것인지는 발행 직전에 물어본다.

구조는 pipeline/threads-scope.ts 에 있습니다. 사이트별 config.json 의 threads 에 account(핸들)와 tokenEnv(토큰이 든 환경변수 이름)를 적고 생략하면 기본값을 씁니다.

/** 기본 환경변수. 계정이 하나뿐이던 시절부터 money 가 쓰던 이름이다. */
export const DEFAULT_TOKEN_ENV = "THREADS_ACCESS_TOKEN";

막는 조건은 셋입니다. threadsEnabled() 는 config?.threads?.enabled === true 일 때만 통과합니다. 설정이 없거나 {} 이거나 null 이면 전부 거짓이라 기본값은 나가지 않는 쪽이고 아니면 이 사이트는 스레드 대상이 아닙니다 로 멈춥니다. account 표기가 없으면 어느 계정으로 나가는지 적어야 합니다 로 발행 자체를 막고 지정된 환경변수가 비어 있으면 <이름> 가 비어 있습니다 (.env.local). 이 뜹니다.

설정만으로는 부족합니다. 환경변수에 남의 계정 토큰을 넣어도 config 검사는 통과하기 때문입니다. 그래서 올리기 직전에 /me 에서 username 하나를 받아 account 와 대조하고 다르면 멈춥니다.

토큰의 계정이 다릅니다 — config 는 @{account} 인데 토큰은 @{username} 입니다.
content/{site}/config.json 의 threads.tokenEnv 와 .env.local 을 확인하세요.

커밋 메시지의 표현으로는 "호출 한 번 값"입니다. whoami 는 비슷해 보이지만 id, username 등을 받아 출력할 뿐 대조하지 않습니다.

테스트가 보장하는 범위도 구분해 둘 만합니다. pipeline/threads-scope.test.ts 의 다섯 개는 순수 함수만 봅니다 — enabled: true 일 때만 통과하는지, tokenEnv 를 지정하면 그 환경변수를 읽는지, 생략하면 기본 이름으로 떨어지는지, account 가 없으면 예외를 던지는지, enabled: false 면 거부되는지입니다. 반대로 /me 대조는 테스트에 없습니다. 실제 API 호출이라 런타임에만 도는 안전장치이고 테스트가 초록이어도 그 경로까지 검증된 것은 아닙니다.

지금 값은 이렇습니다. money 는 enabled: true 에 account: "aichive_app", dev 는 account: "llm_dev" 와 tokenEnv: "THREADS_ACCESS_TOKEN_DEV" 를 적어 둔 채 enabled: false 입니다. 이름만 적어 두고 스위치는 꺼 둔 상태라 토큰이 준비되면 enabled 만 켜면 됩니다.

글 하나를 올리는데 API 호출이 두 번인 이유

게시는 한 번에 끝나지 않습니다. 먼저 POST /{user-id}/threads 로 미디어 컨테이너를 만들어 creation_id 를 받고 POST /{user-id}/threads_publish 에 그 값을 넘겨야 올라갑니다. 사이에는 대기가 들어갑니다.

컨테이너 생성 호출로 creation id 를 받고 기본 30초를 기다린 뒤 publish 호출로 실제 게시하는 두 단계 흐름과, 중간에 실패하면 앞 조각만 올라간 상태로 남는다는 것을 표시한 그림

두 호출 사이의 대기는 문서 권장값이다. 코드 기본값은 30초로 뒀다.

대기 시간은 상수로 빼 환경변수로 덮어쓸 수 있게 했습니다.

/** 문서 권장값. 짧게 주면 컨테이너가 아직 준비되지 않아 실패한다. */
const PUBLISH_DELAY_MS = Number(process.env.THREADS_PUBLISH_DELAY_MS ?? 30_000);

여기서 층위를 나눠야 합니다. Meta 문서의 표현은 "It is recommended to wait on average 30 seconds before publishing" 으로 평균 30초 대기를 권장한다는 말이고 "즉시 게시하면 실패한다"고는 하지 않습니다. 그런데 위 주석과 커밋 메시지, pipeline/sources.md 는 "바로 게시하면 컨테이너가 준비되지 않아 실패한다"고 단정해 적어 뒀습니다. 그건 우리가 적어 둔 문장이지 문서의 주장이 아닙니다.

실제 호출은 세 줄로 이어집니다.

const container = await api("threads", params);
await new Promise((r) => setTimeout(r, PUBLISH_DELAY_MS));
const published = await api("threads_publish", { creation_id: String(container.id) });

컨테이너 파라미터는 media_type: "TEXT" 와 text 가 기본이고 조건에 따라 topic_tag, reply_to_id(답글로 이어 붙일 때), link_attachment(그 글에 URL 이 실렸을 때만)가 붙습니다. 글 하나가 500자까지라는 문서 제약도 MAX_TEXT = 500 으로 옮겨져 있습니다.

호출이 둘이라는 사실은 실패했을 때 더 크게 옵니다. 글 여러 개를 답글로 이어 붙이는 도중 실패하면 앞부분만 올라간 반쪽 상태가 됩니다. 주석의 이유는 이렇습니다 — "되돌릴 수가 없으므로(threads_delete 권한이 앱에 없다) 실패한 지점을 정확히 알려 사람이 손으로 이어 붙일 수 있게 한다." 그래서 에러는 몇 개까지 올라갔는지와 마지막 id 를 알려주고 "API 로는 못 지웁니다 — 앱에서 직접 지우거나, 남은 조각을 답글로 이어 붙이세요"로 끝납니다. 삭제 권한을 요청하지 않았으니 롤백은 사람의 일입니다.

같은 글이 두 번 올라가지 않게 막는 장치는 발행 이력 쪽에 따로 있는데 그 이야기는 발행 이력으로 재발행을 막은 기록에 적어 뒀습니다.

근거와 참고

아래 Meta 문서 네 건은 모두 2026-08-25 에 확인했습니다.

이 글 공유하기xXfacebook페이스북