대용량 이미지 변환을 효율적으로 처리하는 병렬 이미지 변환 시스템입니다.
Redis 기반 큐와 Kotlin 코루틴을 활용해 빠르고 안정적인 이미지 처리 파이프라인을 구축했습니다.
- 프로젝트명: Image Sprint
- 기간: 2025.6.24 ~ 2025.7.20
- 목적: 대용량 이미지 변환 작업을 안정적이고 빠르게 처리하는 백엔드 시스템 구축
- 대상 사용자: 이미지 리사이징, 포맷 변환, 워터마크 삽입 등이 필요한 일반 사용자 및 콘텐츠 플랫폼
이미지 변환은 대부분의 콘텐츠 플랫폼에서 자주 사용되는 기능이지만, 이미지 개수가 많아지거나 워터마크, 포맷 변경 등 옵션이 다양해지면 서버 자원이 급격히 소모되며 처리 속도 또한 느려지는 문제가 발생합니다.
특히 한 번의 요청에 수십~수백 장의 이미지를 변환해야 하는 경우, 기존 방식으로는 변환 실패, 응답 지연, 서버 부하 등의 문제가 발생할 수 있어 사용자 경험이 크게 저하됩니다.
이 프로젝트는 요청 기반의 이미지 일괄 변환 작업을 Redis 큐에 적재하고, 워커 서버가 Kotlin 코루틴 기반으로 병렬 처리하여 이미지 변환 속도와 안정성 모두를 확보하는 시스템을 구현한 것입니다. 또한 S3 업로드, 변환 완료 알림, Webhook 호출, 실시간 진행률 추적(SSE) 등 부가 기능을 통해 사용자 중심의 이미지 처리 파이프라인을 구축하는 것을 목표로 했습니다.
이미지 업로드 → 옵션 설정 → 변환 요청 → 병렬 처리 → S3 저장 → 완료 알림 및 다운로드까지 하나의 흐름으로 자동화된 고성능 이미지 변환 시스템을 만들고자 했습니다.
이미지 병렬 변환 시스템은 단순한 CRUD 수준의 프로젝트가 아닌, 도메인 중심의 설계와 인프라 확장성, 처리 책임의 분리가 핵심인 시스템입니다.
이에 따라 본 프로젝트는 클린 아키텍처(Clean Architecture) 를 기반으로 레이어를 분리하고, 역할에 따라 레포지터리를 모듈 단위로 나누어 구성했습니다.
이러한 구조를 택한 이유는 다음과 같습니다.
- 도메인 비즈니스 로직과 인프라 세부 구현을 분리하여 유연성과 유지보수성 확보
- JPA와 R2DBC 등 서로 다른 기술 스택을 병렬로 적용하되, 각 모듈에 캡슐화
- 추후 Redis, S3, 외부 API 등 확장에도 모듈 단위로 대응 가능
image-sprint/
├── core/ // 도메인, 유스케이스, 인터페이스 정의
├── api-server/ // 사용자 요청 처리 (Spring Web + JPA 기반)
├── worker-server/ // 이미지 변환 전용 워커 (Coroutine + R2DBC 기반)
├── infrastructure-jpa/ // JPA 기반 DB 접근 구현체 (api-server에서 사용)
├── infrastructure-r2dbc/ // R2DBC 기반 DB 접근 구현체 (worker-server에서 사용)
├── infrastructure-redis/ // Redis 기반 Pub/Sub, 큐, 캐시 구현체
├── common/ // 공통 DTO, 유틸, 상수 등 공유 코드
주요 흐름은 다음과 같습니다.
[사용자 요청]
⬇️
Job 생성 (API 서버)
⬇️
Redis 큐에 Job 등록
⬇️
Worker 서버에서 병렬 이미지 변환 수행
⬇️
S3 업로드 & DB 업데이트
⬇️
변환 완료 알림 & 다운로드 제공
- 사용자가 여러 이미지를 업로드하고, 리사이즈, 포맷 변경, 품질 조절, 워터마크 등의 옵션을 설정할 수 있습니다.
- 최대 한번에 50장까지 처리 가능합니다.
- 하나의 이미지는 20MB 크기를 넘어서는 안됩니다.
- 내가 요청한 변환 작업들을 리스트로 확인할 수 있습니다.
- 작업이 완료되면 알림이 오고, 변환된 이미지 묶음을 ZIP 파일로 다운로드할 수 있습니다.
- 슬랙, 디스코드 등 외부 시스템에 작업 완료 알림을 자동 전송할 수 있습니다.
- 실시간으로 다운로드 현황을 확인하실 수 있습니다.
- Kotlin 1.9.x
- Spring Boot 3.5.x
- MySQL
- Redis
- AWS S3
- JPA + R2DBC
- Coroutine
- Clean Architecture + DDD
-
역할: 사용자 요청 수신, DB 저장, 진행률 조회, Presigned URL 발급, Webhook 관리 등 입출력이 명확한 API 서버입니다.
-
선택 이유:
- 요청 처리에는 Spring MVC의 직관적인 구조가 적합했고, API 스펙 정리나 Swagger 문서화도 용이했습니다.
- DB 접근은 동기적 JPA로 충분하며, 트랜잭션 관리가 간단하고 직관적이기 때문에 안정적인 CRUD 처리에 적합했습니다.
- 사용자 수요가 갑자기 폭발적으로 몰릴 가능성이 낮은 서비스 특성상, Reactive를 굳이 도입할 필요는 없었습니다.
- 결론: API 서버는 직관성과 유지보수성을 우선시했으며, JPA + Spring Web 조합이 가장 안정적이었습니다.
-
결론: API 서버는 직관성과 유지보수성을 우선시했으며, JPA + Spring Web 조합이 가장 안정적이었습니다.
-
역할: Redis 큐로부터 Job을 받아 다수의 이미지를 동시에 변환하고 S3 업로드, 상태 업데이트, Webhook 전송까지 수행하는 고부하 처리 전용 서버입니다.
-
선택 이유:
- job 안에 수십~수백 장의 이미지가 존재하며, 이를 동시 처리(concurrent processing) 해야 작업 시간을 단축할 수 있습니다.
- Spring Web + 멀티스레드 방식으로도 구현 가능하지만, 다음과 같은 문제가 있습니다:
- 스레드 수가 늘어날수록 메모리와 컨텍스트 스위칭 비용이 증가
- 이미지 처리 로직 중 I/O 지연(S3, 파일 디스크 등)에 묶여 있어, 스레드 낭비가 큼
- Kotlin Coroutine은 비동기 처리에도 코드가 간결하고 가독성이 좋으며, 스레드 효율이 뛰어남
-
Reactive 대신 Coroutine을 택한 이유:
- Worker는 외부 연동이 많고 도메인 로직이 복잡하므로, 직관적인 절차형 코드 스타일을 유지하면서도, 간결하게 비동기를 처리하고 싶었음
- Reactor보다 Coroutine이 학습 곡선이 낮고 유지보수가 용이함
-
결론: Worker 서버는 높은 동시성 + 가독성 + 효율성이 요구되며, Kotlin Coroutine은 그 요구사항을 완벽하게 충족하는 기술이었습니다.
상황
- 이미지 변환 시스템은 이미지 수백 장을 병렬 처리하고 S3 업로드, 웹훅 전송 등 고부하 작업까지 담당하는 입출력 분리형 구조가 필요했음
- 이에 따라 하나의 서버에 모든 기능을 몰아넣기보다는, API 서버와 워커 서버를 역할에 따라 분리
- 각자의 처리 특성에 맞는 기술 스택이 필요해 보였음
문제
- 다양한 I/O 연산이 복합적으로 얽혀 있는 구조에서 동기 기반 API와 비동기 변환 작업이 충돌하며 복잡도가 증가할 우려
- 워커가 동시에 수십~수백 장의 이미지를 처리해야 하는 상황에서 스레드 기반 동시성 처리(Spring Web + @Async)는 성능·리소스 낭비가 발생
해결
- 클린 아키텍처 기반의 멀티 모듈 구조 도입: core, api-server, worker-server, infrastructure-*, common으로 명확하게 역할 분리
- API 서버:Spring Web + JPA 선택 이유:
- 사용자 요청 수신, 이미지 변환 요청, 알림, Webhook 관리 등 단순하고 명확한 요청-응답 구조
- 트랜잭션이 필요한 CRUD 위주 작업이 많아 동기적인 JPA가 적합
- 워커 서버의 경우 Kotlin + Coroutine + R2DBC를 선택
- Job 단위로 수십~수백 장의 이미지 변환을 동시에 처리해야 하는 고동시성 환경
- Coroutine 기반 구조를 통해 S3 업로드, DB 저장 등 I/O 중심 작업은 thread blocking 없이 효율적으로 처리
- 이미지 변환은 CPU-bound 작업이므로 Coroutine을 활용해 다중 스레드에서 비동기 병렬 처리
- Reactor 대비 절차지향에 가까운 코드 스타일로 복잡한 비동기 로직을 보다 가독성 높고 유지보수하기 쉬운 구조로 구현
회고
- 도메인 특성과 처리 책임에 따라 기술을 선택하고 구조화한 사례
- Kotlin Coroutine 기반 워커는 낮은 리소스로 높은 동시성과 안정성을 확보할 수 있었던 구조적 선택이라고 생각함
상황
-
이미지 변환 시스템에서 하나의 Job은 수십~수백 장의 이미지를 병렬로 변환하며, 웹 클라이언트는 해당 Job의 진행률을 SSE(Server-Sent Events) 방식으로 실시간 확인할 수 있어야 했음
-
각 이미지의 처리 결과는 RDB에 기록되며, 진행률은 완료 이미지 수 / 전체 이미지 수로 계산되어짐
문제
- 초기에는 이미지가 하나 처리될 때마다 doneCount를 RDB에 UPDATE하는 방식이었는데, 다음과 같은 문제가 발생:
- 과도한 RDB 쓰기 부하: 이미지가 갯수만큼 update 쿼리가 병렬로 발생
- 실시간성 저하: 클라이언트에 전송되는 진행률 계산도 매번 DB 조회가 필요해 SSE 전송 지연
- 동시성 문제: 병렬 환경에서 동일 필드(doneCount) 동시 업데이트 시 정합성 충돌 가능
해결
- RDB 대신 Redis 기반의 캐시 + Pub/Sub 구조로 진행률 집계 구조를 개선했습니다:
- doneCount, totalCount를 Redis의 Hash 형태로 저장하여 경량 연산과 원자성 보장
- 각 이미지 처리 완료 시 HINCRBY로 doneCount를 증가시켜 Redis 집계값을 즉시 갱신
- 갱신된 진행률은 Redis Pub/Sub을 통해 클라이언트에 SSE로 실시간 전파
- 기존 DB는 최종 완료 시 doneCount와 요약 데이터만 기록하여 쓰기 부하 최소화
회고
- Redis 도입 후, Job 처리 시 진행률 업데이트로 인한 병목이 완전히 해소되었고, 클라이언트의 실시간 UI 반응 속도도 평균 수백 ms → 10ms 이하로 크게 향상
- 병렬 환경에서도 Redis의 원자적 연산으로 경합 없이 안정적 처리 가능했으며, 확장성 측면에서도 DB I/O 병목 없이 대량 Job을 무리 없이 처리할 수 있는 구조로 발전시킬 수 있었음
- 해당 개선은 단순한 성능 최적화가 아니라, 비동기 시스템에서의 정합성과 실시간성의 균형을 고민한 설계 경험이라 생각함
좋았던 점: 단순한 CRUD를 넘어, 실시간성, 비동기 처리, 병렬성, 분산 구조를 직접 설계하고 구현한 경험이 매우 인상 깊었습니다. 또한 RDB의 부하를 고려하여 Redis 기반의 실시간 진행률 집계 및 SSE 구조로 개선한 과정은 실무에서도 바로 적용 가능한 아키텍처 고민과 선택이었다고 생각합니다. 단순 기능 구현을 넘어서, 성능 최적화, 확장성, 장애 복구까지 고려한 시스템 설계 경험이 된 점이 특히 만족스러웠습니다.
아쉬웠던 점: 아키텍처 구성에 많은 시간을 투자하면서, 상대적으로 다양한 트래픽 상황에 대한 성능 테스트는 충분히 수행하지 못한 점이 아쉬웠습니다. 또한, R2DBC와 같은 비동기 DB 기술을 처음 적용하면서, 트랜잭션/에러 핸들링에 대한 경험이 부족해 시행착오가 있었습니다.
보완할 점: 업로드 실패 시 리트라이, 작업 재시도 등 운영 시 발생 가능한 이슈들을 더 고려했으면 좋았을 것 같습니다.