음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

인스타그램 API 자동 발행 에러 3가지 해결 — PNG·CDN·한글 경로


[시리즈] 코드로 인스타그램 운영 자동화 (전체 5편)
0. 전체 개요 — 허브 (발행예정)
1. 캐러셀(카드뉴스) 자동 생성·발행
2. 그래프 API 인증·셋업
3. 자동 발행 에러 3가지 (9004·CDN·한글 경로) ← 현재 편
4. 릴스 영상 자동 편집 (ffmpeg)

자동 발행은 됐는데, 발행 직전에서 계속 막혔다

인스타그램 API 발행 에러로 사흘을 날렸습니다. 슬라이드 이미지는 다 만들었고 그래프 API 토큰도 정상인데, 막상 게시 단계에서 9004, 2207052, media download failed 같은 코드만 돌아왔습니다. 아래는 그 자동 발행 파이프라인에서 만난 에러 3가지를 하나씩 뜯어보며 어떻게 넘겼는지 적은 것입니다.

세 가지 다 인증·토큰 문제가 아니라 발행 직전 "이미지를 인스타가 받아들이는 형태"의 문제였습니다. 정확한 에러 코드와 해결 코드를 같이 적어 두니, 같은 메시지를 검색해 들어온 분이라면 바로 대조해 보면 됩니다. (토큰 발급·갱신·개발모드에서 라이브 전환 같은 인증 셋업은 이 글에서 다루지 않습니다. 그 부분은 이전 셋업편을 참고하세요.)


발행 흐름 먼저 — 공개 URL → media 컨테이너 → publish

해결을 이해하려면 인스타그램 그래프 API의 발행 흐름을 알아야 합니다. 인스타그램 콘텐츠 게시 API는 로컬 파일 업로드를 받지 않습니다. 인터넷에서 접근 가능한 공개 이미지 URL만 받습니다. 그래서 캐러셀 자동 발행은 늘 다음 3단계로 흐릅니다.


[1] 이미지 호스팅
     로컬 PNG → GitHub 업로드 → 공개 이미지 URL 확보
        │
        ▼
[2] media 컨테이너 생성
     POST /{ig-user-id}/media  (image_url 전달)
     → 컨테이너가 그 URL을 "다운로드"해서 처리
        │
        ▼
[3] 발행
     POST /{ig-user-id}/media_publish  (creation_id 전달)

핵심은 2단계입니다. image_url을 넘기면 인스타그램 서버가 그 URL을 직접 다운로드해서 미디어로 변환합니다. 즉, 막히는 지점은 거의 다 "인스타그램이 그 URL을 못 읽거나, 읽었는데 포맷을 거부하는" 경우입니다. 실제 코드에서는 ig_create_item()이 캐러셀 아이템 컨테이너를 만들고, ig_create_carousel()로 묶은 뒤 ig_publish()로 게시합니다.


def ig_create_item(ig_id, token, image_url):
    r = requests.post(f"{GRAPH}/{ig_id}/media",
                      data={"image_url": image_url,
                            "is_carousel_item": "true",
                            "access_token": token})
    j = r.json()
    if "id" not in j:
        sys.exit(f"아이템 컨테이너 실패: {r.text[:400]}")
    return j["id"]

이 image_url 한 줄 때문에 만난 에러가 아래 세 가지입니다.


에러 1 — 인스타가 PNG를 거부한다 (9004 / 2207052)

가장 먼저 만난 벽입니다. 슬라이드를 캡처하면 PNG로 떨어지는데, 그 PNG를 그대로 호스팅해서 image_url로 넘기자 인스타그램 그래프 API가 9004와 2207052 에러로 계속 거부했습니다. 토큰도 멀쩡하고 URL도 브라우저에서 잘 열리는데도 그랬습니다.

원인은 포맷이었습니다. 인스타그램 콘텐츠 게시 API는 JPEG만 안정적으로 수신합니다. PNG는 미디어 처리 단계에서 거부되거나 2207052(미디어 다운로드/처리 실패) 계열로 떨어집니다. 문서에는 PNG도 된다고 적혀 있는 경우가 있지만, 캐러셀 콘텐츠 게시에서는 JPEG가 사실상 유일하게 신뢰할 수 있는 포맷입니다. 이게 인스타그램 PNG 업로드 오류의 정체였습니다.

해결은 업로드 직전에 PIL(Pillow)로 JPEG 변환을 끼워 넣는 것이었습니다. 호스팅하는 바이트 자체를 JPEG로 만들고 확장자도 .jpg로 올립니다.


from PIL import Image
import io

