이전 글인 API Latency Postmortem 게시글에서 이미지 처리 성능 문제를 다뤘었는데,
그 후속 작업으로 이미지 아키텍처 개선을 계획하고 있었습니다.
개선 목표 및 계획
이번 아키텍처 개선의 주요 목표는 두 가지였습니다.
- API 응답 속도 개선: 기존 1500ms 이상이던 이미지 관련 API 응답 시간 단축
- 다중 이미지 처리 기능: 단일 이미지에서 다중 이미지 지원으로 확장
기존 클라이언트 → 서버 → S3 방식을 클라이언트 → S3 직접 업로드로 변경하여 이를 달성할 계획이었습니다.
- 클라이언트가 서버에 Pre-signed URL 요청
- 서버가 업로드용 URL 반환
- 클라이언트가 S3로 직접 업로드
- 업로드된 파일 키를 서버로 전달
연관 작업:
- 이미지 조회 개선: Spring 애플리케이션 내부 캐싱 제거
- S3와 클라이언트 사이에 CDN 도입으로 이미지 조회 성능 향상
규모가 큰 리펙토링이다보니 여유를 두고 진행하려던 작업하려 계획하고 있었는데,
데모데이 10일 전 예상치 못한 문제가 발생했습니다.
문제 발생
저희 클라이언트는 Vercel을 통해 배포하고 있는데, 여기서 이미지 최적화 기능을 활용하고 있었습니다.
같은 이미지라도 다양한 크기로 사용되는 경우가 많았기 때문입니다.
여기서 기존 Pre-signed GET 방식에서는 URL이 30분마다 변경되다 보니,
Vercel이 매번 새로운 URL로 인식해서 캐싱이 전혀 되지 않았습니다.
이미지 요청이 올 때마다 S3에서 계속 받아오면서 최적화 한도를 금방 초과했습니다.
추가 비용을 내서 한도를 늘리는 것도 방법이지만, 근본적인 문제는 URL이 계속 변경된다는 점이었습니다.
클라이언트에서는 캐싱을 위해 고정된 이미지 URL이 필요한 상황이었습니다.
결국 기존 계획에 고정 이미지 URL 제공이라는 추가 목표가 생기면서,
계획하고 있던 아키텍처 리팩토링을 즉시 시작하기로 결정했습니다.
설계 및 적용
기존 이미지 로직을 여러 서비스에서 사용하고 있던 상태였고, 단일 이미지에서 다중 이미지 지원으로 변경해야 했기 때문에 DB 테이블과 데이터 마이그레이션까지 필요한 변경 범위가 큰 작업이었습니다.
그래서 단계적으로 리팩토링을 진행하기로 했습니다.
1. 도메인 엔티티 변경
먼저 응원 도메인과 스토리 도메인에서 기존 단일 이미지 키를 참조하던 방식을 변경해야 했습니다.
응원 이미지, 스토리 이미지 도메인을 새로 추가하여 1:N 연관관계를 맺는 작업이 필요했습니다.
2. DB 테이블 변경
기존에 없던 cheer_image, story_image 테이블을 추가하고, 서비스와 리포지토리 로직을 모두 변경해야 했습니다.
테이블 구조가 바뀌면서 관련 테스트들도 전부 수정이 필요했습니다.
3. Pre-signed URL 방식 변경
기존 방식은 이미지 요청이 오면 Pre-signed URL을 생성해서 30분간 내부 캐싱하는 GET 방식이었습니다.
하지만 이제 클라이언트가 직접 이미지를 업로드하므로 기존 이미지 업로드 로직과 캐싱 로직을 모두 삭제하고,
PUT 요청용 Pre-signed URL을 10분간만 유효하게 변경했습니다.
4. 동기 처리 방식 선택
클라이언트 팀과 논의를 거쳐 비동기 처리 대신 동기 처리 방식을 선택했습니다.
비동기 처리는 구현 복잡도와 콜백 지옥 문제가 있어서, 사용자가 최종 작성 버튼을 누르면 다음 순서로 동작하도록 했습니다:
- 서버에 PUT용 Pre-signed URL 요청
- 받은 URL로 S3에 직접 업로드
- 업로드 완료 후 서버에 POST 요청
5. 임시 경로와 가비지 처리
클라이언트가 업로드 책임을 가지면서 발생할 수 있는 문제들을 고려했습니다.
업로드 중 에러나 연결 끊김, 서버 장애로 인한 S3-DB 불일치 문제를 해결하기 위해 다음과 같이 설계했습니다:
- Pre-signed URL 발급 시 /temp 경로로 업로드되도록 설정
- 최종 게시 시 서버에서 /domain/domainId/ 하위로 이미지 이동 처리
- /temp 경로에 7일 자동 삭제 라이프사이클 정책 적용
6. CDN 도입
S3 앞단에 CloudFront를 배치하여 이미지 조회 성능을 개선하고, S3 퍼블릭 접근을 차단해서 CDN을 통해서만 고정된 URL로 접근할 수 있도록 구성했습니다.
7. BREAKING CHANGE 명시
기존 서버 경유 방식에서 클라이언트 직접 업로드 방식으로 완전히 바뀌면서, API 요청/응답 구조가 근본적으로 변경되었습니다.
이러한 변경사항으로 인해 팀에서 따르는 Conventional Commits 규칙에 따라 PR 마지막 커밋에 BREAKING CHANGE를 명시해서 메이저 버전 업이 필요함을 명확히 표시했습니다.
트러블슈팅
작업이 끝나고 PR을 올리니 그 사이에 다른 팀원이 작업한 새로운 기능이 선반영되어 있어서 대규모 conflict가 발생했습니다.
처음에는 rebase로 혼자 해결해보려고 했지만, 잘못하다가는 다른 팀원의 작업 내용까지 날려버릴 수 있겠다는 생각이 들었습니다.
그래서 팀원에게 함께 conflict를 해결하자고 제안했습니다.
회의 시간을 잡고 IntelliJ Code With Me를 통해 실시간으로 코드를 보면서 어떤 부분을 반영하고 어떤 부분을 유지해야 하는지 논의했습니다. 그 결과 문제 해결에 3시간이 걸렸지만 안전하게 충돌을 해결할 수 있었습니다.
이 과정에서 PR 범위를 너무 크게 잡았나 하는 고민도 들었습니다.
하지만 이번 작업은 도메인 엔티티부터 DB 테이블, API 구조까지 연쇄적으로 변경되는 작업이라 단계별로 나누기 어려웠습니다. 각 단계가 서로 의존성이 있어서 중간에 dev 브랜치에 올리면 개발 서버 배포에 문제가 생길 수 있었기 때문입니다.
만약 기존 서비스들이 추상화가 잘 되어있었다면 PR을 나누면서 점진적으로 변경할 수도 있었을 것 같습니다.
앞으로는 이런 큰 변경을 염두에 두고 코드 구조를 미리 개선해두는 것이 필요하겠다는 생각이 들었습니다.
결과
작업 완료 후 가장 걱정되는 부분은 성능이었습니다.
이번 아키텍처 변경의 핵심 목표가 이미지 업로드와 다운로드 속도 개선이었기 때문입니다.
다운로드는 CDN을 도입했으니 성능 향상을 기대할 수 있었지만, 업로드가 문제였습니다.
클라이언트에서 동기 방식으로 처리하고 서버와의 통신이 여러 번 발생하는 구조상 2초 이내 완료가 어려울 것으로 예상했습니다.
그래서 클라이언트 팀과 논의 후에 대비책을 마련했습니다.
만약 속도가 나오지 않으면 UI적으로 일단 등록된 것처럼 보여주고, 인스타그램처럼 나중에 업로드 상태를 피드백하는 방식으로 사용자 경험을 개선하기로 했습니다.
클라이언트 요청부터 완료까지의 전체 시간은 클라이언트-서버 통합 모니터링이 없어 현재 정확하게 파악하기 어렵지만,
기존 5MB 이미지 크기 제한 덕분에 이미지 업로드부터 최종 게시글 게시 응답까지 대략 2초 정도가 소요됩니다.
하지만 여전히 1초가 넘는 대기 시간이 발생하여 사용자 경험 측면에서 개선이 필요한 상황입니다.
현재 측정 가능한 서버 레이턴시는 아래와 같습니다.
최초 Pre-signed URL 요청부터 최종 게시까지 총 300ms 이내로 나가는 것을 확인할 수 있었습니다.


