티스토리 뷰

※ Aritra Roy의 미디엄 글 EveryThing You Need To Know About Memory Leaks In Android Apps 를 번역/요약한 글입니다.


Java에서는 가비지 콜렉터가 메모리 관리를 해주지만 안드로이드에서 메모리 누수 문제를 겪지 않는 프로그래머는 없을 듯 합니다. 이 글은 가비지 콜렉터의 컨셉을 쉽게 이해할 수 있고 저자의 그 동안의 경험으로 메모리 누수를 예방하는 체크리스트를 제공해주어서 번역하게 되었습니다.

메모리 누수는 많은 개발자들에게 다루기 힘들어하는 컨셉입니다. 그들은 어렵고 시간 소모적이고 지루하고 불필요하다 느끼지만 전부 틀렸습니다. 제대로 알게 되면 확실히 좋아하게 될 겁니다.

가비지 콜렉터는 친구이지만 아닐때도 있다.

자바는 강력한 언어입니다. C나 C++처럼 메모리 할당과 해제를 우리 스스로 하지 않아도 되죠.

처음으로 드는 질문은 자동으로 메모리 할당/해제를 처리해주는 빌트인 메모리 관리 시스템을 갖추고 있다면 왜 우리가 신경을 써야 하느냐는 거죠.

가비지 콜렉터는 자신이 해야 하는 일을 합니다. 하지만 우리가 불필요한 메모리 청크를 모으는 것을 가비지 콜렉터는 알 수 없죠.

그러니까 기본적으로 문제는 우리에게 있다는 것입니다.

가비지 콜렉터에 대해 더 알아봅시다

가비지 콜렉터가 실제로 어떻게 일을 하는지 알아봅시다. 컨셉은 꽤 단순하지만 작동은 때때로 꽤 복잡합니다. 지금은 단순한 것에 더 초점을 맞춰봅시다.

GC Roots > Reachable Objects, Non reachable objects > Garbage

모든 안드로이드 애플리케이션은 객체가 인스턴스화되는 그리고 메서드가 호출되는 시작지점이 있습니다. 그래서 이 시작 지점을 메모리 트리의 루트로 볼 수 있습니다. 루트부터 시작에서 객체들이 꼬리에 꼬리를 물며 레퍼런스를 유지하죠.

레퍼런스의 체인은 메모리 트리를 만들며 형성됩니다. 가비지 콜렉터는 GC 루트에서 시작해 직접/간접적으로 루트에 링크된 객체들을 순회하는 거죠. 이 프로세스 끝에 GC에서 방문되지 않는 객체들이 존재하게 됩니다.

이것들은 가비지 내지 죽은 객체입니다. 즉 가비지 콜렉터에서 모아지는 녀석들입니다.

그래서, 메모리 누수는 뭐죠?

단순하게 말해서 메모리 누수는 목적이 끝난 객체를 오랫동안 잡고 있는 상태입니다.

모든 객체는 스스로의 생애를 가집니다. 만약에 한 객체가 직접적으로 또는 간접적으로 이 생애가 끝난 객체를 가지고 있으면 가비지 콜렉터가 그것을 모을 수 없습니다.

하지만 좋은소식은 앱에서 발생하는 모든 메모리 누수를 걱정할 필욘 없다는 겁니다. 모든 메모리 누수가 앱을 해치는 건 아닙니다.

아주 마이너한 누수가 있고 안드로이드 프레임워크 스스로 가지는 것이 있습니다. 고칠 수는 없습니다. 이것들을 앱의 성능에 최소한의 영향을 주므로 무시할 수 있습니다.

하지만 앱이 충돌할 수 있는 것을 우리는 신경써야 합니다.

왜 메모리 누수를 고치는데 신경을 써야 할까요?

어느누구도 많은 메모리를 잡아먹고 몇 분 후 죽는 앱을 원하지는 않습니다. 사용자가 앱을 사용할수록 힙 메모리는 증가하고 메모리 누수를 해결하지 못하면 힙 메모리는 지속적으로 커져 더 큰 메모리 할당을 할 수 없는 순간에 죽어버립니다.

