2. Spring Bean과 메모리 관리

Spring Boot 앱은 IoC 컨테이너가 모든 Bean 객체의 생명주기를 관리합니다. "Spring이 알아서 관리해준다"는 말이 맞긴 한데, 코드를 어떻게 짜느냐에 따라 GC가 절대 건드릴 수 없는 객체들이 생겨버립니다. 이 챕터는 "왜 Heap이 계속 차오르는가"의 근본 원인을 Bean 관리 관점에서 7가지 패턴으로 분류해 설명합니다.

1. Spring IoC 컨테이너와 GC의 관계

GC는 "더 이상 아무도 참조하지 않는 객체"만 수거합니다. "아무도 참조하지 않는다"는 판단 기준이 바로 GC Root입니다.

GC Root — 여기서 참조되는 객체는 절대 수거 불가

즉, Spring의 ApplicationContext 자체가 GC Root입니다. 애플리케이션이 살아있는 한, ApplicationContext → Singleton Bean 참조는 끊어지지 않습니다. 문제는 그 Bean 안에 무엇이 담기느냐입니다.

GC Root ApplicationContext @Service Bean 내부 컬렉션 / 캐시 GC 불가 ❌

Bean 내부의 필드가 외부 객체를 참조하면, 그 객체 전부가 GC 불가 상태가 됩니다. Bean이 Old Gen에 승격되는 순간, 참조되는 모든 객체도 Old Gen에서 나오지 못합니다.

JVM HEAP
Young Gen
새 객체 생성 → 단명 객체 대부분 여기서 소멸 (Minor GC)
Old Gen ⚠
Singleton Bean + 그 Bean이 참조하는 모든 객체 → 앱 종료까지 상주
Metaspace
클래스 메타데이터, Spring Proxy 클래스 바이트코드
Stack
스레드별 지역 변수, 호출 프레임 (GC Root)

2. Bean Scope와 메모리 수명

Spring은 Bean 생성 정책을 Scope로 제어합니다. Scope를 잘못 이해하면 의도와 다른 메모리 생명주기를 갖게 됩니다.

Scope 생성 시점 소멸 시점 Heap 위치 주의점
singleton (기본값) 앱 시작 (또는 첫 요청) 앱 종료 Old Gen 영구 상주 내부 상태 가지면 누수 위험
prototype 매번 getBean() Spring이 관리하지 않음 → GC에 맡겨짐 Young → Old (참조 끊기면 GC) Singleton에 주입 시 Singleton화 됨 (케이스 2)
request HTTP 요청 시작 HTTP 응답 완료 Young → GC (단명) Web MVC 환경에서 안전
session 세션 생성 세션 만료 Old Gen (세션 시간만큼) 대용량 객체 넣으면 세션 수 × 크기만큼 Heap 점유

3. 코드 패턴별 GC 누수 케이스 7가지

1
Singleton Bean 내부의 무한 성장 컬렉션
가장 흔하고 치명적인 패턴 — Old Gen 서서히 포화
❌ 잘못된 코드
@Service
public class AuditService {
    // Singleton Bean의 필드 = Old Gen에 영구 존재
    private final List<AuditLog> logs
        = new ArrayList<>();

    public void log(AuditLog entry) {
        logs.add(entry); // 쌓기만 하고 제거 없음
    }
}
✅ 올바른 코드
@Service
public class AuditService {
    // 크기 제한 + 자동 eviction
    private final Cache<String, AuditLog> logs =
        Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(1, HOURS)
            .build();

    public void log(AuditLog entry) {
        logs.put(entry.getId(), entry);
    }
}
현상 (Symptom) Heap 사용률이 GC 직후에도 줄지 않고 우상향. Full GC가 발생해도 Old Gen 사용량 회복 불가. jmap -histo:live 실행 시 AuditLog 인스턴스 수가 수십만 개.
핵심 원인 Singleton Bean의 필드는 Bean이 살아있는 한(= 앱 종료까지) GC Root 참조가 유지됩니다. List 안의 AuditLog 객체도 모두 도달 가능(Reachable) 상태이므로 GC가 수거할 수 없습니다.

