← Blog

사진 한 장이 서버를 멈춰 세운 날 — 맛도리 이미지 업로드 파이프라인 트러블슈팅

목차

1. 문제 상황: 업로드는 다섯 단계 어디서든 죽는다

맛도리에서 답변자는 추천하는 가게에 음식 사진 한 장을 붙일 수 있습니다. 기능 설명은 한 문장이면 끝나지만, 이 사진 한 장이 지나가는 길은 다섯 단계짜리 파이프라인입니다. 앱이 사진을 압축하고, 서버에서 업로드 전용 presigned URL을 발급받고, Cloudflare R2에 직접 PUT으로 올리고, 발급 때 받은 사진 키를 답변 등록 요청에 실어 보낸 뒤, 마지막으로 확정(confirm) API를 호출해 서버가 sharp로 변형본을 만들고 EXIF를 제거하게 합니다.

구조가 이렇게 된 데는 이유가 있습니다. 맛도리의 백엔드는 별도 서버 없이 Next.js Route Handlers로만 굴러가는데, Vercel 서버리스 함수에는 요청 바디 용량 제한이 있고 함수 실행 시간이 곧 비용입니다. 사진 바이너리가 서버를 경유하는 순간 제한과 비용을 동시에 떠안게 되므로, 서버는 서명된 URL만 발급하고 바이너리는 앱에서 R2로 직행시키는 것이 서버리스에서는 사실상 표준 답안입니다. R2를 고른 이유는 이전 개발기에서 적었듯 전송량 과금이 없다는 점이 결정적이었습니다.

문제는 단계가 다섯 개면 실패 지점도 다섯 개라는 것입니다. 운영하면서 실제로 겪은 문제를 단계별로 나열해 보면 이렇습니다. 아이폰의 HEIC 포맷과 EXIF 회전 메타데이터가 만든 호환성 문제, 대용량 사진의 용량 검증을 어디서 할 것인가라는 설계 문제, presigned URL의 유효시간과 업로드 실패 처리, 그리고 확정 단계에서 sharp라는 네이티브 모듈이 사진과 무관한 API까지 통째로 끌어내린 프로덕션 장애와 확정 호출이 유실된 사진을 회수하는 문제까지. 이 글에서는 이 문제들을 겪은 순서가 아니라 파이프라인 순서대로 따라가 보겠습니다.

2. HEIC와 눕는 사진: 모바일 사진의 두 가지 함정

첫 번째 함정은 포맷이었습니다. 아이폰 카메라의 기본 저장 포맷은 JPEG가 아니라 HEIC입니다. 반면 업로드 파이프라인은 콘텐츠 타입을 image/jpeg 하나로 고정해 두었습니다. presigned URL을 발급할 때 콘텐츠 타입이 서명에 포함되므로, 앱이 HEIC를 그대로 올리려 하면 타입이 어긋나고, 어찌어찌 올라간다 해도 웹과 안드로이드에서 열리지 않는 파일이 됩니다. 그렇다고 서버에서 HEIC 디코딩을 받아주자니 서버 경유 금지 원칙과 충돌합니다.

해결은 클라이언트 압축 단계에 변환을 겸하게 하는 것이었습니다. 맛도리는 업로드 전에 expo-image-manipulator로 사진을 긴 변 1600픽셀, JPEG 품질 0.8로 압축하는데, 이때 출력 포맷을 JPEG로 지정하면 HEIC 입력도 JPEG로 저장됩니다. 그래서 압축이 필요 없을 만큼 작은 사진이라도 이 단계를 건너뛰지 않고 반드시 통과시킵니다. 리사이즈 배열만 비우고 포맷 변환은 항상 수행하는 구조라, 파이프라인의 나머지 구간은 입력이 무엇이었든 JPEG 하나만 상대하면 됩니다. 음식 사진 기준으로 이 압축 스펙의 화질 열화는 체감하기 어려웠고, 결과물은 대체로 수백 킬로바이트 선에 들어왔습니다.