배포 이후 주요 엔드포인트들도 P95 레이턴시가 GET 요청은 150ms, POST는 350ms이하에서 관리되고 있습니다.

이러한 개선은 이전 포스트모템에서 진행한 인스턴스 스케일업(t2.micro → t3a.small)과
이번 이미지 아키텍처 개선(클라이언트 직접 업로드, CDN 도입)이 복합적으로 작용한 결과입니다.
기존 서버 경유 방식에서는 주요 API의 P95 레이턴시가 GET 800ms, POST 1600ms 이상이었는데,
두 가지 개선 작업을 통해 각각 GET 150ms, POST 350ms으로 약 80%, 78% 단축되었습니다.
개선점
이번 작업으로 주요 목표는 달성했지만, 추가적인 개선이 필요한 부분들을 발견했습니다.
현재 S3 파일 처리에서 두 가지 구조적 문제가 있습니다.
첫째, temp 경로에서 실제 도메인 하위 경로로 다중 파일을 옮기는 과정에서 하나라도 실패하면 전체 작업이 실패하는 구조입니다. 둘째, 이런 S3 파일 이동 작업이 데이터베이스 트랜잭션 안에 포함되어 있어 성능과 안정성 측면에서 문제가 될 수 있습니다.
부분 실패를 허용하는 로직과 트랜잭션 범위 축소가 필요하지만, 이는 데이터 일관성 문제를 동반합니다. S3 작업은 성공했지만 DB에서 문제가 발생하거나, 일부 파일만 처리된 상황에서 어떻게 보상 처리를 할 것인지에 대한 전략을 고민하고 있습니다.
또한, 다중 이미지로 변경되면서 N+1 쿼리 문제를 대비해 엔티티 그래프로 미리 구성해두긴 했지만, 실제 운영에서 쿼리 최적화가 제대로 동작하는지 모니터링과 검증이 필요합니다.
마지막으로 현재 Vercel의 이미지 최적화와 CloudFront CDN의 이중 캐싱 상황을 해결하기 위해, 장기적으로는 SQS와 Lambda를 통한 이미지 처리 파이프라인으로의 전환을 고려하고 있습니다.
'YAPP' 카테고리의 다른 글
| Datadog을 활용한 SLI/SLO 모니터링 및 Burn Rate 알람 적용기 (0) | 2025.09.18 |
|---|---|
| 격리된 Prod 복제 환경에서 안전하게 DB 마이그레이션 수행하기 (2) | 2025.08.29 |
| Dev/Prod 환경 로그 & DB 백업 자동화 적용하기 (0) | 2025.08.13 |
| API Latency Postmortem – 1 : AWS t2.micro 환경에서의 병목 분석과 최적화 (8) | 2025.08.12 |
| 인프라 자동화를 위한 Terraform 도입기 (4) | 2025.08.01 |
