메모리 잘 관리하기 - WeakReference편

모바일 앱 개발할 때 메모리 관리는 어떻게 하고 계신가요? 개발 과정에서 미처 메모리 관리를 놓치시는 경우가 종종 발생하는데요. 왜 메모리 관리가 필요한지 알아보고, 안드로이드 메모리를 잘 관리할 수 있는 WeakReference에 대해 구체적인 사례와 함께 살펴보았습니다.

작성일 2023년 08월 09일

소프트웨어 개발자는 효율적인 메모리 관리를 해야 하고, 빠른 알고리즘을 선택해야 하고... @!$@#%! 생각보다 일이 더 많습니다.

무엇보다 좋은 소프트웨어를 만드는 것도 중요하지만, 소프트웨어를 개발하다 보면 잘 만들기보다는 우선 기능을 빠르게 만들어야 하는 경우도 있고, 동작만 되게 만들어야 하는 경우도 많습니다. 개발 생산성을 챙기다 보면, 성능이나 리소스 관리, 예외 처리 등 놓치는 게 많을 수 있습니다. 그러면 결국 앱의 품질이 떨어질 가능성이 높겠죠?

성능이나 예외 처리를 신경 쓰지 않고 개발하는 경우, 앱이 느리거나 크래시가 바로 발생하기 때문에 개발 단계에서도 개발자가 어느 정도 대응할 수 있습니다.

하지만 조금씩 메모리를 차지하는 경우라면, 개발자가 개발 단계에서 한 번에 찾아내기도 어렵습니다. 그보다 메모리 사용량을 보면서 개발하는 개발자분들을 많이 뵙지 못했습니다.

이번 포스팅에서는 이러한 메모리 관리를 왜 해야 하며, 어떻게 하면 조금이라도 메모리 관리에 신경 쓰며 개발할 수 있을지 알려드리고자 합니다.

어플리케이션은 어떨 때 메모리를 가장 많이 사용할까?

안드로이드뿐만 아니라 대부분의 어플리케이션에서 메모리를 많이 사용하는 기준은 리소스입니다. 다만 어떤 어플리케이션인지에 따라 어떤 리소스를 쓰는지가 다를 것 같은데요.

해당 이미지는 IMQA의 서버쪽 힙 덤프입니다.

리소스를 다루는 어플리케이션일수록 Array, String, Byte 등의 데이터가 많이 보이고, 데이터가 많이 쌓이게 되면 메모리가 꽉 차서 OOM(Out Of Memory)가 발생하게 되죠. 모바일에서도 마찬가지인데요.  OOM이 발생할 경우 앱이 죽기 때문에 큰 문제라고 할 수 있습니다.

특히 사양이 좋은 서버에 비해 모바일은 한정적인 자원으로 앱을 구동해야 하며, 다른 어플리케이션과 메모리를 서로 확보하기 위해 다투다 보면 앱이 느려지겠죠. 또 많은 메모리를 사용하기 위해 메모리에 데이터를 올리는 작업은 자칫 앱 자체가 매우 무거워질 수 있습니다.

메모리의 경우 가득 차면 비우는 GC 작업이 일어나는데, 이러한 GC 작업은 매우 무겁기 때문에 애초에 메모리를 효율적이고 적게 사용하면 좋겠죠?

하지만 앱을 개발하다 보면 어찌 메모리를 쓰지 않으리…

결국 메모리를 써야 한다면, 메모리를 써야 할 땐 쓰고, 다 쓰면 잘 버리는 전략도 필요할 텐데요. 이를 위해 안드로이드 메모리 잘 관리할 수 있는 WeakReference에 대해 살펴보겠습니다.

WeakReference란 무엇인가?

우선 WeakReference가 무엇인지 알기 전에 GC에 대해 알아야 합니다. GC는 Gabage Collection의 줄임말로, IMDev2023에서도 GC에 대해 다룬 영상이 있습니다.

  • [IMDEV 2023] 혼자서도 잘하는 메모리 청소부 쥐씨: https://youtu.be/F4lWAWOTXyg

영상에 나오듯 불필요한 메모리 자원을 청소(초기화)해 주는데요. 큰 메모리를 청소하기 위해 많은 자원을 쓰기 때문에 자칫 GC가 동작하는 동안 서비스가 느려질 수 있습니다.

GC가 메모리에 GC의 대상인지를 체크하는 방법은 Reachable 한지, unReachable한지를 판단하는 것이죠.

