[F-Lab 멘토링 학습]

메모리 누수의 개념, 종류 및 해결방법

everydeveloper 2023. 11. 4. 15:33

메모리 누수의 개념, 종류 및 해결방법

메모리 누수의 개념

메모리 누수(memory leak)란 프로그램이 필요 이상으로 메모리를 점유하고, 더 이상 사용되지 않는 메모리를 반환하지 않는 현상을 말합니다. 이는 시간이 지남에 따라 메모리 사용량이 계속 증가하게 되어, 시스템의 성능을 저하시키거나 최악의 경우 프로그램이나 시스템이 중단되는 결과를 초래할 수 있습니다.

메모리 누수는 다음과 같은 경우에 발생할 수 있습니다:

  1. 동적 메모리 할당 후 반환하지 않음: 프로그래밍 언어에서 malloc() 또는 new 같은 함수로 메모리를 할당한 뒤, free() 또는 **delete**로 반환하지 않을 경우.
  2. 객체 참조 누락: 객체 지향 언어에서 객체가 더 이상 필요 없지만, 다른 객체가 여전히 그 객체를 참조하고 있을 때. 가비지 컬렉션을 지원하지 않는 언어에서는 특히 문제가 될 수 있습니다.
  3. 캐시 사용: 데이터를 캐시에 저장할 때 적절한 만료 시간을 설정하지 않으면, 캐시가 계속해서 커질 수 있습니다.

메모리 누수를 해결하기 위한 방법은 다음과 같습니다:

  1. 코드 리뷰: 코드를 주기적으로 검토하여 누수 가능성이 있는 부분을 찾아봅니다.
  2. 프로파일링 도구 사용: Valgrind, LeakCanary 등의 메모리 누수를 탐지할 수 있는 도구를 사용합니다.
  3. 가비지 컬렉션: Java나 Python과 같이 가비지 컬렉션을 지원하는 언어를 사용하면, 메모리 누수의 가능성이 줄어듭니다. 그러나 가비지 컬렉션이 모든 종류의 누수를 해결하지는 못합니다.
  4. 참조 카운팅: 객체가 더 이상 필요 없을 때 참조 카운트를 감소시켜 메모리를 해제하는 방법도 있습니다.
  5. 스마트 포인터: C++에서는 스마트 포인터를 사용하여 메모리 누수를 방지할 수 있습니다.

메모리 누수는 시스템 리소스를 낭비하므로, 성능 최적화와 안정성을 위해 주의 깊게 관리해야 합니다.

메모리 누수의 종류

메모리 누수(memory leak)는 여러 가지 방식으로 발생할 수 있으며, 그 종류도 다양합니다. 주요한 메모리 누수 유형은 다음과 같습니다:

명시적 누수 (Explicit Leaks)

이 유형은 프로그래머가 명시적으로 메모리를 할당한 후, 해제하지 않아 발생합니다. C/C++에서는 malloc() 또는 new로 할당한 메모리를 free()나 delete로 반환하지 않으면 명시적 누수가 발생합니다.

의존성 누수 (Data Structure Leaks)

복잡한 자료 구조가 메모리 누수를 일으킬 수 있습니다. 예를 들어, 연결 리스트에서 노드를 삭제하지만 메모리를 해제하지 않으면 누수가 발생합니다.

핸들 누수 (Handle Leaks)

이 유형은 파일 핸들, 데이터베이스 커넥션, 소켓 등의 리소스를 적절히 해제하지 않아 발생합니다. 이러한 리소스도 결국 시스템 메모리를 사용하므로, 해제하지 않으면 메모리 누수가 됩니다.

순환 참조 (Cyclic References)

객체 지향 프로그래밍에서 두 객체가 서로를 참조하면 가비지 컬렉터가 이를 회수하지 못할 수 있습니다. 이런 상황에서 메모리 누수가 발생할 수 있습니다.

