ADR‐007: 이미지 업로드 방식 결정 - Moadong/moadong GitHub Wiki

작성일: 2026-09-20 | 상태: Accepted
결정일자: 2026-09-20

왜 이 결정이 필요했나요?

이미지를 올리는 기능이 하나둘 늘면서 업로드 방식이 두 갈래로 갈렸습니다. 동아리 로고와 커버, 활동사진, 홍보 게시글 이미지, 우체통 첨부 사진, 배너, 편지 본문 이미지까지 올릴 곳이 꽤 많아졌거든요.

문제는 새로 붙일 때마다 "이건 어느 쪽으로 하지?"를 다시 고민하게 된다는 거였어요. 기준이 없으니 그때그때 편한 쪽으로 가게 되고, 나중에 보면 왜 이렇게 했는지 설명하기가 어려워집니다.

그래서 어떤 경우에 어느 방식을 쓸지 기준을 정해두기로 했습니다.


어떤 결정을 내렸나요?

올리는 주체가 누구냐를 기준으로 나눴습니다.

  1. 개발자 포털에서 올리는 이미지 (배너, 편지 본문 이미지) → 멀티파트
  2. 서비스 화면에서 올리는 이미지 (동아리 로고·커버·활동사진, 홍보 게시글, 우체통 첨부) → presigned URL

주체가 다르면 호출 빈도가 다르고, 빈도가 다르면 무엇을 감당할 수 있는지가 달라지기 때문입니다.

서비스 화면에 presigned를 쓰는 이유

서비스 화면은 사용자가 몰리는 곳이에요. 동아리 관리자가 활동사진을 올리고, 학생이 우체통에 사진을 첨부합니다. 모집 시즌에는 이게 한꺼번에 들어옵니다.

presigned는 서버가 이미지 바이트를 아예 만지지 않아요. 서버는 "이 주소로 올리세요"라는 서명된 URL만 발급하고, 실제 파일은 브라우저가 R2로 직접 보냅니다. 업로드가 아무리 늘어도 서버 스레드와 R2 커넥션을 쓰지 않는다는 게 큽니다.

대신 약점이 하나 있는데요. presigned PUT 서명은 버킷, 키, Content-Type, 만료 시각까지만 묶고 파일 크기는 묶지 못합니다. URL을 발급할 때 "10MB까지만 올리세요"를 걸어둘 방법이 없어요. 그래서 업로드가 끝난 뒤에 HeadObject로 크기를 재서, 넘으면 지우는 사후 검증을 따로 두고 있습니다.

그럼 POST로 하면 되지 않나요?

S3에는 업로드 크기를 미리 제한하는 content-length-range라는 정책 조건이 있는데, 이건 presigned POST에서만 쓸 수 있습니다. 그래서 "POST로 바꾸면 사후 검증이 필요 없지 않나?"라고 생각할 수 있는데요.

R2가 presigned POST를 지원하지 않습니다. 공식 문서에 이렇게 적혀 있어요.

POST (multipart form uploads via HTML forms) is not currently supported.

R2의 presigned URL이 지원하는 건 GET, HEAD, PUT, DELETE 네 가지뿐입니다. 거기에 더해 AWS SDK for Java v2의 S3Presigner에는 POST를 서명하는 API 자체가 없어요. presignPutObject, presignGetObject 같은 것만 있고 POST용은 없어서, 쓰려면 정책 문서와 SigV4 서명을 직접 만들어야 합니다.

정리하면 사후 검증은 우리가 고른 방식이 아니라 PUT밖에 쓸 수 없어서 생긴 결과입니다.

그래도 크기를 미리 막을 방법은 있습니다

POST가 막혀 있다고 방법이 아예 없는 건 아니에요. presigned PUT도 Content-Length를 서명 대상에 넣을 수 있습니다. 그러면 서명한 크기와 다르게 올릴 때 서명이 어긋나서 R2가 거부합니다.

R2가 실제로 그렇게 동작하는지 dev 버킷(moadong-dev)에 직접 던져서 확인했습니다. (2026-09-20)

서명한 크기 실제 올린 크기 결과
1024 1024 200
1024 2048 403 SignatureDoesNotMatch
1024 512 403 SignatureDoesNotMatch
(미서명) 2048 200 ← 지금 동작