두 번째 함정은 회전이었습니다. 세로로 찍은 사진이 서버 변형을 거치고 나면 옆으로 누워 있는, 이미지 파이프라인을 만들어 본 분이라면 한 번쯤 겪는 고전적인 문제입니다. 원인은 EXIF Orientation 태그입니다. 카메라는 픽셀을 회전해 저장하는 대신 "이 사진은 90도 돌려서 보라"는 메타데이터를 붙이는데, 변형 과정에서 메타데이터가 사라지면 뷰어는 픽셀을 그대로 그려서 사진이 눕습니다. 맛도리는 sharp 처리 체인의 맨 앞에 rotate()를 두는 것으로 해결했습니다. 인자 없이 호출하면 EXIF Orientation을 읽어 픽셀 자체를 그 방향으로 회전해 주므로, 이후 어떤 뷰어에서도 방향이 맞습니다.

여기에는 부수 효과가 하나 있는데, 오히려 이쪽이 더 중요했습니다. 변형 출력을 WebP로 저장하면서 메타데이터를 실어 보내지 않기 때문에, 회전을 픽셀에 반영하고 나면 EXIF 전체가 자연스럽게 제거됩니다. 사진 EXIF에는 촬영 위치 GPS 좌표가 들어 있을 수 있습니다. 인증 좌표를 저장하지 않는 앱이 사진 메타데이터로 사용자의 위치를 흘린다면 그 원칙은 반쪽짜리가 됩니다. 회전 문제의 해결책이 곧 프라이버시 원칙을 사진까지 확장하는 장치가 된 셈입니다.

3. 용량 검증은 어디서 하는가, 그리고 presign의 순서

대용량 사진 처리에서 고민한 것은 리사이즈 자체보다 검증의 위치였습니다. 클라이언트 압축을 거치면 사진은 보통 수백 킬로바이트가 되지만, 클라이언트는 신뢰 경계 밖에 있습니다. 앱을 거치지 않고 API를 직접 두드리는 요청은 압축 스펙을 지킬 이유가 없으므로, 서버 어딘가에는 상한 검사가 있어야 합니다.

선택지는 두 가지였습니다. 하나는 presigned URL 서명에 콘텐츠 길이를 포함해 업로드 시점에 스토리지가 거부하게 만드는 방식입니다. 원천 차단이라는 점에서는 이쪽이 정석에 가깝습니다. 실제로 검토했지만 기각했습니다. 서명에 길이를 넣으면 클라이언트가 보낼 바이트 수를 사전에 정확히 맞춰야 하는데, 이는 React Native 업로드 구현의 세부 동작에 의존하게 됩니다. 당시는 스토어 제출 직전이었고, 이미 실기기에서 동작이 검증된 업로드 경로를 검증 없이 바꾸면 사진 기능이 통째로 죽는 위험을 안는 것이었습니다. 그래서 다른 하나, 업로드 뒤 확정 단계에서 검사하는 방식을 택했습니다. 서버가 오브젝트의 크기를 조회해 8MB 상한을 넘으면 변형을 만들지 않고 원본까지 그 자리에서 삭제한 뒤 사진 없는 상태로 되돌립니다. 여기에 확정 호출 없이 방치되는 원본이 남을 수 있어, 스토리지의 수명 규칙으로 원본 프리픽스에 만료를 걸어 일정 기간이 지난 미확정 원본은 자동 정리되게 했습니다. 발급 API 자체에도 호출 한도를 두어 남용 여지를 줄였습니다.

presign과 압축의 순서도 사소하지만 실패율에 영향을 준 부분입니다. presigned URL의 유효시간은 60초로 짧게 잡았는데, 처음 흐름을 짤 때 발급을 먼저 받고 압축을 시작하면 어떻게 되는지 따져봤습니다. 저사양 기기에서 고해상도 사진의 압축은 수 초를 먹을 수 있고, 그 시간이 서명 유효시간을 갉아먹습니다. 그래서 순서를 뒤집어 압축을 전부 끝낸 뒤에 발급을 받습니다. URL이 만들어진 순간부터 실제 PUT까지의 간격이 네트워크 왕복 하나 수준으로 줄어들어, 유효시간을 짧게 유지하면서도 만료로 인한 실패를 걱정하지 않게 됐습니다.

[업로드 순서 — 압축이 먼저다]
① 압축·변환: 긴 변 1600px + JPEG q0.8 (HEIC → JPEG 겸행)
② presign 발급: 유효 60초, 콘텐츠 타입 고정      ← 압축이 끝난 뒤에
③ R2로 직접 PUT → 2xx 밖이면 실패로 처리
④ 답변 등록 요청에 photoKey 첨부
⑤ confirm 호출은 fire-and-forget (실패해도 답변은 살아 있음)

