트러블슈팅

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

devcomet 2025. 5. 24. 16:37
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
반응형