이펙티브 코틀린 Item 48: 더 이상 사용하지 않는 객체의 레퍼런스를 제거하라

woga·2023년 12월 27일
0

코틀린 공부

목록 보기
49/54
post-thumbnail

자바는 가비지 컬렉터가 객체 해제와 관련된 모든 작업을 한다. 하지만 그렇다고 메모리 관리를 무시하면 메모리 누수가 발생해서 상황에 따라 OOM이 발생한다. 그래서 요 아이템 주제처럼 사용하지 않는 객체의 레퍼런스를 유지하면 안 된다라는 규칙 정도는 지켜주자.

안드로이드를 처음 시작하는 개발자 중에 Activity를 여러 곳에서 자유롭게 접근하기 위해서 companion 프로퍼티에 이를 할당하는 경우가 있습니다.

class MainActivity : Activity() {
	override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
        // ...
        activity = this
   }
   
   // ...
   companion object {
   	// do not that
    // create memory leak
    var activity: MainActivity? = null
  }
}

객체에 대한 참조를 컴패니언으로 유지해 버리면, 가비지 컬렉터가 해당 객체에 대한 메모리 해제를 할 수 없다. 액티비티는 굉장히 큰 객체이다. 따라서 큰 메모리 누수가 발생하게 된다. 일단 이런 리소스를 정적으로 유지하지 않는 것이 좋다. 그래서 객체에 대한 레퍼런스를 다른 곳에 저장할 때는 메모리 누수가 발생할 가능성을 언제나 염두에 두자

메모리 문제는 굉장히 미묘한 곳에서 발생하는 경우가 많다. 아래 스택 구현을 한 번 보자면,

class Stack {
	private var elements: Array<Any?> = arrayOfNulls(DEFAULT_INITIAL_CAPACITY)
    private var size = 0
    
    fun push(e: Any) {
    	ensureCapacity()
        elements[size++] = e
    }
    
    fun pop(): Any? {
    	if (size == 0) {
        	throw EmptyStackException()
        }
        return elements[--size]
   }
   
   private fun ensureCapacity() {
   		if (elements.size == size) {
        	elements = elements.copyOf(2 * size + 1)
        }
   }
   
   companion object {
   		private const val DEFAULT_INITIAL_CAPACITY = 16
   }
}

여기서 문제는 pop을 할 때 size를 감소시키기만 하고 배열 위의 요소를 해제하는 부분이 없다.
스택에 1000개 요소가 있다면 여기서 pop을 실행해서 size를 1까지 줄였다고 해보자. 요소 1개만 의미가 있고 나머지는 의미가 없다. 하지만 위 코드의 스택은 1000개의 요소를 모두 붙들고 놓아주지 않으므로 GC도 이를 해제하지 못한다. 999개의 요소도 메모리르 낭비하게 되고 메모리 누수가 발생하게 된다.

그럼 구현을 어떻게 수정해야 할까? 바로 레퍼런스에 null 설정을 한다.

fun pop(): Any? {
	if (size == 0) {
    	throw EmptyStackException()
    }
    val elem = elements[--size]
    elements[size] = null
    return elem
}

그래서 만약 mutableLazy를 구현할 때 initializer가 사용된 후에도 해제되지 않는다면 메모리 누수가 발생한다.
null로 설정하기만 하면, 가비지 컬렉터가 이를 처리할 수 있다.

이런 최적화 처리가 과연 중요할까? 거의 사용되지 않는 객체까지 이런 것을 신경쓰는 것은 오히려 좋지 않을 수도 있다.
하지만 오브젝트에 null을 설정하는 것은 그렇게 어려운 일이 아니므로 무조건 하는 것이 좋다.
특히 많은 변수를 캡쳐할 수 있는 함수 타입, Any 또는 제네릭 타입과 같은 미지의 클래스일 때는 이러한 처리가 중요하다.

라이브러리를 만들 때 이런 최적화가 중요하다. 코틀린 stdlib에 구현되어 있는 lazy 델리게이트는 사용 후에 모두 initializer를 null로 초기화한다.

일반적인 규칙은 상태를 유지할 때는 메모리 관리를 염두에 두어야 한다는 것이다.

코드를 작성할 때는 메모리와 성능 뿐만 아니라 가독성과 확장성을 항상 고려해야 한다. 일반적으로 가독성이 좋은 코드는 메모리와 성능적으로도 좋다.
가독성 좋은게 메모리와 성능적으로도 좋다. 가독성이 좋지 않은 코드는 메모리와 CPU 리소스 낭비를 숨기고 있을 가능성이 높다. 물론 둘 사이에 트레이드 오프가 발생하는 경우도 있다.
이럴 때는 일반적으로 가독성과 확장성을 더 중시하는 것이 좋다. 예외적으로 라이브러리를 구현할 때는 메모리와 성능이 더 중요하다.

절대 사용되지 않는 객체를 캐시해서 저장해 두는 경우 메모리 누수가 발생한다. 캐시도 좋지만 이게 OOM이 발생하면 아무런 도움이 되지 않는다.
해결 방법은 소프트 레퍼런스를 사용하는 것이다. 그럼 GC가 메모리가 필요한 경우에 이를 알아서 해제한다. 하지만 메모리가 부족하지 않아서 해제되지 않았다면 이를 활용할 수 있다.
화면 위 다이알로그도 약한 레퍼런스를 사용하는 것이 좋을 수 있다.

메모리 누수는 예측하기 어렵다. 애플리케이션이 클래시되기 전까지 있는지 확인하기 힘들다. 그래서 메모리 누수는 안드로이드 앱에서 더 큰 문제가 된다.
메모리 사용량에 엄격한 제한이 있기 때문에 별도의 도구들을 활용해서 메모리 누수를 찾는 것도 좋은 방법이다. (힙 프로파일러, 릭 카나리 등)

사실 객체를 수동으로 해제해야 하는 경우는 굉장히 드물다. 일반적으로 스코프를 벗어나면서 어떤 객체를 가리키는 레퍼런스가 제거될 때 객체가 자동으로 해제된다.

변수를 지역 스코프에 정의하고 톱레벨 프로퍼티 또한 객체 선언(companion 객체 포함)으로 큰 데이터를 저장하지 않는 것이다.

profile
와니와니와니와니 당근당근

0개의 댓글