Geek Story · Dev Log
로그인은 됐는데 업로드는 왜 안 될까 — 로컬 에디터와 R2를 연결하기까지
실제 리뷰를 쓰기 위해 만든 로컬 에디터에 미디어 업로드를 연결했습니다. CORS, 영상 제한, 운영 환경변수, DB 마이그레이션, 공개 URL 문제를 단계별로 확인한 과정을 기록합니다.
더미 리뷰를 실제 기록으로 바꾸려니
Geek Story의 화면을 만드는 동안 더미 데이터는 유용했다. 제목과 이미지가 어디에 놓이는지, 리뷰가 쌓이면 어떤 모습이 되는지 확인할 수 있었다. 하지만 화면이 갖춰졌다고 읽을 이유까지 생기는 것은 아니었다. 이제는 직접 써본 경험을 채워야 했다.
실제 리뷰를 준비하니 글뿐 아니라 사용 장면을 담은 이미지와 영상도 필요했다. 리뷰를 계속 쓰려면 작성 환경부터 편해야 했다. 그래서 GitHub Pages에 올릴 Markdown 파일을 로컬에서 작성하는 에디터를 만들었다.
처음에는 미디어 가공까지 한곳에서 끝내고 싶었다. 그런데 Filee로 테스트 파일과 실제 리뷰용 파일을 준비하면서 범위를 다시 생각했다. 필요한 것은 리뷰를 편하게 쓰는 도구였는데, 영상 변환처럼 무거운 기능까지 직접 만들면 에디터 자체의 개발 범위가 커졌다. 미디어 준비에는 기존 도구를 활용하고, 에디터에는 작성과 업로드를 연결하는 기능을 담기로 했다. 기본적인 이미지 축소·압축은 에디터에 남겼다.
준비한 파일은 Cloudflare R2에 저장하고, 업로드 API가 반환한 공개 주소를 본문에 넣는 흐름이었다. 하지만 로그인 연결을 끝내도 업로드는 계속 실패했다. 요청 허용 설정부터 파일 검증, R2와 DB 저장, 공개 주소까지 하나씩 확인해야 했다.
사이트의 로그인과 로컬 에디터 사이
로그인은 geek-story.com에서 이루어지고, 에디터는 http://127.0.0.1:3939에서 실행됐다. 사이트에서 로그인했다고 별도 출처의 에디터가 곧바로 사용자 토큰을 사용할 수 있는 것은 아니었다.
에디터에서 전용 로그인 페이지를 열고 로컬 에디터에 연결을 누르면 토큰을 전달하도록 했다. 토큰은 받을 수 있게 됐지만, 업로드는 여전히 별도로 확인해야 했다.
토큰을 다른 환경으로 전달해도 안전한지에 대한 의문은 남았다. 연결이 된다는 것과 보안 검토를 마쳤다는 것은 다르다. 여기서는 당시 확인한 연결 문제와 해결 과정을 정리한다.
출처를 허용했는데도 CORS 오류가 계속됐다
브라우저에서 처음 확인한 CORS 오류에는 원인을 좁힐 단서가 있었다. 요청 출처는 http://127.0.0.1:3939인데, API의 Access-Control-Allow-Origin 응답은 https://geek-story.com이었다.
API가 허용하는 출처에 로컬 에디터가 빠져 있었다. 백엔드에 에디터 출처를 추가했는데도 같은 오류가 계속됐다. 코드는 로컬에서 수정했지만 브라우저는 여전히 이전 설정으로 실행 중인 운영 API를 호출하고 있었다. 변경한 코드를 운영 컨테이너에 반영해야 했다.
변경한 백엔드를 배포한 다음, 실제 에디터의 출처와 요청 조건으로 preflight 응답을 확인했다.
curl -i -X OPTIONS https://api.geek-story.com/media \
-H 'Origin: http://127.0.0.1:3939' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'
응답은 204였고, 허용 출처는 http://127.0.0.1:3939, 허용 헤더는 Content-Type,Authorization으로 돌아왔다. 이 조건으로 보낸 preflight 요청에는 변경한 운영 설정이 반영돼 있었다.
다만 OPTIONS가 성공해도 실제 업로드 요청인 POST /media가 인증과 파일 검증을 통과해 저장까지 끝나는지는 알 수 없었다. 다음에는 실제 요청의 응답을 확인해야 했다.
방문자가 거의 없어도 깨진 화면을 보여주고 싶지는 않았다. 로컬에서 충분히 확인하고 싶었지만, 당시 마련한 환경에서는 운영 API와 배포 설정을 포함한 확인이 필요했다. CORS를 로컬에서 테스트할 수 없는 것은 아니다. 당시 내가 준비한 환경이 운영 구성을 충분히 재현하지 못했다.
서버는 준비된 파일도 다시 검사해야 했다
에디터에서 미디어를 준비하더라도 서버에서는 실제 파일 내용을 검사하도록 했다. 확장자나 클라이언트가 보낸 MIME만으로는 파일 형식과 재생 가능 여부를 판단하기 어려웠기 때문이다. 미리 가공한 파일을 받는 방식은 서버의 변환 작업을 줄였지만, 파일을 준비하는 쪽이 규격을 맞춰야 한다는 부담은 남았다.
영상 검사에는 ffprobe가 필요했다. 처음에는 로컬에 이 도구가 없어 검증 환경부터 준비해야 했다. 로컬용 바이너리를 추가하고, Docker 이미지에도 FFmpeg를 설치해 실행 경로를 설정했다. 코드뿐 아니라 검증에 쓰는 외부 도구도 실행 환경에 갖춰져 있어야 했다.
요청을 받는 쪽의 제한도 함께 맞췄다. 최대 10MiB 파일에 multipart 데이터가 더해지는 것을 고려해 Nginx의 요청 크기 제한은 11MiB로 설정했다. 브라우저에서 업로드할 때에는 Content-Type을 직접 지정하지 않고 FormData에 맞는 boundary를 브라우저가 붙이도록 했다. 두 설정은 연동 과정에서 미리 맞췄다. 실제로 발생한 장애의 원인으로 확인된 항목은 아니다.
관리자 화면에서는 업로드가 실패해도 일반적인 오류 메시지만 보여줘 이유를 알기 어려웠다. 서버가 반환한 검증 메시지와 오류 코드를 표시하도록 바꿨다. 그래야 파일 조건에 걸린 것인지, 저장 중에 실패한 것인지 구분할 수 있었다.
10초를 20초로 늘려도 업로드되지 않는 영상
처음에는 R2 무료 한도 안에서 더 많은 파일을 사용하고 싶어 영상 길이를 10초 미만으로 제한했다. 하지만 실제 리뷰 영상을 준비하니 보여주고 싶은 동작과 흐름을 담기에 너무 짧았다.
파일을 아끼려다 설명에 필요한 장면까지 잘라내고 있었다. 결국 길이 제한을 20초 미만으로 바꿨다. 정확히 20초인 영상은 허용하지 않는 조건이다. 서버 검증과 오류 메시지, 관리자 안내, API 문서를 함께 바꾸고 관련 테스트와 빌드를 확인했다.
길이 제한을 바꾼 뒤에도 INVALID_VIDEO 오류가 발생했다. 메시지는 영상이 1080p 크기 이내의 재생 가능한 파일이어야 한다는 내용이었다. 당시 업로드한 파일을 검사한 결과는 다음과 같았다.
| 항목 | 확인 결과 |
|---|---|
| 길이 | 10.385초 |
| 용량 | 약 1.11MiB |
| 형식 | VP9 / WebM |
| 해상도 | 3024 × 1964 |
길이와 용량은 제한 안에 있었다. 문제는 해상도였다. 서버는 총 픽셀 수를 1920 × 1080, 약 207만 픽셀로 제한했는데, 파일은 약 594만 픽셀이었다. 파일 용량이 작아도 해상도 조건을 넘을 수 있었다.
이번에는 길이가 아니라 영상 크기를 줄여야 했다. 비율을 유지하면서 1662 × 1080 정도로 축소하는 방법을 안내받았지만, 그 설정으로 다시 내보낸 파일의 업로드 결과까지 당시 기록에서 확인되지는 않는다.
파일의 길이, 용량, 형식, 해상도를 나눠 보니 거절된 이유를 알 수 있었다. 20초 미만으로 늘린 것은 리뷰에 필요한 장면을 담기 위한 선택이었다. 그 기준으로 무료 한도 안에서 얼마나 오래 운영할 수 있는지는 아직 검증하지 않았다.
같은 503인데 고쳐야 할 곳이 달랐다
영상 검증 다음에는 503 응답이 나왔다. 같은 상태 코드였지만 두 오류의 원인은 달랐다.
먼저 STORAGE_NOT_CONFIGURED와 함께 R2 설정이 필요하다는 메시지가 돌아왔다. 확인해보니 로컬 .env에는 R2 설정이 있었지만 운영 Compose가 사용하는 .env.production에는 빠져 있었다. 운영 컨테이너는 로컬에 설정한 값을 사용하고 있지 않았다. 운영용 설정에 R2 항목을 반영하고 재배포해야 했다.
다음 오류는 MEDIA_SAVE_FAILED였다. 이번에는 R2 업로드 이후 DB에 미디어 정보를 저장하는 단계에서 실패했다. 운영 DB에 media 테이블이 없었다. 새 기능의 코드를 배포하면서 필요한 마이그레이션이 적용되지 않은 상태였다.
DB를 백업한 뒤 누락된 AddMedia 마이그레이션을 적용했고, 테이블과 인덱스 생성 완료를 확인했다. 이 오류는 환경변수를 채우는 것으로 해결할 수 없었다.
| 응답 코드 | 실패한 단계 | 필요한 조치 |
|---|---|---|
STORAGE_NOT_CONFIGURED | R2 저장 설정 확인 | 운영 환경변수 반영과 재배포 |
MEDIA_SAVE_FAILED | 미디어 정보의 DB 저장 | 누락된 DB 마이그레이션 적용 |
상태 코드만 보면 둘 다 503이었다. 세부 오류 코드를 확인하자 하나는 환경변수, 다른 하나는 DB 스키마 문제로 좁혀졌다.
R2를 처음 설정할 때에는 읽기 전용 권한을 골랐다가, 업로드에 필요한 개체 읽기 및 쓰기 권한을 선택하고 대상 버킷만 지정하도록 안내받았다. 서버용 API 토큰의 유효기간과 임시 업로드 URL의 만료시간도 구분해야 했다. 이는 초기 설정을 이해하며 정리한 내용이다. 앞의 운영 503 오류와는 원인이 달랐다.
저장 실패 뒤의 처리도 필요했다. R2 저장과 DB 저장은 하나의 DB 트랜잭션으로 함께 되돌릴 수 없으므로, R2에 파일을 올린 뒤 DB 저장이 실패하면 업로드한 파일을 제거하고 삭제 실패 시 재시도할 수 있도록 처리했다. 이 처리는 연동 중에 미리 추가했다. 운영에서 모든 정리·재시도 경로를 검증한 기록은 없다.
테스트를 통과하고도 운영에서 문제가 생긴 이유
미디어 기능을 추가하면서 테스트 환경도 함께 바뀌어야 했다. DB 설정 테스트는 새 미디어 엔티티와 마이그레이션을 기대값에 반영해야 했다. HTTP 통합 테스트에서는 테스트 DB 대체 모듈에 미디어 저장소가 없고 DB 제공 범위도 부족해 의존성 주입이 실패했다. 미디어 저장소 대체와 테스트 모듈의 제공 범위를 수정했다.
새 기능에 필요한 테이블과 의존성이 테스트 환경에도 있어야 했다. 실패한 테스트의 기대값만 바꾸는 것으로 끝낼 수는 없었다. 별도로 실행 환경의 로컬 포트 접근 제한에 막힌 테스트는 권한을 받아 재실행했다.
서버의 초기 R2·미디어 API·관리자 연동 작업을 마쳤을 때 빌드와 린트, 단위 테스트 71개, HTTP 통합 테스트 25개가 통과했다. 이 수치는 초기 연동 작업을 확인했을 때의 결과다. 당시 영상 검증은 길이 정보가 없으면 INVALID_DURATION, 10초 이상이면 VIDEO_TOO_LONG을 반환하는 초기 기준이었다. 이후 20초 미만으로 바꿀 때 확인한 테스트 결과까지 합친 수치는 아니다.
초기 테스트 완료 시점에는 실제 운영 R2 업로드와 운영 DB 마이그레이션 적용까지 확인하지 않았다. 앞에서 다룬 운영 환경변수 누락과 테이블 누락은 이후 연결 과정에서 드러났다. 테스트에서는 통과했어도, 운영에 필요한 설정과 테이블이 빠져 있었다.
업로드된 파일의 주소가 404를 반환했다
업로드 문제를 넘긴 뒤에도 리뷰의 동영상이 보이지 않았다. 본문과 플레이어는 렌더링됐지만, 본문에 들어간 영상 주소 두 개가 404 Not Found를 반환했다.
R2에 파일이 실제로 업로드돼 있는 것을 확인하고 공개 주소를 비교하니 차이가 보였다. 본문에 저장된 URL에는 /geek-story-media 경로가 빠져 있었다.
본문에 저장된 주소
https://media.geek-story.com/media/<파일명>.webm
실제로 확인한 공개 주소
https://media.geek-story.com/geek-story-media/media/<파일명>.webm
두 영상 주소를 수정한 뒤 각각 200 OK와 video/webm 응답을 확인했고, 사이트 빌드도 통과했다. 이때는 아직 배포 전이라 URL 응답과 빌드 성공까지만 확인했다. 배포 후 모든 브라우저에서 재생되는지까지 검증한 것은 아니다.
기존 본문의 주소만 고쳐서는 다음 업로드에서 같은 문제가 반복될 수 있었다. API가 올바른 URL을 반환하도록 백엔드의 R2_PUBLIC_BASE_URL도 실제 공개 경로에 맞춰야 했다.
R2_PUBLIC_BASE_URL=https://media.geek-story.com/geek-story-media
R2에 파일이 있어도 본문의 주소가 틀리면 독자는 영상을 볼 수 없었다.
아직 확인할 일이 남았다
처음에는 로그인을 연결하고 파일을 전송하면 될 것 같았다. 실제 글에서 미디어를 보여주려면 다음 과정이 모두 이어져야 했다.
- 브라우저가 에디터 출처에서 API로 요청을 보낼 수 있어야 한다.
- API가 인증과 실제 파일의 제한 조건을 확인해야 한다.
- 운영 서버에 R2 설정과 필요한 DB 스키마가 준비돼 있어야 한다.
- 반환된 공개 주소가 실제 파일을 가리켜야 한다.
- 그 주소를 넣은 글이 배포된 화면에서도 정상적으로 표시돼야 한다.
돌아보면 이 작업의 완료 기준은 마지막 단계까지였어야 했다. 당시 모든 과정을 하나의 시나리오로 검증한 기록은 남아 있지 않다. preflight 응답, 파일 검사, 마이그레이션 적용, 공개 URL 응답, 빌드를 각각 확인한 기록이 남아 있다. 각 단계의 결과는 확인했지만, 전체 흐름을 한 번에 확인했다고 쓰기는 어렵다.
이번에 글을 개정하며 에디터 문서를 다시 확인하니 기존의 10초 미만 안내가 남아 있었다. 당시 바꾼 20초 미만 기준과 달라서, 현재 운영 기준을 확인하고 안내도 맞춰야 한다.
초기 관리자 화면에서는 이미 압축한 파일을 선택해 올렸다. 로컬 에디터에는 기본 이미지 압축이 있지만, 관리자 화면의 브라우저 압축기는 아직 남은 작업이었다. 게시글과 미디어를 연결해 관리하고 미사용 파일을 자동으로 정리하는 기능도 필요했다. 앞서 추가한 DB 저장 실패 시 파일 정리는, 업로드 후 글에 쓰이지 않은 파일까지 정리해주지는 않는다.
앞으로 이 흐름을 다시 확인할 때에는 실제 리뷰 파일 하나로 로그인부터 업로드, URL 접근, 글 저장과 배포 후 재생까지 이어서 확인하려 한다. 길이·해상도 제한에 걸리는 파일도 함께 확인해야 허용되는 경우와 거절되는 경우를 설명할 수 있다.
더미 데이터로 화면을 만들 때는 보이지 않던 문제들이 실제 리뷰 파일을 넣으면서 드러났다. 다음에는 업로드 응답을 받은 뒤에도 반환된 주소를 열고, 배포한 글에서 미디어가 표시되는 것까지 확인해야 작업을 마쳤다고 할 수 있겠다.