업로드가 끝내 실패했을 때의 UX 원칙은 "사진이 글을 막지 않는다"입니다. PUT 응답이 2xx 범위를 벗어나면 실패로 간주하고 답변 등록을 진행하지 않는 대신, 사진을 빼고 올리거나 다시 시도할 수 있다는 안내를 띄웁니다. 추천의 본질은 가게와 이유 텍스트이지 사진이 아니므로, 사진 업로드 실패가 답변 자체를 잃게 만드는 구조만은 피하고 싶었습니다. 같은 이유로 로컬 개발 환경처럼 스토리지 설정이 없는 곳에서는 업로드 API가 명시적인 오류를 돌려주고 사진 기능만 조용히 비활성화됩니다.

4. sharp가 API 전체를 끌어내린 날

확정 단계의 주인공은 sharp입니다. 원본에서 목록용 400픽셀 썸네일과 상세용 1200픽셀 변형을 WebP로 만들어 저장합니다. 그런데 출시 직전 프로덕션에서, 사진과 아무 상관 없는 API들이 한꺼번에 500을 뱉는 장애를 만났습니다. 추천 투표, 좋아요, 설문 마감 잡, 일일 정리 잡, 그리고 업로드 확정까지 동시에 죽어 있었습니다.

원인을 따라가 보니 뿌리는 sharp의 정체였습니다. sharp는 libvips라는 네이티브 라이브러리에 바인딩된 모듈이라, 실행 환경에 맞는 플랫폼 바이너리가 없으면 코드 실행이 아니라 모듈 로드 자체가 실패합니다. 에러 메시지는 ERR_DLOPEN_FAILED, libvips 공유 라이브러리를 열 수 없다는 내용이었습니다. 문제는 이 로드 실패가 전파되는 범위입니다. sharp를 파일 최상단에서 import하면, 그 파일을 import하는 모듈, 다시 그 모듈을 import하는 라우트까지 전부 로드에 실패합니다. 사진 처리 함수는 확정 라우트뿐 아니라 마감 잡과 여러 쓰기 라우트가 공유하는 모듈에 물려 있었고, 그래서 사진 기능 하나의 배포 환경 문제가 투표와 좋아요와 마감을 통째로 끌어내린 것입니다.

수정은 로딩 시점을 옮기는 것이었습니다. 최상단 import를 걷어내고, 실제로 변형을 만드는 순간에 동적 import로 sharp를 불러오도록 바꿨습니다. 한 번 로드한 뒤에는 프라미스를 재사용해 중복 로드도 막습니다.

// sharp 로드 실패가 라우트 전체를 죽이지 않도록 — 변형 시점까지 지연
function importSharp() {
  return import('sharp').then((mod) => mod.default);
}
let sharpPromise: ReturnType<typeof importSharp> | null = null;
function loadSharp(): ReturnType<typeof importSharp> {
  return (sharpPromise ??= importSharp());
}

이렇게 하면 바이너리 문제가 재발해도 실패의 반경이 사진 변형 하나로 좁혀집니다. 투표와 마감은 멀쩡히 돌고, 사진만 보류 상태로 남았다가 문제 해결 후 재처리됩니다. 함께 손본 것이 두 가지 더 있습니다. 확정 라우트의 실행 시간 상한을 60초로 올렸습니다. 변형 두 종을 만들고 검열 API까지 부르기에 서버리스 기본값 10초는 빠듯했기 때문입니다. 그리고 처리 함수를 멱등하게 만들었습니다. 사진 상태가 처리 대기가 아니면 아무 일도 하지 않고 조용히 끝나므로, 확정이 중복 호출되거나 재시도 경로가 겹쳐 도착해도 변형이 두 번 만들어지거나 상태가 꼬이는 일이 없습니다.

5. 사라진 사진 회수하기 — 그리고 스윕이 놓친 두 축

확정 호출은 클라이언트 입장에서 fire-and-forget입니다. 답변 등록이 성공하면 확정을 던져두고 화면을 떠나며, 실패해도 다시 부르지 않습니다. 텍스트가 먼저 게시되어야 한다는 원칙 때문인데, 그 대가로 확정이 유실되는 경우가 생깁니다. 업로드 직후 앱이 종료되거나 네트워크가 끊기면, R2에 원본은 있는데 변형은 없는 사진이 처리 대기 상태로 남습니다.