# 인스타 콘텐츠 게시 API는 JPEG만 안정적으로 수신(PNG는 9004/2207052로 거부).
# PNG를 JPEG로 변환해 .jpg 로 호스팅한다.
img = Image.open(p).convert("RGB")          # 알파 채널 제거 (PNG → RGB)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)    # quality 90으로 인코딩
data = buf.getvalue()
name = p.stem + ".jpg"                       # 01.png → 01.jpg

convert("RGB")가 중요합니다. PNG는 투명도(알파 채널)를 가질 수 있는데 JPEG는 알파를 지원하지 않습니다. RGB로 변환하지 않고 바로 JPEG로 저장하면 에러가 납니다. quality=90은 화질과 파일 크기의 절충점인데, 카드뉴스 텍스트가 뭉개지지 않으면서 인스타그램 권장 용량 안에 충분히 들어옵니다. 이 변환 한 단계로 9004/2207052가 사라졌습니다.


에러 2 — raw.githubusercontent.com URL을 인스타가 거부한다

PNG를 JPEG로 바꾸고 GitHub에 올린 뒤, 처음에는 raw.githubusercontent.com URL을 image_url로 넘겼습니다. 브라우저에서는 이미지가 멀쩡히 열리는데, 인스타그램 미디어 컨테이너는 그 URL을 처리하지 못하고 또 실패했습니다.

raw.githubusercontent.com은 사람이 브라우저로 열어 보라고 만든 경로지 머신이 대량으로 이미지를 가져가라고 만든 CDN이 아닙니다. GitHub는 이 raw 도메인에 Content-Type: text/plain 응답을 주거나 핫링크/레이트리밋을 걸어 두는 경우가 있고, 인스타그램 미디어 페처 입장에서는 "이미지가 아닌 응답" 또는 "차단된 요청"으로 받아 거부합니다.

해결은 같은 GitHub 파일을 jsDelivr CDN URL로 바꿔 주는 것이었습니다. jsDelivr는 GitHub 저장소 파일을 CDN으로 그대로 서빙해 주는 무료 서비스라서, 추가 업로드 없이 URL 형식만 바꾸면 됩니다. CDN이 정상적인 Content-Type과 캐시 헤더를 붙여 응답하므로 인스타그램 미디어 페처가 문제없이 받습니다.


# raw.githubusercontent.com 도 IG가 받긴 하나, 기본은 jsDelivr CDN.
if g.get("cdn", "jsdelivr") == "raw":
    url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{qpath}"
else:
    url = f"https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{qpath}"

두 URL 형식의 차이는 이렇습니다.


# 거부되던 raw URL
https://raw.githubusercontent.com/<user>/<repo>/<branch>/<path>

# 통과하는 jsDelivr CDN URL
https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch>/<path>

앞 구분자가 raw는 슬래시(/)지만 jsDelivr는 @라는 점만 주의하면 됩니다. 같은 파일, URL 형식만 교체했는데 인스타그램이 받아들이기 시작했습니다.


에러 3 — 한글 폴더/파일명이 URL에서 안 먹힌다 (media download failed, 9004)

마지막이 제일 오래 잡았습니다. JPEG로 바꾸고 jsDelivr로 올렸는데도 특정 편만 media download failed와 함께 9004로 떨어졌습니다. 어떤 편은 되고 어떤 편은 안 되는 게 한참 이해가 안 됐습니다.

차이는 폴더 이름이었습니다. 발행 폴더가 02_한도크레딧, 03_회의록처럼 한글을 포함하면, 그 한글이 URL 경로에 그대로 들어갑니다. 브라우저는 비ASCII 문자를 알아서 인코딩해 주지만, 인스타그램 미디어 페처는 URL의 비ASCII 문자(한글)를 그대로 읽으려다 다운로드에 실패합니다. 그래서 메시지가 media download failed였던 것입니다.

해결은 URL을 만들기 전에 경로를 urllib.parse.quote로 percent-encoding 하는 것입니다. 한글이 %ED%95%9C... 형태로 바뀌어 ASCII URL이 되면 인스타그램 페처가 정상적으로 다운로드합니다.


from urllib.parse import quote

# 인스타 미디어 페처는 URL의 비ASCII(한글 폴더명 등)를 못 읽으므로
# 경로를 퍼센트 인코딩한다.
qpath = quote(path)   # "instagram/02_한도크레딧/01.jpg"
                      # → "instagram/02_%ED%95%9C%EB%8F%84.../01.jpg"
url = f"https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{qpath}"

quote는 기본적으로 슬래시(/)는 보존하므로 경로 구분은 그대로 두고 한글만 인코딩합니다. 폴더 구조를 망가뜨리지 않으면서 비ASCII만 안전하게 처리합니다. 이 한 줄로 한글 폴더 편들도 전부 발행됐습니다.


