Spring Boot 앱은 IoC 컨테이너가 모든 Bean 객체의 생명주기를 관리합니다. "Spring이 알아서 관리해준다"는 말이 맞긴 한데, 코드를 어떻게 짜느냐에 따라 GC가 절대 건드릴 수 없는 객체들이 생겨버립니다. 이 챕터는 "왜 Heap이 계속 차오르는가"의 근본 원인을 Bean 관리 관점에서 7가지 패턴으로 분류해 설명합니다.
GC는 "더 이상 아무도 참조하지 않는 객체"만 수거합니다. "아무도 참조하지 않는다"는 판단 기준이 바로 GC Root입니다.
즉, Spring의 ApplicationContext 자체가 GC Root입니다.
애플리케이션이 살아있는 한, ApplicationContext → Singleton Bean 참조는 끊어지지 않습니다.
문제는 그 Bean 안에 무엇이 담기느냐입니다.
Bean 내부의 필드가 외부 객체를 참조하면, 그 객체 전부가 GC 불가 상태가 됩니다. Bean이 Old Gen에 승격되는 순간, 참조되는 모든 객체도 Old Gen에서 나오지 못합니다.
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 점유 |
@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);
}
}
jmap -histo:live 실행 시 AuditLog 인스턴스 수가 수십만 개.
maximumSize, expireAfterWrite) 사용으로 크기/TTL 기반 자동 eviction.
또는 Kafka/Redis로 외부 저장소 위임.
@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 가능
}
}
rows에 데이터가 계속 누적됩니다. 요청이 쌓일수록 rows의 크기가 커지고,
서로 다른 요청의 데이터가 섞이는 버그까지 발생합니다.
ObjectProvider<T> 또는 ApplicationContext.getBean()으로
매 호출 시점에 새 인스턴스를 가져옵니다. 메서드 스택에만 참조가 남으므로 메서드 종료 후 GC 가능.
@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 전파 + 정리)
@Async가 사용하는 Thread Pool의 스레드는 재사용됩니다.
스레드 A가 처리하다가 남긴 UserContext(대용량 권한 리스트 포함)가
다음에 같은 스레드를 사용하는 요청에서도 살아있습니다.
메모리 누수 + 보안 버그(다른 유저 권한 노출) 동시 발생.
finally { ctx.remove(); } 는 선택이 아닌 필수.
@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;
}
}
maximumSize,
expireAfterWrite를 반드시 설정합니다.
또는 Redis를 CacheManager로 사용하면 Heap 외부(Off-heap/원격)에 저장됩니다.
@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) { ... }
}
externalBus에 의해 계속 참조됩니다.
새 Bean과 구 Bean이 동시에 이벤트를 처리하는 버그가 발생하고, 구 Bean은 GC가 불가합니다.
@PreDestroy에서 외부 버스에 unregister 필수.
Spring ApplicationEvent를 사용하는 경우 ApplicationListener를 구현하면 Spring이 자동 관리합니다.
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);
}
}
clear() 후 재삽입보다는 새 Map을 만들어 참조를 교체하는 패턴이 더 안전합니다.
(clear 중 동시 접근 문제 방지)
@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());
}
@Modifying @Query JPQL 벌크 업데이트 — 엔티티를 아예 Heap에 올리지 않음 (가장 빠름)
(2) 페이지 단위 + entityManager.clear() — 1차 캐시를 주기적으로 비워 메모리 제어
(3) Spring Batch Chunk Processing — 대용량 배치에 권장
누수가 의심될 때, 다음 순서로 접근합니다.
jvm_memory_used_bytes{area="heap"} 그래프가 GC 후에도 우상향하면 누수 의심.
일반적인 사용 패턴은 GC 후 반드시 내려와야 합니다.
jmap -histo:live <pid> 로 현재 살아있는 객체 종류와 수를 확인합니다.
예상치 못하게 많은 특정 클래스가 있다면 해당 클래스가 누수 원인입니다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprofjmap -dump:format=b,file=heap.hprof <pid>
-Xlog:gc*:file=/tmp/gc.log 로 GC 로그를 수집합니다.
Full GC 빈도가 높거나, GC 후 해제되는 메모리양이 점점 줄어든다면 누수 확실합니다.
| # | 케이스 | 근본 원인 | 탐지 방법 | 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() |
Singleton Bean은 ApplicationContext가 직접 참조를 유지하므로
GC Root와 연결된 상태입니다. Bean 내부 필드에 컬렉션이나 대용량 객체를 담으면,
그 객체들도 GC Root에서 도달 가능(Reachable)해져 GC가 수거할 수 없습니다.
트래픽이 쌓일수록 Old Gen이 포화되어 Full GC와 STW 빈도가 증가합니다.
해결책은 상태를 Bean 외부(Redis, DB, Caffeine 같은 제한된 캐시)로 위임하는 것입니다.
Thread Pool의 스레드는 재사용됩니다. ThreadLocal.set() 후
remove()를 빠뜨리면, 해당 스레드가 다음 요청을 처리할 때
이전 요청의 데이터가 그대로 남아있습니다.
이는 메모리 누수와 동시에 데이터 격리 위반(다른 사용자 데이터 노출) 버그를 유발합니다.
try-finally 블록에서 remove()를 보장하는 것이 필수입니다.
Spring의 기본 CacheManager인 ConcurrentMapCacheManager는
내부적으로 ConcurrentHashMap을 사용하는데, 크기 제한이 없습니다.
상품 ID나 유저 ID처럼 cardinality가 높은 키를 캐시하면 항목이 무한히 쌓여
Old Gen을 포화시킵니다. 실무에서는 반드시 Caffeine CacheManager를 명시하고
maximumSize와 expireAfterWrite를 설정해야 합니다.
@Transactional 내에서 findAll()로 수백만 건을 조회하면
JPA 1차 캐시(Persistence Context)가 트랜잭션 동안 모든 엔티티를 Heap에 유지합니다.
Dirty Checking을 위한 원본 스냅샷도 함께 보관하므로 실제 메모리는 2배 이상 소비됩니다.
해결 방법은 세 가지입니다:
첫째, 단순 업데이트라면 @Modifying @Query로 JPQL 벌크 업데이트를 사용합니다.
둘째, 엔티티 처리가 필요하다면 페이지 단위로 가져온 후 entityManager.clear()로
1차 캐시를 주기적으로 비웁니다.
셋째, 대규모 배치는 Spring Batch Chunk Processing으로 구조화합니다.
GC는 "참조가 끊긴 객체"만 수거한다 — Bean 내부 필드에 뭔가 담았다면, 그 Bean이 살아있는 한(= 앱이 실행되는 내내) GC는 그 객체를 건드릴 수 없다.