회수는 두 겹입니다. 확정 처리 중 변형이나 저장이 실패하면 QStash에 지연 재시도를 예약해 두고 일단 처리 대기로 응답합니다. 그리고 하루 한 번 도는 정리 잡이 처리 대기 상태로 일정 시간이 지난 사진을 훑어 재처리를 시도하고, 하루를 넘겨도 원본이 도착하지 않은 사진은 포기 처리합니다. 포기해도 답변 텍스트는 그대로 남고 사진 자리만 비웁니다. 설문 마감에 쓰던 지연 콜백과 안전망 패턴을 사진 파이프라인이 그대로 재사용한 것입니다.

이 안전망에서 최근에 잡은 버그가 하나 있습니다. 사진을 붙일 수 있는 곳이 답변 하나였던 초기와 달리, 기능이 늘면서 동네 추천과 내 맛집 지도까지 세 축이 같은 파이프라인을 타게 됐습니다. 그런데 정리 잡의 스윕 쿼리는 여전히 답변 테이블만 훑고 있었습니다. 확정 라우트는 세 축을 다 알고 있는데 스윕만 몰랐던 것입니다. 결과적으로 동네 추천이나 지도 사진이 한 번 처리 대기에 빠지면 영영 회수되지 않았습니다. 수정하면서 축 목록을 상수 테이블 하나로 정본화하고, 라우트의 소유자 판별과 스윕이 전부 그 목록에서 파생되도록 바꿨습니다. 이제 축이 하나 늘면 스윕은 자동으로 따라옵니다. 스윕의 배치 상한도 축별로 걸어서, 한 축이 밀려 있어도 다른 축의 회수가 굶지 않게 했습니다. 변형이 이미 존재하는 사진은 처리가 끝난 것이므로 만료 대상에서 제외하고 검수 대기로만 분류하는 조건도 이때 함께 다듬었습니다.

6. 배운 점

이미지 파이프라인을 운영하며 얻은 교훈을 정리해 보겠습니다. 첫째, fire-and-forget을 쓰려면 회수 경로가 계약의 일부여야 합니다. 확정 호출을 잊어도 되는 구조는 편하지만, 그 편함은 유실분을 되찾는 스윕이 있을 때만 성립합니다. 그리고 그 스윕은 파이프라인에 새 소비자가 붙을 때마다 함께 넓어져야 합니다. 분기 목록을 한 곳에 정본화하고 나머지를 파생시키는 구조는 이 실수의 재발을 코드 수준에서 막아줬습니다.

둘째, 네이티브 모듈은 격리해서 다뤄야 합니다. sharp 장애의 본질은 이미지 처리 실패가 아니라 모듈 로드 실패의 전파 범위였습니다. 순수 JS 모듈과 달리 네이티브 바인딩은 배포 환경이라는 외부 변수에 로드 성공 여부가 걸려 있으므로, 실패해도 무너지는 범위가 그 기능 하나로 좁혀지도록 로딩 시점을 설계하는 편이 안전합니다. 셋째, 검증의 위치는 이론적 정석보다 검증된 경로의 안정성을 기준으로 정할 수도 있다는 것입니다. 서명 시점 차단이 더 깔끔했지만, 출시 직전에 동작이 확인된 경로를 흔들지 않는 선택에도 충분한 근거가 있었습니다. 대신 사후 검사와 자동 정리로 같은 목적을 달성했습니다.

마지막으로, 모바일 사진은 파일이 아니라 관습의 묶음이라는 점입니다. HEIC라는 포맷, EXIF라는 메타데이터, 회전이라는 표시 규약까지, 카메라 생태계의 관습을 하나씩 파이프라인의 언어로 번역해야 비로소 사진 한 장이 안전하게 흘러갑니다. 그 번역 과정에서 회전 보정이 프라이버시 장치가 되기도 했습니다. 사진 업로드는 어느 서비스에나 있는 흔한 기능이지만, 흔하다는 것과 쉽다는 것은 다르다는 걸 이 파이프라인이 가르쳐 줬습니다.

광고

관련 글