DEVELOPMENT NOTE / Modu Square
이미지 첨부 기능 전송 비용 최적화
이미지 첨부 기능 구현
- JPEG·PNG·WebP 이미지를 게시글당 최대 5장, 파일당 10MB까지 첨부
- 글쓰기 화면에 업로드와 변환 상태 표시
- 변환이 끝난 WebP 이미지를 게시글 상세 화면에 노출

변환 완료 후 게시글 상세 화면에 노출된 WebP 이미지
문제
- 브라우저가 보낸 원본 이미지를 애플리케이션 서버가 받은 뒤 Object Storage에 다시 저장
- 이미지 업로드가 늘수록 API 처리와 무관한 바이트 전송이 서버 네트워크와 메모리를 점유
- 인기 게시글에서 원본 이미지를 반복 제공하면 조회 수만큼 전송량 증가
1,117,288바이트 PNG를 초당 3건씩 30초간 업로드해 서버 경유 시 부하 측정.

- 90건 업로드 동안 애플리케이션 서버로 95.90MiB 유입
기술 선택
애플리케이션 서버 경유 업로드 — 제외
- 파일 크기만큼 서버 네트워크 사용량이 늘고 API 요청과 이미지 전송이 같은 자원을 사용
Presigned URL 직접 업로드 — 채택
- Media Service는 10분 동안 유효한 업로드 URL만 발급
- 브라우저가 Object Storage로 원본을 직접 보내 애플리케이션 서버를 전송 경로에서 제외
요청 시 원본 제공 — 제외
- 구현은 단순하지만 게시글 조회마다 큰 원본 파일을 반복 전송
Kafka 비동기 WebP 변환 — 채택
- Worker가 업로드 완료 이벤트를 받아 원본을 최대 1600×1200 WebP로 변환
- 변환 작업을 게시글 저장 요청에서 분리하고
PENDING → PROCESSING → READY상태를 UI에 표시
동시 갱신 문제 해결
등록 흐름을 점검하다가 이미지 변환 완료와 게시글 연결이 같은 media_asset 행을 동시에 수정하는 문제 확인.
- Worker가
article_id = null인 데이터를 먼저 조회 - 게시글 저장 과정에서
article_id연결 - Worker가 이전 상태의 엔티티 전체를 저장하며
article_id를 다시null로 변경
Worker는 이미지 처리 필드만 조건부로 갱신하도록 수정.
@Query("""
update MediaAsset media
set media.thumbnailKey = :thumbnailKey,
media.thumbnailSize = :thumbnailSize,
media.status = board.media.domain.MediaStatus.READY,
media.updatedAt = :updatedAt
where media.mediaId = :mediaId
and media.status = board.media.domain.MediaStatus.PROCESSING
""")
int markReady(...);
- 게시글 연결 필드와 이미지 처리 필드의 갱신 범위 분리
- 두 작업의 실행 순서를 바꿔 가며 재현한 회귀 테스트에서
READY상태와article_id유지 확인
부하 테스트
같은 이미지와 요청률로 서버 경유 업로드와 저장소 직접 업로드를 각각 90건씩 비교.
| 지표 | 변경 전 | 적용 후 |
|---|---|---|
| 애플리케이션 서버로 들어온 원본 | 95.90MiB | 0바이트 |
| 파일당 조회 이미지 | 1,117,288바이트 | 52,748바이트 |
| 90회 조회 전송량 | 95.90MiB | 4.53MiB |

- WebP 변환 후 파일 크기와 90회 조회 전송량 95.28% 감소
- 로컬 MinIO 비교이므로 실제 CDN 요금이 아닌 애플리케이션 경유 바이트와 파일 크기를 판단 기준으로 사용
판단과 한계
- 운영 환경에서는 CDN 도메인과 원본 접근 제한, 업로드 소유권 검증 필요
- 처리량 증가에 대비한 Worker 확장과 변환 실패 격리는 후속 과제