Fix: Caffeine Cache (maximumSize, expireAfterWrite) 사용으로 크기/TTL 기반 자동 eviction. 또는 Kafka/Redis로 외부 저장소 위임.
2
Prototype Bean을 Singleton에 @Autowired 직접 주입
Prototype scope 의미가 사라지고 사실상 Singleton화
❌ 잘못된 코드
@Component
@Scope("prototype") // 매번 새로 만들려는 의도
public class ReportBuilder {
    private List<Row> rows = new ArrayList<>();
    // 상태(state)를 가진 객체
}

@Service
public class ReportService {
    @Autowired
    private ReportBuilder builder; // ← 여기가 문제!
    // ReportService가 Singleton이므로
    // builder도 딱 한 번만 주입됨 = Singleton화
}
✅ 올바른 코드
@Service
public class ReportService {
    @Autowired
    private ObjectProvider<ReportBuilder> provider;

    public Report build(Request req) {
        // 매 호출마다 새 인스턴스 획득
        ReportBuilder builder = provider.getObject();
        return builder.build(req);
        // 메서드 끝나면 참조 소멸 → GC 가능
    }
}
현상 (Symptom) ReportBuilder 내부 rows에 데이터가 계속 누적됩니다. 요청이 쌓일수록 rows의 크기가 커지고, 서로 다른 요청의 데이터가 섞이는 버그까지 발생합니다.
Fix: ObjectProvider<T> 또는 ApplicationContext.getBean()으로 매 호출 시점에 새 인스턴스를 가져옵니다. 메서드 스택에만 참조가 남으므로 메서드 종료 후 GC 가능.
3
@Async 환경에서 ThreadLocal 미제거
Thread Pool 재사용 + ThreadLocal 잔류 = 이전 요청 데이터가 다음 요청에 노출
❌ 잘못된 코드
@Service
public class SecurityService {
    private static final ThreadLocal<UserContext> ctx
        = new ThreadLocal<>();

    @Async // Thread Pool에서 실행
    public void processAsync(Long userId) {
        ctx.set(loadUserContext(userId)); // 대용량 권한 객체
        try {
            doWork();
        } finally {
            // ctx.remove() 빠뜨림! ← 핵심 실수
        }
    }
}
✅ 올바른 코드
@Async
public void processAsync(Long userId) {
    ctx.set(loadUserContext(userId));
    try {
        doWork();
    } finally {
        ctx.remove(); // 반드시! finally 보장
    }
}

// 또는 Spring Security의
// DelegatingSecurityContextExecutor 활용
// (자동으로 SecurityContext 전파 + 정리)
현상 (Symptom) @Async가 사용하는 Thread Pool의 스레드는 재사용됩니다. 스레드 A가 처리하다가 남긴 UserContext(대용량 권한 리스트 포함)가 다음에 같은 스레드를 사용하는 요청에서도 살아있습니다. 메모리 누수 + 보안 버그(다른 유저 권한 노출) 동시 발생.
핵심 원인: ThreadLocal의 값은 해당 스레드가 살아있는 한 GC가 수거할 수 없습니다. Thread Pool의 스레드는 앱이 살아있는 내내 재사용되므로, remove 없이는 영구 잔류합니다.

Fix: finally { ctx.remove(); } 는 선택이 아닌 필수.
4
@Cacheable 무제한 캐시 성장
기본 ConcurrentHashMap 캐시 = 크기 제한 없음 → Heap 포화
❌ 잘못된 코드
@Service
public class ProductService {
    // 기본 CacheManager = ConcurrentMapCacheManager
    // → 크기 제한 없는 ConcurrentHashMap
    @Cacheable("productDetails")
    public ProductDetail getDetail(Long productId) {
        return repo.findById(productId).orElseThrow();
    }
}

