네. 그리고 그 이유를 이해하려면 소프트웨어 개발에서 가장 뿌리 깊은 오해 중 하나를 버려야 합니다.
흔히 알려진 사실: 가비지 컬렉션은 메모리 누수를 방지한다. 진실: 가비지 컬렉션은 특정 유형의 메모리 문제, 즉 프로그램이 특정 메모리 블록에 대한 모든 포인터를 잃어버리고 해당 블록을 해제할 방법이 없는 '매달린 참조(dangling reference)'를 방지할 뿐입니다. 가비지 컬렉션이 방지하지 못하는, 그리고 방지할 수도 없는 문제는 바로 그 반대입니다. 프로그램이 더 이상 필요하지 않은 객체에 대한 참조를 유지하는 경우입니다. 가비지 컬렉터는 활성 참조를 감지하면 해당 객체를 그대로 둡니다. 하지만 개발자는 해당 객체의 사용을 중단할 의도가 있었던 것입니다. 결과적으로 메모리 용량은 계속 증가합니다. 이는 기존의 메모리 누수와 마찬가지로 무한한 메모리 증가, 결국 OOM 오류, 서비스 재시작으로 이어지지만, 작동 방식은 다릅니다.
이러한 구분은 문제를 찾는 위치와 해결 방법에 영향을 미치기 때문에 중요합니다. C와 C++에서 메모리 누수는 특정 데이터가 누락된 것을 의미합니다. free() or deleteJava, Python, C#, Go 또는 JavaScript에서 이는 해제되었어야 할 참조가 해제되지 않았음을 의미합니다. 가비지 컬렉터가 오작동하는 것이 아니라, 프로그램이 유지해서는 안 되는 참조를 올바르게 처리하고 있는 것입니다.
쓰레기 수거원들이 실제로 하는 일
가비지 컬렉터의 역할은 프로그램의 루트, 전역 변수, 스택 변수 및 스레드 로컬 상태에서 더 이상 접근할 수 없는 메모리를 식별하고 회수하는 것입니다. 루트에서 접근 가능한 모든 객체는 활성 상태로 간주됩니다. 접근할 수 없는 객체는 가비지 컬렉션 대상이 됩니다.
세 가지 주요 GC 전략은 각각 도달 가능성을 다르게 정의합니다.
마크 앤 스윕 (Java의 HotSpot, Go, C#/.NET에서 사용됨) 루트 집합에서 시작하여 도달 가능한 모든 객체 참조를 순회합니다. 순회 중에 도달하지 못한 객체는 가비지 컬렉션됩니다. 이는 순환 참조를 올바르게 처리합니다. 서로를 가리키지만 다른 곳에서는 도달할 수 없는 두 객체는 모두 가비지 컬렉션됩니다. 이 방식이 처리할 수 없는 것은 루트에서 도달 가능한 컨테이너(정적 가비지 컬렉션)입니다. HashMap클래스 레벨 List항목이 추가되고 삭제되지 않기 때문에 무한히 커지는 시스템입니다.
참조 계수 (CPython의 주요 메커니즘인) 사이클 컬렉터는 각 객체를 가리키는 참조 수를 추적합니다. 참조 수가 0이 되면 객체는 즉시 해제됩니다. 일반적인 오류 유형은 A가 B를 참조하고 B가 A를 참조하는 순환 참조입니다. 두 객체 모두 참조 수가 1 이상이기 때문에 해제되지 않습니다. CPython은 보조 사이클 컬렉터를 통해 이 문제를 해결하지만, 사이클 컬렉터는 특정 객체와 관련된 예외적인 경우를 처리해야 합니다. __del__ 방법.
세대별 가비지 컬렉션 (대부분의 최신 런타임에서 위와 함께 사용됨)은 객체를 생존 기간에 따라 젊은 세대와 오래된 세대로 나눕니다. 수명이 짧은 객체는 낮은 비용으로 자주 수집되는 반면, 수명이 긴 객체는 드물게 수집됩니다. 이로 인해 메모리 누수가 발생할 수 있는데, 수명이 짧아야 할 객체가 실수로 계속 남아 있으면 오래된 세대로 이동하여 수집 빈도가 점점 낮아지므로 누수를 관찰하고 진단하기가 더 어려워집니다.
이러한 메커니즘들은 프로그램이 해당 객체를 계속 사용해야 하는지 여부를 확인하는 것이 아니라, 단지 해당 객체에 접근 할 수 있는지 여부만 확인합니다.
GC 언어에서 메모리 누수를 일으키는 다섯 가지 메커니즘
1. 정적 컬렉션 및 클래스 수준 컬렉션
Java의 정적 필드, Python의 클래스 변수, C#의 정적 속성은 애플리케이션 프로세스가 실행되는 동안 유지됩니다. 정적 필드에 저장된 컬렉션은 명시적으로 삭제하지 않는 한 프로그램 전체 수명 동안 객체가 누적됩니다.
자바
// Java: static cache that grows without bound
public class SessionManager {
// Lives for the lifetime of the JVM process
private static final Map<String, UserSession> sessions = new HashMap<>();
public void createSession(String userId) {
sessions.put(userId, new UserSession(userId));
// Session is added but never removed when it expires
}
// Missing: cleanup when session expires or user logs out
}
The sessions 맵은 클래스 로더에서 접근할 수 있고, 클래스 로더는 루트에서 접근할 수 있습니다. 모든 UserSession 생성된 데이터는 계속 누적됩니다. 장시간 실행되는 서비스에서는 이로 인해 몇 시간 또는 며칠에 걸쳐 데이터가 소진됩니다. 해결 방법은 능동적인 제거(호출)입니다. sessions.remove(userId) 로그아웃 또는 만료 시) 또는 시간 제한 캐시와 같이 자동 삭제 기능이 있는 구조로 전환합니다.
2. 이벤트 리스너 및 콜백
옵저버, 리스너 또는 콜백을 등록하면 이벤트 소스에서 리스너로의 참조가 생성됩니다. 리스너가 등록 해제되지 않으면 이벤트 소스는 이벤트 소스가 존재하는 동안 리스너를 계속 활성화 상태로 유지하며, 이는 의도한 것보다 훨씬 오래 지속될 수 있습니다.
파이썬
# Python: listener retained by long-lived event source
class DataSource:
def __init__(self):
self._listeners = []
def add_listener(self, listener):
self._listeners.append(listener)
# Missing: remove_listener method
class DataProcessor:
def __init__(self, source: DataSource):
self.source = source
source.add_listener(self._on_data) # strong reference held in source
def _on_data(self, data):
pass
# Each DataProcessor created is never collected because
# DataSource._listeners holds a reference to its bound method
source = DataSource()
for _ in range(10000):
processor = DataProcessor(source)
# processor goes out of scope here
# but source._listeners still holds 10,000 bound method references
3. 스레드 풀의 스레드 로컬 변수
스레드 로컬 저장소는 특정 작업이 아닌 스레드 단위로 범위가 지정됩니다. 스레드 풀을 사용하는 애플리케이션에서는 여러 요청에 걸쳐 스레드가 재사용되므로, 한 요청에서 설정된 스레드 로컬 변수는 동일한 스레드가 처리하는 모든 후속 요청에 대해 계속 설정된 상태로 유지됩니다.
자바
// Java: ThreadLocal leak in a thread pool
public class RequestContextHolder {
private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();
public static void setContext(RequestContext ctx) {
context.set(ctx);
}
public static RequestContext getContext() {
return context.get();
}
// Missing: clearContext() method called after each request
}
// In a servlet or request handler:
public void handleRequest(HttpRequest req) {
RequestContextHolder.setContext(new RequestContext(req));
// ... process request
// Missing: RequestContextHolder.clearContext();
// The RequestContext stays associated with this thread indefinitely
}
수백만 개의 요청을 처리하는 50개의 스레드로 구성된 스레드 풀에서 각 스레드는 결국 일정량의 스레드를 보유하게 됩니다. RequestContext 마지막 요청의 데이터, 연결 세부 정보, 사용자 자격 증명, 요청 매개변수를 참조합니다. 하지만 스레드 자체가 수집되지 않기 때문에 이러한 정보는 수집되지 않습니다.
4. 대규모 객체 그래프를 포착하는 클로저
클로저는 자신이 속한 스코프 내의 변수에 대한 참조를 캡처합니다. 특히 JavaScript와 Python에서는 작은 콜백 함수나 핸들러가 의도치 않게 큰 객체, 전체 DOM 트리, 요청 객체, 대규모 데이터 세트 등을 캡처하여, 필요한 시점보다 훨씬 오랜 시간이 지난 후에도 회수되지 않는 경우가 발생할 수 있습니다.
자바 스크립트
// JavaScript: closure capturing large object in event listener
function setupHandler() {
const largeDataset = fetchLargeDataset(); // 50MB of data
document.getElementById('btn').addEventListener('click', function() {
// Only uses one field, but captures entire largeDataset in closure
console.log(largeDataset.summary);
});
// largeDataset cannot be collected as long as the button exists
// because the event listener closure holds a reference to it
}
// Fix: capture only what you need
function setupHandlerFixed() {
const largeDataset = fetchLargeDataset();
const summary = largeDataset.summary; // extract only needed data
document.getElementById('btn').addEventListener('click', function() {
console.log(summary); // closure captures only the string
});
// largeDataset is now eligible for collection
}
5. 캐시 삭제 없는 무제한 캐시
캐시는 운영 서비스에서 메모리 누수의 가장 흔한 원인 중 하나입니다. 제거 정책 없이 크기가 커지는 캐시는 항상 접근 가능하기 때문에 수집할 수 없는 객체가 누적되는 저장소가 됩니다.
날카로운
// C#: unbounded dictionary used as cache
public class PriceService {
// Growing forever -- no eviction, no size limit
private static Dictionary<string, decimal> _priceCache = new();
public decimal GetPrice(string productId) {
if (!_priceCache.TryGetValue(productId, out var price)) {
price = FetchPriceFromDatabase(productId);
_priceCache[productId] = price;
}
return price;
}
// After processing 1 million unique product IDs, the cache
// holds 1 million entries and grows with every new product
}
// Fix: use a cache with eviction policy
using Microsoft.Extensions.Caching.Memory;
public class PriceService {
private readonly IMemoryCache _cache;
public PriceService(IMemoryCache cache) {
_cache = cache;
}
public decimal GetPrice(string productId) {
return _cache.GetOrCreate(productId, entry => {
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SizeLimit = 1; // bounded by cache size configuration
return FetchPriceFromDatabase(productId);
});
}
}
언어별 메모리 누수
자바
정적 컬렉션 외에 자바에서 가장 흔한 메모리 누수 패턴은 다음과 같습니다. finalize() 메서드와 내부 클래스. 비정적 내부 클래스는 외부 클래스 인스턴스에 대한 암묵적인 참조를 유지합니다. (안드로이드에서) 액티비티 또는 프래그먼트 내부에 정의된 익명 클래스는 해당 액티비티에 대한 참조를 유지하므로 화면 회전이나 탐색 후에도 가비지 컬렉션되지 않습니다.
자바의 WeakHashMap 키가 의미 있는 객체인 경우 정적 컬렉션 문제를 해결합니다. 다른 곳에서 해당 키에 접근할 수 없게 되면 항목이 자동으로 제거됩니다. 더 이상 참조되지 않을 때 해제해야 하는 값의 경우에는 다음과 같습니다. WeakReference SoftReference 가비지 컬렉션을 방지하는 강력한 참조를 생성하지 않고 객체를 래핑합니다.
탐지 도구: 힙 덤프 분석을 위한 Java VisualVM, Eclipse Memory Analyzer (MAT), JProfiler, YourKit 및 HeapHero. 다음을 찾아보세요. byte[], char[]예산 및 Object[] 힙 히스토그램의 증가 추세는 일반적으로 확장되는 컬렉션의 데이터 구조를 뒷받침합니다.
Python
파이썬의 가비지 컬렉터(GC)는 참조 카운팅(기본)과 순환 참조 컬렉터(보조)의 두 계층으로 구성됩니다. 참조 카운팅 계층은 카운트가 0에 도달하면 객체를 즉시 해제하므로 빠르고 예측 가능합니다. 순환 참조 컬렉터는 순환 참조를 처리하지만 실행 빈도가 낮고 예외가 발생할 수 있습니다.
파이썬
# Python circular reference with __del__ -- historically problematic
class Node:
def __init__(self):
self.child = None
def __del__(self):
print("Node deleted")
a = Node()
b = Node()
a.child = b
b.child = a # circular reference
del a, b # neither freed in Python 2; freed by cycle collector in Python 3.4+
Python 3.4 이상 버전은 대부분의 순환 참조를 올바르게 처리합니다. 나머지 메모리 누수 패턴은 모듈 수준 전역 변수에 저장된 객체, 크기가 커지는 객체 등입니다. defaultdict or dict 레지스트리로 사용되는 구조체와 Python GC에 노출되지 않는 C 확장 모듈의 메모리.
발각: tracemalloc (표준 라이브러리) memory_profiler, objgraph. 그만큼 objgraph.show_most_common_types() objgraph.show_growth() 함수는 어떤 유형이 누적되는지 식별합니다.
C# 및 .NET
C# 메모리 누수는 일반적으로 이벤트 핸들러의 구독 해제 실패, 정적 이벤트 소스가 구독자 인스턴스에 대한 참조를 유지하는 경우, 그리고 IDisposable 객체가 해제되지 않은 경우에 발생합니다.
날카로운
// C#: event handler leak
public class Publisher {
public static event EventHandler DataChanged; // static event
}
public class Subscriber {
public Subscriber() {
Publisher.DataChanged += OnDataChanged; // strong reference
}
private void OnDataChanged(object sender, EventArgs e) { }
// Missing: Dispose() calling Publisher.DataChanged -= OnDataChanged
}
The Subscriber 한 번 수집되면 절대 수집되지 않습니다. Publisher 정적 이벤트가 구독자 인스턴스를 참조하는 델리게이트를 가지고 있기 때문에 해당 이벤트는 (정적 이벤트이므로 영원히) 존재합니다. 해결 방법은 다음과 같습니다. IDisposable 그리고 구독을 취소하려면 Dispose(), 또는 사용 WeakEventManager.
탐지 도구: .NET 메모리 프로파일러, dotMemory, Visual Studio 진단 도구, 그리고 프로덕션 힙 덤프 분석을 위한 SOS 확장 기능이 설치된 WinDbg.
Go
Go 언어는 매우 짧은 stop-the-world 일시 중지 시간을 가진 마크 앤 스윕 GC를 사용합니다. 그럼에도 불구하고 Go 프로그램은 고루틴 누수, 즉 시작되었지만 종료되지 않는 고루틴으로 인해 메모리 누수가 발생합니다.
go
// Go: goroutine leak -- channel never closed, goroutine blocked forever
func processItems(items []Item) {
results := make(chan Result)
go func() {
for _, item := range items {
results <- process(item)
}
// Missing: close(results)
// This goroutine blocks on the send if no one reads after items run out
}()
for result := range results {
handleResult(result)
}
// If the goroutine blocks and results is never closed,
// the loop here also blocks -- both goroutines leak
}
유출된 각 고루틴은 스택(초기 2KB, 필요에 따라 증가)과 참조하는 힙 객체를 보유합니다. 요청당 하나의 고루틴이 유출되는 서비스는 시간이 지남에 따라 수천 개의 고루틴이 누적될 수 있습니다.
발각: runtime.NumGoroutine() 측정 기준, pprof 고루틴 프로필 엔드포인트(/debug/pprof/goroutinepprof 프로파일링 도구와 goleak을 테스트에 사용합니다. pprof 프로파일링은 모든 고루틴의 스택 트레이스를 보여주므로 어떤 함수가 무한정 차단되는지 쉽게 파악할 수 있습니다.
자바스크립트와 Node.js
Node.js에서 가장 흔한 메모리 누수 패턴은 전역 변수, 제거되지 않고 누적되는 이벤트 에미터 리스너, 그리고 제대로 닫히지 않은 버퍼/스트림과 관련이 있습니다.
자바 스크립트
// Node.js: EventEmitter listener leak
const EventEmitter = require('events');
const emitter = new EventEmitter();
function createHandler() {
emitter.on('data', (data) => {
// This listener is never removed
// Each call to createHandler adds another listener
console.log(data);
});
}
// Called once per request in a server:
// After 11 calls, Node prints MaxListenersExceededWarning
// After thousands of calls, thousands of listeners accumulate
// Fix: use emitter.once() for one-time listeners
// or explicitly remove with emitter.off()
탐지 방법: Chrome 개발자 도구의 힙 스냅샷(브라우저 JavaScript용) node --inspect Chrome 개발자 도구(Node.js용)를 사용하면 다음과 같습니다. clinic.js 프로덕션 환경의 Node.js 프로파일링을 위해, heapdump 시간에 따른 힙 스냅샷을 캡처하고 비교하는 npm 패키지입니다.
GC 언어별 예방 패턴
프로덕션 환경에서 누수가 발생한 후에 디버깅하는 대신, 이러한 구조적 패턴은 가장 흔한 원인을 예방합니다.
약한 참조 가비지 컬렉션을 막지 않고 객체를 참조할 수 있도록 합니다. 가비지 컬렉션(GC)이 메모리가 필요할 때, 참조가 존재하더라도 약하게 참조된 객체는 가비지 컬렉션 대상이 될 수 있습니다. WeakReference<T> Java 및 C#으로, weakref.ref() 파이썬으로, WeakRef 참조하는 객체가 참조되는 객체의 수명을 연장해서는 안 되는 캐시 및 옵저버 등록을 위한 JavaScript 코드입니다.
크기 제한 캐시 및 제거 기능 원시 데이터를 대체합니다 HashMap or dict 목적에 맞게 설계된 캐시 구현을 통해: Guava의 CacheBuilder 자바에서, functools.lru_cache or cachetools 파이썬에서, IMemoryCache C#의 크기 제한과 관련하여 node-lru-cache Node.js에서.
명시적 생명주기 관리 등록된 리스너, 열린 리소스 또는 스레드 로컬 상태를 가진 객체가 다음을 구현하도록 보장합니다. close(), dispose()또는 이와 동등한 방식으로 정리 작업을 수행하고 호출자가 항상 이를 호출하도록 합니다. try-with-resources 자바에서, with 파이썬의 문장, using C#에서의 선언 및 defer 이동 중.
고루틴 취소 Go에서 사용하는 context.Context 취소 기능을 통해 모든 고루틴이 종료될 수 있는 경로를 확보합니다. 취소를 선택하는 고루틴은... ctx.Done() 배출 신호를 보내 축적을 방지할 수 있습니다.
정적 분석을 통해 생산 전 잠재적 누출을 찾아내는 방법
메모리 프로파일링은 메모리 누수가 발생한 후에 이를 찾아냅니다. 정적 분석은 코드가 실행되기 전에 메모리 누수를 유발하는 구조적 패턴을 찾아냅니다.
SMART TS XL의 정적 코드 분석 이 연구는 Java, Python, C#, JavaScript 및 기타 언어의 코드베이스 구조적 특성을 분석하여 의도치 않은 잔존과 관련된 패턴을 식별합니다. 이러한 패턴에는 문서화된 제거 절차가 없는 정적 컬렉션, 해당 등록 해제 절차가 없는 이벤트 리스너 등록, 정리 절차가 없는 ThreadLocal 사용, 그리고 해당 해제 절차가 없는 리소스 할당 등이 포함됩니다.
애플리케이션 종속성 매핑 기능은 GC 언어 메모리 누수에서 흔히 발생하는 긴 참조 체인을 드러냅니다. 이러한 체인은 프로그램 루트에서 여러 단계의 간접 참조를 통해 도달할 수 있는 객체들을 드러내는데, 여기서 루트로 가는 경로는 객체를 임시로 저장하기 위해 만들어진 수명이 긴 컨테이너를 통과합니다.
수년 또는 수십 년 전에 메모리 관리 패턴이 정립된 대규모 레거시 코드베이스를 관리하는 조직의 경우, SMART TS XL의 영향 분석 이 기능을 통해 문제 해결 범위를 체계적으로 설정할 수 있습니다. 특정 안티 패턴(무제한 정적 캐시, 제거되지 않은 리스너)이 발생하는 모든 위치를 식별하고, 영향을 받는 구성 요소를 열거하고, 프로덕션 환경에서 발생하는 사고를 하나씩 발견하는 대신 위험도 순서대로 해결 계획을 수립할 수 있습니다.


