해외에서만 열리지 않던 이미지
해외에서 접속할 때만 사용자의 작업물이 보이지 않는 문제가 있었습니다. 국내에서는 재현되지 않아 해외 리전의 조건을 그대로 만들어가며 원인을 찾았습니다.

안녕하세요, Toonkit 개발팀입니다.
해외에 계신 몇몇 사용자분들이 이런 문의를 주셨습니다.
"로그인도 되고 결제도 정상인데, 제가 만든 컷이나 영상이 하나도 안 보여요."
문의를 받고 바로 확인해보려 했지만, 저희 쪽에서는 같은 화면이 정상적으로 보였습니다. 로컬에서도, 스테이징에서도, 사내에서 열어본 프로덕션에서도 문제가 없었습니다. 재현이 되지 않으니 무엇이 실패하고 있는지조차 볼 수 없는 상태였습니다.
토큰 인증 구조
Toonkit에서 만든 컷과 영상은 오브젝트 스토리지에 저장되고, CDN을 통해 브라우저로 전달됩니다. 다만 이 결과물은 만든 사람 외에는 열어볼 수 없어야 합니다. URL만 알면 누구나 받을 수 있으면 안 되니까요.
그래서 사용자 콘텐츠를 서빙하는 CDN에는 토큰 인증이 걸려 있습니다. 랜딩 페이지 배너나 커뮤니티 샘플처럼 공개된 자산은 토큰 없이 열리는 별도 CDN에서 서빙하고, 사용자가 직접 만든 결과물은 서명된 토큰이 붙은 요청만 통과시킵니다. 토큰은 대략 이런 모양입니다.
st=시작시각~exp=만료시각~acl=허용경로~hmac=서명값
서버가 사용자별로 허용 경로와 유효 기간을 정해 서명하고, 프론트엔드가 이 토큰을 이미지·영상 URL의 쿼리 파라미터로 붙여서 요청합니다. CDN 엣지가 서명을 검증해서 통과시킬지 거부할지 결정합니다.
모니터링
가장 먼저 한 일은 모니터링을 붙이는 것이었습니다. NewRelic으로 실패하는 요청들을 추적해봤습니다.
결과는 기대만큼 자세하지 않았습니다. 요청이 실패했다는 사실, 즉 Error라는 것만 보고됐고 그 이상은 알 수 없었습니다.
이건 계측을 부실하게 해놨기 때문이라기보다 브라우저의 제약이었습니다. img 태그나 video 태그가 리소스를 받다가 실패하면 브라우저는 error 이벤트를 발생시키는데, 이 이벤트에는 HTTP 상태 코드가 담기지 않습니다. 403인지 404인지 500인지, 응답 본문에 무슨 이유가 적혀 있었는지 자바스크립트에서는 알 방법이 없습니다. 그저 "이 URL을 못 받았다"는 사실만 남습니다.
결국 실패가 일어나는 환경을 직접 보는 수밖에 없었습니다.
해외 리전에서 재현
그래서 해외에서 접속하는 환경을 직접 만들었습니다. 해외 리전에 EC2 인스턴스를 하나 띄우고, SSH로 SOCKS 프록시 터널을 열었습니다.
ssh -D 1080 -N -i "pem.pem" user@IP
-D 1080은 로컬 1080 포트를 SOCKS 프록시로 열어 모든 트래픽을 그 서버를 거쳐 나가게 합니다. -N은 원격 셸을 열지 않고 터널만 유지하겠다는 뜻입니다. 이제 로컬 1080으로 보낸 요청은 해외 리전에서 나가는 요청이 됩니다.
그 프록시를 통해 Chrome을 새로 띄웠습니다.
open -na "Google Chrome" --args --proxy-server="socks5://127.0.0.1:1080" --user-data-dir="/tmp/us-test7" --lang=en-US --accept-lang=en-US
--user-data-dir로 프로필을 분리한 게 중요했습니다. 이렇게 하지 않으면 평소 쓰던 Chrome 창이 그대로 열려서 프록시가 적용되지 않거나, 기존 세션과 캐시가 섞여 판단을 흐립니다. 별도 프로필로 띄우면 쿠키도 캐시도 없는 깨끗한 상태에서 시작할 수 있습니다. --lang과 --accept-lang은 로케일까지 해외 사용자와 같게 맞추기 위해 넣었습니다.
이 브라우저로 Toonkit에 접속하자 사용자분들이 말씀하신 화면이 그대로 재현됐습니다. 네트워크 탭을 열어보니 페이지도 API도 다 정상인데, 사용자 콘텐츠를 서빙하는 CDN으로 가는 요청들만 403으로 떨어지고 있었습니다. 토큰 검증 단계에서 막히고 있다는 걸 여기서 처음 확인했습니다.
토큰 자체를 의심해봤지만 서명은 정상이었습니다. 같은 토큰으로 국내에서 요청하면 문제없이 통과했고, 만료되지도 않았고, 허용 경로도 맞았습니다. 오리진 연결 문제인가 싶어 토큰이 필요 없는 공개 CDN으로 같은 해외 경로를 테스트했는데 이쪽은 정상이었습니다. 지역 차단도 아니었습니다. 거부 응답에는 토큰 인증에서 실패했다고 적혀 있었으니까요.
클라우드 공급자에게 연락
같은 토큰이 리전에 따라 다르게 판정된다는 것까지 좁혀진 뒤 클라우드 공급자에게 연락했습니다. 저희가 통제할 수 있는 변수는 거의 다 확인한 상태였습니다. 답변이 오기까지는 시간이 좀 걸렸습니다.
돌아온 답변은, URL을 인코딩하지 않고 요청하면 해외에서도 정상적으로 동작한다는 것이었습니다.
저희 프론트엔드 코드를 보니 토큰을 URL에 붙일 때 encodeURIComponent를 거치고 있었습니다. 토큰 문자열 안의 =와 /가 %3D, %2F로 바뀌어 나가고 있었던 겁니다.
여기서 서버 쪽 설정이 걸렸습니다. 토큰을 만들 때 저희는 escapeEarly를 끈 상태로 서명합니다. 인코딩하기 전 원문 문자열을 기준으로 서명값을 계산한다는 뜻입니다. 그러면 요청도 같은 원문으로 와야 서명이 맞습니다. 클라이언트가 중간에서 인코딩을 한 번 더 하면 CDN이 검증하는 문자열과 서버가 서명한 문자열이 달라지고, 서명은 당연히 어긋납니다.
한 가지는 여전히 이상했습니다. 이 인코딩 로직은 리전과 관계없이 항상 똑같이 실행되는데, 정작 문제는 특정 리전으로 나가는 요청에서만 나타났습니다. 같은 URL을 리전마다 다르게 처리한다는 뜻이고, 그 차이가 정확히 어디서 오는지는 저희도 명확히 알지 못합니다. 다만 인코딩을 걷어내면 모든 리전에서 정상 동작한다는 건 분명했습니다.
토큰을 붙이는 부분만 원문 그대로 전송하도록 고쳤습니다. 같은 함수가 서로 다른 두 곳에 있었기 때문에 양쪽을 모두 수정했고, 혹시 다른 곳에도 같은 패턴이 남아 있는지 코드베이스 전체를 검색해 확인했습니다. 배포 후 해외 프록시로 다시 접속해 정상 응답을 확인했습니다.
단일 파일 접근 토큰
대부분의 화면은 이 수정으로 정상화됐습니다. 그런데 한 군데가 남았습니다.
이미지를 생성할 때 참조로 넘긴 원본 이미지를 보여주는 화면이 있는데, 이 경로는 토큰을 만드는 방식이 달랐습니다. 일반적인 화면은 사용자 소유 경로 전체에 접근을 허용하는 토큰을 쓰지만, 이 경로는 외부 AI 모델에 파일 URL을 직접 넘겨야 했습니다. 외부로 나가는 URL에 사용자의 전체 경로를 열어주는 토큰을 붙이는 건 위험하니, 넘길 파일 딱 하나만 허용하는 토큰을 따로 만들고 있었습니다.
그래서 이 토큰의 허용 경로에는 와일드카드가 없었습니다. 정확히 그 파일 경로 하나만 적혀 있었습니다.
앞서 만들어둔 해외 프록시 환경을 여기서 다시 썼습니다. 같은 객체, 같은 키로 허용 경로만 바꿔가며 요청을 던져봤습니다.
acl=/user123/animation456/cuts/image.png → 403
acl=/user123/animation456/cuts/image.png* → 200
경로 끝에 별표 하나를 붙이면 통과했습니다. 객체도 같고 키도 같고 인코딩도 문제없는 상태에서 마지막 글자 하나로 결과가 갈렸습니다. 캐시가 비어 있는 상태에서도 마찬가지였고, 국내에서는 둘 다 통과했습니다.
이 토큰은 원래 파일 하나만 가리키도록 만든 것이었으니, 끝에 별표를 붙이면 허용 범위가 넓어지는 게 아닌지 확인이 필요했습니다. 저희 스토리지 구조에서는 이 경로로 시작하는 다른 파일이 생기지 않습니다. 썸네일은 파일명 앞에 접두어가 붙고, 다른 포맷 변환본은 확장자만 바뀌기 때문에 원본 경로를 접두어로 갖지 않습니다. 실질적으로는 여전히 그 파일 하나만 열립니다. 확인 후 별표를 붙이는 것으로 수정했습니다.
재발 방지
리전 차이는 테스트 코드로 재현할 수 없습니다. 국내에서 실행되는 테스트는 두 경우 모두 통과시키기 때문에, 테스트를 아무리 촘촘하게 써도 이 버그는 잡히지 않습니다.
그래서 검증 대상을 바꿨습니다. 토큰이 실제로 통과하는지를 테스트하는 대신, 토큰의 허용 경로 문자열이 의도한 형태로 만들어지는지를 고정하는 회귀 테스트를 넣었습니다. 나중에 누군가 이 부분을 리팩터링하면서 별표를 지우면 테스트가 깨지도록 해둔 겁니다. 왜 이 형태여야 하는지도 코드 주석에 실측값과 함께 남겼습니다. 주석이 없으면 불필요해 보이는 와일드카드라서, 다음 사람이 정리해버릴 가능성이 높았습니다.
돌아보면 저희 로컬 개발과 QA가 대부분 국내 네트워크 환경에서 이뤄지다 보니, 이런 리전별 차이를 배포 전에 잡아내지 못했습니다. 지금은 CDN 토큰과 관련된 변경이 있으면 해외 리전에서 한 번 더 확인하고 있습니다. EC2 인스턴스 하나와 SSH 명령 한 줄이면 되는 일인데, 문제를 겪고 나서야 절차에 넣었습니다.
CDN처럼 리전마다 다른 계층을 거치는 인프라에서는 같은 요청이 어디서 나가는지에 따라 결과가 달라질 수 있습니다. 비슷한 증상을 만난다면 로그를 더 파기 전에 그 리전의 조건을 그대로 만들어 요청을 던져보는 편이 빨랐습니다.