새벽의 기록

[iOS] 이미지 업로드 전처리 메모리 최적화 및 성능 개선 본문

[iOS]

[iOS] 이미지 업로드 전처리 메모리 최적화 및 성능 개선

OneTen 2026. 2. 4. 17:53

지난 글에서 이어지는 내용입니다.

https://dawning-record.tistory.com/143

 

[iOS] Clean Architecture 기반 이미지 업로드 파이프라인 구축기 (feat. AWS S3/TCA)

YAPP 27기 iOS 파트원으로써 참여한 Neki 프로젝트의 1차 스프린트에서 앱의 핵심 기능 중 하나인 이미지 업로드 기능을 구현했습니다.단순히 API를 호출하는 것을 넘어, AWS S3의 Presigned URL 방식을 도

dawning-record.tistory.com

 

네트워크 파이프라인은 뚫렸는데, 정작 고해상도 이미지를 여러 장 선택하니 앱이 버벅이며 기기가 뜨거워지는 현상을 목격했습니다.

이번 글에서는 업로드를 위해 이미지를 데이터 엔티티로 변환하는 '전처리 과정'에서 발생한 메모리 스파이크 문제를 해결하고, 처리 속도를 약 2.5배 향상시킨 리팩토링 과정을 기록합니다.

 

https://github.com/YAPP-Github/27th-App-Team-2-iOS/pull/59

 

[Refactor] #58 - 이미지 업로드 메모리 최적화 및 성능 개선 by OneTen19 · Pull Request #59 · YAPP-Github/27th-A

🌴 작업한 브랜치 refactor#58 ✅ 작업한 내용 고해상도 이미지 다중 선택 시 발생하던 메모리 폭증(Memory Spike) 현상과 UI 프리징(Blocking) 문제를 해결하기 위해 업로드 파이프라인을 전면 리팩토링

github.com


🚨 문제 상황: 고해상도 사진을 견디지 못하다

테스트 환경인 iPhone 16에서 48MP급 고해상도 이미지 10장을 선택했을 때 앱이 버벅이고 처리과정에서 기기의 온도가 비정상적으로 높아지는 현상을 목격했습니다.

 

원인 분석 1 - 불필요한 디코딩/인코딩

기존 로직은 이미지 데이터를 업로드용 엔티티로 변환하기 위해 다음과 같은 과정을 거쳤습니다.

  1. PhotosPicker에서 데이터 로드
  2. Data -> UIImage로 변환 (디코딩)
  3. UIImage -> Data로 다시 변환 (인코딩)
  4. 엔티티(data)로 변환 후 업로드

문제로 에측한 단계는 2번~3번입니다. 압축되어 있던 데이터를 UIImage로 만드는 순간, 이미지는 픽셀 단위의 비트맵으로 메모리에 풀립니다. 48MP 이미지는 장당 약 190MB의 메모리를 점유하게 되는데, 이걸 여러장 처리하니 메모리 사용량이 커지며 앱이 버벅이고 뜨거워졌습니다. 또한 그렇게 변환한 UIImage를 다시 Data로 인코딩하는 불필요한 작업이 반복되고 있었습니다.

현재 프로젝트 서버 스펙상으로는 별도의 리사이징이나 압축과정 없이 단순히 이미지 파일만 보내면 되는데, 굳이 UIImage로 변환해서 엔티티로 바꿀 필요가 없었습니다.

 

원인 분석 2 - 직렬 처리

 

이전 글에 작성했듯이 업로드 자체는 병렬로 구현했지만, 업로드를 하기 위한 준비 과정(변환 로직) for-loop 안에서 순차적으로 돌고 있었습니다. 이로 인해 이미지 개수가 늘어날수록 UI 프리징(Blocking) 시간이 정비례하여 늘어났습니다.


🛠 해결 전략

No-Decoding 파이프라인 (UIImage 제거)

가장 큰 병목인 UIImage 변환 과정을 완전히 제거했습니다. 이미지 데이터를 가공(필터, 리사이징)하는 것이 아니라면, Data 타입을 그대로 유지하는 것이 핵심입니다.

  • Before: Data  UIImage (메모리 폭발) → Data
  • After: Data (Raw) → Upload

대신, UIImage를 쓰지 않으면 알 수 없었던 파일 형식(JPEG/PNG 등)을 판별하기 위해, 파일의 헤더(Magic Number)를 읽어 형식을 감지하는 로직(detectedImageFormat)을 추가했습니다.

이미지 매직넘버는 이미지 파일의 종류를 식별하기 위해 파일 시작 부분에 고정적으로 들어가는 특별한 바이트(숫자 또는 문자열) 시퀀스로, 파일의 '지문'과 같아 파일의 형식을 빠르게 파악하고 손상 여부를 확인하는 데 사용됩니다. 예를 들어, JPEG는 FF D8으로 시작하고 PNG는 89 50 4E 47으로 시작하는 것이 매직넘버입니다.

 

