[iOS / SwiftUI] 메모리 사용량이 갈수록 늘어나요

박준혁 - Niro·2024년 10월 2일
1

SwiftUI

목록 보기
7/7
post-thumbnail
post-custom-banner

안녕하세요 Niro 🚗 입니다

Treehouse 라는 앱을 개발하면서 생긴 에피소드에 대해 적어볼까 합니다

바로 메모리 사용량 증가 이슈입니다!

그동안 여러 프로젝트를 해왔지만 메모리 사용량에 신경쓰면서 개발한 경험이 부족한데 이번 기회에 글도 쓰면서 정리할 수 있게 되어서 뜻깊은 경험이 되지 않을까 싶네요!


😦 스크롤 하는데 메모리 사용량이 증가한다?

Treehouse 앱은 다른 SNS 서비스 처럼 사진을 올릴 수 있는 폐쇄형 SNS 입니다.

사진을 다루게 되면 메모리 사용량에 대해 민감하기 때문에 서버에 올릴 때 용량이 3MB 이하만 가능하도록 설계가 되어있습니다.
서버 용량에 대한 압박도 있구요 ...

그래서 클라이언트에서 자체적으로 이미지 다운샘플링을 통해서 3MB 이하로 용량을 낮추고 서버에 올리게 되는 과정이 존재합니다.

또한 view 재사용 이슈로 인해서 동일한 이미지를 받기 위해 네트워크 요청을 하면 비효율적이겠죠?

이 부분은 URLCache 를 사용하여 메모리나 디스크에 동일한 URLRequest 가 있는지 확인하고 존재한다면 해당 결과값을 꺼내오도록 되어있습니다!

아~ 이제 이미지 때문에 메모리 사용량에 대해 신경 안써도 되겠다!

그런데 말입니다...

스크롤을 해보니 계속 메모리가 증가하는 현상을 발견했습니다....
(사실 위에 있는 이미지는 극적으로 변화량을 보여드리기 위해 총 10장의 사진을 위에서 아래 끝까지 스크롤 약 30회 반복한 결과입니다)

그렇다면 왜.. 메모리가 증가되는지 확인을 위해 더 자세히 데이터를 들여다보고자 Instruments 에서 확인을 해보았습니다

위, 아래로 스크롤을 반복하면 도대체 어떤 메모리가 증가 될까 확인해보니 위의 이미지 처럼

VM: ImageIO_PNG_Data 목록의 메모리가 증가 되는 현상을 발견했습니다!

ImageIO 는 이미지 포맷 처리,이미지 로드, 메타데이터, 압축, 품질 등 이미지에 전반적인 모든 작업을 도와주는 아주 감사한 프레임 워크입니다

그런데...

프로젝트 내에서 ImageIO 를 직접적으로 사용한 곳은 이미지를 서버에 올릴 때 다운샘플링 과정 말고는 사용하지 않는데

도대체 왜 사용이 되고 메모리가 증가할까?

라는 고민을 하게 되었습니다....


😵 ImageIO, 너 어디서 사용되는거야

지금 생각하면 바보같은 고민이였지만, 그 당시에는 결과를 찾기까지 많은 고민을 했었습니다...

결론부터 말하자면

로드된 Data 객체가 Image, UIImage 로 로드 될때 사용되는 것으로 파악했습니다!

우리가 서버에 올리거나 다운 받을 때 이미지는 메타데이터 즉, iOS 에서는 Data 유형을 사용하게 됩니다

해당 데이터를 우리가 이미지로 출력해야 할때 ImageIO 가 사용이 되고 ImageUIImage 로 로드될 때 사용이 되는 것이죠!

위에서 설명드린 것처럼 이미지에 대한 데이터는 URLCache 를 사용해서 메모리나 디스크에 저장이 되어있고 동일한 이미지를 요청 시 Data 유형을 꺼내오도록 되어있습니다.

그러면 DataImageUIImage 로 변환되거나 해당 유형을 사용하는 곳에서 메모리가 누수되는 현상이 발생되겠구나!

라는 생각이 떠올랐습니다


😱 왜 UIImage 를 갖고 있게 만들었을까..?

먼저 배경부터 설명을 드리자면 url 로 직접 이미지를 로드하기 위해 처음엔 AsyncImage 를 사용했지만 불편한 부분이 존재했고 view 재사용 시 계속 이미지를 요청하는 현상으로 인해

이미지를 불러오는 과정은 ImageLoader 라는 클래스가 전담하고 CustomAsyncImage 에 전달해서 로딩, 성공, 실패 에 대한 상태에 맞게 이미지를 출력해주는 컴포넌트를 만들어 놓았습니다.

  • ImageLoader
