들어가며
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

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를 이용해서 분석해보겠습니다.
다운로드 사이트:
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 를 통해 덤프 파일을 엽니다.

경우에 따라 heapdump 파일이 경로에 있는데도 불구하고 안보일 수도 있습니다.
이럴 땐, All Files(*)를 선택해서 heapdump를 선택해주세요.

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

처음 화면에 보이는 건 Leak Suspects Report입니다.
한 눈에 바로 들어오는 원그래프가 나오는데 바로 직감적으로 알 수 있습니다.
저희가 생성한 로그들인거죠.
하단에 보면 클래스패스를 통해 어떤 객체가 메모리를 사용하고 있는지 확인할 수 있습니다.

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

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


도미네이터 트리를 통한 메모리 축적 과정을 볼 수도 있습니다.
도미네이터 트리는 특정 노드로 가는 과정이 반드시 다른 특정 노드를 거쳐야 한다는 '지배' 개념으로 구성된 트리입니다.
이를 통해서 어떤 게 반드시 통과된다는 것을 볼 수 있죠.
Overview - Histogram
좀 더 자세히 알아보기 위해서 Leak Suspects Report에서 벗어나 Overview로 가보겠습니다.

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

누른 뒤 Histogram이 있습니다.
눌러봅시다.

클래스 이름 기준으로 얼만큼 객체가 있는지 볼 수 있습니다.
Object: 객체 개수
Shallow Heap: 객체 자체가 차지하는 메모리 크기
Retained Heap: 그 객체가 사라질 경우, 연쇄적으로 GC(가비지 콜렉트)될 수 있는 모든 객체들의 메모리 크기 합계로 실질적으로 객체가 메모리 점유하고 있는 총량

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

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

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

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

data class RequestLog(
val timestamp: LocalDateTime,
val method: String,
val uri: String,
val headers: Map<String, String>,
)
자세히보면 RequestLog 객체의 정보가 그대로 있는 것까지 볼 수 있습니다.
이런 연관 관계의 의미가 LoggingService가 사라진다면 밑에 있는 모든 게 다 메모리 할당 해제가 된다는 의미입니다,.
'지배'의 관계를 표시하는 겁니다.
객체 내용 확인하는 방법

LoggingService 우클릭 -> List Objects -> 레퍼런스 유형 선택
with outgoing references: 해당 객체가 참조하는 다른 객체들
with incoming references: 이 객체를 참조하는 다른 객체들

with outgoing references를 눌렀을 땐,
이렇게 어떤 객체를 지금 참조 하고 있는지 모두 볼 수 있습니다.
그러면 왜 메모리 해제가 안되고 있는지 알 수 있을까요?
Paths to GC Roots를 이용하시면 됩니다.

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

beanFactory 안에 있는 LoggingService 객체의 변수 val 때문에 GC가 되고 있지 않다는 것을 볼 수 있습니다.
'서버 공부 > Spring' 카테고리의 다른 글
| [Spring]부하테스트 후 서버 성능 개선해보기(feat. Valkey) (8) | 2025.08.12 |
|---|---|
| [Spring]Google Static Maps API 적용기(지도 로딩 속도 최적화하기) (4) | 2025.08.01 |
| [Spring]지도 경로 로딩 최적화 문제 해결하기 (feat. PostGIS, Ramer-Douglas-Peucker) (8) | 2025.07.12 |
| Spring에서 HTTP 요청이 들어오면 처리되는 전체적인 흐름 (0) | 2025.02.24 |
| [Spring+MongoDB]한 컬렉션에서 중복된 필드값 검증하기(feat. 고유 인덱스) (0) | 2025.02.13 |