닫히지 않은 스트림 누수 (Unclosed Streams)

파일이나 네트워크 스트림을 열었을 때, 이를 닫지 않으면 발생하는 메모리 누수입니다.

캐시 누수 (Cache Leaks)

캐시에 데이터를 계속 추가하다 보면, 캐시 크기가 커져서 메모리 누수가 발생할 수 있습니다. 이는 적절한 캐시 정책을 적용하지 않았을 때 흔히 발생합니다.

중복 할당 누수 (Duplicate Allocations)

같은 데이터에 대해 두 번 이상 메모리를 할당하지만 한 번만 해제하는 경우에 발생합니다.

메모리 조각화 (Memory Fragmentation)

이 유형은 직접적인 메모리 누수는 아니지만, 메모리가 작은 조각으로 나뉘어 효율적으로 사용하지 못하는 현상을 의미합니다. 이로 인해 실제로는 사용 가능한 메모리가 있음에도 불구하고, 큰 블록의 메모리를 할당할 수 없게 되는 문제가 발생할 수 있습니다.

메모리 누수를 방지하거나 해결하기 위해서는 해당 누수의 유형을 정확히 파악하고 적절한 해결책을 적용해야 합니다. 프로파일링 도구, 코드 리뷰, 테스팅 등을 통해 이를 관리할 수 있습니다.

각각의 방법 해결 방법

각 유형의 메모리 누수와 그에 대한 해결 방법을 살펴보겠습니다.

명시적 누수 (Explicit Leaks)

해결 방법: 코드를 정확히 검토하여 malloc() 또는 new와 같이 메모리를 할당한 후에 free() 또는 delete로 반환하는지 확인합니다. 프로파일링 도구를 사용해 누수를 탐지할 수도 있습니다.

의존성 누수 (Data Structure Leaks)

해결 방법: 자료 구조를 변경하거나 삭제할 때 메모리를 적절히 해제해야 합니다. 리스트, 트리 등의 자료 구조를 다룰 때 삭제 로직을 신중하게 구현해야 합니다.

핸들 누수 (Handle Leaks)

해결 방법: 파일, 데이터베이스, 네트워크 연결 등을 닫을 때 핸들도 함께 해제해야 합니다. 이러한 작업은 보통 finally 블록이나 using 구문 등을 통해 구현됩니다.

순환 참조 (Cyclic References)

해결 방법: 참조 카운팅이나 가비지 컬렉션을 사용하면 대부분의 순환 참조 문제를 해결할 수 있습니다. 수동으로 메모리를 관리하는 경우, 순환 참조를 피하기 위한 설계를 고려해야 합니다.

닫히지 않은 스트림 누수 (Unclosed Streams)

해결 방법: 파일이나 스트림을 열었다면, 작업이 완료되는 즉시 닫아주어야 합니다. 이러한 작업은 try-finally 블록이나 with 문을 사용하여 항상 닫히도록 할 수 있습니다.

캐시 누수 (Cache Leaks)

해결 방법: 캐시에 대한 적절한 만료 정책을 설정하여 오래된 데이터가 자동으로 제거되도록 해야 합니다. 또는 캐시 크기에 제한을 둬서 무한히 커지지 않도록 관리할 수 있습니다.

중복 할당 누수 (Duplicate Allocations)

해결 방법: 할당 전에 이미 할당된 메모리가 있는지 확인하고, 불필요한 중복 할당을 피해야 합니다.

메모리 조각화 (Memory Fragmentation)

해결 방법: 메모리 풀(memory pool)을 사용하거나, 객체를 미리 할당해 두고 재사용하는 등의 방법으로 메모리 조각화 문제를 완화할 수 있습니다.

각 유형의 메모리 누수가 발생하지 않도록 주의 깊게 코드를 작성하고, 가능하면 자동화된 테스트나 코드 리뷰, 메모리 프로파일링 도구를 활용하여 지속적으로 모니터링해야 합니다.

