[안드로이드] 이미지 Compress는 왜 suspend여야 할까

유진·2024년 3월 6일

Android

목록 보기
1/18
post-thumbnail

리뷰 작성 화면에서 이미지 업로드는 흔한 기능이다.
하지만 “이미지를 어떻게 업로드하느냐”에 따라 앱의 안정성과 성능은 크게 달라진다.

이 글에서는

  • 이미지 압축 라이브러리(Compressor)를 도입하게 된 배경
  • 왜 이미지 압축 로직이 suspend 함수여야 했는지
  • Coroutine 기반으로 구조를 어떻게 재설계했는지

를 실제 문제 해결 경험을 바탕으로 정리한다.


문제 상황: 이미지 업로드가 불안정했다

리뷰 작성 기능에서 다음과 같은 문제가 반복적으로 발생했다.

문제 1. 원본 이미지 그대로 업로드

  • 사용자가 선택한 이미지를 압축 없이 그대로 업로드

  • 이미지 크기: 약 5~6MB

  • 결과:

    • S3 버킷 용량 초과
    • 업로드 실패 빈번
    • 네트워크 환경에 따라 업로드 타임아웃 발생

문제 2. 업로드 타이밍이 잘못되어 있었다

  • 사진을 선택하는 순간 바로 업로드 실행

  • 사용자가 리뷰 작성을 취소해도

    • 이미지는 S3에 업로드됨
    • 사용되지 않는 이미지가 스토리지에 계속 쌓임

즉, 리뷰 작성과 무관하게 불필요한 업로드가 발생하는 구조였다.


해결 전략 개요

문제를 다음 두 단계로 나누어 해결했다.

  1. 이미지 용량 자체를 줄인다
  2. 업로드 타이밍을 “리뷰 작성 완료 이후”로 늦춘다

이를 위해 다음 선택을 했다.

  • 이미지 압축 라이브러리 도입
  • Coroutine 기반 suspend 함수로 업로드 구조 재설계

이미지 압축 라이브러리: Compressor

이미지 압축을 위해 사용한 라이브러리는 zetbaitsu의 Compressor이다.

선택 이유

  • Android Bitmap 기반
  • 사용법이 단순함
  • 파일 단위 압축 지원
  • 품질(quality) 조절 가능
val compressedFile =
    Compressor.compress(context, file) {
        quality(80)
    }

압축 결과

  • 기존: 5~6MB
  • 압축 후: 0.3~0.5MB
  • 용량 절감률: 약 84~93%

이미지 품질 저하가 체감되지 않는 수준에서 전송 크기를 대폭 줄일 수 있었다.


그런데 왜 compress는 suspend여야 할까?

이미지 압축 코드를 보면 겉으로는 단순해 보인다.

Compressor.compress(context, file)

하지만 내부적으로는 전혀 단순하지 않다.


이미지 압축은 “가벼운 작업”이 아니다

이미지 압축 과정에는 다음 작업들이 포함된다.

  • 파일 IO (원본 파일 읽기)
  • Bitmap 디코딩
  • 리사이즈
  • 재압축
  • 압축 파일 저장

이는 모두 CPU + IO를 동시에 사용하는 무거운 작업이다.

만약 이 로직을 UI 스레드에서 실행한다면:

  • 프레임 드랍
  • 스크롤 끊김
  • 심하면 ANR 발생

즉, 이미지 압축은 반드시 메인 스레드 밖에서 실행되어야 한다.


suspend의 의미: “UI 스레드를 점유하지 않는다”

그래서 이미지 압축 함수는 다음과 같이 정의했다.

private suspend fun compressImage(
    imageString: String?
): MultipartBody.Part? {
    if (imageString != null) {
        val file = File(imageString)

        val compressedFile =
            Compressor.compress(this@ReviewWriteRateActivity, file) {
                quality(80)
            }

        val requestFile =
            compressedFile.asRequestBody("image/*".toMediaTypeOrNull())

        return MultipartBody.Part.createFormData(
            "multipartFileList",
            compressedFile.name,
            requestFile
        )
    }
    return null
}

여기서 핵심은 suspend 키워드다.


suspend가 필요한 이유를 정리하면

1. UI 스레드를 블로킹하지 않기 위해

suspend 함수는
Coroutine Dispatcher 위에서 실행되며,
작업 중 스레드를 점유하지 않는다.

withContext(Dispatchers.IO) {
    compressImage(path)
}
  • 메인 스레드 안전
  • ANR 위험 제거

2. 순차 로직을 유지하기 위해

이미지 압축 → 업로드는
순서가 중요한 작업이다.

Coroutine을 사용하면 다음처럼 자연스럽게 표현할 수 있다.

viewModelScope.launch {
    val imagePart = compressImage(path)
    uploadReview(imagePart)
}

콜백이나 Handler 없이 “위에서 아래로 읽히는 코드”가 된다.


3. 생명주기와 함께 취소되기 위해

suspend 함수는 Coroutine Scope에 묶인다.

  • 화면이 종료되면
  • ViewModel이 클리어되면
  • Coroutine이 자동 취소됨

즉,

  • 압축 중이던 이미지 작업도 함께 중단
  • 메모리 누수 방지

업로드 구조 재설계

기존 구조는 다음과 같았다.

  • 사진 선택
  • 즉시 업로드
  • 리뷰 작성 여부와 무관

이를 다음처럼 변경했다.

변경 후 구조

  1. 사용자가 사진 선택
  2. 로컬에서만 보관
  3. “리뷰 작성 완료” 버튼 클릭
  4. suspend 기반 이미지 압축
  5. 압축 완료 후 업로드

이로 인해

  • 불필요한 업로드 제거
  • S3 스토리지 낭비 제거
  • 업로드 실패율 감소

라는 효과를 얻었다.


결과 정리

정량적 개선

  • 이미지 용량: 5~6MB → 0.3~0.5MB
  • 전송 데이터 감소: 최대 93%
  • 업로드 성공률 대폭 향상

구조적 개선

  • UI 스레드 안전성 확보
  • 리뷰 작성 플로우와 업로드 로직 분리
  • Coroutine 기반 비동기 처리로 가독성 향상
profile
안드로이드... 좋아하세요?

0개의 댓글