Root에서 참조하고 있는지, 즉 사용하고 있는 객체인지를 체크하는 방법들이 있고, 그 방법을 통해 Reachable 한지를 판단하는 것이죠. (이 부분은 위 영상에 매우 잘 설명되어 있으니, 확인해 보세요.)

그러면 어떨 때 WeakReference를 쓰는 것일까요?

매번 GC가 돌 때마다 메모리 회수가 된다는 WeakReference는 언제 써야 하는 것일까요?

캐싱

데이터가 언제든 날아가도 좋은 데이터의 대표적인 케이스에는 캐싱 데이터가 있죠. 메모리가 부족할 때 캐싱 데이터가 없어지더라도 재요청하여 가져오면 되는 것이겠죠? 물론 캐싱 데이터가 날아가지 않도록 해야 하는 중요한 데이터라면, WeakReference를 쓰면 안 되겠죠.

특히 모바일에서 많이 쓰는 캐싱 중에는 이미지 캐싱이 많습니다. 많은 이미지 라이브러리에서는 이런 무거운 비트맵 데이터들을 무조건 메모리에 올리기보다는 상황에 따라서 다시 로드하고, GC가 돌았을 때 날리는 전략을 사용하고 있습니다.

Observability한 객체 - 약한 참조 만들기

요새 다양한 라이브러리가 Reactive해지면서 Observer, Delegate와 같은 패턴이 많이 사용되는데요. 이러한 패턴들은 객체를 감시하는 경우가 많고, 객체가 해제되었는지를 체크하기가 쉽지 않습니다. 상황에 따라서 이러한 객체들이 서로 순환 참조가 되면서 GC의 대상이 되지 못하는 경우가 있습니다. 비즈니스 코드를 잘 고려해야겠지만, 이러한 순환 참조를 막기 위해 WeakReference를 써서 문제를 해결하는 경우도 많습니다.

구체적인 케이스를 알고 싶어요.

Context의 참조는 WeakReference로!

A액티비티에서 B액티비티를 참조해야 할 때 다음과 같이 한다면 어떻게 될까요?

public class MainActivity extends Activity {
    private final MyHandler mainHandler = new MyHandler(this);

    private static class MyHandler extends Handler {
        private final MainActivity mActivity;

        MyHandler(MainActivity mActivity) {
            this.mActivity = mActivity;
        }

        @Override
        public void handleMessage(@NonNull Message msg) {
            this.mActivity. ....
            ...
        }
    }
}

다음과 같은 코드가 있다고 했을 때 로직상 이슈는 없을지도 모르겠습니다. 다만, 앱이 진행되다 보면 Activity나 Fragment와 같은 Context 객체는 상황에 따라 GC가 되기도 하며, Stack에 따라 지워지기도 합니다. 이럴 때 다음과 같이 WeakReference로 방어 코드를 작성한다면 조금 더 안전할 수 있습니다.

public class MainActivity extends Activity {
    private final MyHandler mainHandler = new MyHandler(this);

    private static class MyHandler extends Handler {
        private final WeakReference<MainActivity> mActivity;

        MyHandler(MainActivity mActivity) {
            this.mActivity = new WeakReference<MainActivity>(mActivity);
        }

        @Override
        public void handleMessage(@NonNull Message msg) {
            MainActivity activity = this.mActivity.get();
            ...
        }
    }
}

다음과 같이 작성한다면 mActivity를 감싼 WeakReference를 통해 객체를 받아오기 때문에 안전한 방어 코드가 작성될 수 있으며, 메모리 릭을 막을 수 있게 됩니다. 특히 이러한 핸들러를 이용한 개발은 view layer에서 많이 사용되는데요. Java8의 Optional과는 다른 느낌이라는 것도 알고 계시면 더 좋을 것 같습니다.

이러한 방식은 iOS에서도 마찬가지입니다. iOS에서 사용되는 Delegate의 경우 상황에 따라 자기 자신을 셀프 참조하는 경우가 있는데요. 이때도 마찬가지로 Objective C의 weak를 이용하여 설정하는 것이 일반적입니다.

protocol SomeDelegate: AnyObject {
    func didSomething()
}

class SomeClass {
    weak var delegate: SomeDelegate?
}

당신은 사용하지 않았지만, 오픈소스에서 캐싱할 때 사용되는 WeakReference