final class ImageLoader {
    private var url: String?
    var state: LoadState = .loading
    
    init() {
	// ...
	}
    
    @MainActor
    func fetch() async {
    	do {
            let (data, _) = try await URLSession.shared.data(for: request)
            if let image = UIImage(data: data) {
                state = .success(image)
            } else {
                state = .failure
            }
        } catch {
            print("Error loading image: \(error)")
            state = .failure
        }
    }
}
  • CustomAsyncImage
struct CustomAsyncImage: View {
    @State var imageLoader: ImageLoader
    // ...
     init(// ... ,
     	onImageLoaded: @escaping (UIImage) -> Void) {
        	if type == .postImage {
           	 	self.onImageLoaded = onImageLoaded
        	}
		}
	)
}
  • SinglePostView
struct SinglePostView: View {
	 @State private var loadedImage = [(Int,UIImage)]()

    VStack {
    	// ...
    }
    .fullScreenCover(isPresented: $isDetailImage) {
    	ImageDetailCarouselView(selectedIndex: selectedImage ?? 0, images: $loadedImage)
    }
	
    @ViewBuilder
    var singleImageView: some View {
        CustomAsyncImage(url: postImageURLs.first ?? "",
                         type: .postImage,
                         width: 314,
                         height: 200) { image in
                         	self.loadedImage.append((0, image))
                         }
	}
    
    // ...                  
}

이미지 데이터를 받아왔다면 UIImage 로 변환해서 CustomAsyncImage 에 넘겨주는 역할을 하게 됩니다.

근데 여기서 뭔가 이상한 곳을 발견하신 분이 있으실까요?
SinglePostView 에서 loadedImage 프로퍼티에 IntUIImage 를 저장하고 있다는 점입니다.

이미 CustomAsyncImage 에서 Image 를 출력하고 있는데 왜 UIImage 를 탈출 클로저로 반환해서 할당하고 있었을까요..?

이미지를 눌렀을 때 풀 사이즈의 이미지를 보여주기 위한 ImageDetailCarouselView 에서 이미지를 바인딩 시켜주기 위함이였습니다.

URLCache 에 해당 이미지가 이미 저장이 되어있는지 확인하는 작업을 걸치지 않고 바로 이미지를 보여주고 싶어서 이런 구조를 갖게 되었습니다.

그러면...

실제로 loadedImage 배열의 요소 개수가 계속 증가하는지 확인을 해보니 증가하기도 하지만 다시 1로 바뀌는 현상도 존재했습니다.
아마도 View 의 재사용으로 인해 다시 초기화 되어서 이런 현상이 발생했던거 같습니다.


자, 이제 어디서 메모리가 계속 증가되는지 가설을 세워보고 근거를 찾아보았습니다.

loadedImage 배열에 index 와 UIImage 가 할당되면서 UIImage 에 대한 참조가 살아있기 때문에 메모리가 계속 증가가 될 것이다!

라고 최종으로 결론을 짓고 코드를 수정해보았습니다.


⚒️ 비효율적인 구조를 바꿔보자!

위에서 제시한 가설을 통해 코드를 바꾸고자 다음과 같은 해결책을 세워봤습니다.

  1. CustomAsyncImage 마다 초기화 한 ImageLoader 의 구조를 변경
  2. UIImage 를 직접 배열에 할당하는 구조를 변경

바로 코드를 수정해볼까요?

1. ImageLoader 를 계속 할당하지 말자...

final class ImageLoader {
    static let shared = ImageLoader()
    
    private init() {}
    
    @MainActor
    func loadImage(url: String) async -> LoadState {
        guard let fetchURL = URL(string: url) else {
            return .failure
        }
        
        let request = URLRequest(url: fetchURL, cachePolicy: .returnCacheDataElseLoad)
        let usage = ImageLoader.getCurrentCacheUsage()
        
        do {
            let (data, _) = try await URLSession.shared.data(for: request)
            return .success(data)
        } catch {
            print("Error loading image: \(error)")
            return .failure
        }
    }
}
struct CustomAsyncImage: View {
    
    @State private var loadState: LoadState = .loading
    
    Group {
        switch loadState {
        // ...
        
        case .success(let data):
			Image(uiImage: UIImage(data: data) ?? UIImage())
        		.resizable()
            	.aspectRatio(contentMode: .fill)
        }
    }
    .task {
        loadState = await ImageLoader.shared.loadImage(url: url)
     }
}

가장 먼저 작업한 건 CustomAsyncImage 마다 할당하지 않고 사용하도록 ImageLoader 를 싱글톤 구조로 바꾸었습니다.