가비지 컬렉터를 쓰면 메모리 누수에서 안전해?

가비지 컬렉터(Garbage Collector, 이하 GC)를 사용하는 언어에서는 일반적으로 명시적인 메모리 해제를 신경 쓰지 않아도 되기 때문에, 어느 정도 메모리 관리가 간편해집니다. 그러나 가비지 컬렉터가 있는 환경에서도 메모리 누수는 발생할 수 있습니다. 가비지 컬렉터는 주로 '더 이상 접근할 수 없는 객체'를 회수하는데, 이러한 가비지 컬렉션 방식에는 몇 가지 주의점이 있습니다.

순환 참조 (Cyclic References)

객체 A와 B가 서로를 참조하고 있다면, 이들은 절대 '접근 불가능한' 객체로 판단되지 않을 수 있습니다. 따라서 가비지 컬렉터는 이를 회수하지 못하고 메모리 누수가 발생할 가능성이 있습니다. 그러나 최신의 GC 알고리즘은 이러한 순환 참조도 탐지할 수 있습니다.

오래 살아있는 객체 (Long-lived Objects)

데이터 캐시나 싱글톤 객체 등, 애플리케이션 실행 도중 계속 참조되는 객체는 메모리를 계속 점유하게 됩니다. 이는 메모리 누수로 이어질 수 있습니다.

이벤트 리스너 (Event Listeners)

이벤트 리스너를 등록하고 해제하지 않으면, 해당 리스너가 계속 메모리에 남아있을 수 있습니다. 이는 특히 웹 개발에서 자주 발생하는 문제입니다.

핸들 누수 (Handle Leaks)

파일 핸들, 네트워크 소켓, 데이터베이스 연결 등도 메모리를 차지합니다. 이러한 리소스는 가비지 컬렉터의 관리 범위 밖에 있기 때문에, 명시적으로 해제해주어야 합니다.

메모리 누수 탐지 도구의 한계

가비지 컬렉터가 활성화된 환경에서는 일부 메모리 누수 탐지 도구가 정확한 정보를 제공하지 못할 수 있습니다.

즉, 가비지 컬렉터가 있는 환경에서도 메모리 누수를 예방하기 위한 주의가 필요합니다. 이를 위해 코드의 로직을 철저히 검토하고, 필요한 경우 메모리 프로파일링 도구를 사용하여 메모리 사용 상태를 주기적으로 확인하는 것이 좋습니다.

가비지 컬렉터는 객체와 변수 관련해서 만 메모리 관리를 해줘?

가비지 컬렉터(Garbage Collector, 이하 GC)는 주로 힙(heap) 영역에 할당된 객체의 생명 주기를 관리합니다. 힙 영역은 동적으로 할당된 메모리를 저장하는 공간으로, 대부분의 객체가 이곳에 위치하게 됩니다. 그래서 일반적으로 가비지 컬렉터는 객체의 메모리 관리에 중점을 둡니다.

스택(stack) 영역의 지역 변수는 함수 호출과 함께 생성되고, 함수가 종료되면 자동으로 메모리가 해제됩니다. 따라서 스택 영역의 변수는 가비지 컬렉터의 관리 대상이 아닙니다.

그러나 다음과 같은 경우에는 주의가 필요합니다:

클로저 (Closures)

함수가 내부 함수를 반환하고, 내부 함수가 외부 함수의 지역 변수에 접근할 경우, 그 변수는 클로저로 인해 '캡쳐'될 수 있습니다. 이렇게 되면 해당 변수는 가비지 컬렉션의 대상이 되지 않을 수 있습니다.

전역 변수와 정적 변수

전역 변수와 정적 변수는 프로그램이 종료될 때까지 메모리에 남아 있습니다. 이러한 변수들은 가비지 컬렉터의 관리 대상이 아니며, 개발자가 직접 관리해야 합니다.

핸들과 리소스

