서버 공부/Spring

[Spring]heapdump를 이용해서 메모리 누수(Memory Leak) 찾아보기 feat. Eclipse Memory Analyzer

나맘임 2025. 10. 22. 15:49

 

들어가며

Spring 서버에서 메모리 누수가 있는지 확인하는 방법을 공부해서 정리해봤습니다.

 

메모리 누수(Memory Leak)??

동적으로 할당하여 사용한 메모리가 해제될 수 없는 상태가 된 것을 누수라고 표현하곤합니다.

쓸모 없는 데이터가 계속 쌓이니까 어느 순간 실제 필요한 데이터를 불러와야하지만 꽉차서 여러 오류가 발생합니다.

그걸 해결하기 위한 대표적인 해결책이 가비지 콜렉터이나 이 방법으로 해결하지 못하는 경우도 많습니다.

그럴 땐 직접 메모리힙을 보면서 뭐가 지금 누수가 생긴 지 확인해야 합니다.

heapdump를 사용해서 분석하면 더욱 더 편하게 할 수 있습니다.

Heapdump가 뭐에요?

애플리케이션이 실행 중인 특정 시점에서 JVM의 힙 메모리 영역을 스냅샷으로 캡처하여 저장한 파일입니다.

힙 메모리라는 게 Java나 Kotlin에서 클래스의 인스턴스 생성할 때 객체들이 동적으로 할당되는 공간을 말합니다.

Heapdump에 포함된 정보

실행 중인 애플리케이션의 모든 활성 객체의 상세 정보가 담겨있습니다.

각 객체 인스턴스의 주소, 유형, 클래스 이름, 크기 같은 정보와 함께 다른 객체를 참조하는지 여부도 확인할 수 있습니다.

클래스 메타 정보와 객체 간의 참조 관계도 포함되어 있기 때문에 전체 메모리 구조를 파악할 수 있습니다.

Heapdump를 추출하기 전에 간단한 누수 상황을 만들어봅시다!

모든 API 접속에 대해 로그를 남긴다고 생각해보겠습니다.

하지만 초보 갭라자가 단순하게 클래스에 멤버변수로 리스트를 만들고 로그를 여기에 저장해뒀습니다!

이 상황을 가정하여 한 번 해볼게요.

 

LogggingService

@Service
class LoggingService {
    private val requestLogs = mutableListOf<RequestLog>()

    fun logRequest(request: HttpServletRequest) {
        requestLogs.add(
            RequestLog(
                timestamp = LocalDateTime.now(),
                method = request.method,
                uri = request.requestURI,
                headers = request.headerNames.toList().associateWith {
                    request.getHeader(it)
                }
            )
        )
    }

    @Scheduled(fixedDelay = 1000) // 마다
    fun printStats() {
        println("Total logged requests: ${requestLogs.size}")
        // 로그를 지우지 않음!
    }
}

data class RequestLog(
    val timestamp: LocalDateTime,
    val method: String,
    val uri: String,
    val headers: Map<String, String>,
)

 

api 요청을 했을 때, 시간, method, uri, 헤더를 남깁니다.

그리고 1초마다 현재 로그가 얼만큼 쌓여있는지 콘솔에 print 시킵니다.

LogggingInterceptor

@Component
class LoggingInterceptor (
    private val loggingService: LoggingService
) : HandlerInterceptor {

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        repeat (100000){
            loggingService.logRequest(request)
        }
        return true
    }
}

빠르게 메모리를 점유시키기 위해서 한 번 요청으로 똑같은 로그 10만개를 만들었습니다.

WebMvcConfig & Application(메인클래스)

@Configuration
class WebMvcConfig(
    private val loggingInterceptor: LoggingInterceptor
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(loggingInterceptor)
            .addPathPatterns("/**") // 모든 경로에 적용
            .excludePathPatterns("/actuator/**") // actuator는 제외
    }
}
@EnableScheduling
@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

 

스케줄러 및 인터셉터가 동작하도록 셋팅을 해줍니다.

 TestController

