메모리 관리하기



reference: 

https://blog.aritraroy.in/everything-you-need-to-know-about-memory-leaks-in-android-apps-655f191ca859





 GC?

JVM의 GC에 대해서 간략하게 정리를 해보겠습니다. 안드로이드 소스를 자바(혹은 코틀린)로 작성을 하지만, GC에 대해 자세히 공부해본 적이 없단 것은 살짝 부끄러운 부분입니다. 그래서 여기서 간략하게 정리하겠습니다.




어떤 자바 어플리케이션이든, 처음에 프로그램이 시작되고 객체를 생성하는 과정이 생길 것입니다. 그 처음 생성되는 객체는 GC의 root object가 됩니다. 그 root object로부터 또 다른 객체들이 생성되고 직/간접적으로 참조될 것입니다. 그 object들을 트리 구조로 관리하게 됩니다. 메모리 트리가 되는 것이죠.

GC는 이 트리의 root로 부터 순회하면서 방문되지 않는 객체들이 생기게 될텐데요, 그것이 바로 GC의 대상이 됩니다. 어떤 객체도 참조하고 있지 않은 메모리가 회수 대상이 되는 것이죠. 상당히 상식적이라고 생각할 수 있겠습니다.


 메모리 누수?

메모리가 누수된다는 의미는 더이상 쓸모가 없는 메모리를 계속해서 참조하고 있다는 것입니다. 사용 되지 않는 메모리임에도 불구하고 계속해서 어플리케이션으로부터 참조가 된다면, GC의 대상이 아니게 되기 때문에 불필요하게 메모리를 차지하겠죠. 바이트 단위로 메모리를 정말 타이트하게 메모리를 관리하기란 큰 프로젝트일수록 아무리 전문가라도 버거운 일일 것 같습니다. 하지만 기본적인 메모리 누수는 당연히 이루어져야 하는 일이겠죠? 

GC는 앱의 사용 메모리가 계속 증가하는 양상을 보인다면 short GC를 하게 되는데, 앱과 병렬로 진행되며 소요시간이 크지 않아 성능에 주는 영향이 미미합니다. 그럼에도 불구하고 정말 많은 메모리가 쓰이기 시작하면 "stop-the-world" GC가 수행됩니다. 이름이 말해주듯, 모든 동작을 멈추고 메모리를 회수하는 작업만 하게 됩니다. 어플리케이션 성능에 상당한 영향을 미칠 것입니다.


 어떻게 메모리 누수를 감지할까?

Android studio에서 기본적인 메모리 모니터를 제공하는데, 런타임에서 사용되는 메모리를 그래프 형식으로 시각화 해주기 때문에 상당히 유용합니다. 아마 거의 모든 개발자들이 이미 알고 있는 부분일 것입니다. 하지만 어떤 메모리가 많이 잡아먹고 있는지 분석하기 위해서는 메모리 덤프를 통해야 할텐데, 상당히 시간을 많이 소요하는 작업일 수 있습니다. 그런 부분을 도와주는 라이브러리가 있습니다. 

LeakCanary(https://github.com/square/leakcanary)가 그런 역할을 대신 수행해 줍니다. (역시 Square군요..)

이 라이브러리는 어플리케이션과 같이 동작하면서 필요할 때 메모리를 덤프 해주고, 메모리 누수가 일어날 것 같은 상황을 예측하고 원인 분석 및 스택 트레이스를 덤프 해주는 기능도 합니다. 굉장히 좋은 라이브러리 같네요. 조만간 사용해보고 새 블로그를 작성해보겠습니다.


흔한 메모리 관리 실패 사례

안드로이드(를 비롯해서 다른 플랫폼도 어쩌면 동일할 수 있는) 클라이언트에서 발생하는 메모리 실패 사례를 살펴보겠습니다. 기본적인 아이디어는 위에서 언급한 바와 마찬가지로, 변경되거나 더 이상 사용되지 않는 객체를 참조하는 일을 저지르는 사례들 입니다.

1. Unregistered Listeners

어떤 모듈로 부터 데이터를 얻어온 후 뷰에 적용하여 보여주는 역할이 클라이언트 대부분의 작업일 것입니다. 그럴 때 사용하는 것이 listener인데요, 그 데이터를 사용하는 액티비티가 죽거나 더이상 사용되지 않음에도 모듈이 계속 갖고 있는 때입니다. 굉장히 자주 일어나는 실수 중에 하나이며 놓치기 쉬울 뿐더러 발생하는 누수도 상당합니다.

해결법은 간단합니다. 액티비티의 onDestroy()에서 그 모듈로부터 자신을 listener 등록 해제 하는 것입니다.

2. Inner Classes

클래스 안의 클래스를 작성하는 경우는 많습니다. 액티비티에서 새로운 thread나 AsyncTask를 수행한다고 가정하면, 아래와 같은 코드가 되겠죠:

public class BadActivity extends Activity {
    private TextView mMessageView;
    @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.layout_bad_activity);
mMessageView = (TextView) findViewById(R.id.messageView);
        new LongRunningTask().execute();
}
    private class LongRunningTask extends AsyncTask<Void, Void, String> {
        @Override
protected String doInBackground(Void... params) {
return "Am finally done!";
}
        @Override
protected void onPostExecute(String result) {
mMessageView.setText(result);
}
}
}