파일, 네트워크 연결, 그래픽 리소스 등도 메모리를 차지하는데, 이러한 리소스는 대체로 가비지 컬렉터에 의해 관리되지 않습니다. 이러한 리소스는 프로그래머가 명시적으로 해제해주어야 합니다.

네이티브 라이브러리

C/C++ 같은 저수준 언어로 작성된 라이브러리를 사용할 경우, 그 라이브러리가 동적으로 할당한 메모리는 Java나 Python과 같은 고수준 언어의 가비지 컬렉터에 의해 관리되지 않을 가능성이 높습니다.

요약하면, 가비지 컬렉터는 주로 힙 영역의 객체를 대상으로 메모리 관리를 하지만, 그 외의 변수나 리소스에 대해서는 대체로 관여하지 않습니다. 이러한 부분은 개발자가 명시적으로 관리해야 합니다.

해시맵과 관련해서 해시 충돌이 발생했을 때 그 버킷 내에 새로 어레이 리스트 등을 생성해서 해시 컬랙션을 관리하는 경우에도 메모리 관리를 해줘?

해시맵(HashMap)에서 해시 충돌이 발생할 경우, 일반적으로 연결 리스트(Linked List)나 배열 리스트(ArrayList) 등을 사용해 그 버킷(bucket) 내에 여러 요소를 저장합니다. 이를 "체이닝(Chaining)"이라고 합니다. 이러한 연결 리스트나 배열 리스트 객체도 힙(heap) 영역에 할당되므로, 가비지 컬렉터(Garbage Collector, 이하 GC)의 관리 대상이 됩니다.

가비지 컬렉터는 다음과 같은 상황에서 이러한 객체를 메모리에서 해제할 수 있습니다:

  1. 해시맵 자체가 더 이상 참조되지 않을 경우: 해시맵이 가비지 컬렉션의 대상이 되면, 그 안에 있는 모든 버킷과 연결 리스트, 배열 리스트 등도 함께 가비지 컬렉션의 대상이 됩니다.
  2. 특정 키-값 쌍이 삭제되어 그에 대한 참조가 사라진 경우: 연결 리스트나 배열 리스트 내에서 특정 요소가 삭제되면, 그 요소에 대한 참조가 없어집니다. 그로 인해 해당 요소는 가비지 컬렉션의 대상이 될 수 있습니다.
  3. 해시맵의 크기를 줄이는 연산이 수행된 경우: 일부 해시맵 구현체에서는 resize나 shrink와 같은 메서드를 통해 내부적으로 메모리를 최적화할 수 있습니다. 이 과정에서 불필요한 버킷이나 리스트가 제거될 수 있습니다.

다만, 해시맵이 계속 사용되고 있다면, 그 내부의 버킷이나 리스트는 메모리에서 해제되지 않습니다. 만약 해시맵이 매우 크게 확장되어 메모리 사용이 문제가 될 경우, 개발자는 적절한 최적화 방안을 고려해야 합니다. 이에는 해시맵의 초기 크기 설정, 로드 팩터 설정, 해시 함수의 선택 등이 포함될 수 있습니다.

요약하자면, 해시맵 내부의 버킷과 리스트 등도 가비지 컬렉터에 의해 관리될 수 있으나, 해시맵이 계속 참조되고 있다면 해당 구조들이 자동으로 메모리에서 해제되지는 않습니다. 이러한 상황에서는 개발자가 직접 메모리 관리 전략을 수립해야 할 필요가 있습니다.

일단 힙인 경우는 GC가 관리해주고 GC의 오동작으로 힙 메모리 누수가 발생할 가능성은 적어?

네, 일반적으로 힙(heap) 영역은 가비지 컬렉터(Garbage Collector, 이하 GC)에 의해 관리됩니다. 가비지 컬렉터의 주요 목표는 더 이상 참조되지 않는 객체를 식별하고, 그 메모리를 회수하는 것입니다. 이 과정은 꽤 세밀하게 설계되고 테스트되므로, GC 자체의 '오동작'으로 인한 메모리 누수는 매우 드문 경우입니다.