@RestController
class TestController {

    @PostMapping("/test")
    fun test(@RequestBody body: String): String {
        return "Received: $body"
    }
}

 

TestController에 api 요청을 보내면 로그 10만개가 쌓입니다.

이를 몇 번씩 해보셔도 됩니다.

이제 heapdump를 추출해볼까요?

Heapdump를 추출하는 방법

JVM 옵션을 사용한 자동 생성

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump/heapdump.hprof

 

-XX:+HeapDumpOnOutOfMemoryError: 메모리 부족 오류 발생 시 힙 덤프를 생성합니다.

-XX:HeapDumpPath: 덤프 파일이 저장될 경로와 파일명을 지정합니다.

 

가장 권장되는 방법입니다. 

애플리케이션 실행 시 아래 JVM 옵션을 추가하면 OutOfMemoryError가 발생했을 때 자동으로 힙 덤프 파일을 생성해 줍니다.

별도의 오버헤드가 거의 없어 운영 환경에서도 필수로 사용하는 옵션입니다.​

Spring actuator 사용

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-actuator'

 

application.properties

management.endpoints.web.exposure.include=heapdump
management.endpoint.heapdump.access=unrestricted

 

웹브라우저에서 링크 입력

http://localhost:8080/actuator/heapdump

그림 1. actuator로 다운받은 heapdump

actuator의 heapdump 엔드포인트를 활성화해서 가져올 수 있습니다.

다만, 보안적인 문제가 있으므로 실제 프로덕션 환경에서 사용하기엔 무리가 있습니다.

 

jcmd, jmap 명령어로 직접 생성

애플리케이션이 리눅스에서 실행 중인 상태에서 직접 힙 덤프를 생성하기 위한 명령어로 사용량이 비정상적으로 높을 때 원인 분석을 위해 사용합니다.

 

1. 프로세스 ID 확인

jps -l

2. jcmd를 이용한 덤프 생성(JDK 8 이상 권장)

jcmd <pid> GC.heap_dump /path/to/dump/heapdump.hprof

 

3. jmap을 이용한 덤프 생성

jmap -dump:live,format=b,file=/path/to/dump/heapdump.hprof <pid>

:live 옵션은 이제 가비지 컬렉터의 대상이 되는 객체를 제외하고, 현재 활성화된 객체들만 덤프에 포함시킵니다.

 

추출한 Heapdump 분석 방법

heapdump 분석을 위한 여러 프로그램이 있지만 jdk 진영에서 무료로 쓸 수 있는 Eclipse MAT를 이용해서 분석해보겠습니다.

 

다운로드 사이트:

https://www.eclipse.org/mat/

 

Memory Analyzer (MAT) | The Eclipse Foundation

The Eclipse Foundation provides our global community of individuals and organizations with a mature, scalable, and business-friendly environment for open source …

eclipse.dev

 

MAT를 실행하고 File > Open Heap Dump 를 통해 덤프 파일을 엽니다.

그림 2. heapdump 파일이 안보인다면

경우에 따라 heapdump 파일이 경로에 있는데도 불구하고 안보일 수도 있습니다.

이럴 땐, All Files(*)를 선택해서 heapdump를 선택해주세요.

 

그림 3. 첫 화면 설정하는 화면

heapdump 분석을 위해선 Leak Suspects Report를 눌러 줍니다.

Leak Suspects Report 분석

그림 4. Leak Suspects Report 메인 화면

처음 화면에 보이는 건 Leak Suspects Report입니다.

한 눈에 바로 들어오는 원그래프가 나오는데 바로 직감적으로 알 수 있습니다.

저희가 생성한 로그들인거죠.

하단에 보면 클래스패스를 통해 어떤 객체가 메모리를 사용하고 있는지 확인할 수 있습니다.

그림 5. 각 문제 상황에 대해 맨 밑을 보면 Details가 있다

설명 하단의 Details를 누르면 좀 더 자세히 어떤 상황인지 알 수 있습니다.