// 상품 ID가 수천만 개라면?
// Heap에 수천만 개 ProductDetail 상주
// → Old Gen 포화 → Full GC → STW
✅ 올바른 코드
// CaffeineCacheManager 명시적 설정
@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager mgr =
            new CaffeineCacheManager("productDetails");
        mgr.setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(50_000)     // 최대 5만 항목
                .expireAfterWrite(10, MINUTES)
        );
        return mgr;
    }
}
현상 (Symptom) 앱 시작 후 시간이 지날수록 Heap 사용률이 점진적으로 증가합니다. 상품 조회 패턴이 다양할수록(Long-tail traffic) 캐시 히트율은 낮고 메모리만 채워집니다.
Fix: Spring Cache에서 Caffeine을 CacheManager로 지정하고 maximumSize, expireAfterWrite를 반드시 설정합니다. 또는 Redis를 CacheManager로 사용하면 Heap 외부(Off-heap/원격)에 저장됩니다.
5
외부 이벤트 버스에 등록된 리스너 미해제
Spring이 관리하지 않는 참조 = @PreDestroy 없으면 Bean이 GC 불가
❌ 잘못된 코드
@Component
public class OrderEventListener {
    @Autowired
    private GuavaEventBus externalBus; // 외부 라이브러리

    @PostConstruct
    public void init() {
        externalBus.register(this); // 등록
    }

    @PreDestroy
    public void destroy() {
        // externalBus.unregister(this); ← 깜빡!
    }

    @Subscribe
    public void on(OrderEvent e) { ... }
}
✅ 올바른 코드
@Component
public class OrderEventListener {
    @Autowired
    private GuavaEventBus externalBus;

    @PostConstruct
    public void init() {
        externalBus.register(this);
    }

    @PreDestroy
    public void destroy() {
        externalBus.unregister(this); // 반드시!
        // Spring은 이 참조를 모름 → 직접 해제
    }

    @Subscribe
    public void on(OrderEvent e) { ... }
}
현상 (Symptom) Hot reload(dev-tools) 또는 Bean 재생성 시 구 Bean이 externalBus에 의해 계속 참조됩니다. 새 Bean과 구 Bean이 동시에 이벤트를 처리하는 버그가 발생하고, 구 Bean은 GC가 불가합니다.
핵심 원인: Spring은 자신이 직접 관리하는 참조만 정리합니다. 외부 라이브러리(Guava EventBus, RxJava, Reactor 등)가 가진 참조는 스스로 해제해야 합니다.

Fix: @PreDestroy에서 외부 버스에 unregister 필수. Spring ApplicationEvent를 사용하는 경우 ApplicationListener를 구현하면 Spring이 자동 관리합니다.
6
Static 필드에 대용량 객체 참조
Static = GC Root → 클래스가 언로드되기 전까지 절대 GC 불가
❌ 잘못된 코드
public class CouponRuleCache {
    // static 필드 = GC Root에 직결
    private static final Map<String, CouponRule> rules
        = new HashMap<>();

    public static void reload(List<CouponRule> newRules) {
        // 기존 데이터 지우지 않고 추가만!
        newRules.forEach(r -> rules.put(r.getId(), r));
        // reload 호출마다 Map 크기 증가
    }
}
✅ 올바른 코드
public class CouponRuleCache {
    private static volatile Map<String, CouponRule> rules
        = Collections.emptyMap();

    public static void reload(List<CouponRule> newRules) {
        // 새 Map 생성 후 참조 교체
        // 구 Map은 참조 끊겨서 GC 가능
        Map<String, CouponRule> fresh = new HashMap<>();
        newRules.forEach(r -> fresh.put(r.getId(), r));
        rules = Collections.unmodifiableMap(fresh);
    }
}
현상 (Symptom) 쿠폰 규칙 reload 기능을 만들었는데, reload 할 때마다 Heap 사용량이 증가합니다. 이전 버전 규칙 + 새 버전 규칙이 같은 Map 안에 공존하고, 이전 규칙은 절대 GC되지 않습니다.
핵심 원인: Static 필드는 해당 ClassLoader가 살아있는 한 GC Root에 연결됩니다. Map 자체를 교체(새 인스턴스로 재할당)하면 구 Map에 대한 참조가 끊겨 GC 가능해집니다.