그러나 이러한 메모리 관리 방식도 100% 완벽하다고 할 수는 없습니다. 예를 들어, 다음과 같은 상황에서는 문제가 발생할 수 있습니다:

  1. 순환 참조: 고급 가비지 컬렉션 알고리즘은 순환 참조를 탐지할 수 있지만, 간단한 참조 카운팅 방식을 사용하는 환경에서는 문제가 될 수 있습니다.
  2. 프로그램 로직의 오류: 개발자가 실수로 객체에 대한 참조를 유지하고 있으면, GC는 그 객체를 '사용 중인 객체'로 판단하고 메모리를 회수하지 않습니다. 이는 코드의 문제이지, GC의 문제는 아닙니다.
  3. 네이티브 리소스: 자바나 .NET과 같이 가비지 컬렉터를 사용하는 환경에서도, C/C++ 같은 네이티브 코드를 호출하는 경우 그 코드에서 발생한 메모리 누수는 GC가 관리하지 못합니다.
  4. 핸들 누수: 파일이나 네트워크 연결과 같은 '핸들'은 메모리를 차지하지만, 이들은 대개 가비지 컬렉터의 관리 범위에서 벗어납니다.
  5. 쓰레드 누수: 쓰레드 생성과 소멸이 제대로 이루어지지 않으면, 쓰레드와 관련된 메모리가 누수될 수 있습니다. 쓰레드 자체는 GC의 관리 대상이 아닙니다.

따라서, 가비지 컬렉터는 힙 영역의 메모리 관리를 꽤 잘 해주지만, 그 외의 경우나 특별한 상황에서는 개발자가 주의를 기울여야 합니다. 가비지 컬렉터의 '오동작'보다는 주로 프로그램의 로직이나 특수한 상황에서 메모리 누수가 발생하곤 합니다.

순환 참조에 대해 자세히 알려줘

순환 참조(circular reference)란 두 개 이상의 객체가 서로를 참조하는 현상을 말합니다. 이러한 순환 참조는 메모리 누수의 원인이 될 수 있습니다. 간단한 예로, 객체 A가 객체 B를 참조하고, 객체 B가 다시 객체 A를 참조하는 상황을 들 수 있습니다. 이런 경우에는 가비지 컬렉터(Garbage Collector, 이하 GC)가 어떤 객체를 먼저 해제해야 할지 판단하기 어려울 수 있습니다.

다만, 현대의 고급 가비지 컬렉션 알고리즘은 순환 참조를 효과적으로 관리할 수 있습니다. 예를 들어, 자바의 JVM(Java Virtual Machine)이나 파이썬의 CPython 인터프리터는 "Mark-and-Sweep" 알고리즘과 같은 메커니즘을 사용하여 순환 참조를 탐지하고 메모리를 회수할 수 있습니다.

Mark-and-Sweep 알고리즘

  1. Mark Phase: 모든 'reachable' 객체를 찾아 '마킹'합니다. 'Reachable' 객체란 루트 객체(root object)로부터 어떤 방식으로든 접근할 수 있는 객체를 말합니다. 루트 객체는 보통 스택(stack), 전역 변수, CPU 레지스터 등에서 찾을 수 있습니다.
  2. Sweep Phase: 마킹되지 않은 객체를 메모리에서 해제합니다.

이 알고리즘을 사용하면, 순환 참조가 있는 객체 집합도 루트 객체로부터 접근할 수 없다면 가비지 컬렉션의 대상이 됩니다.

