CodeGym /행동 /JAVA 25 SELF /지역 변수, 메모리 누수, 약한 참조

지역 변수, 메모리 누수, 약한 참조

JAVA 25 SELF
레벨 64 , 레슨 2
사용 가능

1. 지역 변수는 어디에 저장되는가

가장 기본적인 것부터 시작해 봅시다: 지역 변수입니다. 지역 변수는 메서드 내부에서 선언되어 해당 메서드가 실행되는 동안에만 존재합니다. 그들의 생애는 짧고 극적입니다: 메서드가 끝나는 즉시 모든 지역 변수는 흔적도 없이 사라집니다.

Java에서 지역 변수는 스택에 저장됩니다. 각 스레드는 자신만의 스택을 가집니다. 어떤 메서드가 다른 메서드를 호출하면, 로컬 변수와 복귀 주소를 담은 새로운 ‘프레임’(stack frame)이 스택에 추가됩니다. 메서드가 종료되면 해당 프레임은 스택에서 제거됩니다.

예시: 지역 변수의 수명

public class LocalVariableDemo {
    public static void main(String[] args) {
        int a = 42; // 지역 변수 a는 main에서만 생존
        printSquare(a);
        // 여기서는 변수 b가 이미 없습니다!
    }

    public static void printSquare(int b) {
        int square = b * b; // 지역 변수 square
        System.out.println("제곱: " + square);
        // printSquare를 빠져나가면 모든 지역 변수가 사라진다
    }
}

중요한 점: 지역 변수가 참조라면(예: String, Scanner, 배열), 참조 자체는 스택에 있지만 객체는 힙에 있습니다! 참조가 사라지고 그 객체를 가리키는 다른 참조가 없다면, 가비지 컬렉터가 그 객체를 제거할 수 있습니다.

도해

Stack (main용):
| int a = 42      |
| args            |
-------------------
Heap:
| [new로 생성된 객체들] |

2. Java의 메모리 누수: 미신인가, 현실인가?

많은 입문자들은 이렇게 생각합니다: “Java에는 가비지 컬렉터가 있잖아! 그럼 메모리 누수는 없겠네!” 안타깝게도 이는 신화일 뿐이며, 첫 대형 프로젝트에서 바로 깨집니다.

가비지 컬렉터는 살아 있는 참조가 하나도 없는 객체만 제거합니다. 어딘가에 참조가 남아 있다면(설령 전혀 예상 못 한 곳이라도), 그 객체는 끝까지 메모리에 붙어 있게 됩니다. 아니면 OutOfMemoryError가 발생할 때까지요.

예시 1: 정적 컬렉션 함정

import java.util.ArrayList;
import java.util.List;

public class MemoryLeakDemo {
    // 아, 이 정적 컬렉션!
    private static final List<String> BIG_LIST = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            BIG_LIST.add("문자열 번호 " + i);
        }
        System.out.println("백만 개의 문자열이 추가됨");
        // main이 끝나도, JVM이 살아 있는 동안 BIG_LIST는 메모리에 남는다
    }
}

무슨 일이 벌어지는가?

  • 정적 변수 BIG_LIST는 클래스가 살아 있는 동안(보통 JVM의 수명 종료까지) 존재합니다.
  • 리스트에 추가된 모든 문자열은 가비지 컬렉터가 제거할 수 없습니다 — BIG_LIST를 통해 항상 참조가 존재하기 때문입니다.
  • 이런 컬렉션을 정리하는 것을 깜빡하면 메모리 누수가 발생합니다.

예시 2: 구독 해지되지 않은 리스너 (listeners)

import java.util.ArrayList;
import java.util.List;

class EventSource {
    private final List<Runnable> listeners = new ArrayList<>();

    public void addListener(Runnable listener) {
        listeners.add(listener);
    }

    // ... 다른 메서드 ...
}

public class ListenerLeakDemo {
    public static void main(String[] args) {
        EventSource source = new EventSource();
        Runnable listener = () -> System.out.println("이벤트!");
        source.addListener(listener);
        // source.removeListener(listener)를 호출하는 것을 잊으면, listener는 영원히 메모리에 남습니다!
    }
}

문제: 리스너가 더 이상 필요 없는데도 목록에서 제거하지 않으면, 리스너와 그가 참조하는 모든 객체가 메모리에 남습니다.

예시 3: 절대 비워지지 않는 캐시

import java.util.HashMap;
import java.util.Map;

public class CacheLeakDemo {
    private static final Map<String, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            // 매번 1KB 배열을 생성
            CACHE.put("key" + i, new byte[1024]);
        }
        System.out.println("캐시에 백만 개의 항목이 추가됨");
        // 캐시는 계속 커지고, 메모리는 고갈되어 OutOfMemoryError!
    }
}