Fix: clear() 후 재삽입보다는 새 Map을 만들어 참조를 교체하는 패턴이 더 안전합니다. (clear 중 동시 접근 문제 방지)
7
@Transactional 내 대용량 엔티티 전체 조회 (JPA 1차 캐시 폭탄)
트랜잭션 내내 EntityManager가 모든 엔티티를 Heap에 유지
❌ 잘못된 코드
@Transactional
public void expireAllPromotions() {
    // 수백만 건 전체를 Heap에 로딩
    List<Promotion> all = repo.findAll();

    // JPA 1차 캐시(EntityManager)가
    // 트랜잭션 동안 모든 Promotion을 유지
    // → flush 전까지 변경분도 메모리에 쌓임
    all.forEach(p -> p.setStatus(EXPIRED));
    // → GC: Young GC 폭발 → Old Gen 이동
    //       → Full GC + STW
}
✅ 올바른 코드 (벌크 업데이트)
// JPQL 벌크 업데이트: 엔티티 Heap 로딩 없음
@Modifying
@Transactional
@Query("UPDATE Promotion p SET p.status = 'EXPIRED' " +
       "WHERE p.endDate < :now")
int bulkExpire(@Param("now") LocalDateTime now);

// 또는 페이지 단위 처리
@Transactional
public void expireInChunks() {
    Pageable page = PageRequest.of(0, 1000);
    Page<Promotion> chunk;
    do {
        chunk = repo.findByStatus(ACTIVE, page);
        chunk.forEach(p -> p.setStatus(EXPIRED));
        entityManager.flush();
        entityManager.clear(); // 1차 캐시 비우기
    } while (chunk.hasNext());
}
현상 (Symptom) 배치성 메서드 실행 시 Heap 사용률이 급격히 치솟습니다. Young GC가 연속으로 발생하고, 살아남은 객체들이 Old Gen으로 이동해 Mixed GC 또는 Full GC가 유발됩니다. 수백만 건이면 OOM이 발생할 수 있습니다.
핵심 원인: JPA의 1차 캐시(Persistence Context)는 트랜잭션이 끝날 때까지 모든 조회된 엔티티를 Heap에 유지합니다. Dirty Checking을 위해 원본 스냅샷도 함께 보관하므로 실제 메모리 사용량은 엔티티 크기의 2배 이상입니다.

Fix 우선순위: (1) @Modifying @Query JPQL 벌크 업데이트 — 엔티티를 아예 Heap에 올리지 않음 (가장 빠름) (2) 페이지 단위 + entityManager.clear() — 1차 캐시를 주기적으로 비워 메모리 제어 (3) Spring Batch Chunk Processing — 대용량 배치에 권장

4. 메모리 누수 탐지 — 어디서 새는지 찾는 법

누수가 의심될 때, 다음 순서로 접근합니다.

1
Heap 트렌드 모니터링 (Grafana)
jvm_memory_used_bytes{area="heap"} 그래프가 GC 후에도 우상향하면 누수 의심. 일반적인 사용 패턴은 GC 후 반드시 내려와야 합니다.
2
객체 분포 확인 — jmap
jmap -histo:live <pid> 로 현재 살아있는 객체 종류와 수를 확인합니다. 예상치 못하게 많은 특정 클래스가 있다면 해당 클래스가 누수 원인입니다.
3
Heap Dump 생성
OOM 발생 시 자동 덤프: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
수동 덤프: jmap -dump:format=b,file=heap.hprof <pid>
4
Eclipse MAT (Memory Analyzer Tool) 분석
Dominator Tree: 어떤 객체가 가장 많은 메모리를 "독점"하는지 확인합니다.
Leak Suspects Report: MAT가 자동으로 누수 패턴을 분석해 원인 후보를 제시합니다.
5
GC Log 분석
-Xlog:gc*:file=/tmp/gc.log 로 GC 로그를 수집합니다. Full GC 빈도가 높거나, GC 후 해제되는 메모리양이 점점 줄어든다면 누수 확실합니다.

5. 케이스 요약 정리