또한 이미지가 불려와졌는지 상태값을 갖고 있었는데CustomAsyncImage 가 상태를 관리하도록 loadImage 메서드에서 상태를 반환하도록 변경해주었습니다.

동일한 ImageLoader 를 공유하게 되면서 메모리 사용량이 줄어드는 효과도 있고

ImageLoader 는 이미지를 불러오는 역할만 갖도록 변경하고 CustomAsyncImage 는 상태에 따른 이미지를 보여주는 역할만을 담당하도록 역할과 책임을 분리하여 더욱 깔끔한 구조로 변경되었습니다.

2. UIImage 를 갖고 있지 말자

loadedImage 배열에 UIImage 에 대한 참조가 살아있기 때문에 메모리가 계속 증가가 될 것이다!

라고 가설을 만들었었죠?

이미 첫번째 해결책에서 onImageLoaded 클로저를 통해서 UIImage 를 넘겨주는 동작이 없어졌으므로 자연스럽게

UIImage 를 직접 갖고 있지 않게되었고 ImageDetailCarouselView 에서 customAsyncImage 를 사용하여 이미지를 불러올 수 있도록 바꿨습니다.

물론, 이미지가 로드 될때 바로 보여지는 것이 아닌 로딩 View 가 생기는 현상이 추가 됐지만 메모리가 계속 증가되는 현상보다는 더욱 낫다는 판단이였습니다!


💡 그래서 얼마나 좋아졌어요?

ImageDetailCarouselView 에서 사진을 바로 보여주기 위해서 UIImage 를 바인딩 해주어야 겠다 라는 생각로 인해 생긴 불상사였습니다..

변화를 좀더 극적으로 보고 싶어 위에서 설명드린 것처럼 약 30번의 스크롤 통해 얻은 데이터이지만

최대 메모리 사용량이

약 1 GB 에서 약 292 MB 로 71.5 % 줄일 수 있었습니다.

물론, 30번이나 스크롤을 통한 결과이지만 가장 긍정적인 것은 그래프의 형태가 우상향하지 않는다는 점으로 평균 사용량이 낮아졌다는 것입니다!

또한 스크롤 할때의 끊김 현상이나 부드러움 등 사용자 경험이 증가되는 긍정적인 결과도 얻었습니다!


📌 정리하자면!

아주 긴 글 읽으시느라 고생 많으셨습니다.

이번 글은 메모리 사용량이 계속 증가하는 현상을 파악하고 근거를 찾고 해결한 과정을 적어보았습니다.

  1. Instrument 도구를 통해 VM: ImageIO_PNG_Data 목록의 메모리가 증가 되는 현상을 발견

  2. ImageIOImageUIImage 로 변환되거나 해당 유형을 사용하는 곳에서 실행된다는 것을 파악

  3. Image, UIImage 로 변환, 사용하고 있는 컴포넌트에서 메모리 누수가 발생할 것이라는 가설을 세움

  4. 실제로 UIImage 를 갖고 있는 배열로 인해 메모리가 증가되는 것을 발견

  5. ImageLoader 의 구조와 UIImage 를 갖고 있지 않는 구조로 변경

직접 메모리 사용량을 분석해보고 왜 증가하는지 가설을 세우고, 근거를 찾고, 해결책을 찾는 과정을 나열해보았습니다.

다행히도 제가 세운 가설이 맞았고 구조 변경을 통해서 메모리 증가 현상을 방지할 수 있었습니다.


@State private var loadedImage = [(Int,UIImage)]()

단 한줄로 인해서 이렇게 메모리의 사용량이 크게 차이가 난다는 결과를 보면서

정말로 한줄 한줄 적을 때마다 어떤 Side effect 가 존재할지, 더 효과적인 구조인지를 생각해보면서 만들어야겠다!

라는 생각을 하게 되었습니다...

메모리 사용량을 줄이기 위해서 엄청 대단한 무언가를 사용한 것도 아니고 특별한 나만의 스킬을 사용했다 라는 글이 아니라

겨우 배열에 할당된 UIImage 때문에 벌어진 일이라 다른 분들에게 이 글이 큰 도움이 될지는 모르겠지만

이런 과정을 통해서 해결을 했고 다른 분들에게도 이런 자그마한 코드 한줄이 이런 현상을 발생시킬 수 있다는 것을 공유하고 정리를 할 수 있다는 것에 뿌듯하네요!

다음에도 더 좋은 글로 찾아오겠습니다!


참고자료

🔗 [Refactor] #157 - Feed 에서 스크롤 시 메모리 사용량 증가 현상 해결

profile
📱iOS Developer, 🍎 Apple Developer Academy @ POSTECH 1st, 💻 DO SOPT 33th iOS Part
post-custom-banner

0개의 댓글