결론: GC가 있어도 객체의 생명주기를 관리하지 않으면 메모리 누수는 쉽게 발생합니다!

3. 약한 참조(WeakReference)와 그 친구들

때때로, 더 이상 아무도 참조하지 않으면 GC가 객체를 제거할 수 있는 캐시나 컬렉션이 필요합니다. 이를 위해 약한 참조(WeakReference)가 고안되었습니다.

일반(강한, strong) 참조

String s = new String("hello"); // strong 참조

객체 s는 strong 참조가 하나라도 존재하는 한 메모리에 남아 있습니다.

약한 참조 (WeakReference)

import java.lang.ref.WeakReference;

public class WeakRefDemo {
    public static void main(String[] args) {
        String strong = new String("안녕, 세계!");
        WeakReference<String> weak = new WeakReference<>(strong);

        System.out.println("정리 전: " + weak.get()); // 참조가 있음

        strong = null; // strong 참조 제거

        System.gc(); // GC에 메모리 정리를 요청(보장되지 않음!)

        // 시간이 지나면 weak.get()이 null이 될 수 있음
        System.out.println("GC 이후: " + weak.get());
    }
}

어떻게 동작하나?

  • strong 참조가 하나라도 있는 동안 GC는 객체를 삭제하지 않습니다.
  • 약한 참조만 남아 있으면, 다음 GC 때 객체가 삭제될 수 있습니다.
  • 메서드 weak.get()은 객체가 아직 살아 있으면 그 객체를 반환하고, 삭제되었으면 null을 반환합니다.

약한 참조는 어디에 쓰나?

주요 용도는 캐시입니다. 객체가 메모리에서 제거되어도 치명적이지 않은 경우에 유용합니다. 예를 들어, 이미지를 캐시하되 캐시가 모든 메모리를 점유하길 원하지 않을 때.

예시: WeakHashMap

WeakHashMap은 키를 약한 참조로 보관하는 컬렉션입니다. 키를 더 이상 아무도 참조하지 않으면, 해당 엔트리는 맵에서 제거됩니다.

import java.util.Map;
import java.util.WeakHashMap;

public class WeakHashMapDemo {
    public static void main(String[] args) {
        Map<Object, String> map = new WeakHashMap<>();
        Object key = new Object();
        map.put(key, "값");

        System.out.println("정리 전: " + map);

        key = null; // 키에 대한 strong 참조 제거

        System.gc(); // GC 수행 요청

        // 시간이 지나면 맵이 비게 됩니다!
        try { Thread.sleep(100); } catch (InterruptedException ignored) {}
        System.out.println("GC 이후: " + map);
    }
}

주의: WeakHashMap은 키에만 적용됩니다 — 값은 일반적인 strong 참조로 저장됩니다.

Soft, Weak, Phantom: 참조 가족 전체

Java에는 네 가지 참조 유형이 있으며(‘강도’ 순서):

참조 유형 객체가 GC로 언제 제거되는가? 용도
Strong
strong 참조가 하나도 없을 때만 일반 변수, 컬렉션
Soft
메모리가 부족할 때 가능하면 오래 유지하고 싶은 캐시
Weak
strong 참조가 없으면 다음 GC 사이클에서 캐시, WeakHashMap, 리스너
Phantom
finalization 이후, 객체 제거를 추적할 때 특수 작업, 힙 외부 리소스 정리
  • SoftReference — 메모리가 부족하면 객체가 제거됩니다(이미지 캐시 등과 잘 맞습니다).
  • WeakReference — 다른 참조가 없으면 첫 GC에서 제거됩니다.
  • PhantomReference — 가장 ‘유령’ 같은 유형으로, 복잡한 시나리오에서 필요하며 초보자는 거의 사용하지 않습니다.

4. 실습: 메모리 누수 예시와 그 해결

누수 예시: 정적 리스트

import java.util.ArrayList;
import java.util.List;

public class LeakExample {
    private static final List<byte[]> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new byte[1024 * 1024]); // 1MB
            if (i % 10 == 0) System.out.println("추가됨 " + i + " MB");
        }
    }
}

무슨 일이 일어날까?
프로그램은 사용 가능한 메모리를 빠르게 소모하고 OutOfMemoryError와 함께 종료될 것입니다. 정적 리스트가 생성된 모든 배열에 대한 참조를 보관하기 때문입니다.

해결: 약한 참조 사용

모든 객체가 항상 접근 가능할 필요가 없다면, 약한 참조로 보관할 수 있습니다:

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