문제가 딱히 발견되지 않을 수도 있겠습니다만, AsyncTask가 시간이 많이 걸리는 작업을 한다고 가정한다면? 위의 코드로는 액티비티보다 AsyncTask가 더 오래 살아있을 수도 있습니다. 그런 경우, AsyncTask는 더이상 데이터를 뿌려줄 액티비티가 없음에도 불구하고 불필요하게 액티비티의 TextView를 잡고있게 되는 것입니다. 상상만 해도 끔찍하네요.

이런 경우, 참조하는 객체를 WeakReference로 갖고있으면 간단히 해결할 수 있겠습니다. (GC가 수행되면 수거되는 메모리 대상)

3. Anonymous Class

2번 경우와 똑같습니다. 익명 클래스는 아주 유용하지만 메모리 누수에 쉽게 노출됩니다. 흔히 예를 들자면, Retrofit으로 API를 호출하고 그에 대한 응답 Callback을 작성할 것인데, 이를 익명 클래스로 작성하는 경우가 있을 것입니다.

public class MoviesActivity extends Activity {
    private TextView mNoOfMoviesThisWeek;
    @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.layout_movies_activity);
mNoOfMoviesThisWeek = (TextView) findViewById(R.id.no_of_movies_text_view);
        MoviesRepository repository = ((MoviesApp) getApplication()).getRepository();
repository.getMoviesThisWeek()
.enqueue(new Callback<List<Movie>>() {

@Override
public void onResponse(Call<List<Movie>> call,
Response<List<Movie>> response) {
int numberOfMovies = response.body().size();
mNoOfMoviesThisWeek.setText("No of movies this week: " + String.valueOf(numberOfMovies));
}
                    @Override
public void onFailure(Call<List<Movie>> call, Throwable t) {
// Oops.
}
});
}
}

onResponse()가 호출되기 전에 액티비티가 무조건 살아야 있어야만 할 것입니다. 그래야 메모리가 누수되지 않겠죠? 2번과 똑같은 상황입니다. 

4. Wrong Contexts

안드로이드에서 Context는 매우 유용하게 여기저기서 사용되는 객체입니다. 그러나 어플리케이션, 액티비티, 서비스에서 모두 같은 Context를 사용하는 것이 아닙니다.

이를 잘 이해하고 적절한 Context를 참조하여 엉뚱한 메모리 누수가 발생하지 않을 필요가 있겠습니다.

이 부분 다음 포스트에서 작성해보도록 하겠습니다. 


Conclusion

위 사례들에서 관통되는 공통적인 이슈는, 액티비티와 그 상호작용하는 모듈의 lifecycle이 서로 일치하지 않는 데서 오는 것으로 볼 수 있습니다. AsyncTask나 Retrofit 모듈 모두, 액티비티에서 발생하는 상황을 모르고 있기 때문에 엉뚱한 메모리를 참조하거나 심지어 크래쉬까지 발생시키는 것입니다. 결론적으로, 액티비티가 회전되거나 파괴될 때 그 상황을 모듈에 적절히 알려야할 필요가 있습니다.

최근 구글에서 제안한 설계 라이브러리 중에서 ViewModel, LiveData 등은 모두 이러한 점을 보완하기에 적절한 것 같습니다. 위 언급과 마찬가지로 모든 문제는 모듈들이 액티비티의 lifecycle을 인지하지 못하는 것에서 비롯됩니다. 구글은 이 라이브러리들을 lifecycle-aware라는 표현으로 수식하였는데요, 말 그대로 모듈들에게 액티비티의 현재 lifecycle을 인지하도록 강제하여 위와 같은 사태를 좀 더 효율적으로 관리할 수 있을 것 같습니다.


'programming > android' 카테고리의 다른 글

Companion Objects in Kotlin  (0) 2018.04.08
어떤 Context를 써야 하나요?  (0) 2018.03.26
Splash 화면 구성하기  (3) 2018.02.20
Kotlin Coroutines #2  (0) 2018.01.28
Kotlin Coroutines #1  (1) 2018.01.27

+ Recent posts