따라서 이미지 데이터의 첫 몇 바이트(헤더)를 읽어 판별하는 로직을 Data 익스텐션으로 구현했습니다.

JPEG, PNG뿐만 아니라 최근 웹에서 자주 쓰이는 WebP 형식(RIFF/WEBP 헤더)까지 지원하도록 처리했습니다.

// Data+.swift

extension Data {
    /// 데이터의 매직넘버(MagicNumber)를 확인하여 이미지 포맷을 판별합니다.
    var detectedImageFormat: ImageFileFormat {
        // 데이터가 너무 짧으면 기본값(JPEG) 반환
        guard self.count > 12 else { return .jpeg }
        
        let header = self.prefix(12)
        let firstByte = header[0]
        
        // 1. PNG 확인 (0x89로 시작)
        if firstByte == 0x89 {
            return .png
        }
        
        // 2. WebP 확인 (RIFF ... WEBP)
        if header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46 && // "RIFF"
           header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50 { // "WEBP"
            return .webp
        }
        
        // 3. 나머지는 JPEG로 간주
        return .jpeg
    }
}

 

TaskGroup 기반 병렬 처리

TCA Reducer 내부에서 TaskGroup을 활용해 이미지 로드와 엔티티 변환을 병렬로 처리했습니다.

여기서 핵심은 item.loadTransferable(type: Data.self)입니다. UIImage.self가 아닌 Data.self로 로드함으로써 비트맵 디코딩 과정을 생략하여 메모리 스파이크를 원천 차단했습니다.

// ImagePickerFeature.swift (Reducer)

case let .pickerItemsChanged(items):
    state.pickerItems = items
    state.isLoading = true
    
    return .run { send in
        // 🚀 TaskGroup을 사용한 병렬 처리 시작
        let entities = await withTaskGroup(of: ImageUploadEntity?.self) { group in
            for item in items {
                group.addTask {
                    // 핵심: UIImage가 아닌 Data 타입으로 로드 (메모리 절약)
                    guard let data = try? await item.loadTransferable(type: Data.self) else {
                        return nil
                    }
                    
                    // 위에서 만든 Extension으로 포맷 감지
                    let format = data.detectedImageFormat
                    return ImageUploadEntity(data: data, format: format)
                }
            }
            
            // 결과 수집
            var results: [ImageUploadEntity] = []
            for await result in group {
                if let result { results.append(result) }
            }
            return results
        }
        
        // 변환 완료 액션 전송
        await send(.uploadReady(entities))
    }
  1. User Action: 사용자가 사진을 선택하면 .pickerItemsChanged 액션이 발생합니다.
  2. Effect (.run):
    • TaskGroup이 이미지 개수만큼 Child Task를 생성합니다.
    • 각 Task는 병렬적으로 Data를 로드하고 포맷을 감지합니다.
  3. Completion: 모든 처리가 끝나면 .uploadReady 액션을 통해 변환된 [ImageUploadEntity]를 State에 반영하고 업로드를 시작합니다.

이제 10장의 고해상도 이미지를 처리하더라도 메인 스레드가 차단(Blocking)되지 않으며, 메모리 사용량 또한 안정적으로 유지됩니다.


📊 성능 측정 및 검증

가설이 맞는지 정확한 검증을 위해 여러 분석도구를 활용하여 전후 데이터를 비교했습니다.

 

측정 도구 및 방식

Xcode Instruments

메모리 할당량(Heap Allocations)과 가상 메모리(Anonymous VM) 변화를 추적하여, UIImage 디코딩 시 발생하는 비트맵 메모리 폭증 현상을 모니터링했습니다.

 

Logger

CFAbsoluteTimeGetCurrent()를 사용하여 이미지 로드 시작부터 변환 완료까지의 구간별 소요 시간(Duration)을 정밀 측정했습니다.

 

OSSignposter (Interval & Event)

beginInterval과 endInterval을 사용하여 이미지 로드(I/O)와 변환(Processing) 구간을 시각화했습니다.

이를 통해 기존 로직의 직렬(Serial) 처리 병목과 개선된 로직의 병렬(Parallel) 수행 여부를 그래프로 확인했습니다.

 


측정 결과

Memory Usage (Xcode Instruments - Allocations)

  개선 전 개선 후

Xcode Instruments - Allocations

 

기존

 

그래프의 거대한 산 (00:30 ~ 00:53 구간)

  • 현상: 30초 부근에서 그래프가 갑자기 솟아올라 거대한 파란색 벽(Plateau)을 형성하고, 약 20초 동안 내려오지 않고 유지되다가 53초쯤에 뚝 떨어집니다.
  • 의미: 사진 10장을 선택해서 변환하는 동안 메모리가 해제되지 않고 계속 쌓여 있었다는 뜻입니다.
  • 위험성: 저 파란색 산의 높이가 기기의 가용 메모리(RAM) 한계를 넘으면 바로 앱이 꺼집니다(OOM Crash).