위에 설명한 대로 모바일에서 사용하는 무거운 리소스는 캐싱을 하는 것이 기본적인 전략입니다. 많은 오픈소스들이 캐싱을 지원하고 있고, 기본적으로 많이 사용되는 캐싱 전략에 LRU 캐싱을 많이 사용하기도 합니다. 하지만, 캐싱 알고리즘과 GC 전략은 다르기 때문에 같이 쓰이는 경우가 많습니다.

AQuery와 같은 오픈소스의 경우, 이미지 캐시와 파일 캐시를 사용하여 이미지를 캐싱합니다. 이미지 캐싱은 LRU 알고리즘을 기반으로 작동하며, 이 이미지 객체를 WeakReference로 감싸서 메모리 누수를 최소화합니다.

import android.content.Context;
import android.graphics.Bitmap;
import android.support.v4.util.LruCache;
import java.lang.ref.WeakReference;

public class ImageCache {
    private LruCache<String, WeakReference<Bitmap>> memoryCache;

    public ImageCache(Context context) {
        // 최대 메모리의 1/8을 메모리 캐시로 사용
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8;

        memoryCache = new LruCache<String, WeakReference<Bitmap>>(cacheSize) {
            @Override
            protected int sizeOf(String key, WeakReference<Bitmap> bitmapWeakReference) {
                Bitmap bitmap = bitmapWeakReference.get();
                if (bitmap != null) {
                    // 실제 비트맵 객체의 크기를 반환하여 메모리 크기 계산
                    return bitmap.getByteCount() / 1024;
                } else {
                    // 이미지가 가비지 컬렉터에 의해 제거된 경우, 0을 반환하여 크기를 계산하지 않음
                    return 0;
                }
            }
        };
    }

    public void put(String url, Bitmap bitmap) {
        memoryCache.put(url, new WeakReference<>(bitmap));
    }

    public Bitmap get(String url) {
        WeakReference<Bitmap> weakRef = memoryCache.get(url);
        if (weakRef != null) {
            Bitmap bitmap = weakRef.get();
            if (bitmap != null) {
                // 이미지가 메모리 캐시에 존재하는 경우
                return bitmap;
            } else {
                // 이미지가 가비지 컬렉터에 의해 제거된 경우
                memoryCache.remove(url);
            }
        }
        return null;
    }
}

iOS도 이미지를 캐싱할 때 WeakReference를 사용합니다.

SDWebImage라는 라이브러리는 iOS 앱에서 비동기 이미지 로딩 및 캐싱을 지원하는 라이브러리인데요. 안드로이드의 이미지 라이브러리처럼, 이미지 캐싱은 메모리 캐시와 디스크 캐시를 사용하여 구현되었고, 특히 메모리 캐시에서 이미지를 WeakReference로 감싸서 캐싱하여 메모리 관리를 최적화합니다.

#import <SDWebImage/SDWebImage.h>

@interface MyViewController : UIViewController

@property (nonatomic, strong) SDWebImageManager *imageManager;
@property (nonatomic, strong) NSCache *imageCache;

@end

@implementation MyViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // SDWebImageManager 객체 생성
    self.imageManager = [SDWebImageManager sharedManager];
    
    // NSCache 객체 생성 (메모리 캐시)
    self.imageCache = [[NSCache alloc] init];
    self.imageCache.countLimit = 100; // 캐시 크기 제한 (예: 100개)
}

- (void)downloadImageWithURL:(NSURL *)url {
    // 이미지 다운로드
    [self.imageManager loadImageWithURL:url options:0 progress:nil completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
        if (image && finished) {
            // 이미지가 성공적으로 다운로드되었고, 이미지가 최종적으로 화면에 보여진 경우에만 캐시에 저장
            [self.imageCache setObject:[NSValue valueWithNonretainedObject:image] forKey:url.absoluteString];
        }
    }];
}

- (UIImage *)cachedImageWithURL:(NSURL *)url {
    NSValue *value = [self.imageCache objectForKey:url.absoluteString];
    UIImage *image = [value nonretainedObjectValue];
    return image;
}

@end

위의 코드에서 MyViewController 클래스는 이미지를 비동기적으로 다운로드하고, 메모리 캐시에 캐싱하는 간단한 예시를 확인할 수 있습니다. SDWebImageManager를 사용하여 이미지를 다운로드하고, NSCache를 사용하여 메모리 캐시에 이미지를 WeakReference로 감싸서 캐싱합니다.

이렇듯 WeakReference를 사용하지 않으셨더라도, 사용하고 계신 많은 오픈소스에서 WeakReference를 이용하여 메모리를 관리하는 것을 알 수 있습니다.

