
파일은 올라가는데 화면은 계속 실패라고 떴다.
203MB 영상 하나 올리는데 63%쯤 갔다가 다시 0으로 돌아오고, 100%를 찍고도 또 뭔가 한 번 더 하더라.
처음엔 multipart가 문제라고 봤는데 반만 맞았다.
왜 63%에서 자꾸 다시 시작했을까?
내가 처음 본 증상은 이랬다.
화면에서 보인 문구도 꽤 헷갈렸다.
처음엔 이걸 보고 multipart 파트 업로드가 진짜 터지는 줄 알았다.
63%라는 숫자도 애매하게 파트 단위처럼 보여서 더 그랬고.
근데 브라우저 콘솔을 보니까 힌트가 딱 하나 있었다.
PUT [도메인]/... net::ERR_FAILED 200 (OK)
CORS error
이 로그가 진짜였다.
200인데 실패. 이 조합은 좀 쎄하다 싶더라.
뭘 먼저 잘못 짚었을까?
한 번에 맞춘 건 아니고 좀 돌아갔다.
내가 먼저 했던 건 이런 순서였다.
여기까지 하고 나니까 겉보기엔 좀 나아졌다.
근데 또 이상한 증상이 생겼다.
이건 내가 한 번 헷갈린 부분인데
처음에는 업로드 자체 실패와 완료 처리 실패를 같은 실패로 묶어놨던 거였다.
파일 바이트 전송은 이미 끝났는데, 뒤쪽 complete 단계가 흔들리면 다시 전체 파일을 올리게 돼 있었던 거다. 이건 진짜 별로였다.
그래서 클라이언트 흐름을 먼저 이렇게 나눴다.
try {
await uploadBlob(file, putUrl)
await retryComplete()
} catch (uploadError) {
// direct PUT 자체가 실패한 경우에만 fallback
await uploadViaProxy(file)
}
이 수정으로 적어도 100% 찍고 또 전체 재업로드하는 건 막혔다.
근데 여전히 fallback 문구는 계속 떴다.
왜 200 OK인데 브라우저는 실패라고 봤을까?
여기서부터가 핵심이었다.
서버 입장에선 성공이었다.
업로드 게이트웨이 로그에는 PUT 200
실제 파일도 스토리지에 저장됨
object HEAD도 됨
브라우저 입장에선 실패였다.
이거다.
파일 전송이 실패한 게 아니라, 성공 응답을 브라우저가 못 읽고 있었던 거였다.
처음엔 개발환경이라 그런가 싶었다.
근데 생각해보면 운영도 cross-origin이다.
origin이 다르면 둘 다 CORS를 제대로 맞춰야 한다.
즉 로컬에서만 보였을 뿐, 구조상 같은 종류의 문제였다.
어디가 진짜 원인이었을까?
preflight OPTIONS는 통과하고 있었다.
문제는 실제 PUT 200 응답 쪽이었다.
이때 의심한 게 두 개였다.
이게 겹치면 브라우저가 응답을 이상하게 처리할 수 있다.
서버 로그엔 200이 남는데 JS는 실패로 보는, 딱 그 그림이 나온다.
나도 처음엔 업로드 로직만 계속 봤다.
알고보니 응답 헤더를 안 본 게 삽질의 시작이었다.
어떻게 고쳤냐면?
핵심은 간단했다.
업로드 게이트웨이가 Access-Control-* 헤더를 단독으로 책임지게 만들었다.
map $http_origin $upload_cors_origin {
default "";
"https://[도메인]" $http_origin;
"http://localhost:3333" $http_origin;
}
location / {
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Expose-Headers;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Vary;
add_header Access-Control-Allow-Origin $upload_cors_origin always;
add_header Access-Control-Allow-Methods "PUT, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Content-Length, X-Amz-Date, X-Amz-Content-Sha256" always;
add_header Access-Control-Expose-Headers "ETag" always;
add_header Vary "Origin" always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $upload_cors_origin always;
add_header Access-Control-Allow-Methods "PUT, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Content-Length, X-Amz-Date, X-Amz-Content-Sha256" always;
add_header Access-Control-Expose-Headers "ETag" always;
add_header Vary "Origin" always;
add_header Content-Length 0;
add_header Content-Type "text/plain; charset=utf-8";
return 204;
}
proxy_pass http://[내부경로];
}
포인트는 딱 이거다.
upstream CORS 헤더는 숨김
Nginx가 최종 응답 헤더를 한 벌만 내려줌
허용 origin은 운영, 로컬 개발 둘 다 명시
실제 PUT 응답에서도 동일한 CORS 정책 유지
이거 반영하고 나니까 direct upload가 브라우저에서 정상 성공으로 잡혔다.
fallback 문구도 안 뜨고, 두 번 업로드하는 것처럼 보이던 현상도 멈췄다.
왜 웹하드가 이렇게 지저분해졌을까?
조금 솔직하게 말하면 기능이 망가진 게 아니라
망가질 때마다 방어막을 하나씩 덧댄 상태였다.
multipart
direct upload
complete retry
proxy fallback
thumbnail 후처리
하나하나는 이유가 있었는데
direct upload 성공 판정이 불안정한 상태에서 위에 계속 덧발라서 흐름이 복잡해진 거였다.
이럴 때 제일 무서운 게
실제 원인은 응답 헤더인데 앱 코드만 죽어라 붙잡고 있는 경우다.
이번이 그랬다.
다음에 또 만나면 뭘 먼저 볼까?
Q. 200 OK인데도 업로드 실패가 뜰 수 있나?
A. 뜬다. 서버는 성공했는데 브라우저가 CORS 때문에 응답을 읽지 못하면 JS는 실패로 본다.
Q. OPTIONS가 204면 CORS는 끝난 거 아닌가?
A. 아니다. 실제 PUT 응답에도 올바른 Access-Control-Allow-Origin 같은 헤더가 있어야 한다.
Q. 개발환경에서만 보이면 무시해도 되나?
A. 보통 안 된다. 로컬에서 먼저 터졌을 뿐 운영도 cross-origin이면 같은 계열 문제일 가능성이 높다.
Q. fallback 붙여두면 안전한 거 아닌가?
A. 임시 복구용으론 쓸 수 있는데, 원인을 가리기 시작하면 더 지저분해진다. 특히 업로드 본문을 서버 메모리로 받는 방식이면 오래 들고 갈 구조는 아니다.
Q. 이번에 제일 크게 틀린 판단은 뭐였나?
A. multipart 숫자만 보고 업로드 바이트 전송 문제라고 단정한 거였다. 나중에 보니 파일은 잘 올라가고 있었고, 브라우저가 응답을 못 읽고 있었던 거였다.
django에는 apache랑 드라이브직업으로했는데 next로 마이그레이션하고나서 뭐가뭔지 하나도모르겠다ㅈ3ㄷ4ㄹ'ㅎ;ㅓㅁㅇㄴ라히