public class LeakFixed {
    private static final List<WeakReference<byte[]>> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new WeakReference<>(new byte[1024 * 1024]));
            if (i % 10 == 0) System.out.println("추가됨 " + i + " MB");
            System.gc(); // GC에 대한 힌트(즉시 정리를 보장하지 않음!)
        }
    }
}

이제 배열에 대한 다른 참조가 없으면 GC가 배열을 삭제할 수 있습니다 — 리스트는 약한 참조만 보관합니다.

5. 전형적인 메모리 누수 시나리오

  • 이벤트 리스너: 리스너 제거를 잊으면 객체는 영원히 살아 있습니다.
  • 정적 컬렉션: 정리되지 않는 캐시, 전역 리스트 — 모두 누수로 이어질 수 있습니다.
  • 내부 클래스와 람다: 내부 클래스나 람다가 외부 객체에 대한 참조를 캡처하면, 외부 객체가 살아 있는 동안 해당 인스턴스가 제거되지 않습니다.

내부 클래스 예시

public class Outer {
    private byte[] bigArray = new byte[1024 * 1024 * 100]; // 100MB

    public Runnable createTask() {
        // 익명 내부 클래스가 Outer에 대한 참조를 캡처!
        return new Runnable() {
            @Override
            public void run() {
                System.out.println("작업 실행 중");
            }
        };
    }

    public static void main(String[] args) {
        Outer outer = new Outer();
        Runnable task = outer.createTask();
        // outer = null이더라도, task는 여전히 bigArray를 붙잡고 있음!
    }
}

해결: 불필요한 참조를 보관하지 않도록 정적 내부 클래스를 사용하거나 로직을 별도 클래스로 분리하세요.

6. 실습: 캐시에서 약한 참조 사용

학습용 애플리케이션에 WeakHashMap을 사용하는 가장 단순한 캐시를 추가해 봅시다.

import java.util.Map;
import java.util.WeakHashMap;

public class ImageCache {
    private final Map<String, byte[]> cache = new WeakHashMap<>();

    public void put(String name, byte[] data) {
        cache.put(name, data);
    }

    public byte[] get(String name) {
        return cache.get(name);
    }

    public static void main(String[] args) {
        ImageCache cache = new ImageCache();
        cache.put("cat", new byte[1024 * 1024]); // 1MB
        System.out.println("고양이가 캐시에 추가됨");

        // 키 "cat"에 대한 참조가 더 이상 없으면 객체는 GC로 삭제될 수 있음
    }
}

실제 애플리케이션(예: 이미지 라이브러리)에서는 약한 참조가 드물게 사용되는 데이터를 자동으로 제거하여 메모리 고갈을 피하는 데 도움이 됩니다.

7. Strong vs Weak: 언제 무엇을 쓸까?

  • Strong 참조 — 기본값입니다. 수명이 보장되어야 하는 모든 것에 사용하세요.
  • 약한 참조 — 캐시, 리스너 등 객체가 제거되어도 치명적이지 않은 경우에 사용하세요.
  • Soft 참조 — 가능하면 오래 유지하고 싶지만 메모리 부족 시 삭제돼도 되는 캐시에 사용하세요.
  • Phantom 참조 — 고급 시나리오용(예: 힙 외부 리소스 정리).

8. 메모리와 참조 작업에서 흔한 실수

실수 №1: “GC가 다 치워 줄 거야!” 가비지 컬렉터는 살아 있는 strong 참조가 하나도 없는 객체만 삭제합니다. 어딘가에 참조를 ‘남겨두면’(예: static-컬렉션), 객체는 영원히 살아 있습니다.

실수 №2: 잊힌 리스너. 객체에 리스너를 추가해 놓고 객체가 파괴될 때 제거하지 않았나요? 리스너와 그가 캡처한 모든 것은 메모리에 남습니다.

실수 №3: 약한 참조 없는 캐시. 자동으로 정리돼야 하는 캐시에 일반 HashMap을 쓰고 있나요? WeakHashMap이나 SoftReference를 사용하는 것이 좋습니다.

실수 №4: 내부 클래스와 람다가 외부 객체를 캡처. 내부 클래스(와 람다)는 암묵적으로 외부 객체에 대한 참조를 보관합니다. 외부 객체보다 오래 해당 인스턴스를 보관하면 누수가 생깁니다.

실수 №5: GC가 즉시 동작하길 기대. System.gc() 호출은 가비지 컬렉션이 즉시 일어난다는 보장이 없습니다. JVM에 대한 ‘요청’일 뿐, 명령이 아닙니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION