1. 메모리 작업 시 흔히 하는 실수
이제 자동 메모리 관리의 마법 이면을 들여다볼 때입니다. C처럼 각 바이트를 직접 챙기지 않더라도, Java에서도 충분히 잘못해서 애플리케이션이 메모리를 식탐 많은 고양이가 소시지를 먹듯 잡아먹고, 심하게 느려질 수 있습니다. 가장 흔한 실수와 이를 피하는 방법을 살펴봅시다.
잊힌 리스너(listeners)
Java에서는 “리스너” 패턴이 자주 사용됩니다 — 다른 객체의 이벤트를 구독하는 객체입니다. 예를 들어, 버튼을 만들고 클릭 핸들러를 추가합니다:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 클릭 처리
}
});
문제: 버튼이나 창이 더 이상 필요 없을 때 removeActionListener로 이 리스너를 제거하는 것을 잊으면 리스너가 메모리에 계속 남습니다. 창을 닫고 그에 대한 모든 참조를 끊었더라도, 리스너 객체가 여전히 창을 참조(혹은 그 반대)하여 GC가 메모리를 해제하지 못하게 합니다.
비유: 이사를 했는데 피자 가게 뉴스레터 구독 해지를 잊어버린 상황을 떠올려 보세요 — 광고가 계속 예전 주소로 옵니다.
정리되지 않는 정적 컬렉션
정적 필드는 클래스가 살아 있는 동안(때로는 애플리케이션이 종료될 때까지) 존재합니다. 예를 들어 정적 컬렉션이 있다면:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
여기에 객체를 추가만 하고 삭제하지 않는다면, 그 객체들은 영원히 메모리에 남습니다. 객체 자체를 참조하는 곳이 더 이상 없더라도, 정적 컬렉션의 참조가 GC가 제거하는 것을 막습니다.
실제 사례: 데스크톱 애플리케이션의 사진 캐시가 절대 비워지지 않는 경우. 몇 시간만 지나도 OutOfMemoryError가 발생합니다.
리소스(파일, 스트림, 연결) 해제하지 않기
Java는 메모리는 회수하지만, 파일 디스크립터, 네트워크 연결 같은 외부 리소스를 자동으로 닫아주지는 않습니다. 파일이나 스트림을 닫는 것을 잊으면 리소스가 계속 열린 채로 남고, 어느 순간 시스템이 “그만, 더 이상 파일은 못 열어!”라고 말하게 됩니다(IOException: Too many open files).
팁: 항상 try-with-resources를 사용하세요:
try (FileInputStream in = new FileInputStream("data.txt")) {
// 파일 읽기
} // in.close()가 자동으로 호출됩니다!
오래 메모리에 남는 큰 객체
큰 배열이나 컬렉션을 만들고 사용한 뒤 “놓아 주는 것”을 잊을 때가 있습니다. 예를 들어:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 각 1MB
}
// ... bigList 정리를 잊음
이 컬렉션이 정적 필드에 있거나, 오랫동안 제거되지 않는 객체 안에 있다면, 그 메모리는 계속 점유됩니다.
내부 및 익명 클래스: 외부 참조 캡처
Java의 익명(및 내부) 클래스는 외부 객체에 대한 암시적 참조를 보관합니다:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r가 어딘가에 저장됨
}
}
객체 r이 정적 컬렉션이나 캐시에 들어가면, 더 이상 필요 없어도 Outer 인스턴스에 대한 참조를 “붙잡고” 있게 됩니다. 그 결과 — 메모리 누수입니다. 람다식은 상황이 조금 낫지만, 람다가 외부 클래스의 필드를 캡처하면 참조는 여전히 유지됩니다.
2. 가비지 컬렉터 사용 시 실수
System.gc() 강제 호출
많은 초보자가 이렇게 생각합니다: “메모리가 부족하네 — System.gc()를 호출하면 다 해결!” 실제로 이것은 JVM에 대한 단순한 요청일 뿐이며, 즉시 수집을 보장하지 않습니다. 자주 사용하면 성능이 급격히 저하되고, 긴 일시정지와 프리즈를 유발할 수 있습니다. 실제 애플리케이션에서는 JVM을 신뢰하는 편이 좋습니다 — 언제 수집할지 JVM이 스스로 결정합니다. 참고로, 일부 JVM은 명시적 GC 호출을 무시할 수 있습니다(예: -XX:+DisableExplicitGC 옵션).
GC 로그를 무시
GC 로그에는 수집이 언제 일어났는지, 얼마나 걸렸는지, 얼마나 많은 메모리가 해제됐는지가 나타납니다. 이 로그를 보지 않으면 긴 일시정지, 잦은 Full GC, 메모리 누수 같은 신호를 놓칠 수 있습니다.
GC 로그 활성화 방법:
java -Xlog:gc* -jar MyApp.jar
또는 구형 JVM의 경우:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
작업에 맞지 않는 GC 선택
수집기 선택은 지연 시간과 안정성에 영향을 줍니다. 낮은 지연 시간이 중요한 경우(거래소, 온라인 게임) 정지 시간이 긴 stop-the-world 방식의 Parallel GC는 좋지 않은 선택입니다. 수집 동안 모든 스레드를 “얼려” 버릴 수 있습니다. G1 GC, ZGC, Shenandoah를 고려하세요.
3. 컬렉션 관련 실수
WeakHashMap 대신 HashMap으로 캐시를 구현.
참조가 더 이상 “살아있지” 않을 때 객체가 자동으로 제거되어야 하는 캐시라면 WeakHashMap을 사용하세요:
Map<Key, Value> cache = new WeakHashMap<>();
일반 HashMap을 사용하면 캐시를 수동으로 비우기 전까지 객체가 계속 살아 있어 메모리 누수를 유발합니다.
요소에 대한 remove() 호출을 잊음.
컬렉션(예: 리스너 목록)에 객체를 추가하고, 더 이상 필요 없을 때 제거하지 않으면 그 객체는 영원히 남을 수 있습니다. 특히 오래 사는 컬렉션(예: 정적 컬렉션)에서 그렇습니다.
4. 모범 사례: 문제를 피하는 방법
리스너는 반드시 제거하세요.
객체가 이벤트를 구독했다면, 더 이상 필요 없을 때 반드시 구독을 해지하세요. dispose() 메서드나 창/화면을 닫을 때 처리하는 것이 편리합니다.
button.removeActionListener(myListener);
캐시에는 약한 참조를 사용하세요.
캐시에 객체 보존 보장이 필요 없다면 WeakReference나 이를 기반으로 한 컬렉션(WeakHashMap)을 사용하세요. 그러면 메모리가 필요할 때 GC가 해제할 수 있습니다.
프로덕션에서 메모리를 모니터링하세요.
jvisualvm, jconsole 또는 APM 시스템을 사용하세요. 사용자 불만이 생기기 전에 누수를 잡는 데 도움이 됩니다.
누수가 의심되면 힙 덤프를 분석하세요
애플리케이션이 평소보다 더 많은 메모리를 “잡아먹기” 시작했다면, 힙 덤프(jmap 또는 jvisualvm 사용) 를 떠서 어떤 객체가 가장 많은 공간을 차지하는지 보세요. 대개 몇 분 안에 범인을 찾을 수 있습니다.
JVM 매개변수를 조정하세요.
- -Xmx — 힙의 최대 크기
- -Xms — 힙의 초기 크기
합리적인 한도를 설정하면 OutOfMemoryError를 피하고, 진단 속도도 빨라집니다.
5. 실습: 메모리 누수 코드 예제와 수정
예제 1: 정적 컬렉션을 통한 누수
public class MemoryLeakDemo {
// 정적 컬렉션 — 영원히 살아 있음
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 매번 1MB
System.out.println("추가됨 " + (i + 1) + " MB");
}
// OutOfMemoryError!
}
}
수정: 로컬 변수를 사용하거나 더 이상 필요 없을 때 컬렉션을 비우세요.
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("추가됨 " + (i + 1) + " MB");
}
// tempList = null; // 명시적으로 null로 설정할 수 있음
// 이제 메서드 종료 후 객체들이 GC 대상이 됨
}
}
예제 2: 리스너로 인한 누수
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// removeListener 메서드가 없음!
}
수정: 리스너를 제거하는 메서드를 추가하고 창을 닫을 때 호출하세요.
public void removeListener(EventListener l) {
listeners.remove(l);
}
예제 3: WeakHashMap 대신 HashMap을 사용한 캐시
Map<Object, Object> cache = new HashMap<>();
// ... 객체 추가
수정: WeakHashMap으로 전환하세요:
Map<Object, Object> cache = new WeakHashMap<>();
메모리 모니터링을 위한 JVM 설정 팁
- GC 로그를 활성화하세요: -Xlog:gc* 또는 -XX:+PrintGCDetails
- 힙 최대 크기를 제한하세요: -Xmx512m
- 캐시를 사용한다면 — 크기를 관리하고, 가능하다면 약한 참조를 사용하세요
- GC를 실험해보세요: -XX:+UseG1GC, -XX:+UseZGC, -XX:+UseShenandoahGC
7. 메모리 작업 시 흔히 하는 실수
오류 1: 잊힌 리스너와 구독. 객체에 리스너를 추가해놓고 제거하지 않으면, 리스너 객체(그리고 그가 참조하는 모든 것)가 메모리에 남습니다. GUI와 이벤트 시스템에서 흔한 고전적인 문제입니다. removeListener/removeActionListener를 사용하세요.
오류 2: 정적 컬렉션을 비우지 않음. 정적 필드는 가장 오래 살아남습니다. 여기에 객체를 넣고 컬렉션을 비우지 않으면, 그 객체는 영원히 메모리에 남습니다. 특히 끝이 없는 캐시에서 교묘하게 나타납니다.
오류 3: 외부 리소스를 닫지 않음. 스트림, 파일, 연결을 열어둔 채로 두었나요? 메모리를 낭비할 뿐 아니라 OS 한계에 부딪힙니다. try-with-resources를 사용하고 리소스를 닫으세요.
오류 4: System.gc() 강제 호출. 만능열쇠가 아니라 JVM에 대한 단순한 요청일 뿐입니다. 자주 호출하면 일시정지와 성능 저하로 이어집니다.
오류 5: 캐시에 일반 컬렉션 사용. 캐시의 객체가 스스로 제거되어야 한다면 약한/soft 참조(WeakHashMap, SoftReference)를 사용하세요. 그렇지 않으면 누수가 발생합니다.
오류 6: 외부 참조를 캡처하는 내부/익명 클래스. 내부 클래스와 람다는 외부 객체에 대한 참조를 암시적으로 붙잡을 수 있습니다. 이를 오래 사는 컬렉션에 저장하면 — 누수로 이어집니다.
오류 7: GC 로그를 무시. GC 로그를 보지 않으면 긴 일시정지나 잦은 Full GC를 알 수 없습니다 — 사용자는 렉과 프리즈로 먼저 알게 됩니다. -Xlog:gc* 또는 -XX:+PrintGCDetails를 활성화하세요.
GO TO FULL VERSION