MyBatis Generator 실행 중 OOM 디버깅 — classpath에 숨어있던 Lombok
Spring Boot 프로젝트에서 MyBatis Generator로 DTO/Mapper를 자동 생성하던 중 OutOfMemoryError: Java heap space 에러를 만났다. 힙 크기를 4GB까지 올려도 재현됐고, 테이블 필터링 범위를 좁혀도 마찬가지였다. 결국 원인은 엉뚱한 곳에 있었다. 이 글은 그 디버깅 과정과, 그 과정에서 내가 정리한 개념들을 기록한 것이다.
요약
- 현상:
generateMyBatisGradle task 실행 시java.util.WeakHashMap.newTable에서 OOM 발생. 힙 크기를 올려도 동일. - 원인: Generator의 classpath에 애플리케이션 런타임 의존성을 통째로 얹는 설정(
sourceSets.main.runtimeClasspath)이 있었고, 그 결과 Lombok 라이브러리가 Generator JVM에 함께 로드되면서 Lombok 내부의JavacAugmentsstatic WeakHashMap이 누적되어 힙을 고갈시켰다. - 해결: Generator task의 classpath를 필요한 최소한(MBG 본체 + JDBC 드라이버 + 커스텀 플러그인 jar)으로 축소. 커스텀 플러그인이 포함된 내부 모듈(
frw)은transitive = false로 선언하여 추이 의존성을 차단.
배경: 프로젝트 구성
Oracle DB 기반 Spring Boot 멀티 모듈 프로젝트. gen 모듈이 DTO/Mapper 자동 생성을 담당하고, build.gradle에 다음과 같은 Gradle task가 정의되어 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
configurations {
generateMyBatis
}
dependencies {
// ... 수많은 application 런타임 의존성 (Spring, Lombok 등)
generateMyBatis 'org.mybatis.generator:mybatis-generator-core:1.4.2'
generateMyBatis 'com.oracle.database.jdbc:ojdbc17:23.26.1.0.0'
}
task generateMyBatis(type: JavaExec) {
classpath = configurations.generateMyBatis + sourceSets.main.runtimeClasspath
mainClass = 'org.mybatis.generator.api.ShellRunner'
args = ['-configfile', "${projectDir}/generatorConfig.xml", '-overwrite']
}
generatorConfig.xml에서는 특정 스키마의 모든 테이블(tableName="%")을 대상으로 코드 생성을 지시하고 있었고, 프로젝트 자체 커스텀 LombokPlugin(생성된 DTO에 @Data, @Builder 등을 주입)을 플러그인으로 사용하고 있었다.
현상
IntelliJ의 Gradle Tool Window에서 generateMyBatis task를 실행하면 수 분간 돌다가 다음과 같이 실패했다.
1
2
3
java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.WeakHashMap.newTable(WeakHashMap.java:194)
...
프로젝트 루트에는 java_pid13032.hprof라는 약 847MB 크기의 힙 덤프 파일이 떨어졌다. -XX:+HeapDumpOnOutOfMemoryError JVM 옵션이 활성화되어 있었기에 OOM 순간 힙 스냅샷이 자동 저장된 것이다.
디버깅 1단계: 힙 크기 부족 의심 (오답)
처음엔 단순히 “테이블이 많아서 메타데이터 로드 중 힙이 부족한 것”이라고 판단했다. JavaExec가 기본 JVM 옵션으로 동작하기 때문에 힙 크기가 256~512MB 정도였을 것이라 보고, 최대 힙을 2GB, 이후 4GB로 상향했다.
1
2
3
4
5
6
7
task generateMyBatis(type: JavaExec) {
maxHeapSize = '4g'
jvmArgs = [
'-XX:+HeapDumpOnOutOfMemoryError',
"-XX:HeapDumpPath=${buildDir}/heapdumps",
]
}
결과: 여전히 동일 지점에서 OOM. 힙 크기가 문제가 아니었다는 강력한 신호였다. 4GB를 다 먹고도 부족하다는 건, 어딘가에서 메모리가 계속 쌓이고 정리되지 않고 있다는 뜻이다.
디버깅 2단계: JDBC 드라이버 캐시 의심 (부분 오답)
스택 트레이스의 WeakHashMap.newTable이 단서였다. WeakHashMap은 key가 GC되면 자동으로 엔트리를 비워주는 Map이다. 정상 상황에서는 계속 성장하지 않는다. 만약 성장하고 있다면, key들이 어디선가 strong reference로 붙잡혀 GC되지 못하고 있다는 의미다.
당시 의심은 Oracle JDBC 드라이버(ojdbc17:23.x) 내부 캐시였다. Oracle JDBC는 Statement 캐시, 메타데이터 캐시 등을 내부적으로 운영하며, 과거 유사 사례에서 이런 캐시 누수로 OOM이 났다는 보고가 있다.
이에 따라 드라이버 캐시를 최소화하는 옵션을 추가했다.
1
2
3
4
5
6
jvmArgs = [
'-Doracle.jdbc.implicitStatementCacheSize=0',
'-Doracle.jdbc.useThreadLocalBufferCache=false',
'-XX:+UseG1GC',
'-XX:SoftRefLRUPolicyMSPerMB=1',
]
결과: 또 같은 지점에서 OOM. 드라이버 캐시도 원인이 아니었다.
디버깅 3단계: 힙 덤프의 Incoming References를 따라가기 (정답)
이쯤 되어서야 힙 덤프를 제대로 분석하기 시작했다. IntelliJ의 Profiler에서 hprof 파일을 열면 여러 분석 뷰가 제공되는데, OOM 디버깅의 정석은 다음 순서다.
- Dominators 탭에서 힙을 가장 많이 점유한 객체 식별
- 그 객체의 Incoming References를 따라 올라가며 “누가 이것을 strong reference로 붙잡고 있는가”를 역추적
- 체인의 상위에서 나오는 클래스/패키지 이름으로 범인 라이브러리 특정
Dominator 최상위는 java.util.WeakHashMap$Entry[2097152]였다. 엔트리 테이블 크기가 2²¹, 즉 약 210만 슬롯. 일반적인 코드 생성 워크로드에서 나올 수 없는 규모다. Retained size는 121MB.
그 WeakHashMap$Entry[]의 Incoming References 체인을 따라가보니:
1
2
3
4
5
6
7
8
java.util.WeakHashMap$Entry[2097152]
table of java.util.WeakHashMap
values of lombok.core.FieldAugment$MapWeakFieldAugment
statically from JCTree_generatedNode of lombok.javac.JavacAugments
java.lang.Object[366]
elementData of java.util.ArrayList
classes of lombok.launch.ShadowClassLoader (4.36 MB)
statically from classLoader of lombok.launch.Main
체인에 lombok.* 패키지가 반복적으로 등장한다. 범인은 Lombok이었다.
근본 원인 분석
Lombok의 JavacAugments가 왜 이 맥락에서 성장하는가
Lombok은 일반 라이브러리와 동작 방식이 다르다. classpath에 로드되면 Java 컴파일러(javac) 내부에 끼어들어 AST(Abstract Syntax Tree, 구문 트리)를 조작할 준비를 한다. 이때 내부적으로 각 AST 노드에 대한 부가 정보를 lombok.javac.JavacAugments라는 static 필드에 담긴 WeakHashMap에 매핑해둔다.
이 맵의 key는 AST 노드, value는 부가 메타데이터다. 정상적인 javac 컴파일 맥락에서는 각 컴파일 단위가 끝날 때 AST 노드들이 GC되고 맵도 자동으로 비워진다.
그런데 MyBatis Generator는 컴파일을 하지 않는다. 단순히 DB 메타데이터를 읽고 Java 소스 텍스트를 만들어 파일로 저장하는 도구다. 이 과정에서 많은 클래스 리플렉션과 동적 로딩이 일어나고, Lombok이 자기 초기화 과정에서 ShadowClassLoader를 통해 366개 클래스를 strong reference로 유지한다. 이 클래스들이 WeakHashMap의 key 체인을 붙잡으면서 GC되지 못한 엔트리가 계속 누적된다. 결국 테이블이 리사이즈될 때 다음 단계 크기(2²², 약 420만 슬롯)의 Entry 배열을 할당하려다 힙이 터진다.
즉, Generator에게는 필요조차 없는 Lombok이 우연히 classpath에 올라와 있었다는 것이 근본 원인이었다.
왜 Lombok이 Generator classpath에 있었나
원인은 다음 한 줄이었다.
1
classpath = configurations.generateMyBatis + sourceSets.main.runtimeClasspath
sourceSets.main.runtimeClasspath는 해당 모듈의 애플리케이션을 실제로 실행할 때 필요한 모든 라이브러리를 가리킨다. Spring Boot 기반 모듈이라면 Spring, 자동 구성된 각종 스타터, 그리고 Lombok까지 전부 포함된다.
하지만 MyBatis Generator는 애플리케이션이 아니라 별개의 코드 생성 도구다. 이 도구가 실제로 필요한 건:
mybatis-generator-core(Generator 본체)- JDBC 드라이버 (DB 메타데이터 조회용)
- 프로젝트에서 선언한 커스텀 플러그인이 있다면 그 클래스
이게 전부다. Spring도, Lombok도, DevTools도 필요 없다. 그런데 “+sourceSets.main.runtimeClasspath“가 이 모든 것을 싸잡아 Generator JVM에 올렸고, 그 중 Lombok이 자기 몫의 메모리 문제를 일으킨 것이다.
커스텀 LombokPlugin과 Lombok 라이브러리의 혼동
중요한 구분이 하나 있다. 이 프로젝트에서 generatorConfig.xml의 다음 선언:
1
<plugin type="org.mybatis.generator.plugins.LombokPlugin" />
이 LombokPlugin은 MyBatis Generator가 제공하는 빌트인 플러그인이 아니다. frw 모듈의 src/main/java/org/mybatis/generator/plugins/LombokPlugin.java에 자체 구현된 커스텀 클래스다. 패키지 이름 때문에 빌트인처럼 보이지만, PluginAdapter를 상속해 직접 만든 것이다.
이 플러그인이 하는 일은 단순하다. MyBatis Generator가 생성하는 Java 소스 텍스트에 @Data, @Builder 같은 어노테이션 문자열을 삽입할 뿐이다. Lombok 라이브러리를 호출하거나 AST를 조작하지 않는다. @Data는 생성된 DTO가 나중에 컴파일될 때 Lombok이 처리하는 것이고, Generator 실행 시점에는 그저 문자열에 불과하다.
정리하면:
- 플러그인 실행에 필요한 것: 플러그인 클래스 파일(
LombokPlugin.class) - 플러그인 실행에 필요 없는 것: Lombok 라이브러리 jar
이 구분을 놓친 채 “Lombok 플러그인을 쓰려면 Lombok jar가 있어야지”라고 생각하기 쉽지만, 실제로는 그렇지 않다.
해결
1단계: classpath를 최소한으로 축소
Generator JVM의 classpath에서 sourceSets.main.runtimeClasspath를 제거했다.
1
2
3
4
5
6
7
8
9
10
task generateMyBatis(type: JavaExec) {
classpath = configurations.generateMyBatis
mainClass = 'org.mybatis.generator.api.ShellRunner'
args = ['-configfile', "${projectDir}/generatorConfig.xml", '-overwrite']
maxHeapSize = '4g'
jvmArgs = [
'-XX:+HeapDumpOnOutOfMemoryError',
"-XX:HeapDumpPath=${buildDir}/heapdumps",
]
}
이 변경만으로 OOM 원인인 Lombok이 Generator JVM에 로드되지 않게 된다.
2단계: ClassNotFoundException 대응
1단계 변경만으로는 문제가 하나 더 남는다. 커스텀 LombokPlugin이 있는 frw jar까지 classpath에서 빠지기 때문에, 플러그인 로딩 시 ClassNotFoundException: org.mybatis.generator.plugins.LombokPlugin이 발생한다.
해결: frw를 Generator 전용 configuration에 추이 의존성 없이 추가한다.
1
2
3
4
5
6
7
dependencies {
generateMyBatis 'org.mybatis.generator:mybatis-generator-core:1.4.2'
generateMyBatis 'com.oracle.database.jdbc:ojdbc17:23.26.1.0.0'
generateMyBatis('com.jpmc.xxx:frw:1.+:plain') {
transitive = false
}
}
transitive = false는 “frw jar 자체만 가져오고, frw가 의존하는 다른 라이브러리는 무시하라”는 지시다. 이 프로젝트의 frw는 Lombok과 MyBatis Generator를 compileOnly로 선언하고 있어 추이 의존성이 원래도 Lombok을 물고 오지는 않지만, 혹시 모를 다른 전이 오염을 막기 위해 명시적으로 차단해두는 편이 안전하다.
결과적으로 Generator JVM의 classpath는 다음 세 개로 수렴한다.
mybatis-generator-core-1.4.2.jarojdbc17-23.x.jarfrw-1.x-plain.jar(커스텀 플러그인만 포함)
이 구성으로 OOM이 재발하지 않았고, 전체 스키마 테이블에 대해 문제없이 코드 생성이 완료됐다.
덤으로 정리한 개념들
OutOfMemoryError: Java heap space
JVM은 시작 시 힙 메모리의 최대 크기를 지정받는다(-Xmx). 이 한계를 넘는 메모리를 할당하려 하면 OOM이 발생한다. 중요한 것은 힙이 가득 찼다는 사실 자체가 아니라 “왜 가득 찼는가”이다. 가득 찬 이유는 크게 두 가지다.
- 정상 워크로드 대비 힙이 작다: 힙 크기를 올리면 해결된다.
- 어딘가에서 메모리가 해제되지 않고 누적된다 (leak): 힙을 올려도 시간이 흐르면 다시 터진다. 이번 케이스가 여기에 해당했다.
판별 기준: 힙을 여러 배 올려도 같은 지점에서 OOM이 재현되면 leak 쪽으로 무게를 두고 힙 덤프를 분석하는 게 맞다.
WeakHashMap과 reference 종류
Java는 객체 참조를 strong/soft/weak/phantom 네 종류로 나눈다.
- Strong reference: 일반 변수 참조. 이게 살아있는 한 GC 대상이 아니다.
- Soft reference: 메모리가 부족할 때만 GC됨. 캐시 용도로 적합.
- Weak reference: 다음 GC 때 바로 수거 가능.
- Phantom reference: 객체가 수거된 후 후처리용.
WeakHashMap은 key를 weak reference로 보관한다. 따라서 다른 곳에서 key를 strong reference로 붙잡고 있지 않다면 GC가 돌 때 엔트리가 자동 삭제된다. 반대로 어딘가에서 strong reference로 key를 붙잡고 있다면 WeakHashMap은 무한히 성장할 수 있다. 이번 케이스는 Lombok의 ShadowClassLoader가 classes ArrayList로 클래스들을 붙잡고 있어 weak key들이 수거되지 못하고 누적된 상태였다.
Gradle classpath와 configuration
Gradle에서 configuration은 “같은 목적의 의존성들을 묶는 논리적 그룹”이다. 대표적인 configuration:
implementation: 애플리케이션 런타임에 필요한 일반 의존성compileOnly: 컴파일 시에만 필요, 런타임 classpath에 없음annotationProcessor: 컴파일 시 어노테이션 처리기runtimeOnly: 런타임에만 필요
여기에 우리가 직접 만든 generateMyBatis 같은 커스텀 configuration을 추가할 수 있다. 각 configuration은 독립된 classpath를 가지며, 어떤 작업에 어떤 configuration을 넘길지 선택할 수 있다.
sourceSets.main.runtimeClasspath는 main 소스셋을 실제 실행할 때의 합산 classpath(= implementation + runtimeOnly + 컴파일된 main 출력 + 그 추이 의존성 전부)를 가리킨다. 이걸 그대로 Generator 같은 독립 도구의 classpath로 넘기는 건 도구에 불필요한 라이브러리를 잔뜩 주입하는 것과 같다. 도구가 그걸 무해하게 무시하면 다행이지만, 이번 Lombok처럼 로드되는 순간 자기 초기화를 시작하는 라이브러리가 섞이면 부작용이 발생한다.
Transitive dependency
의존성 A가 B를 필요로 하고, B가 C를 필요로 할 때, A만 선언해도 Gradle은 B와 C를 자동으로 가져온다. 이 자동 포함되는 간접 의존성을 추이(transitive) 의존성이라 한다.
편리한 기본 동작이지만, “A는 필요하지만 A가 딸고 오는 C는 원치 않는다” 는 경우에 대비해 transitive = false 옵션이 제공된다. 이번 해결에서는 frw jar에서 필요한 건 그 안의 커스텀 플러그인 클래스 하나뿐이었으므로, frw의 추이 의존성이 classpath를 오염시키는 일이 없도록 transitive = false를 명시했다.
TIL
-
OOM의 스택 트레이스가
WeakHashMap.newTable처럼 라이브러리 내부 지점을 가리키면, 힙 크기 조정보다 먼저 힙 덤프 분석부터 한다. 힙을 두 배, 세 배 올려봐서 동일 지점에서 터지면 누수임이 거의 확정이다. -
힙 덤프 분석의 핵심은 Dominator → Incoming References → 라이브러리 식별이다. 메모리를 차지한 객체 자체보다 “누가 그 객체를 붙잡고 있는가”가 답을 준다. strong reference 체인을 따라 올라가다 나오는 패키지 이름이 범인이다.
-
“애플리케이션 실행용 classpath”와 “코드 생성 도구용 classpath”는 별개다. 두 context의 요구사항이 다르므로, 편의상 합쳐 쓰는 순간 예상치 못한 부작용이 생길 수 있다. Generator/Codegen 같은 독립 도구는 반드시 자기 전용 configuration만 받도록 격리한다.
-
Lombok처럼 JVM에 로드되자마자 자기 초기화를 시작하는 라이브러리는 classpath 오염에 민감하다. “거기 있어도 안 쓰면 그만”이라는 일반 라이브러리의 직관이 Lombok에는 적용되지 않는다.
-
라이브러리와 소스에서 쓰는 플러그인 클래스를 혼동하지 말 것.
LombokPlugin(코드 텍스트에 어노테이션 문자열을 넣는 플러그인)과 Lombok 라이브러리(컴파일 시 AST를 조작하는 라이브러리)는 관계없다. 플러그인이 돌기 위해 라이브러리가 필요할 것이라는 직관을 먼저 의심해야 했다.
참고
힙 덤프 분석 시 유용한 무료 도구:
- IntelliJ IDEA Ultimate의 Profiler (이번에 사용)
- Eclipse Memory Analyzer (MAT)
- VisualVM
모두 Dominator 트리와 Incoming References 조회를 지원한다. 어떤 도구를 쓰든 분석 절차는 동일하다.