순환 참조 관리의 주의점

  1. 참조 카운팅(Reference Counting): 일부 언어나 라이브러리는 참조 카운팅만을 사용하여 메모리를 관리할 때가 있습니다. 이러한 경우에는 순환 참조가 메모리 누수를 일으킬 수 있습니다.
  2. 사용자 정의 정리 작업: 객체가 해제될 때 특별한 작업을 해야 하는 경우, 순환 참조 상황에서는 그 작업이 예상대로 수행되지 않을 수 있습니다.
  3. 리소스 해제: 순환 참조된 객체가 파일 핸들, 네트워크 소켓 등의 리소스를 가지고 있을 경우, 이 리소스들이 제대로 해제되지 않을 수 있습니다.

요약하면, 순환 참조는 메모리 누수의 가능성을 높일 수 있으나, 현대의 가비지 컬렉션 알고리즘은 이를 상당히 잘 관리해줍니다. 그럼에도 불구하고, 참조 카운팅을 사용하는 환경이거나 특별한 리소스 관리가 필요한 경우에는 개발자가 주의를 기울여야 합니다.

자바에서 특별히 주의해야할 메모리 누수 해결이나 대처 방법

자바에서는 가비지 컬렉터(Garbage Collector, GC)가 메모리 관리를 대부분 처리해주지만, 여전히 메모리 누수가 발생할 가능성이 있습니다. 다음은 자바에서 특별히 주의해야 할 몇 가지 메모리 누수 해결 및 대처 방법입니다.

1. 정적 필드(Static Fields)

정적 필드는 클래스가 로드될 때 생성되고 프로그램이 종료될 때까지 메모리에 남아 있습니다. 그래서 이러한 필드에 대한 참조가 계속 유지되면 메모리 누수가 발생할 수 있습니다.

대처 방법:

  • 가능하다면 정적 필드의 사용을 피하거나, 프로그램 수명과 동일하게 유지해야 하는 객체만 정적 필드로 사용합니다.

2. 컬렉션(Collection) 객체

리스트, 맵, 셋 등의 컬렉션 객체가 계속 커지거나, 제거되지 않는 객체를 참조하고 있다면 메모리 누수가 발생할 수 있습니다.

대처 방법:

  • 컬렉션에 추가된 객체를 적절한 시점에 제거해주어야 합니다.
  • WeakHashMap, SoftReference 등을 사용하여 자동으로 회수될 수 있는 참조를 만들 수 있습니다.

3. 내부 클래스와 익명 클래스

내부 클래스나 익명 클래스는 자동으로 외부 클래스의 참조를 가집니다. 이로 인해 외부 클래스의 객체가 메모리에 계속 남아 있을 수 있습니다.

대처 방법:

  • 내부 클래스에 static을 붙여 외부 클래스와의 암묵적인 참조를 끊을 수 있습니다.

4. 리스너(Listener)와 콜백(Callback)

리스너나 콜백이 제거되지 않고 남아있으면, 해당 리스너나 콜백이 참조하는 객체는 GC의 대상이 되지 않습니다.

대처 방법:

  • 적절한 시점에 리스너나 콜백을 제거해 주어야 합니다.

5. 네이티브 리소스

자바에서 JNI(Java Native Interface)를 통해 네이티브 코드를 호출할 경우, 자바의 GC 범위를 벗어나는 메모리 누수가 발생할 수 있습니다.

대처 방법:

  • 네이티브 코드에서 메모리 해제를 명시적으로 수행해야 합니다.

6. 쓰레드 로컬 변수(Thread Local Variables)

쓰레드 로컬 변수가 메모리 누수를 일으킬 수 있습니다, 특히 웹 서버와 같이 쓰레드가 재사용되는 환경에서는 더욱 주의해야 합니다.

대처 방법:

  • 사용이 끝난 후에는 ThreadLocal.remove()를 호출하여 변수를 제거해야 합니다.

메모리 누수 문제는 종종 성능 저하와 장애로 이어지기 때문에, 코드 리뷰나 프로파일링 도구를 사용하여 주기적으로 코드를 점검하는 것이 중요합니다.