작게 올려도 막힙니다. 범위가 아니라 등호거든요. 그래서 쓰려면 이런 모양이 됩니다.

  1. 클라이언트가 presign 요청할 때 파일 크기를 같이 보낸다
  2. 서버가 fileSize <= 10MB인지 확인하고, 통과하면 그 크기를 서명에 넣어 URL을 발급한다
  3. 클라이언트가 다른 크기로 올리면 R2가 403으로 거부한다

크기를 속여도 소용없어요. 작게 신고하면 그 크기로 서명되니 큰 파일을 못 올리고, 크게 신고하면 2번에서 막힙니다. 서버 코드는 PutObjectRequest.builder()에 .contentLength(...) 한 줄이면 됩니다. AWS SDK Java v2가 이 값을 X-Amz-SignedHeaders에 넣어주거든요.

그런데 아직 적용하지 않았습니다. 이유가 몇 가지 있어요.

  • 프론트가 presign 요청에 파일 크기를 실어 보내야 해서, 백엔드만으로 끝나지 않습니다.
  • URL이 특정 크기에 묶여서, 다른 파일로 재시도하려면 URL을 다시 발급받아야 합니다.
  • fetch 바디가 ReadableStream이면 Transfer-Encoding: chunked가 되면서 Content-Length가 사라져 403이 납니다. 지금은 File을 그대로 넘기니 괜찮지만, 나중에 스트리밍 업로드를 붙이면 깨집니다.
  • HeadObject가 통째로 사라지지도 않습니다. 크기 확인 말고 객체가 실제로 올라왔는지 확인하는 일도 겸하고 있거든요. URL만 받고 업로드는 안 한 채 저장하면 깨진 이미지 링크가 DB에 남습니다.

그래서 확실한 이득은 "왕복이 줄어든다"보다 "크기를 넘긴 파일이 R2에 아예 올라오지 않는다" 쪽입니다. 지금은 일단 올라왔다가 지워지니까요.

개발자 포털에 멀티파트를 쓰는 이유

개발자 포털은 운영자만 쓰는 화면이에요. 배너를 교체하거나 편지를 쓸 때만 들어가니까 호출이 몰릴 일이 없습니다.

이런 곳에서는 멀티파트가 오히려 낫습니다. 서버가 파일을 직접 받으니까 R2에 올리기 전에 확장자, MIME, 크기를 전부 거를 수 있어요. presigned에서 필요했던 사후 검증이 아예 필요 없고, 왕복도 PUT 한 번으로 끝납니다.

업로드 하나가 끝날 때까지 서버 스레드가 묶이긴 하지만, 운영자 한두 명이 가끔 쓰는 화면이라 이 정도는 감당할 수 있다고 봤습니다. 그보다는 "올리기 전에 거른다"는 단순함이 더 이득이에요.

다른 대안들:

  • ❌ 전부 presigned로 통일: 일관성은 좋아지지만, 저빈도 경로까지 사후 검증을 떠안게 됩니다. 잘못된 파일이 R2에 한 번 올라갔다가 지워지는 흐름을 운영자 화면에서까지 감수할 이유가 없었어요.
  • ❌ 전부 멀티파트로 통일: 서비스 화면 업로드가 전부 서버를 거치게 됩니다. 업로드가 끝날 때까지 톰캣 스레드가 묶이고 AWS SDK 동기 클라이언트의 커넥션 상한에도 걸려요. 사용자가 몰리는 경로에서는 감당하기 어렵습니다.

허용 포맷 목록은 어디서 왔나요?

두 방식 모두 같은 여섯 개를 허용합니다. 백엔드 확장자 Set(ClubImageUtil), presign DTO의 @Pattern(UploadUrlRequest), 프론트 ALLOWED_IMAGE_TYPES 세 군데에 같은 목록이 복제돼 있어요.

jpg, jpeg, png, gif, bmp, webp

출발점은 커밋 a41465e7f(2025-05-19, "파일 확장자 검증 및 파일 리사이징 기능 구현")입니다. 확장자 목록으로 태어났고, 같은 커밋에 Thumbnailator 서버 리사이즈가 함께 들어왔습니다. 커밋에도 PR에도 포맷을 고른 이유는 적혀 있지 않습니다.

bmp는 왜 있나요