우리가 기억해야 할 한 가지는 가비지 콜렉션이 무거운 프로세스라는 점입니다. 가비지 콜렉터가 더 적게 동작하는 것이 좋습니다.

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

안드로이드 스튜디오의 모니터 도구를 사용하면 메모리 사용량 뿐만 아니라 네트워크, CPU, GPU 사용량도 확인 가능합니다.

디버깅 할 때 메모리 모니터를 확인해보세요. 메모리 누수의 첫번째 증상은 메모리 사용량 그래프가 앱을 사용할수록 지속적으로 증가하는 것입니다. 그리고 앱이 백그라운드에 있어도 줄어들지 않는 것입니다.

하지만 이것으론 충분하지 않고 Dump Java Heap 옵션을 사용해 힙 덤프를 생성하면 그 시간대의 메모리 스냅샷을 볼 수 있습니다. 지루하고 반복적인 일 같죠? 그렇습니다.

우리 엔지니어들은 게으른 경향이 있고 그게 바로 LeakCanary가 나온 이유죠. 이 라이브러리는 앱과 함께 동작해 메모리를 덤프하고 잠재적 메모리 누수를 관찰하고 깔끔하고 유용한 스택 트레이스로 누수의 원인을 찾아줍니다.

가장 보편적인 메모리 누수 시나리오와 수정방법

나의 경험에 의하면 이것은 메모리 누수를 이끄는 가장 보편적인 시나리오입니다. 그리고 안드로이드 개발을 하면서 당신도 매일 맞닥뜨리는 시나리오이기도 합니다.

우리가 한번 언제 어디서 메모리 누수가 일어나는지 알아 낸다면 큰 어려움 없이 고칠 수 있겠죠.

등록되지 않은 리스너

우리가 액티비티에 리스너를 등록하는 순간이 많이 있습니다. 하지만 등록해제하는 것을 잊죠. 이것은 커다란 메모리 누수를 쉽게 일으킵니다.

간단한 예제를 보죠. 앱에서 위치를 업데이트하고자 할때 LocationManager 시스템 서비스를 가져와 위치 업데이트를 위한 리스너를 등록합니다.

private void regifterLocationUpdates(){
  mManager = (LocationManager) getSystemService(LOCATION_SERVICE);
  mManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, TimeUnit.MINUTES.toMillies(1), 100, this);
}

액티비티에 이 리스너를 구현하면 LocationManager가 계속해서 레퍼런스를 유지하고 있죠. 액티비티가 죽으면 안드로이드 프레임워크는 onDestroy()를 호출하지만 가비지 콜렉터는 인스턴스를 제거할 수 없습니다. 왜냐하면 LocationManager 가 여전히 강력한 레퍼런스를 가지고 있어서죠.

해결책은 아주 간단합니다. onDestroy()에서 리스너를 해제하면 됩니다. 우리가 가장 많이 잊어버리는 부분입니다.

@Override
public void onDestroy(){
  super.onDestroy();
  if(mManager != null) {
    mManager.removeUpdates(this);
  }
}

내부 클래스

내부클래스는 자바에서는 보편적이고 많은 안드로이드 개발자들이 사용합니다. 하지만 부적절하게 사용하면 잠재적인 메모리 누수의 원인이 됩니다.

예제를 다시 봅니다.

public class BadActivity extends Activity{
  private TextView mMessageView;
  
  @Override
  protected void onCreate(Bundle savedInstnaceState) {
    super.onCreate(savedInstanceState);
    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);
    }
  }
}

이것은 길게 동작하는 작업을 백그라운드 스레드에서 시작하는 단순한 액티비티 예입니다. 작업이 끝나고 나면 결과는 TextView 에 보여집니다.

문제는 비정적인 내부 클래스가 외부 클래스 (액티비티)에 암시적인 레퍼런스를 가지고 있다는 점입니다. 만약 우리가 화면을 전환하거나 이 백그라운드 작업이 액티비티보다 생애가 길면 가비지 콜렉터가 액티비티 인스턴스를 메모리에서 수집하지 못하도록 할 것입니다.

하지만 해결책은 여전히 단순합니다.

public class GoodActivity extends Activity {
  private AsyncTask mLongRunningTask;
  private TextView mMessageView;
  
