이전에 이미지 관련 비용 최적화를 통해 약 88% 개선한 경험이 있다. (개선 후에 한 달이 지나고 나서 이전 비용 평균과 비교한 수치)
CloudFront 적용기 [AWS - S3]
이전 이미지 관련 최적화 포스팅이다.https://hyunco.tistory.com/78 현재는 라즈베리파이로 이동해 있음. 명확한 해결은 없는 상" data-og-host="hyunco.tistory.com" data-og-source-url="https://hyunco.tistory.com/78" data-og-u
hyunco.tistory.com
간단히 요약하자면 원본 이미지 파일을 업로드하고 조회하던 방식에서,
업로드 시 webp 형식으로 변환, 압축, 리사이징 + 조회 시 cdn 캐싱을 통해 개선한 내용이다.
비용은 확실히 줄일 수 있었으나, 또 다른 문제가 발생했다.
이번 포스팅을 통해 해당 문제를 해결하는 과정과 개선 결과를 정리하려고 한다.
우선 해결 과정을 알아보기 전에, 동기와 비동기의 차이에 대해 알아야 한다.
👩💻 완벽히 이해하는 동기/비동기 & 블로킹/논블로킹
동기/비동기 & 블로킹/논블록킹 프로그래밍에서 웹 서버 혹은 입출력(I/O)을 다루다 보면 동기/비동기 & 블로킹/논블로킹 이러한 용어들을 접해본 경험이 한번 쯤은 있을 것이다. 대부분 사람들은
inpa.tistory.com
동기(Synchronous)
- 작업을 순차적으로 실행하며, 이전 작업이 끝나야 다음 작업이 실행됨
- 한 스레드가 어떤 작업을 처리하는 동안, 그 스레드는 블로킹 상태가 됨
val result = uploadImage()
println("이미지 업로드 결과: $result") // 업로드가 끝날 때까지 이 줄은 실행되지 않음
비동기(Asynchronous)
- 작업을 요청하고 즉시 다음 작업을 진행함
- 작업 결과는 콜백/이벤트/코루틴 등으로 나중에 응답받음
- 서버 입장에서는 “잠시 대기 중”인 작업을 효율적으로 처리 가능
CoroutineScope(Dispatchers.IO).launch {
val result = uploadImageAsync()
println("비동기 이미지 업로드 결과: $result")
}
println("이 줄은 즉시 실행됨")
동기/비동기, 블로킹/논블로킹은 서로 다른 개념이다.
이 둘은 헷갈릴 수 있지만, 교차될 수 있는 개념이다.
동기(Synchronous) | 작업을 요청하고 결과를 받을 때까지 기다리는 방식 |
비동기(Asynchronous) | 작업을 요청만 하고, 결과는 나중에(콜백, 이벤트 등) 받는 방식 |
블로킹(Blocking) | 요청한 스레드가 멈춰서 기다리는 상태 |
논블로킹(Non-Blocking) | 요청한 스레드가 멈추지 않고 다른 작업을 계속할 수 있음 |
쉽게 말해 동기/비동기는 작업 흐름 제어 방식이고, 블로킹/논블로킹은 스레드 사용 여부를 결정한다.
프로세스와 스레드 기본 이해
프로세스(Process)
- 운영체제가 실행 중인 프로그램에 대해 생성한 독립된 실행 단위
- 고유한 메모리 공간(Heap, Stack, Code, Data 등)을 가짐
- 프로세스 간에는 메모리 공유가 안 됨 (IPC 필요)
스레드(Thread)
- 프로세스 내에서 실행되는 작업 단위
- Heap(객체 메모리)은 공유하지만, Stack(함수 호출, 지역 변수)은 독립적
- 하나의 프로세스는 여러 개의 스레드를 가질 수 있음 → 멀티스레딩
요약 : 프로세스는 독립적 실행 단위, 스레드는 병렬 작업 단위
블로킹이란?
블로킹(Blocking)은 스레드가 작업을 기다리는 동안 아무 일도 하지 못하는 상태다.
예: DB에 요청하는 경우
val result = jdbcTemplate.queryForObject("SELECT * FROM users", User::class.java)
이 순간,
- 이 작업을 수행 중인 스레드는 DB 응답을 기다리며 멈춰 있음 (blocked)
- 즉, Stack Frame이 그대로 유지되고 힙을 쓰지 않음
- 해당 스레드는 다른 일을 못 함 → CPU 자원 낭비
자바는 1 스레드 1 작업 방식이 기본이라, 요청마다 새로운 스레드가 필요하다.
1000명 동시 요청이면 1000개 스레드가 필요하고, 이건 메모리와 CPU에 큰 부담이다.
"Stack Frame이 그대로 유지된다"
- Stack Frame이란?
- 메서드가 호출될 때 JVM이 사용하는 임시 작업 공간이다.
- 예를 들어 fun upload() 같은 함수가 호출되면, 그 함수에 필요한 지역 변수, 매개변수, 리턴 주소 등을 담는 일종의 블록이 스택에 쌓인다고 보면 된다.
- 그런데 만약 DB 요청 같은 블로킹 I/O 작업을 만났을 때,
- 해당 스레드는 그 작업이 끝날 때까지 대기해야 함.
- 이때 메서드의 스택 프레임은 메모리에 계속 남아있음 → 스레드 하나가 계속 "자리 차지"하고 있는 상태.
- 즉, 스레드가 놀면서도 메모리는 계속 먹고 있다는 뜻
"힙을 쓰지 않는다"
- 힙(Heap)은 new나 val obj = ... 처럼 객체를 생성할 때 사용되는 공유 메모리 영역이다.
- Stack은 함수 실행을 위한 단기적인 공간이고, Heap은 객체 생명주기 전체 동안 살아남는 메모리다.
- 블로킹 상태에서는 그저 응답을 기다리기만 하니까, 새로운 객체를 만들지도 않고 힙 메모리에 무언가 저장할 일도 사실상 거의 없음. 오히려 스택 메모리만 계속 차지하는 셈
왜 중요한가?
- 스택은 JVM에서 스레드마다 독립적으로 할당됨 → 즉, 스레드 수가 많아질수록 메모리도 많이 잡아먹게 된다 (보통 1MB ~ 2MB/스레드)
- 블로킹 방식에서는 작업이 끝날 때까지 스택이 풀리지 않으니까, 동시 접속자가 많아지면 스레드 수가 폭발하고 이로 인해 메모리도 엄청나게 소모됨.
- 심하면 OutOfMemoryError, Too many open threads 에러로 서버가 뻗을 수도 있다..!
반대로 코루틴(논블로킹)은?
- suspend fun으로 중단 가능한 함수로 만들고,
- 중간에 대기할 게 생기면 스택 프레임을 복사해서 잠깐 치워 놓고,
- 스레드는 다른 작업으로 넘어가고,
- 응답이 오면 스택 프레임을 다시 불러와서 이어서 실행한다.
→ 즉, 스레드는 놀지 않고, 메모리도 효율적으로 사용함
결국 블로킹 I/O는 작업 대기 중에 스레드도 잡아먹고 스택 메모리도 계속 유지해야 해서 비효율적이다.
논블로킹 구조에서는 어떻게 동작하는가?
> 핵심은 IO 작업이 완료되기를 기다리는 동안 스레드가 쉬지 않는다는 것
예를 들어 Kotlin Coroutine은,
CoroutineScope(Dispatchers.IO).launch { val result = httpClient.get("http://...")
// 이 작업은 비동기 + 논블로킹 println("응답: $result") }
- launch가 호출되면 Coroutine이 생성됨 (가벼운 단위의 실행 블록)
- HTTP 요청이 시작됨 → IO 리소스가 요청을 처리
- Coroutine은 중단(suspend) → 스레드에서 빠져나감
- 응답이 오면 해당 Coroutine이 다시 재개(resume) → 이어서 실행됨
중요한 점
- 스레드는 놀지 않음
- 중단된 Coroutine은 일시적으로 대기열(queue) 같은 곳에 들어감
- 응답 도착 시 → 다시 스레드에 올라가서 이어 실행
JVM 레벨에서도 이걸 가능하게 하려면 스레드 대신 Continuation이라는 개념으로 관리함
즉, 호출 스택 자체를 객체처럼 만들어서 저장해 두고 나중에 복원함 (→ 비동기 상태머신)
Stack과 Heap 관점에서의 동작
Stack (지역 변수, 함수 호출) | Heap (객체, 공유 리소스) | |
동기/블로킹 | 유지된 채 멈춤 | 그대로 |
비동기/논블로킹 | Stack 비움 (중단됨) | 상태는 저장돼 있음 |
결국 비동기는 특히 IO-bound 작업에 유리하다.
IO 작업이란,
- DB 접근
- 파일 읽기/쓰기
- 네트워크 요청 (HTTP, S3 등)
- 외부 API 호출
이런 작업들은 대부분 “요청 후 응답 대기 시간이 길고, CPU 사용률은 낮음”
이럴 때 스레드를 멈추지 않고 다른 요청을 처리하는 것이 더 효율적이다.
비동기 + 논블로킹 구조가 IO 작업에 강력한 이유다.
동기(Synchronous)와 비동기(Asynchronous) 비교 정리
동기(Synchronous) | 비동기(Asynchronous) | |
처리 방식 | 작업이 끝날 때까지 대기 | 대기 없이 바로 다음 작업 처리 |
스레드 사용 | 하나의 요청 = 하나의 스레드 | 하나의 스레드로 여러 작업 처리 가능 |
응답 시간 | 느릴 수 있음 (I/O 블로킹) | 빠름 (I/O 시간 동안 다른 작업 처리) |
개발 난이도 | 직관적, 디버깅 쉬움 | 복잡, 디버깅/테스트 어려움 |
처리량 | 낮음 (스레드가 막힘) | 높음 (자원 효율적 사용) |
트랜잭션 일관성 | 관리 쉬움 | 복잡 (상태 동기화 어려움) |
예외 처리 | 코드 흐름 안에서 명확 | 흐름이 분리되어 추적 어려움 |
대표 예 | 파일 읽기, 단순 계산 | HTTP 호출, DB 쿼리, S3 업로드 |
그렇다면 전부 비동기로 작업하는 게 좋지 않을까 하는 의문이 든다.
왜 그렇지 않은지 비동기의 단점 위주로 정리하자면,
1. 코드 복잡도 증가
- 흐름이 분리됨 (launch, callback, suspend, future, promise 등)
- 디버깅이나 로그 추적 시 "지금 어떤 흐름인지" 파악이 어려워짐
2. 예외 처리 까다로움
- 예외가 비동기 블록 안에서 발생하면 일반적인 try-catch로는 안 잡힘
- 예외 누락 시 예기치 않은 동작 or 앱 전체 오류
3. 상태 동기화 어려움
- A → B → C 순으로 실행되길 원하는 로직도, 병렬 실행되면 순서 보장이 안 됨
- 공유 데이터 접근 시 race condition 발생 가능 → 락이나 채널 필요
4. 트랜잭션 제어 어려움
- 하나의 트랜잭션 안에서 비동기 실행은 어렵고, 분산 트랜잭션 필요
- 예: Spring에서는 @Transactional이 비동기 블록에선 무시될 수 있음
5. 메모리 사용 주의
- 코루틴도 메모리를 먹고, 너무 많이 띄우면 오히려 성능 저하
언제 동기/비동기를 선택해야 할까?
- 연산량 작고, 순서 보장이 중요한 로직 - 코드가 단순하고 안정적이기 때문
- 트랜잭션이 중요한 로직 - 롤백, 커밋 시점을 명확히 보장하기 때문
비동기
- 외부 API 요청, DB 조회, S3 업로드 - 대기 시간 동안 다른 작업 처리 가능하기 때문
- 병렬 처리로 성능 개선이 필요한 경우 - CPU/IO 자원 효율을 극대화할 수 있기 때문
비동기는 확실히 강력하지만, “복잡도”와 “예외/상태 관리”라는 대가를 치러야 한다.
그래서 "IO 중심의 작업은 비동기", "순서 중요하고 일관성 필요한 작업은 동기"처럼 상황에 맞게 선택해야 된다.
--아래부터 현재 구조 및 문제점과 어떻게 해결하게 됐는지를 기록--
기존 구조와 문제점 "응답 지연"
- 기존 방식: 사용자가 이미지를 업로드하면, 서버에서 아래와 같은 순서로 처리가 진행된다.
- 사용자가 이미지 업로드 요청
- 서버에서 Multipart 파일 수신
- 서버에서 이미지 WebP 변환 및 압축 + 리사이징
- 이미지 파일을 AWS S3에 업로드
- 업로드된 파일의 경로(CloudFront 도메인 url)를 응답
이 과정은 모두 동기적으로 처리되어 있었고, 모든 작업이 완료될 때까지 클라이언트는 응답을 기다려야 했다.
- 문제점: 압축 및 업로드에 시간이 걸리면서 클라이언트 응답이 지연.
즉각적인 피드백이 필요한 기능에서도 이미지 변환 및 업로드가 완료될 때까지 클라이언트가 응답을 기다려야 하기 때문에 다음과 같은 문제가 있었다.
- 사용자는 "저장(이미지 포함) 버튼"을 눌렀지만, 수 초 동안 로딩 화면이 보이는 현상
- 특히 이미지 파일 크기가 클 경우, WebP 변환 및 S3 업로드까지 2~3초 이상 걸리는 경우 발생
- 단순한 요청이어도 이미지가 포함되어 있으면 전체 요청이 느려짐
- 모바일 환경에서는 더 뚜렷한 UX 저하가 발생
이미지는 업로드 후에는 '보여주기만 하면 되는' 리소스다.
그렇다면 굳이 사용자가 기다릴 필요 없이 이미지 외 정보만 먼저 응답하고, 이미지 처리는 서버에서 알아서 하면 되지 않을까?
하지만 여기서 또 다른 방법이 있다.
바로 Presigned URL을 이용하여 클라이언트에서 직접 업로드하는 방식이다.
그럼에도 불구하고 비동기 처리를 선택한 이유를 정리해보자.
Presigned URL 방식이란?
Presigned URL은 AWS S3에서 제공하는 방식으로, 임시로 업로드 가능한 URL을 서버에서 발급하고, 클라이언트가 직접 업로드하는 구조다.
서버: 업로드 가능한 URL 생성 → 클라이언트에게 전달
클라이언트: 변환/압축 → 해당 URL에 직접 업로드
서버: 업로드 완료 후 필요한 메타데이터만 저장
이 방식의 장점으로 세 가지 정도 들 수 있겠다.
- 서버 부하 감소 : 서버는 단순히 URL만 발급하므로 이미지 처리나 업로드 비용이 클라이언트로 분산됨
- 빠른 응답 : 서버는 URL만 주고 끝이므로 응답이 빠르고, 클라이언트도 별도로 업로드 타이밍을 조절 가능
- 보안상 안전 : Presigned URL은 유효시간이 짧고, 권한이 제한되어 있어 일정 수준 이상 안전
하지만 현재 서비스에 적용하지 않은 이유로는 다음과 같다.
1. 변환/압축/리사이징 책임을 클라이언트에게 넘기기 어려움
- WebP 변환은 브라우저마다 지원 상태가 다름 (특히 iOS Safari는 변환에 제한적이라고 함)
- 모바일 앱/웹 등 플랫폼별로 호환성과 코드 관리 비용이 커짐
2. 사용자 디바이스 성능 의존성
- 이미지 압축 및 WebP 변환은 CPU 연산을 요구, 저사양 기기에서는 렉/앱 크래시 유발 가능성 있음
- 특히 고화질 이미지 업로드 시, 모바일에서 오히려 더 느려질 수 있음 (우리 서비스는 모바일 유저가 많다!)
3. 클라이언트 측 실패 시 추적 및 재시도 어려움
- 변환 실패 or 업로드 실패 시 서버에서는 알 수 없음
→ 예외 상황 처리, 로깅, 재시도 로직이 어려움 - Slack 알림이나 Sentry와 같은 서버 기반 관찰 도구를 활용하기 어려워짐
Presigned URL 방식은 명확한 장점이 있는 기술이지만,
우리는 변환 제어, 예외 처리, 클라이언트 부담 최소화라는 이유로 서버에서 모든 과정을 처리하되, 비동기화로 UX를 해치지 않도록 개선하는 쪽을 선택했다.
그럼 수많은 비동기 처리 방식들 중에 내가 선택한 기술은 무엇이며, 왜 선택했는지 이유를 보자.
자바/코틀린에서 자주 사용되는 비동기 처리 방식들
Future / CompletableFuture | @Async (Spring) | Coroutine (Kotlin) | |
기술 스택 | Java (8 이상) | Spring Framework | Kotlin (kotlinx.coroutines) |
스레드 방식 | 명시적 스레드 or ExecutorService | Spring의 TaskExecutor (기본: ThreadPool) | Suspend + CoroutineDispatcher |
코드 스타일 | 콜백 체이닝 or get() 호출 | 선언형으로 간단히 메서드에 어노테이션 | 순차적으로 작성하는 직관적인 스타일 |
예외 처리 | try-catch + exceptionally | 전역 예외처리 or try-catch | try-catch or SupervisorJob |
취소 가능성 | 제한적 (취소 어려움) | 거의 불가 | job.cancel() 등으로 가능 |
각 방식의 장단점
Future / CompletableFuture
- 장점
- 자바 표준, Spring 없이도 사용 가능
- 외부 API 호출 병렬화에 강점
- 단점
- get()은 블로킹
- thenApply, thenCompose 체이닝이 복잡하고 가독성 낮음
- 취소나 타임아웃 처리 어렵고 예외 처리도 번거로움
get()은 블로킹
CompletableFuture.get()을 호출하면 결과가 올 때까지 현재 스레드가 멈춘다. 즉, 동기적으로 기다리게 되기 때문에 비동기 코드의 장점을 희석시킬 수 있다.
ex)
CompletableFuture<String> future = apiCall();
String result = future.get(); // 여기서 응답이 올 때까지 스레드가 BLOCK
thenApply, thenCompose 체이닝이 복잡
여러 비동기 작업을 순차 또는 병렬로 연결하려면 .thenApply(), .thenCompose() 등을 체이닝해야 해서 가독성이 나빠지고 예외 처리도 어렵다.
ex)
apiCall()
.thenApply(this::parseResult)
.thenCompose(this::saveToDb)
.exceptionally(e -> handleError(e));
코드는 비선형적으로 길어지고, 흐름이 분산되어 디버깅이 어려워지는 단점이 있다.
@Async
- 장점
- Spring에서 간단하게 적용 가능 (어노테이션)
- AOP 기반으로 기존 코드 유지보수하기 쉬움
- 단점
- 리턴 타입은 Future, CompletableFuture, void만 허용
- 내부적으로 별도 스레드풀 관리 필요
- 테스트 어려움 (mocking, wait 필요)
Coroutine
- 장점
- 가장 직관적이고 가독성 높음 (동기처럼 작성 → 비동기 실행)
- 성능적으로도 가볍고 효율적 (스레드보다 훨씬 적은 자원 사용)
- 구조화된 concurrency 지원 (Job, Scope, SupervisorJob)
- 단점
- Kotlin 환경이 전제
- suspend, launch, withContext 등 초기 학습 필요
- Spring의 @Transactional과 호환 이슈 조심
Coroutine의 구조화된 Concurrency
코루틴은 작업의 생명주기를 명시적으로 관리할 수 있는 구조를 제공한다. 즉, 부모-자식 관계로 코루틴을 관리하면서, 예외 처리와 취소를 안전하고 깔끔하게 할 수 있다.
- CoroutineScope: 코루틴의 생명주기를 관리하는 범위
- Job: 각 코루틴의 작업 단위
- SupervisorJob: 자식 중 하나가 실패해도 나머지에는 영향을 주지 않도록 함
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
launch { taskA() } // 실패해도
launch { taskB() } // taskB는 영향 없음
}
이 덕분에 예외 전파 제어, 리소스 누수 방지, 명확한 취소 처리가 가능하다. 스프링의 @Async보다 더 세밀하게 병렬 작업을 제어할 수 있게 된다.
예시 코드 비교
CompletableFuture 예시 (Java)
CompletableFuture.supplyAsync(() -> {
return externalApi.call();
}).thenApply(result -> {
return process(result);
});
@Async 예시 (Spring)
@Async
public void processBackgroundTask(String value) {
// 백그라운드로 실행됨
someService.doHeavyWork(value);
}
---
service.processBackgroundTask("hello");
// 즉시 리턴됨, 비동기로 처리됨
Coroutine 예시 (Kotlin)
fun doSomething() {
CoroutineScope(Dispatchers.IO).launch {
val result = heavyWork()
println("결과: $result")
}
println("이건 바로 실행됨")
}
suspend fun heavyWork(): String {
delay(1000)
return "완료"
}
왜 코루틴(Coroutine)을 선택했는가?
우리 서비스에서는 이미지를 압축( + WebP 변환) 및 리사이징 후 S3에 업로드하는 로직이 있었고, 기존에는 이 모든 작업이 동기 처리되어 있어 사용자 경험에 부정적인 영향을 주고 있었음.
이 문제를 해결하기 위해 비동기 처리가 필요했지만,
- @Async 방식은 Java 기반에 더 적합하고, 세밀한 컨트롤 및 코틀린 친화성이 떨어졌고,
- CompletableFuture는 리턴값 기반으로 설계되었기에 fire-and-forget 패턴에 부적합했으며, 예외처리와 흐름 제어도 복잡함
왜 fire-and-forget 패턴을 써야 했나?
1. 사용자 응답이 즉시 가능해야 했기 때문
현재 우리 서비스는 이미지를 업로드할 수 있는 api들이 전부 공통적으로 이미지가 꼭 즉시 압축 및 업로드돼야 할 필요는 없었다. 그 외의 정보들이 훨씬 중요했기 때문이다.
그래서 이미지 업로드는 백그라운드로 처리하고, 응답은 빠르게 줄 수 있어야 했다.
2. 업로드 결과에 따라 응답을 달리할 필요가 없기 때문
예를 들어, 업로드가 실패하더라도 클라이언트에 별도로 알림을 주거나 재요청을 유도할 필요가 없었고,
클라이언트는 원본 이미지를 잠시 쓰다가, 이후 이미지 URL이 교체되면 그대로 따라가면 된다.
→ 즉, 결과에 따라 클라이언트의 추가 액션이 필요 없는 작업 = fire-and-forget에 적합하다고 판단.
3. 단순히 성공/실패 로그만 남기면 되는 로직이기 때문
- 실패 시 Slack 알림,
- 자동 재시도 가능성 고려 등은 내부 처리지, 응답에 포함될 필요 없음.
총정리 : 그래서 왜 Coroutine이 적절했을까?
fire-and-forget에 최적화 | launch {} 블록으로 즉시 비동기 실행하고 결과를 기다릴 필요 없이 응답을 바로 리턴 |
구조화된 concurrency 제공 | CoroutineScope, Job, Dispatcher로 작업 단위 명확하게 구분 가능하며, 오류 전파 및 취소도 간편 |
Dispatcher 제어 용이 | Dispatchers.IO를 통해 이미지 변환 및 S3 업로드 같은 IO 작업에 최적화된 스레드풀 사용 가능 |
코틀린 프로젝트와의 자연스러운 통합 | 전체 서비스가 Kotlin으로 구성되어 있어, 코루틴은 문법적/철학적으로도 일관성 유지에 유리 |
직관적인 예외 처리와 로깅 | try-catch 안에서 바로 Slack 알림 전송 등 실행 흐름 내 예외 대응이 용이 |
Dispatchers.IO란?
kotlinx.coroutines.Dispatchers.IO는 IO (입출력) 작업에 최적화된 코루틴 디스패처다.
여기서 IO 작업이란 다음과 같은 걸 의미함.
- 네트워크 요청 (ex. S3 업로드, DB 접근)
- 파일 읽기/쓰기 (ex. 이미지 변환용 파일 스트림 읽기)
- 디스크 접근
- 블로킹 API 호출 (내부적으로 Thread를 점유하고 기다려야 하는 작업)
왜 IO 작업엔 Dispatchers.IO가 적합한가?
1. 많은 스레드를 효율적으로 다룰 수 있음
- Dispatchers.IO는 기본적으로 64개의 스레드를 사용하는 스레드풀에서 동작한다.
- 필요에 따라 코어 수 × 64까지 확장 가능하다. (IO_PARALLELISM_PROPERTY_NAME 참고)
- 즉, 많은 파일/네트워크 요청을 동시에 처리할 수 있다.
2. CPU 작업과 분리됨
- Dispatchers.Default는 CPU 연산용 코어 수에 비례한 스레드풀 (ex. 8-core 시스템이면 8개)
- Dispatchers.IO는 블로킹 IO 작업 때문에 CPU가 놀지 않도록 분리된 스레드풀을 사용한다.
- 만약 이미지 압축 같은 작업을 Default에서 돌리면, CPU 코어 점유가 많아져 다른 코루틴 작업들이 느려질 수 있다.
요약
이미지 변환 : 파일 스트림을 읽고 디코딩 후 변환하는 작업이기 때문에 파일 읽기/쓰기(디스크 IO)
S3 업로드 : 네트워크를 통해 객체를 업로드하는 작업이기 때문에 네트워크 IO
그래서 최종적으로 작성한 코드 중 주요 코드만 보자면 아래와 같다.
fun uploadImage(file: MultipartFile, category: String? = null): String {
val filePath = generateFilePath(category)
CoroutineScope(Dispatchers.IO).launch {
try {
val webpData = convertToWebpWithResize(file.inputStream)
storageService.uploadFile(webpData, "image/webp", filePath)
} catch (e: Exception) {
logger.error(e) { "[이미지 업로드 실패] $filePath" }
slackErrorReportService.sendErrorReportToSlack(filePath, e)
// 자동 재시도 로직
}
}
return filePath
}
동작 흐름을 살펴보자면,
- 이미지 업로드 요청
- 고유 파일 경로 생성
- filePath 응답 리턴 (클라이언트는 여기서 응답받게 됨)
이렇게 흘러가게 된다.
여기서 2번과 3번 사이에 비동기 코루틴이 실행되는데,
- convertWebpWithResize() - 이미지 압축 및 Webp 변환
- storageService.uploadFile() - S3 업로드
- 예외 발생 시 sendErrorReportToSlack() - FeignClient 통해 슬랙 메시지 전송
이런 작업들이 진행된다.
구성 요소 분석
CoroutineScope(...) | 코루틴의 범위를 정하는 컨텍스트 객체 생성 |
Dispatchers.IO | IO에 최적화된 스레드 풀을 제공 (기본적으로 최대 64개의 스레드 사용) |
.launch { ... } | 코루틴을 Job 형태로 실행. 결과를 기다리지 않고 비동기로 실행됨 |
내부 동작 흐름
CoroutineScope(Dispatchers.IO).launch {
// background 작업
}
① CoroutineScope(Dispatchers.IO)
내부적으로 다음처럼 객체가 생성됨.
val context: CoroutineContext = Dispatchers.IO
val scope: CoroutineScope = CoroutineScope(context)
즉, Dispatchers.IO라는 디스패처를 코루틴 컨텍스트로 가진 스코프 생성
② .launch { ... }
- CoroutineScope.launch는 내부적으로 GlobalScope.launch(context) { ... } 와 유사하게 작동
- launch는 내부적으로 Job 객체를 생성하여 컨텍스트의 디스패처(IO) 에게 실행 요청을 던짐
launch의 내부는 실제로 이런 식으로 생겨있다.
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = ...
결국 Job을 만들어서 context에 등록된 Dispatcher(IO)의 CoroutineScheduler에 실행을 맡긴다고 보면 된다.
③ Dispatchers.IO의 동작
- Dispatchers.IO는 내부적으로 CoroutineScheduler 기반의 thread pool (기본 64개)을 사용
- 이 풀 안에서 남는 스레드가 있으면 코루틴을 하나 할당하고, 없으면 대기열에 넣음
- 실행되면 그 블록 안의 코드는 해당 스레드에서 순차적으로 실행
CoroutineScheduler -> Worker[] -> 선택된 스레드가 실행
④ 예외 처리 및 실행 결과
- launch는 Job을 반환하지만 우리가 변수에 담지 않으면 fire-and-forget으로 실행됨
- 예외가 발생하면 CoroutineExceptionHandler가 없다면 로그로만 출력되고 무시됨
- 그래서 예외 처리는 try-catch로 직접 감싸줘야 함
간단한 요약 흐름
2. launch { ... } → Job 생성, 해당 풀에 실행 요청
3. 남는 IO 스레드가 있으면 즉시 실행, 없으면 대기열에 보관
4. 블록 실행 → 완료 또는 실패 (예외는 try-catch 필요)
최종 개선 자료
각각 이미지 업로드를 포함한 다른 API 두 개씩을 전/후로 비교한 값이다.
이해를 위해 한 항목씩만 제일 평균값에 가까운 수치로 캡쳐했다.
최종적으로 아래와 같은 결과를 얻었다.
프로필 이미지 업로드 | 1.76s → | 22.37ms | 약 98.7% 감소 |
편지 이미지 업로드 | 1.84s → | 48.33ms | 약 97.4% 감소 |
평균 1.8초 → 35ms 수준으로 약 98% 이상 응답 속도 개선
사용자는 업로드 이미지 외 다른 응답을 즉시 수신, 서버는 비동기로 이미지 압축 및 S3 업로드 처리
너무 길게 작성하느라 뒤죽박죽 된 것 같은데..
이후에 기회가 된다면 자동 재시도 로직 포함해서 정리하면 좋을 것 같다.
'대충 넘어가지 않는 습관을 위한 기록' 카테고리의 다른 글
Slack Webhook을 활용한 자동화 레포트 전송 - Spring Boot (1) | 2025.03.12 |
---|---|
Java 15 - 멀티라인 문자열, 특수 이스케이프 문자 (0) | 2025.02.27 |
운영 서버 스웨거 사용 제한 (0) | 2025.02.12 |
AWS 해킹 기록.. (0) | 2025.02.12 |
CloudFront 적용기 [AWS - S3] (0) | 2025.02.06 |