디버깅 교훈 — AI가 짠 코드인데 원인이 폴더 이름이었다

세 에러를 다 잡고 나서 가장 기억에 남은 건 세 번째였습니다. 코드는 AI에게 시켜서 깔끔하게 짰고 로직에는 흠이 없어 보였는데, 어떤 편은 발행되고 어떤 편은 media download failed로 죽는 게 도무지 설명이 안 됐습니다. 토큰을 의심하고, API 버전을 의심하고, 권한을 의심하며 세 시간을 보냈습니다. 결국 원인은 "폴더 이름이 한글이라서"였습니다.

돌아보면 두 가지가 시간을 잡아먹었습니다.

  • 에러 메시지는 거짓말을 안 한다. media download failed는 말 그대로 "다운로드 실패"였습니다. 권한이나 토큰이 아니라 URL을 못 가져온 것이라는 뜻이었는데, 메시지를 곧이곧대로 읽기까지 시간이 걸렸습니다.
  • 되는 케이스와 안 되는 케이스의 차이부터 본다. 같은 코드인데 편마다 결과가 갈리면 코드가 아니라 입력(데이터)의 차이입니다. 폴더 이름 한글 여부가 유일한 차이였습니다.

그리고 하나 더. AI에게 시킨 코드라고 디버깅이 면제되는 건 아니었습니다. 오히려 사람이 안 짠 코드라 "여긴 당연히 맞겠지" 하고 건너뛴 곳에서 막혔습니다. 호스팅 URL을 한 번이라도 인스타그램 입장에서(이게 ASCII인가?) 들여다봤으면 훨씬 빨랐을 일입니다.

인스타그램 그래프 API 자동 발행에서 발행 직전 단계는 결국 "인스타그램 서버가 내 이미지 URL을 받아들이는가"로 압축됩니다. JPEG 포맷, CDN URL, ASCII 경로 — 이 세 전제를 호스팅 단계에 미리 박아 두면 9004 류 에러는 거의 다 사라집니다.


자주 묻는 질문

Q. error 9004는 정확히 무슨 뜻인가요?

A. 9004는 인스타그램 그래프 API가 image_url로 받은 이미지를 가져오거나 처리하지 못했을 때 나오는 광범위한 코드입니다. 원인이 한 가지가 아니라 (1) URL을 다운로드 못 함(한글 경로·차단된 도메인), (2) 받았는데 포맷이 안 맞음(PNG) 등이 모두 9004/2207052로 묶여 떨어집니다. 그래서 이 글의 세 해결책을 순서대로 적용하는 게 효과적입니다.

Q. PNG를 꼭 JPEG로 바꿔야 하나요? 처음부터 JPEG로 캡처하면 안 되나요?

A. 처음부터 JPEG로 캡처해도 됩니다. 다만 슬라이드 캡처 단계는 보통 PNG가 기본이라, 발행 직전에 PIL로 Image.open(p).convert("RGB") 후 save(..., format="JPEG", quality=90)로 변환하는 편이 파이프라인을 덜 건드립니다. convert("RGB")로 알파 채널을 제거하는 것을 빠뜨리지 마세요.

Q. raw.githubusercontent.com이 원래는 됐는데 갑자기 안 되기도 하나요?

A. 간헐적으로 됐다 안 됐다 합니다. raw 도메인은 레이트리밋·핫링크 정책·Content-Type 응답이 일정하지 않아서, 인스타그램 미디어 페처 입장에서 신뢰하기 어렵습니다. 같은 파일을 jsDelivr CDN URL(cdn.jsdelivr.net/gh/...@branch/...)로 바꾸면 안정적으로 통과합니다.

Q. 한글 경로 percent-encoding은 quote만 쓰면 충분한가요?

A. 경로(path)에는 urllib.parse.quote로 충분합니다. quote는 슬래시를 보존하므로 폴더 구조를 깨지 않고 한글만 %XX로 바꿉니다. 다만 쿼리스트링 값에 비ASCII가 들어간다면 그 부분은 quote에 safe=""를 주거나 urlencode를 쓰는 게 안전합니다. 이 파이프라인은 경로에만 한글이 있어 quote(path)로 해결했습니다.


이 글은 제가 실제 운영 중인 인스타그램 캐러셀 자동 발행 스크립트(공개 URL 호스팅 → media 컨테이너 → media_publish)에서 직접 만난 에러와 해결을 기준으로 정리했습니다. 에러 코드와 코드 스니펫은 실제 발행 로그와 스크립트 기준입니다. (2026년 6월 기준)

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기