모바일 웹에서는 메모리 관리가 필요 없을까?

여러분이 만약 모바일 네이티브가 아닌 모바일 웹 개발자라면 메모리 관리를 익히는 것이 필요 없을까요? 사실 저는 웹 개발을 할 때, 메모리를 효율적으로 관리하는 코드를 집어넣으면서 웹 프론트 개발을 해본 적이 없던 것 같습니다.

모바일 개발은 리소스를 최적화하고 캐싱하는데 필사적인데, 왜 모바일 웹은 그런 작업을 하지 않았을까요?

제가 개인적으로 생각한 답은 '브라우저가 대부분을 대신해 주었고, SSR 페이지에서 생각보다 변화되는 리소스가 없기 때문에 메모리 관리가 많이 필요한 부분이 없지 않았을까?' 라는 것입니다.

그렇다면 요새 유행하는 SPA 프레임워크들은 어떨까요?

React에서 사용하는 WeakReference

React는 가상 DOM을 사용하여 성능을 최적화하는데, 이 과정에서 약한 참조를 활용합니다. React에서는 컴포넌트의 인스턴스를 약한 참조로 저장하여, 해당 컴포넌트가 더 이상 사용되지 않을 때 메모리에서 자동으로 해제될 수 있도록 합니다.

import React, { useEffect, useRef } from 'react';

class MyComponent extends React.Component {
  // 생성자에서 약한 참조를 생성하고, 컴포넌트 인스턴스를 해당 약한 참조에 저장합니다.
  constructor(props) {
    super(props);
    this.componentRef = React.createRef();
  }

  componentDidMount() {
    // 컴포넌트가 마운트된 후에 실행되는 로직입니다.
    // 이 부분에서 컴포넌트 인스턴스를 참조하는 작업을 수행할 수 있습니다.
    // 예: 이벤트 리스너 등록, 외부 라이브러리 초기화 등
    console.log('Component is mounted.');
  }

  componentWillUnmount() {
    // 컴포넌트가 언마운트되기 전에 실행되는 로직입니다.
    // 이 부분에서 컴포넌트 인스턴스를 참조하는 작업을 정리할 수 있습니다.
    // 예: 이벤트 리스너 제거, 외부 리소스 정리 등
    console.log('Component is unmounted.');
  }

  render() {
    return (
      <div ref={this.componentRef}>
        {/* 컴포넌트의 내용 */}
      </div>
    );
  }
}

export default MyComponent;

이렇게 약한 참조를 사용하면, 컴포넌트가 더 이상 사용되지 않을 때 메모리에서 자동으로 해제됩니다. 따라서 메모리 누수를 방지하고 React 애플리케이션의 성능을 개선하는 데 도움이 됩니다.

웹 프론트에서도 마찬가지로 약한 참조에 대한 처리로 WeakReference를 사용하는데요. 이는 React뿐만 아니라 vue, angular와 같은 SPA 프레임워크, 상태 관리를 해주는 Redux, 그리고 많은 데이터 양을 시각화해 주는 D3.js에서도 이와 같은 목적으로 WeakReference를 사용합니다.

물론 메모리를 효율적으로 사용하는 방법으로 사용되지만, 개발자의 입장에서는 안전한 객체 사용을 위한 방어 코드로써 사용해 주는 이유도 생기기 때문에 여러분 코드에서는 어떻게 사용해야 할지 고민해보셔도 좋을 것 같습니다.

여러분들은 WeakReference를 사용해 보셨나요?

이번 포스트에서는 WeakReference의 목적과 다양한 플랫폼 모바일 개발에서 어떨 때 사용되는지 몇 가지 예시를 살펴보았습니다.

저도 모바일 개발을 하면서 생각보다 많이 사용하진 않았는데요. 이는 아마 오픈소스들에서 이미 WeakReference를 적용해서 메모리를 잘 관리해 주고 있었기 때문이겠죠. 하지만 약한 참조와 같은 예시는 여러분들의 비즈니스 코드에서도 일어날 수 있는 예시입니다.

오픈소스들은 왜 WeakReference를 사용하였는지 다시 한번 생각해 보면서 여러분들의 코드에서도 안전한 메모리 관리를 하실 수 있도록 적용해 보면 어떨까요?


<출처>

Share on

Tags

IMQA 뉴스레터 구독하기

국내외 다양한 기술 소식을 선별하여 매월 전달해드립니다. IMQA 뉴스레터를 통해 기술 이야기를 함께해보세요.

구독하기