IceBlogation 개발일지 — 승인은 났는데 검색이 401이었다 (v2.0.0)
쿠팡 파트너스 최종승인이 나서 API 키를 받았다. 넣었더니 상품 검색만 401이었고, 원인은 우리 코드가 서명에 물음표를 하나 더 넣고 있어서였다. 고치는 김에 상품을 긁어오던 브라우저를 통째로 들어냈다.
지난 일지에서 쿠팡 파트너스 최종승인이 반려됐다고 썼다. AI 가상인물 표시가 빠졌다는 이유였다. 220편을 다 고치고 재신청했고, 오늘 승인이 났다.
승인이 나면 파트너스 API 키가 나온다. Access Key와 Secret Key 두 개. 그걸 앱 설정에 넣고 발급 방식을 web에서 api로 바꿨다. 여기까지는 5분이었다.
그리고 상품 검색이 401을 뱉었다.
키가 아니라 우리 코드가 틀렸다
쿠팡 API는 HMAC 서명을 쓴다. 요청을 보낼 때마다 "시각 + 메서드 + 경로"를 비밀키로 해싱해서 헤더에 같이 보낸다. 위조 방지 도장 같은 거다. 서버도 같은 재료로 도장을 찍어보고 두 개가 같은지 본다.
재료가 한 글자라도 다르면 도장이 안 맞는다.
우리 코드 : 시각 + "GET" + "/…/products/search?keyword=…&limit=3"
쿠팡 공식 : 시각 + "GET" + "/…/products/search" + "keyword=…&limit=3"
물음표 하나. 쿠팡 공식 Java 샘플이 주소를 물음표로 쪼갠 다음 구분자 없이 다시 붙이는데, 우리 코드는 주소를 통째로 넘기고 있었다.
실제로 세 번 찔러봤다.
| 시도 | 결과 |
|---|---|
| 물음표 포함 (당시 코드) | 401 Invalid signature |
| 물음표 제외 (수정) | 200 — 실제 상품 데이터 |
| 딥링크 호출 | 200 — 원래부터 되고 있었다 |
세 번째 줄이 이 버그가 오래 숨어 있던 이유다. 딥링크는 POST라서 주소에 쿼리가 아예 없다. 붙일 물음표가 없으니 우연히 서명이 맞았다. 검색만 죽어 있었고, 그때는 검색을 API로 하지 않았으니 아무도 몰랐다.
키를 받은 날에야 드러난 셈이다. 키가 없었으면 영원히 몰랐을 버그다.
상품 주소가 이미 제휴 링크였다
고쳐놓고 응답을 들여다보다가 하나 더 발견했다.
https://link.coupang.com/re/AFFSDP?lptag=AF9274744&subid=…&pageKey=8688160692
검색 결과에 딸려 오는 상품 주소에 lptag가 이미 박혀 있다. 내 파트너스 식별자다. 즉 검색 응답이 곧 제휴 링크다.
우리는 그동안 상품을 찾은 다음 "이 주소를 제휴 링크로 바꿔주세요"라고 한 번 더 호출하고 있었다. 그 호출이 통째로 필요 없어졌다. 글 한 편당 API 호출이 두 번에서 한 번으로 줄었고, 더 중요하게는 "딥링크 발급 실패"라는 실패 유형 자체가 사라졌다.
브라우저를 들어냈다
여기서 판이 바뀌었다.
원래 상품 발굴은 크로미움을 띄워서 쿠팡 검색 페이지를 긁는 방식이었다. 파트너스 API 없이 할 수 있는 유일한 방법이었으니까. 대신 대가가 컸다. 쿠팡은 Akamai로 자동 접근을 막는다. 차단당하면 30분 쿨다운을 걸고, 요청 사이에 60~90초를 쉬고, 10건마다 3분을 더 쉬었다. 배치 하나 도는 데 그 대기 시간이 절반이었다.
이제 상품 검색도 그냥 HTTP 요청 한 번이다.
| 전 (web) | 후 (api) | |
|---|---|---|
| 상품 발굴 | 크로미움으로 쿠팡 페이지 크롤 | HTTP 검색 1회 |
| 제휴 링크 | 파트너스 웹 자동화 (로그인 세션 필요) | 불필요 |
| Akamai 차단 | 잦음 → 30분 쿨다운·3회 재시도 | 없음 |
| 배치 브라우저 | 상시 2개 세션 | 아예 안 만듦 |
| 요청 간격 | 60~90초 + 10건마다 3분 | 불필요 (공식 한도 분당 50회) |
글을 만드는 동안 쿠팡 사이트에 브라우저로 접속하는 경로가 사라졌다. 차단을 피해 다니는 코드를 잘 만드는 것보다, 차단당할 일을 안 만드는 게 낫다.
기존 크롤 경로는 지우지 않고 폴백으로 남겼다. 설정 한 줄을 web으로 되돌리면 그대로 돈다. 새 경로는 기존 코드에 분기 하나를 얹은 형태라, 옛 경로는 한 줄도 건드리지 않았다.
한 번도 불린 적 없는 함수
작업하다 이런 걸 발견했다.
if settings.coupang_source == "api":
return CoupangPartnersAPI()
coupang_source라는 설정은 존재하지 않는다. 예전에 이름을 deeplink_source로 바꾸면서 이 함수만 안 고쳤다. 호출하는 순간 AttributeError로 죽는다.
그런데 아무 문제가 없었다. 아무도 이 함수를 부르지 않았기 때문이다. 코드는 남았는데 호출부만 다른 경로로 옮겨간 상태로 몇 달을 있었다. 테스트도 통과했다. 테스트가 안 부르니까.
고쳐놓고 검증에 "이 함수가 API 모드에서 제대로 된 객체를 돌려주는가"를 한 줄 넣었다. 죽은 코드를 살리는 방법은 결국 누군가 부르게 만드는 것뿐이다.
검증 도구가 거짓말을 했다
빌드를 마치고, 실행 파일 안에 정말 수정본이 들어갔는지 확인했다. 번들에 들어간 컴파일 결과와 디스크의 소스를 다시 컴파일한 결과를 바이트로 비교하는 방식이다.
OK app.coupang.api_client
DIFF app.coupang.sourcing
DIFF app.main
DIFF app.content.backfill
셋이 다르다고 나왔다. 재빌드해야 하나 싶었는데, 이상했다. 같은 방식으로 비교한 api_client는 맞았으니까.
바이트 비교 대신 코드 구조를 재귀적으로 훑어봤다. 명령어 바이트, 상수, 이름, 인자 수를 하나씩 대조했다.
OK app.coupang.api_client
OK app.coupang.sourcing
OK app.coupang
OK app.main
OK app.content.backfill
전부 일치했다. 파이썬이 코드 객체를 직렬화할 때 내부 문자열을 공유하는 방식 때문에, 구조가 같아도 바이트가 달라질 수 있다. api_client가 맞았던 건 운이었다.
측정 도구가 틀렸는데 결과를 믿고 재빌드했으면, 30분을 쓰고 똑같은 DIFF를 다시 봤을 것이다. 결과가 이상하면 대상을 의심하기 전에 자를 의심하는 게 먼저다.
증거
scripts/verify_offline.py 440/440 PASS (427 → +13)
scripts/smoke_exe.py 11/11 PASS
번들 ↔ 디스크 소스 구조 대조 6/6 OK
새로 넣은 13개는 서명 형식 3개, 응답 매핑 3개, 모드 전환 2개, API 소싱 4개다. 소싱 검사는 전부 크롤러를 부르면 즉시 실패하도록 봉인해뒀다. 나중에 누가 브라우저 경로를 실수로 되살리면 테스트가 먼저 안다.
실제 키로 진짜 코드를 태운 결과도 남겼다.
[무선 이어폰] 링크 2건 → link.coupang.com/re/AFFSDP?lptag=AF9274744…
[레고 정리함] 링크 2건 → link.coupang.com/re/AFFSDP?lptag=AF9274744…
[없는 키워드] 링크 0건 → "상품없음 (정보형 글 — 링크 없이 발행 가능)"
브라우저는 한 번도 열리지 않았다. 세 번째 줄도 중요하다. 살 물건이 없는 주제는 실패가 아니라 그냥 링크 없는 정보성 글로 나가야 한다. 그 구분은 그대로 유지됐다.
안 건드린 것
이미 올라간 220편은 그대로 뒀다. 링크도 고지도 손대지 않았다. 새로 만드는 글에만 API 링크가 붙는다.
AI 가상인물 고지와 파트너스 수수료 고지도 무변경이다. 이번 반려를 겪고 나서 그 문구는 한 파일에만 두기로 했고, 이번에도 그 파일은 열지 않았다.
남은 것
내일 글 한 편을 만들어 링크 주소가 제대로 붙는지 눈으로 확인할 생각이다. 주소만 본다. 자기 제휴 링크를 자기가 클릭하는 건 그 자체로 제재 사유다.
SubId는 아직 비워뒀다. 넣으면 블로그별로 수익이 갈려서 보이는데, 파트너스 사이트에 먼저 등록해야 한다.
리포트 API도 있다. 클릭 수, 주문, 수익을 시간당 500번까지 받아올 수 있다. 앱 안에 수익 화면을 붙이는 건 그다음 일이다.
버전은 2.0.0으로 올렸다. 자잘한 기능이 늘어서가 아니라, 물건을 가져오는 통로가 바뀌었기 때문이다.