  @Override
  protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
    setContectview(R.layout.layout_good_activity);
    mMessageView = (TextView) findViewById(R.id.messageView);
    mLongRunningTask = new LongRunningTask(mMessageView).execute();
  }
  
  @Override
  protected void onDestroy() {
    super.onDestroy();
    mLongRunningTask.cancel(true);
  }
  
  private static class LongRunningTask extends AsyncTask<Void, Void, String> {
        private final WeakReference<TextView> messageViewReference;
        public LongRunningTask(TextView messageView) {
            this.messageViewReference = new WeakReference<>(messageView);
        }
        @Override
        protected String doInBackground(Void... params) {
            String message = null;
            if (!isCancelled()) {
                message = "I am finally done!";
            }
            return message;
        }
        @Override
        protected void onPostExecute(String result) {
            TextView view = messageViewReference.get();
            if (view != null) {
                view.setText(result);
            }
        }
    }
}

본 것 처럼 비정적 내부 클래스를 정적 내부 클래스로 바꿨습니다. 정적 내부 클래스는 외부 클래스에 암시적 레퍼런스를 자길 수 없기 때문이죠. 하지만 비정적 변수에 접근할 수 없습니다. (TextView같은) 그래서 필요한 객체 레퍼런스를 내부 클래스의 생성자를 통해 넘겨주어야 합니다.

메모리 누수를 막기위해 객체 레퍼런스를 WeakReference로 래핑하는 것을 강력히 추천합니다.

익명 클래스

익명 클래스도 많은 개발자들이 선호하지만 내 경험으로는 익명 클래스가 가장 보편적인 메모리 누수의 원천입니다.

익명 클래스는 잠재적 메모리 누수의 원인이 될 수 있는 비정적 내부 클래스에 불과합니다.

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.
                    }
        });
    }
}

여기서 인기있는 라이브러리 Retrofit으로 네트워크 호출을 하고 결과를 TextView에 보이고 있습니다. Callable 객체가 액티비트 클래스의 레퍼런스를 유지할 것이 꽤 분명하죠.

만약 네트워크 호출이 매우 느리게 진행되고 호출이 끝나기 전에 액티비티가 회전되거나 없어질 때 전체 액티비티 인스턴스는 누수됩니다.

정적 내부 클래스를 익명 대신 사용하는 것은 항상 추천합니다. 익명 클래스를 쓰지 말라느 것이 아니라 언제가 안전하고 안전하지 않는지 판단해야 함을 의미합니다.

비트맵

모든 이미지는 이미지의 픽셀 데이터를 가지는 비트맵 객체 입니다.

이 비트맵은 꽤 무겁고 잘못 다뤄지만 상당한 메모리 누수로 이어집니다.

비트맵을 적절히 관리하는 방법을 배워야 합니다.

https://developer.android.com/training/displaying-bitmaps/manage-memory.html

Contexts

또다른 메모리 누수의 보통의 이유는 컨텍스트 인스턴스를 잘못 사용하는 것입니다. 컨텍스트는 추상 클래스이고 기능을 제공하기 위해 확장한 많은 클래스가 있습니다 (액티비티, 애플리케이션, 서비스 등)

하지만 이 컨텍스트들간 차이가 있는데 액티비티 레벨의 컨텍스트와 애플리케이션 레벨의 컨텍스트의 차이를 이해하는 것이 중요합니다.

액티비티 컨텍스트를 잘못된 곳에 사용하면 액티비티의 전체 레퍼런스를 유지하고 잠재적 메모리 누수의 원인이 됩니다.

결론

우리는 가비지 콜렉터가 어떻게 동작하는지 알았고 메모리 누수가 무엇인지 그리고 어떻게 앱에 큰 영향을 미치는지 알았습니다. 또한 이런 메모리 누수가 어떻게 감지되고 고쳐질 수 있는지도 알게 되었을 겁니다.

이제 변명하지 말고 좋은 품질과 높은 성능의 앱을 만들어봅시다. 메모리 누수를 막는 것은 더 좋은 사용자 경험을 만드는 것 뿐만 아니라 더 나은 개발자로 가는 것이기도 합니다.

참고문헌

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

댓글