# 케이스 근본 원인 탐지 방법 Fix 핵심
1 Singleton 내부 무한 컬렉션 GC Root → Bean → 컬렉션 참조 끊기지 않음 GC 후 Old Gen 우상향 Caffeine / 외부 저장소로 위임
2 Prototype → Singleton 주입 Singleton 생성 시 한 번만 주입되어 scope 무의미 동일 prototype 인스턴스가 공유됨 ObjectProvider<T>로 런타임 획득
3 @Async ThreadLocal 미제거 Thread 재사용 → ThreadLocal 잔류 → GC 불가 스레드 수 × ThreadLocal 크기만큼 Heap 증가 finally { tl.remove(); } 필수
4 @Cacheable 기본 캐시 무제한 ConcurrentHashMap은 크기 제한 없음 Heap 내 캐시 클래스 인스턴스 수 폭증 Caffeine + maximumSize 명시
5 외부 이벤트 버스 리스너 미해제 Spring이 모르는 외부 참조 → GC 불가 Bean destroy 후에도 리스너 동작 @PreDestroy에서 unregister
6 Static 필드에 컬렉션 누적 static = GC Root → 영구 참조 reload 후 Heap 사용량 감소 없음 새 Map으로 참조 교체 (기존 Map GC 가능화)
7 @Transactional 대용량 전체 조회 JPA 1차 캐시가 트랜잭션 내내 엔티티 유지 배치 실행 시 Heap 급증 + Full GC @Modifying 벌크 업데이트 / 페이지+clear()

6. 면접 대비 핵심 Q&A

Q1. "Spring Singleton Bean에서 상태(state)를 가지면 왜 문제인가요?"

Singleton Bean은 ApplicationContext가 직접 참조를 유지하므로 GC Root와 연결된 상태입니다. Bean 내부 필드에 컬렉션이나 대용량 객체를 담으면, 그 객체들도 GC Root에서 도달 가능(Reachable)해져 GC가 수거할 수 없습니다. 트래픽이 쌓일수록 Old Gen이 포화되어 Full GC와 STW 빈도가 증가합니다. 해결책은 상태를 Bean 외부(Redis, DB, Caffeine 같은 제한된 캐시)로 위임하는 것입니다.

Q2. "ThreadLocal을 Thread Pool 환경에서 사용할 때 주의점은?"

Thread Pool의 스레드는 재사용됩니다. ThreadLocal.set()remove()를 빠뜨리면, 해당 스레드가 다음 요청을 처리할 때 이전 요청의 데이터가 그대로 남아있습니다. 이는 메모리 누수와 동시에 데이터 격리 위반(다른 사용자 데이터 노출) 버그를 유발합니다. try-finally 블록에서 remove()를 보장하는 것이 필수입니다.

Q3. "@Cacheable 사용 시 메모리 문제가 발생한 경험이나 주의점이 있다면?"

Spring의 기본 CacheManager인 ConcurrentMapCacheManager는 내부적으로 ConcurrentHashMap을 사용하는데, 크기 제한이 없습니다. 상품 ID나 유저 ID처럼 cardinality가 높은 키를 캐시하면 항목이 무한히 쌓여 Old Gen을 포화시킵니다. 실무에서는 반드시 Caffeine CacheManager를 명시하고 maximumSizeexpireAfterWrite를 설정해야 합니다.

Q4. "JPA 배치 처리 시 OOM이 발생했다면 어떻게 접근하겠습니까?"

@Transactional 내에서 findAll()로 수백만 건을 조회하면 JPA 1차 캐시(Persistence Context)가 트랜잭션 동안 모든 엔티티를 Heap에 유지합니다. Dirty Checking을 위한 원본 스냅샷도 함께 보관하므로 실제 메모리는 2배 이상 소비됩니다. 해결 방법은 세 가지입니다: 첫째, 단순 업데이트라면 @Modifying @Query로 JPQL 벌크 업데이트를 사용합니다. 둘째, 엔티티 처리가 필요하다면 페이지 단위로 가져온 후 entityManager.clear()로 1차 캐시를 주기적으로 비웁니다. 셋째, 대규모 배치는 Spring Batch Chunk Processing으로 구조화합니다.

이 챕터의 핵심 한 줄

GC는 "참조가 끊긴 객체"만 수거한다 — Bean 내부 필드에 뭔가 담았다면, 그 Bean이 살아있는 한(= 앱이 실행되는 내내) GC는 그 객체를 건드릴 수 없다.