iOS Image Memory Leak 이슈 기록

Tabber·2022년 12월 8일
0

TIL

목록 보기
3/3

AutoRelease Pool

오늘 풀어야 할 업무 중 하나에서 시작하여 찾은 개념이다.

오늘 업무는 굿즈를 제작하는 플로우에서 과도하게 메모리가 사용되어 메모리 릭 이 발생하는 이슈를 해결해야 했다.

문제는 어디서 발생한거야?

자, 문제를 해결하려고 한다면 당연히 어디서 발생했는지부터 확인을 해보아야 한다.

  1. 고화질 이미지 사용

우리 앱의 굿즈 제작 플로우는 다음과 같다.

사진 선택 → 사진 적용/편집 → 제작 완료.

메모리 릭이 나는 부분은 사진 적용/편집 부분이었다. 왜냐? 굿즈를 제작하려면, 원본 사이즈의 사진을 로컬에 저장함과 동시에, 프레임에 원본 사이즈의 사진 또한 적재해야 한다. 바로! 여기서 메모리 릭이 발생하는 상황이 생겼다.

사실 제작할때만 원본 사이즈가 필요한 것이고, 편집을 진행하는 과정에서는 원본 사이즈의 이미지가 필요하지 않다. 따라서 단순 Showing 하는 곳에서는 최소한의 용량으로 사진을 제공해야 하는 것이다.

따라서 이 문제는 이미지를 다운샘플링 하는 과정을 거치게 되었다.

func downsampled(by reductionAmount: Float) -> UIImage? {

        let image = UIKit.CIImage(image: self)
        guard let lanczosFilter = CIFilter(name: "CILanczosScaleTransform") else { return nil }
        lanczosFilter.setValue(image, forKey: kCIInputImageKey)
        lanczosFilter.setValue(NSNumber.init(value: reductionAmount), forKey: kCIInputScaleKey)

        guard let outputImage = lanczosFilter.outputImage else { return nil }
        let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false])
        guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return nil}
        let scaledImage = UIImage(cgImage: cgImage)

        return scaledImage
    }

이게 다운샘플링하는 코드인데, 사실 봐도 잘 모르겠으니 이번 기회에 하나씩 뜯어보겠다.

let image = UIKit.CIImage(image: self)

먼저 UIImage를 CIImage로 변환하였다. UIImage와 CIImage는 차이가 뭐길래 바꾸는 것일까?

UIImage : 앱에서 이미지 데이터를 관리하는 객체이다.

CIImage: 코어 이미지 필터로 처리하거나 생성할 이미지의 표현이다.

UIImage

CIImage

UIImage는 단순히 이미지 데이터를 보여주기 위해서 관리하는 클래스이고, CIImage는 이미지를 편집, 필터를 적용할 때 표현 시킬 수 있는 클래스라고 이해할 수 있을 것 같다.

guard let lanczosFilter = CIFilter(name: "CILanczosScaleTransform") else { return nil }

CIFilter 중 CILanczosScaleTransform 을 선택하여 guard 구문으로 nil 값 확인을 했다. 원본 이미지 고품질 크기 조정 버전을 생성할 때 사용한다고 한다. 일반적으로는 이 필터를 사용하여 이미지를 축소한다.

lanczosFilter.setValue(image, forKey: kCIInputImageKey)

방금 위에서 생성한 필터에 사용할 이미지를 Set하는 코드이다. kCIInputImageKey 는 입력 이미지로 사용할 CIImage 객체의 키라고 한다. 전역 변수이다.

lanczosFilter.setValue(NSNumber.init(value: reductionAmount), forKey: kCIInputScaleKey)

변경할 스케일을 입력하는 코드이다. kCIInputScaleKey 키를 이용하여 스케일을 set 할 수 있다.

guard let outputImage = lanczosFilter.outputImage else { return nil }
let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false])
guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return nil}
let scaledImage = UIImage(cgImage: cgImage)

스케일 다운된 CIImage 정보를 가지고 다시 CGImage를 생성하는 코드이다.

만들어진 CGImage를 이용하여 UIImage 를 제작하는 것을 볼 수 있다.

이렇게 다운 샘플링을 통해서 이미지의 크기를 줄여, 이미지를 로딩하는 속도를 절반으로 줄일 수 있었다.

  1. FileManager 모든 이미지에서 접근

사실 다운 샘플링보다 큰 문제는 이 문제였다고 생각한다.

달력과 같은 많아야 사진을 넣은 곳이 2~3개인 곳에서는 이미지를 불러오는 횟수가 급격하게 적어 상관이 없는 것처럼 보였지만, 스티커와 같이 반복적으로 이미지를 넣는 곳에서는 문제가 확실히 보이게 되었다.

onAppear 상황에서 무조건 FileManager를 통해서 로컬 폴더에 저장해놓은 이미지 데이터 (이미지 파일이 아니다.) 를 불러오는 코드를 실행한다. 문제는 같은 사진이라도 만일 40개의 프레임이 있으면 40번을 불러오는 것이다.

특히 스티커 프레임은 3~40개의 프레임에 모든 이미지를 고화질로 넣는 시도를 하고, 셀이 리프레시 될 때마다 40개를 계속 불러 들어오기 때문에 쓸데없이 메모리에 적재되는 문제가 생겼다.

따라서 한번 추가하고 수정할때마다 100~400MB 정도의 데이터가 적재되는 문제가 발생되었다.

용량도 용량인데, 속도 측면에서도 아주 꽝이었다. 모든 데이터를 계속 불러오는 로직을 메인 큐에서 태우다보니 우선순위가 40개 로드로 바뀌면서 아무것도 할 수 없는 상황이 계속 연출되었다.

이 문제를 해결한 방법은 다음과 같다.

  • 반복된 인덱스의 프레임이 나올 경우 0번째 인덱스에서만 FileManager를 접근하여 이미지를 가져온다.
  • 이후에는 딕셔너리를 통해 이미지를 임시로 저장시켜놓고 사용한다.

생각보다 로직은 단순하지만, 이 방법을 떠올리기 까지 생각보다 시간이 걸렸다. (경험의 차이인가,,뇌의 차이인가)

@State var imageDic: [String: UIImage] = [:]

func photoView(pIdx: Int, mm: CalendarPageModel, item: CalendarPhotoModel) -> some View {
        Group {
            if pIdx == 0 {
                Image(uiImage: LocalFileManager.getImageFromName(type:.Calendar ,fileName: mm.imageLocalUrl, calendarNo: calendarOrderModel?.optionNo != 0 ? String(calendarOrderModel?.optionNo ?? 0) : String(calendarOrderModel?.productNo ?? 0), isExample: isExample))
                    .resizable()
                    .scaledToFill()
                    .frame(width: isExample ? CGFloat(item.width) / sRatio : CGFloat(item.width),
                           height: isExample ? CGFloat(item.height) / sRatio : CGFloat(item.height))
                    .clipped()
                    .onAppear {
                        imageDic[mm.imageLocalUrl] = LocalFileManager.getImageFromName(type:.Calendar ,fileName: mm.imageLocalUrl, calendarNo: calendarOrderModel?.optionNo != 0 ? String(calendarOrderModel?.optionNo ?? 0) : String(calendarOrderModel?.productNo ?? 0), isExample: isExample)
                    }
            } else {
                Image(uiImage: imageDic[mm.imageLocalUrl] ?? UIImage())
                    .resizable()
                    .scaledToFill()
                    .frame(width: isExample ? CGFloat(item.width) / sRatio : CGFloat(item.width),
                           height: isExample ? CGFloat(item.height) / sRatio : CGFloat(item.height))
                    .clipped()
            }
        }
    }

코드는 다음과 같다.

아까 말했듯이 첫 인덱스가 OnAppear 될 때만 딕셔너리의 Key, Value로 매칭시켜 놓고 다음 인덱스부터 매칭시켜놓은 데이터를 사용하는 로직이다.

딕셔너리로 접근하기 때문에 O(1)의 시간 복잡도가 나오기에 접근할 때 속도가 더 빨라졌고, 불필요한 접근을 막아 메모리 릭이 발생하는 것을 막을 수 있었다.

그리고 이 모든 플로우는 AutoRelease Pool 에서 돌아간다.

AutoRelease Pool

AutoReleasepool 이란 참조 카운트가 감소되는 것을 미루면서 나중에 감소되는 것을 보장받기 위한 기법이다.

release 는 호출되는 즉시 참조 카운트를 감소 시키는 것이고, AutoRelease는 나중에 감소 시킨다.

사실 일반적으로는 ARC에 의해서 참조 관리를 해주기 때문에 사용하지 않아도 크게 상관은 없다. 하지만 반복적으로 임시 객체를 많이 생성하는 상황 (우리의 경우 UIImage를 40~50개 생성하는) 이라면 메모리 사용량은 기하급수적으로 증가하게 된다. 이럴 때 메모리를 해제해주지 않으면 앱은 메모리를 감당하지 못하고 죽고만다.

이때 autoreleasepool 블록 내에 반복되는 임시 객체 생성 코드를 넣으면 반복이 끝날 때 마다 release가 되어 할당 → 해제 → 할당 → 해제가 반복되는 것이다.

profile
iOS 정복중인 Tabber 입니다.

0개의 댓글