"서버가 디코드할 수 있는 포맷"을 나열한 거라면 말이 됩니다. JDK ImageIO 기본 디코더가 JPEG/PNG/GIF/BMP라 딱 맞거든요. 그런데 build.gradle에는 thumbnailator만 있고 WebP 디코더 플러그인이 없습니다. 서버가 읽지도 못하는 WebP가 목록에 있으니, 디코더 능력에서 뽑아낸 목록은 아니에요.

그래서 "흔한 이미지 확장자"를 관례대로 적은 것으로 보는 게 맞습니다. jpg/jpeg/png/gif/bmp/webp는 이런 목록을 쓸 때 거의 자동으로 나오는 조합이고, bmp는 실제 수요가 아니라 거기 딸려 들어온 겁니다. 빼도 되지만 빼서 얻는 것도 없어요. 무압축이라 10MB 제한에서는 실용성이 거의 없고, 위험한 포맷도 아닙니다.

svg는 왜 없나요 — 그리고 앞으로도 넣으면 안 됩니다

빠진 이유 자체는 시시합니다. 그 관례 목록에 원래 없었기 때문이에요. 벡터니까요. 보안을 따져서 뺀 게 아닙니다.

결과가 맞았을 뿐인데, 결과는 중요합니다. SVG는 <script>를 품을 수 있어서 image/* 중 유일하게 우리 도메인에서 코드를 실행시킬 수 있는 포맷입니다. R2에 올라간 객체는 viewEndpoint로 그대로 서빙되고, 서빙 시에는 저장된 Content-Type이 우선합니다. 지금 목록이 래스터뿐이라 브라우저는 무엇을 받든 이미지로만 해석하고, 그래서 이 경로가 닫혀 있어요.

로고에 SVG를 넣자는 요구는 자연스럽게 나올 수 있습니다. 벡터라 선명하고 용량도 작으니까요. 그때 거절할 근거가 코드 어디에도 없어서 여기 적어둡니다. 목록에 image/svg+xml을 추가하는 건 한 줄이지만, 그 한 줄이 여는 건 저장소 포맷 하나가 아니라 임의 스크립트 실행 경로입니다. 꼭 필요하다면 별도 ADR에서 sanitize(DOMPurify 등) 또는 분리 도메인 서빙까지 같이 설계해야 합니다.

이 목록이 보증하는 것과 아닌 것

목록이 검사하는 건 클라이언트가 선언한 MIME과 파일명 확장자입니다. 파일 바이트는 아무도 보지 않습니다. presigned 경로에서는 서버가 바이트를 만지지 않는다는 이 ADR의 결정 자체가 그렇게 만들어요. image/png이라고 신고하고 아무 바이너리나 올릴 수 있습니다.

다만 그렇게 올려도 저장된 Content-Type이 image/*로 고정되니 실행 경로는 없고, 남는 피해는 스토리지 남용과 깨진 이미지 정도입니다. 그래서 "화이트리스트가 있으니 업로드가 검증된다"고는 말하지 않는 게 맞습니다. 검증되는 건 딱지뿐이에요.


어떤 효과가 있나요?

장점:

  • 사용자가 몰리는 경로는 서버가 이미지를 만지지 않으니, 업로드가 늘어도 서버 부하는 그대로입니다.
  • 운영자 경로는 업로드 전에 걸러내니까 잘못된 파일이 R2에 닿지 않아요.
  • 새 업로드 기능을 붙일 때 "누가 올리는가"만 보면 방식이 정해집니다.

단점/트레이드오프:

  • 검증 코드가 두 벌이에요. 한쪽에 규칙을 추가하면 다른 쪽도 같이 봐야 합니다.
  • presigned 경로는 사후 검증이라, 크기를 넘긴 파일도 R2에 한 번은 올라갔다가 지워집니다. 업로드 대역폭이랑 R2 오퍼레이션은 이미 쓴 뒤고요. (위 "그래도 크기를 미리 막을 방법은 있습니다"에 해결책과 아직 적용하지 않은 이유를 적어뒀습니다)
  • 홍보 게시글은 서비스 화면과 개발자 포털 양쪽에서 올릴 수 있어서 두 방식이 다 열려 있습니다. 이 경로를 만질 때는 어느 쪽을 보고 있는지 먼저 확인해야 해요.

참고 자료

⚠️ **GitHub.com Fallback** ⚠️