Total Bytes (2.45 GiB) - All Heap & Anonymous VM 행의 Total Bytes

  • 수치: 2.45 GiB
  • 해석: 앱이 이 작업을 수행하기 위해 순간적으로 할당한 메모리의 총량이 약 2.5GB에 달한다는 뜻입니다.
  • 비교: 앱의 평소 메모리 사용량(Persistent)은 117 MiB에 불과합니다. 사진 업로드 하나 때문에 평소의 20배가 넘는 메모리를 끌어다 쓴 겁니다.

-> 거대한 메모리 산(Mount Memory) 형성. 피크 시 2.45GB 점유. 잔잔한 평시와 이미지 변환시의 메모리 사용량 차이 매우 큼

 

 

개선 후

 

그래프

  • 아까 보였던 거대한 파란 산이 사라졌습니다.
  • 바닥에 약간의 움직임만 있을 뿐, 메모리 스파이크가 전혀 발생하지 않았습니다.

통계 (Statistics)

  • Total Bytes (총 할당량): 243.90 MiB
    • 2.45 GB → 243 MB로 약 1/10 수준(90% 감소)으로 줄어들었습니다.
  • All Anonymous VM (비트맵 이미지): 20.52 MiB
    • 917 MB → 20 MB로 약 98% 감소했습니다.
    • UIImage 디코딩을 안 했으니 비트맵 메모리가 거의 0에 가깝게 떨어진 것입니다.

-> 메모리 스파이크가 완전히 사라짐. 피크 시 243MB 유지. 평시와 이미지 변환시의 메모리 사용량 차이가 크게 줄어듦

-> 결과: 메모리 사용량 약 90% 절감

 


병렬 처리 (Xcode Instruments - Profile)

  개선 전 개선 후

Xcode Instruments - Profile


 

기존

  • ImageProcessing 막대가 아주 길게(수 초 동안) 늘어져 있습니다.
  • Photo점들이 촘촘하지 않고 듬성듬성, 일정한 간격으로 하나씩 찍혀 있습니다. (하나 끝나야 다음 거 시작한다는 증거)

루프를 돌며 직렬(Serial)로 이미지를 디코딩했기 때문에, Instruments의 os_signpost로 분석한 결과 작업들이 순차적으로 실행되며 긴 대기 시간을 유발하는 것을 시각적으로 확인할 수 있습니다.

 

 

개선 후

 

파란색 점들이 수직으로 일렬(Vertical Line)로 찍혀 있습니다.

  • TaskGroup을 이용한 병렬 처리(Parallel Processing)의 증거입니다.
  • 작업들이 옆으로 길게 늘어지지 않고, 동시에 와다다닥 실행되고 끝났다는 뜻입니다. (속도가 빠른 이유)

 

속도 확인 (os logger를 통한한 로그 프린트)

  개선 전 개선 후
시간

 

- 개선 전: 10장 기준 총 소요 시간 3.144 + 1.691 초 = 4.835초

- 개선 후: 10장 기준 총 소요 시간 1.953 초

 

4.835초 → 1.953초 로 단축

약 2.5배(60% 시간 단축)


 

사실 이미지를 서버로 업로드 할 때 병렬처리를 하는 것은 불문율로 너무나 당연시되어 있기 때문에 대부분 빼놓지 않고 구현한다고 생각합니다. 하지만 이미지를 업로드 하기 위한 형태(엔티티 등의)로 변환할 때를 고려하는 경우는 이번 일이 있기 전까지 미처 인지하지 못했으며, 생각보다 더 다양하고 여러 곳에서 최적화를 할 수 있고 해야하는구나 라는 감상이 들었습니다.

 

또한 이번 리팩토링을 통해 필요하지 않은 객체 생성(UIImage)이 얼마나 큰 비용을 치르는지 체감할 수 있었습니다.

특히 고해상도 이미지를 다룰 때는 단순히 기능을 구현하는 것을 넘어, 데이터가 메모리 상에서 어떻게 존재하는지 파악하는 것이 중요했습니다. 

 

다만, 현재 구현된 로직의 전제는 서버로 이미지 업로드 시에 별도의 리사이징이나 압축을 하지 않는다는 조건이 깔려있습니다. 

통상 서비스, 특히 이미지를 주로 다루는 앱에서 이미지의 압축과 리사이징은 필수불가결한 요소라고 생각하기에 추후에는 우리 서비스에도 추가될 것으로 예측됩니다. 이 경우에는 Data 비트맵만으로는 압축과 리사이징 요구사항을 구현할 수 없으므로 특정 객체로의 변환이 필요해지는데, 이 과정 역시 여러 방안을 모색하며 최적화를 해 볼 계획입니다.

 

Comments