그림 6. 메모리 축적 과정을 보여준다

어떤 식으로 메모리가 축적됐는지 볼 수 있는데 여기서 reqeustLogs 메서드를 통해서 쌓이고 있다는 것을 볼 수 있죠.

그림 7. 도미네이터 트리를 이용한 메모리 축적 과정도 볼 수 있다

 

도미네이터 트리를 통한 메모리 축적 과정을 볼 수도 있습니다.

도미네이터 트리는 특정 노드로 가는 과정이 반드시 다른 특정 노드를 거쳐야 한다는 '지배' 개념으로 구성된 트리입니다.

이를 통해서 어떤 게 반드시 통과된다는 것을 볼 수 있죠.

 

Overview - Histogram

좀 더 자세히 알아보기 위해서  Leak Suspects Report에서 벗어나 Overview로 가보겠습니다.

그림 8. 상단에 있는 Overview

창의 상단에 보면 현재 열린 창말고도 Overview가 있습니다.

그림 9. Overview 하단에 있는 Histogram

누른 뒤 Histogram이 있습니다. 

눌러봅시다.

그림 10. Histogram 화면

클래스 이름 기준으로 얼만큼 객체가 있는지 볼 수 있습니다.

Object: 객체 개수

Shallow Heap: 객체 자체가 차지하는 메모리 크기

Retained Heap: 그 객체가 사라질 경우, 연쇄적으로 GC(가비지 콜렉트)될 수 있는 모든 객체들의 메모리 크기 합계로 실질적으로 객체가 메모리 점유하고 있는 총량

그림 11. 패키지 별로 그룹화하기

여기서 패키지별로 그룹화를 시키면 좀 더 직관적으로 볼 수 있습니다.

그림 12. 패키지 별로 그룹화 결과

여기서 RequestLog가 엄청 많은 것을 볼 수 있죠.

Overview - Dominator Tree

그림 13. Overview 하단에 Dominator Tree가 있다

Overview에서 Dominator Tree를 눌러줍니다.

그림 14. Dominator Treee는 Retained Heap 내림차순 정렬이 되어있다

바로 LoggingService가 범인임을 알려주고 있습니다.

그림 15. 자세한 내용

 

data class RequestLog(
    val timestamp: LocalDateTime,
    val method: String,
    val uri: String,
    val headers: Map<String, String>,
)

자세히보면 RequestLog 객체의 정보가 그대로 있는 것까지 볼 수 있습니다.

이런 연관 관계의 의미가 LoggingService가 사라진다면 밑에 있는 모든 게 다 메모리 할당 해제가 된다는 의미입니다,.

'지배'의 관계를 표시하는 겁니다.

객체 내용 확인하는 방법

그림 16. 객체 내용 확인법

LoggingService 우클릭 -> List Objects -> 레퍼런스 유형 선택

 

with outgoing references: 해당 객체가 참조하는 다른 객체들

with incoming references: 이 객체를 참조하는 다른 객체들

그림 17. 객체 내용 확인 결과

with outgoing references를 눌렀을 땐,

이렇게 어떤 객체를 지금 참조 하고 있는지 모두 볼 수 있습니다.

그러면 왜 메모리 해제가 안되고 있는지 알 수 있을까요?

Paths to GC Roots를 이용하시면 됩니다.

그림 18. GC Root 추적

여기서 LoggingService를 우클릭해서 Merge Shortest Paths to GC Roots를 눌러줍니다.

여기서 weak,soft phantom 참조등을 제외할 수 있는데 중요한 건 메모리 누수와 직접적인 관련이 있는 강한 참조를 찾기 위해서 'exclude all .. etc. references' 를 선택하시면 됩니다.

그림 19. 결국 메모리 해제가 안되고 있는 건 로그 저장 변수 때문이었다.

beanFactory 안에 있는 LoggingService 객체의 변수 val 때문에 GC가 되고 있지 않다는 것을 볼 수 있습니다.