본문 바로가기
트러블슈팅

Spring Boot에서 발생하는 OutOfMemoryError 완벽 해결 가이드

by devcomet 2025. 5. 24.
728x90
반응형

Spring Boot에서 발생하는 OutOfMemoryError 완벽 해결 가이드

 

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 없는 견고한 시스템을 만들어보시기 바랍니다.

728x90
반응형