Spring Boot 애플리케이션을 운영하다 보면 가장 흔하게 마주치는 문제 중 하나가 바로 OutOfMemoryError입니다.
이 에러는 애플리케이션의 성능을 크게 저하시키고, 심각한 경우 서비스 중단으로 이어질 수 있습니다.
본 글에서는 Spring Boot OutOfMemoryError의 원인부터 해결 방법까지 실무에서 바로 적용할 수 있는 구체적인 해결책을 제시하겠습니다.
Spring Boot OutOfMemoryError란 무엇인가?
OutOfMemoryError는 Java Virtual Machine(JVM)이 더 이상 메모리를 할당할 수 없을 때 발생하는 런타임 에러입니다.
Spring Boot 애플리케이션에서는 주로 다음과 같은 상황에서 발생합니다:
- 대용량 데이터 처리 시 메모리 부족
- 메모리 누수(Memory Leak)로 인한 점진적 메모리 고갈
- JVM 힙 메모리 설정 부족
- 무한 루프나 재귀 호출로 인한 스택 오버플로우
가장 일반적인 OutOfMemoryError 메시지는 다음과 같습니다:
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: PermGen space (Java 7 이하)
java.lang.OutOfMemoryError: Metaspace (Java 8 이상)
Spring Boot 메모리 사용량 모니터링 방법
OutOfMemoryError를 해결하기 위해서는 먼저 현재 애플리케이션의 메모리 사용 패턴을 파악해야 합니다.
Spring Boot에서는 Actuator를 통해 메모리 사용량을 실시간으로 모니터링할 수 있습니다.
Spring Boot Actuator 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,metrics,info
endpoint:
metrics:
enabled: true
메모리 사용량 확인 방법
# 힙 메모리 사용량 확인
curl http://localhost:8080/actuator/metrics/jvm.memory.used
# GC 정보 확인
curl http://localhost:8080/actuator/metrics/jvm.gc.pause
프로그래밍 방식 메모리 모니터링
@RestController
public class MemoryController {
@GetMapping("/memory-status")
public Map<String, Object> getMemoryStatus() {
Runtime runtime = Runtime.getRuntime();
Map<String, Object> memoryInfo = new HashMap<>();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
long maxMemory = runtime.maxMemory();
memoryInfo.put("totalMemory", totalMemory / 1024 / 1024 + " MB");
memoryInfo.put("usedMemory", usedMemory / 1024 / 1024 + " MB");
memoryInfo.put("freeMemory", freeMemory / 1024 / 1024 + " MB");
memoryInfo.put("maxMemory", maxMemory / 1024 / 1024 + " MB");
return memoryInfo;
}
}
JVM 힙 메모리 설정 최적화
Spring Boot OutOfMemoryError의 가장 기본적인 해결책은 JVM 힙 메모리 크기를 적절히 설정하는 것입니다.
기본 JVM 메모리 설정
# 힙 메모리 최소/최대 크기 설정
java -Xms512m -Xmx2048m -jar your-spring-boot-app.jar
# 보다 상세한 메모리 설정
java -Xms1g -Xmx4g -XX:NewRatio=3 -XX:MaxMetaspaceSize=256m -jar app.jar
application.yml을 통한 JVM 설정
# application.yml
server:
port: 8080
# JVM 옵션을 환경변수로 설정
spring:
application:
name: memory-optimized-app
# Docker 환경에서의 메모리 설정
JAVA_OPTS: "-Xms512m -Xmx2048m -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
메모리 설정 가이드라인
메모리 설정 시 다음 원칙을 따르는 것이 좋습니다:
- Xms와 Xmx를 동일하게 설정하여 메모리 재할당 오버헤드 최소화
- 전체 시스템 메모리의 70-80% 정도로 설정
- Metaspace는 클래스 수에 따라 적절히 조정
메모리 누수 탐지 및 해결 방법
Spring Boot 애플리케이션에서 메모리 누수는 OutOfMemoryError의 주요 원인 중 하나입니다.
일반적인 메모리 누수 패턴
// 잘못된 예시: Static Collection 사용
@Service
public class BadCacheService {
private static final Map<String, Object> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, value); // 메모리 누수 위험
}
}
// 개선된 예시: LRU Cache 사용
@Service
public class GoodCacheService {
private final Map<String, Object> cache = new LinkedHashMap<String, Object>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
return size() > 100;
}
};
public void addToCache(String key, Object value) {
cache.put(key, value);
}
}
리소스 해제 패턴
@Service
public class FileProcessingService {
// Try-with-resources 사용
public void processFile(String filePath) {
try (BufferedReader reader = Files.newBufferedReader(Paths.get(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
processLine(line);
}
} catch (IOException e) {
log.error("파일 처리 중 오류 발생", e);
}
}
// @PreDestroy를 통한 리소스 정리
@PreDestroy
public void cleanup() {
// 필요한 리소스 정리 작업
}
}
메모리 프로파일링 도구 활용
// JProfiler나 VisualVM을 위한 힙 덤프 생성
@RestController
public class DiagnosticController {
@GetMapping("/heap-dump")
public ResponseEntity<String> generateHeapDump() {
try {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
HotSpotDiagnosticMXBean mxBean = ManagementFactory.newPlatformMXBeanProxy(
server, "com.sun.management:type=HotSpotDiagnostic", HotSpotDiagnosticMXBean.class);
String fileName = "heap-dump-" + System.currentTimeMillis() + ".hprof";
mxBean.dumpHeap(fileName, true);
return ResponseEntity.ok("힙 덤프가 생성되었습니다: " + fileName);
} catch (Exception e) {
return ResponseEntity.status(500).body("힙 덤프 생성 실패: " + e.getMessage());
}
}
}
대용량 데이터 처리 최적화 전략
Spring Boot에서 대용량 데이터를 처리할 때는 메모리 효율적인 방법을 사용해야 합니다.
페이징 처리를 통한 메모리 최적화
@Service
public class DataProcessingService {
@Autowired
private UserRepository userRepository;
// 잘못된 예시: 모든 데이터를 한번에 로드
public void processAllUsersBad() {
List<User> allUsers = userRepository.findAll(); // OutOfMemoryError 위험
allUsers.forEach(this::processUser);
}
// 개선된 예시: 페이징을 통한 배치 처리
@Transactional(readOnly = true)
public void processAllUsersGood() {
int pageSize = 1000;
int pageNumber = 0;
Page<User> userPage;
do {
Pageable pageable = PageRequest.of(pageNumber, pageSize);
userPage = userRepository.findAll(pageable);
userPage.getContent().forEach(this::processUser);
// 메모리 정리를 위한 명시적 GC 호출 (필요시)
if (pageNumber % 10 == 0) {
System.gc();
}
pageNumber++;
} while (userPage.hasNext());
}
}
스트리밍 처리를 통한 메모리 효율성
@Service
public class StreamProcessingService {
@Autowired
private UserRepository userRepository;
// Stream을 활용한 메모리 효율적 처리
@Transactional(readOnly = true)
public void processUsersWithStream() {
userRepository.findAll()
.stream()
.filter(user -> user.isActive())
.map(this::transformUser)
.forEach(this::saveProcessedUser);
}
// 파일 처리 시 스트리밍 사용
public void processLargeFile(String filePath) {
try (Stream<String> lines = Files.lines(Paths.get(filePath))) {
lines.parallel()
.filter(line -> !line.trim().isEmpty())
.map(this::parseLine)
.forEach(this::processData);
} catch (IOException e) {
log.error("파일 처리 중 오류", e);
}
}
}
Garbage Collection 튜닝 방법
적절한 GC 설정은 OutOfMemoryError 예방에 중요한 역할을 합니다.
G1GC 설정 (권장)
# G1GC 설정 예시
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:G1NewSizePercent=20 \
-XX:G1MaxNewSizePercent=30 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseStringDeduplication \
-jar your-app.jar
GC 로깅 및 모니터링
# GC 로그 설정
java -Xloggc:gc.log \
-XX:+PrintGC \
-XX:+PrintGCDetails \
-XX:+PrintGCTimeStamps \
-XX:+PrintGCApplicationStoppedTime \
-jar your-app.jar
프로그래밍을 통한 GC 모니터링
@Component
public class GCMonitor {
private final List<GarbageCollectorMXBean> gcBeans;
public GCMonitor() {
this.gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
}
@EventListener
public void handleContextRefresh(ContextRefreshedEvent event) {
logGCInfo();
}
private void logGCInfo() {
for (GarbageCollectorMXBean gcBean : gcBeans) {
log.info("GC Name: {}, Collection Count: {}, Collection Time: {}ms",
gcBean.getName(),
gcBean.getCollectionCount(),
gcBean.getCollectionTime());
}
}
@Scheduled(fixedRate = 60000) // 1분마다 실행
public void monitorGC() {
long totalGCTime = gcBeans.stream()
.mapToLong(GarbageCollectorMXBean::getCollectionTime)
.sum();
if (totalGCTime > 5000) { // 5초 이상
log.warn("높은 GC 시간이 감지되었습니다: {}ms", totalGCTime);
}
}
}
캐싱 전략을 통한 메모리 효율성 개선
적절한 캐싱 전략은 메모리 사용량을 줄이고 성능을 향상시킵니다.
Spring Cache 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return cacheManager;
}
}
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found"));
}
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
@CacheEvict(value = "users", allEntries = true)
public void clearAllCache() {
// 모든 캐시 삭제
}
}
메모리 기반 캐시 제한 설정
@Configuration
public class CustomCacheConfig {
@Bean
public CacheManager limitedCacheManager() {
return CacheBuilder.newBuilder()
.maximumSize(500) // 최대 500개 엔트리
.expireAfterAccess(Duration.ofMinutes(30))
.removalListener(notification -> {
log.debug("캐시 엔트리 제거: {} = {}",
notification.getKey(), notification.getValue());
})
.build();
}
}
실시간 메모리 모니터링 및 알림 시스템
메모리 사용량을 실시간으로 모니터링하고 임계값 도달 시 알림을 받는 시스템을 구축할 수 있습니다.
Micrometer와 Prometheus 연동
@Component
public class MemoryMetrics {
private final MeterRegistry meterRegistry;
private final Gauge memoryUsageGauge;
public MemoryMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.memoryUsageGauge = Gauge.builder("jvm.memory.usage.percentage")
.description("JVM 메모리 사용률")
.register(meterRegistry, this, MemoryMetrics::getMemoryUsagePercentage);
}
private double getMemoryUsagePercentage() {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
return ((double) (totalMemory - freeMemory) / totalMemory) * 100;
}
@EventListener
@Async
public void checkMemoryUsage() {
double usage = getMemoryUsagePercentage();
if (usage > 80.0) {
sendMemoryAlert(usage);
}
}
private void sendMemoryAlert(double usage) {
log.warn("높은 메모리 사용률 감지: {}%", String.format("%.2f", usage));
// 슬랙, 이메일 등으로 알림 발송
}
}
자동 메모리 정리 스케줄러
@Component
public class MemoryCleanupScheduler {
@Scheduled(fixedRate = 300000) // 5분마다 실행
public void performMemoryCleanup() {
Runtime runtime = Runtime.getRuntime();
long beforeGC = runtime.totalMemory() - runtime.freeMemory();
// 명시적 GC 실행 (주의: 성능에 영향을 줄 수 있음)
System.gc();
long afterGC = runtime.totalMemory() - runtime.freeMemory();
long freedMemory = beforeGC - afterGC;
if (freedMemory > 0) {
log.info("메모리 정리 완료: {}MB 해제됨", freedMemory / 1024 / 1024);
}
}
@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
public void dailyMemoryReport() {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
log.info("일일 메모리 리포트 - 사용: {}MB, 여유: {}MB, 전체: {}MB",
usedMemory / 1024 / 1024,
freeMemory / 1024 / 1024,
totalMemory / 1024 / 1024);
}
}
OutOfMemoryError 발생 시 응급 처치 방법
OutOfMemoryError가 발생했을 때 즉시 적용할 수 있는 응급 처치 방법들입니다.
힙 덤프 자동 생성 설정
# OOM 발생 시 자동으로 힙 덤프 생성
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/path/to/dump/ \
-XX:OnOutOfMemoryError="kill -9 %p" \
-jar your-app.jar
메모리 사용량 긴급 모니터링
@RestController
public class EmergencyController {
@GetMapping("/emergency/memory-info")
public ResponseEntity<Map<String, Object>> getEmergencyMemoryInfo() {
Map<String, Object> info = new HashMap<>();
Runtime runtime = Runtime.getRuntime();
long maxMemory = runtime.maxMemory();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
double usagePercentage = ((double) usedMemory / maxMemory) * 100;
info.put("maxMemoryMB", maxMemory / 1024 / 1024);
info.put("usedMemoryMB", usedMemory / 1024 / 1024);
info.put("freeMemoryMB", freeMemory / 1024 / 1024);
info.put("usagePercentage", String.format("%.2f%%", usagePercentage));
info.put("criticalLevel", usagePercentage > 90 ? "CRITICAL" : "NORMAL");
return ResponseEntity.ok(info);
}
@PostMapping("/emergency/force-gc")
public ResponseEntity<String> forceGarbageCollection() {
long beforeGC = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
System.gc();
System.runFinalization();
long afterGC = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
long freedMemory = beforeGC - afterGC;
return ResponseEntity.ok(String.format("GC 실행 완료. %dMB 메모리 해제됨",
freedMemory / 1024 / 1024));
}
}
예방적 메모리 관리 모범 사례
OutOfMemoryError를 예방하기 위한 코딩 및 설계 모범 사례입니다.
메모리 효율적인 컬렉션 사용
@Service
public class EfficientCollectionService {
// ArrayList 대신 적절한 초기 크기 설정
public List<String> processData(int expectedSize) {
List<String> result = new ArrayList<>(expectedSize); // 초기 크기 설정
// ... 데이터 처리
return result;
}
// 메모리 효율적인 Map 사용
public Map<String, Object> createOptimizedMap() {
// 예상 크기와 로드 팩터 설정
return new HashMap<>(16, 0.75f);
}
// WeakHashMap 사용으로 메모리 누수 방지
private final Map<String, Object> cache = new WeakHashMap<>();
// 대용량 데이터 처리 시 Iterator 사용
public void processLargeDataset(List<LargeObject> dataset) {
Iterator<LargeObject> iterator = dataset.iterator();
while (iterator.hasNext()) {
LargeObject obj = iterator.next();
processObject(obj);
iterator.remove(); // 처리 후 즉시 제거
}
}
}
리소스 관리 패턴
@Service
public class ResourceManagementService {
// Connection Pool 적절한 크기 설정
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20); // 적절한 풀 크기
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
return new HikariDataSource(config);
}
// 비동기 처리 시 스레드 풀 관리
@Bean
@Primary
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
마무리
Spring Boot OutOfMemoryError는 적절한 진단과 체계적인 접근을 통해 충분히 예방하고 해결할 수 있는 문제입니다.
본 글에서 제시한 해결 방법들을 단계별로 적용하면서 애플리케이션의 메모리 사용 패턴을 지속적으로 모니터링하는 것이 중요합니다.
특히 JVM 힙 메모리 설정 최적화, 메모리 누수 방지, 효율적인 데이터 처리 패턴 적용을 통해 안정적인 Spring Boot 애플리케이션을 구축할 수 있습니다.
정기적인 메모리 모니터링과 성능 튜닝을 통해 OutOfMemoryError 없는 견고한 시스템을 만들어보시기 바랍니다.
'트러블슈팅' 카테고리의 다른 글
JPA N+1 문제 해결 전략: 성능 최적화를 위한 완벽 가이드 (0) | 2025.05.24 |
---|---|
IntelliJ에서 Gradle 버전 충돌 해결하기: 완벽한 트러블슈팅 가이드 (0) | 2025.05.24 |
JPA LazyInitializationException 해결 사례 정리 (0) | 2025.05.21 |
REST API 요청을 최적화하기 위한 Caching 전략 3가지 (1) | 2025.01.19 |
레거시 오라클 쿼리 리팩토링: 주문번호 부분입력으로 편의성 추가(Feat. 성능 최적화) (0) | 2024.04.19 |