JProfiler MCP로 jclasslib 성능을 두 배로 개선한 방법
최근 저는 Codex와 프로파일링 에이전트를 사용해 제 오픈 소스 JVM 바이트코드 뷰어인 jclasslib의 성능을 개선했습니다. 특별한 문제가 있어서가 아니라, JProfiler 16.1과 함께 출시된 저희 MCP 서버를 사용해 보고 있었기 때문입니다. 이 글에서는 에이전트가 클래스 파일 읽기를 두 배 이상 빠르게 만든 방법과, 프로파일링이 더 이상 시작할 이유를 필요로 하지 않게 된 배경을 소개합니다.
시작점
jclasslib은 크게 두 부분으로 구성됩니다. 바이트코드를 읽고 쓰는 라이브러리와, 이를 표시하고 편집하는 Swing UI입니다. 이 프로젝트는 클래스 파일 읽기의 업계 표준인 ASM이 등장하기 전에 시작되었습니다. 뷰어가 클래스 파일 리더가 만들어 내는 표현에 결합되어 있기 때문에, 다른 것으로 전환하는 일은 그때까지 의미가 없었습니다.
ASM은 리소스 사용량 측면에서 강력하게 최적화되어 있으며, jclasslib처럼 전체 구조를 메모리로 읽어 들이는 대신 비지터 패턴을 구현합니다. 실제로 표시되는 소수의 클래스 파일을 읽는 데는 시간이 거의 들지 않기 때문에, 이 점은 한 번도 문제가 되지 않았습니다. 그러나 jclasslib 7.0의 새로운 Find usages 기능은 클래스패스 전체를 스캔합니다. 클래스 파일 읽기 속도가 갑자기 눈에 띄게 되었지만 여전히 괜찮은 수준이어서, 더 깊이 파고들지는 않았습니다.


최적화
JProfiler MCP를 사용할 수 있게 되자 저는 Codex에게 클래스 파일 읽기를 더 빠르게 만들어 보라고 요청했고, 그것은 순식간에 일련의 커밋을 만들어 냈습니다. 에이전트가 발견한 내용을 가장 명확하게 보여 주는 것이 이 커밋입니다. 이 커밋은 비용이 많이 드는 중복 맵 생성을 제거합니다. 이것은 코드만 보고서는 절대 눈치채지 못했을 부분이고, 프로파일링 데이터를 보더라도 "프로그램이 원래 해야 하는 일"이라는 범주로 분류되어 묻혔을 것입니다. 그러나 Codex는 맵 생성을 두 출발점까지 거슬러 올라가 추적한 다음 하나로 통합했습니다.
이 하나의 커밋만으로 프로파일링하면서 실행한 단위 테스트가 662 ms에서 410 ms로 단축되었고, 이는 38%의 속도 향상입니다. 같은 실행에서 이루어진 나머지 커밋들이 여기에 더해졌습니다:
- cb992aac: 읽기 경로에서 중복 맵 삽입 제거
- c7c0155d: 순수 ASCII UTF-8 항목에 대한 CharArray 할당 생략
- 4798108d: 상수 풀 문자열의 지연 디코딩
- a1ae6e7d: Find usages를 위한 클래스패스 스캔 속도 향상
그 밖에 더 작은 커밋들도 여럿 있었습니다. 이를 모두 합치면 JDK 25 JRE의 모든 클래스 파일(25,000개가 넘는 클래스)을 읽는 시간이 851 ms에서 347 ms로 줄어들어, 클래스 파일 읽기가 약 2.5배나 빨라졌습니다. 제목의 "두 배"는 바로 여기서 나온 것입니다.
소스 코드 검토 vs. 프로파일링 데이터
코딩 에이전트가 JProfiler MCP 서버 없이도 이것을 찾아냈을지 궁금하여 물어 보았습니다. 에이전트의 추론이 어떤 형태를 띠는지는 에이전트가 도달하는 결론만큼이나 중요하기 때문에, 아래의 답변은 수정 없이 그대로 두었습니다.
Q: JProfiler MCP는 얼마나 중요했으며, 단순히 소스 코드를 검토하는 것에 비해 결과는 얼마나 더 좋았습니까?
A: 이 작업에서 중요했습니다. 프로파일러가 없었다면 아마 처음에 잘못된 대상을 최적화하는 데 시간을 소비했을 것입니다.
- "이 정확한 테스트를 프로파일링하면서 실행"에서 사용할 수 있는 핫스팟 데이터에 이르는 빠른 경로를 제공했습니다. 바로 전달할 수 있는 JVM 인자를 반환하는
prepare_profiling이 잘 작동했습니다.- 워크플로가 단순하고 조합하기 쉬웠습니다:
prepare_profiling→ Gradle 테스트 실행 →check_status→get_performance_hotspots→expand_performance_hotspot.- 핫스팟 확장이 파서 비용과 테스트 하네스 비용을 구분할 수 있을 만큼 충분히 상세했습니다.
- 실질적인 개선 지점을 직접 드러냈습니다.
ClassFile.setConstantPool에서 클래스를 읽는 시점에 즉시 수행되는 역방향 상수 풀 인덱싱과, 여기서 파생되는String.hashCode/HashMap.put비용입니다. 이것은 소스 코드 수준의 추측으로는 가장 먼저 떠오르는 후보가 아니었습니다.- 잘못된 최적화를 할 가능성을 막아 주었습니다. 소스 코드만 검토하면
ConstantUtf8Info.readUTF가 주요 표적으로 보였을 것입니다. 프로파일링은 그 부분이 핫하다는 것을 보여 주었지만, 첫 번째 수정 대상으로는 최선이 아니었습니다.소스 코드 검토에 비해 얼마나 더 나았습니까?
- 질적으로: 상당히 더 낫습니다.
- 소스 코드 검토만으로는 아마 먼저 modified UTF-8 디코딩 최적화를 시도하거나, JRT 순회 오버헤드를 살펴 보거나, 구조 읽기에서 일반적인 마이크로 최적화를 시도했을 것입니다.
- 프로파일링은 가장 가치가 큰 수정이 다른 곳에 있음을 보여 주었습니다. 클래스를 읽을 때마다 즉시 구축되는 역방향 인덱스를 제거하는 것입니다.
- 그 결과 측정된 개선은 약 662 ms에서 410 ms로, 대략 38%의 속도 향상이었습니다.
- 소스 코드 검토만으로 그 구체적인 수정을 첫 번째 수로 찾아냈을 것이라고 생각하지는 않습니다.
전반적인 중요도
- 이 작업에 대해서: 높음.
- 작업 순서를 "추측하고, 패치하고, 벤치마크한다"에서 "측정하고, 가장 큰 실제 비용을 패치하고, 검증한다"로 바꾸어 놓았습니다.
- 최종 결과는 더 빨랐을 뿐만 아니라, 직관이 아닌 관측된 핫스팟에 근거했기 때문에 훨씬 더 정당화하기 쉬운 결과였습니다.
ASM 추월
진행 과정에서 제가 예상하지 못한 일이 일어났습니다. jclasslib이 뷰어에 가장 중요한 모드들에서 ASM을 추월한 것입니다. 저장소의 AsmPerformanceComparisonTest는 두 라이브러리를 모두 사용하여 JRE의 모든 클래스를 스캔하고, jclasslib 시간 대비 ASM 시간의 비율을 보고합니다. 비율이 1.0이면 동등하고, 1.0 미만이면 jclasslib이 더 빠른 것입니다. JDK 17에서의 최적화 이전 기준선에서는 읽기 전용 모드에서 jclasslib이 ASM보다 약 두 배 느렸습니다 (비율 1.95). HEAD에서는 동일한 26,653개 클래스에 대한 동일한 벤치마크가 이제 다음과 같이 보고합니다:
- 전체 클래스 구조 읽기: 0.77 (jclasslib 347 ms, ASM 453 ms)
- 클래스 읽기 및 재작성: 0.87 (jclasslib 917 ms, ASM 1050 ms)
- 읽기, 바이트코드 디코딩, 재작성: 1.23 (jclasslib 1311 ms, ASM 1068 ms)
이는 상당히 주목할 만한 일입니다. jclasslib의 아키텍처는 메모리에 구축되는 클래스 파일 구조를 요구하는 반면, ASM 아키텍처에서는 클래스 파일이 그저 "흘러 지나가기" 때문입니다. 바이트코드 자체를 명령어 목록으로 디코딩한 다음 다시 내보내야 할 때는 여전히 ASM이 앞서는데, 바로 이 부분에서 ASM의 스트리밍 모델이 중간 표현을 생략해 주기 때문입니다. 그 외의 모든 경우에는 격차가 사라졌습니다.
이유가 필요 없는 프로파일링
프로파일링에는 원래 이유가 필요했습니다. 애플리케이션이 느려지거나 메모리가 부족해질 때 하던 작업이었습니다. 코딩 에이전트와 MCP 서버가 있으면 이런 이유는 더 이상 필요하지 않습니다. 코딩 에이전트는 성능 문제를 백그라운드에서 수정하고, 애플리케이션의 성능을 기준선과 비교하며, 필요하다면 조치를 취할 수 있습니다.
다만 JProfiler MCP 서버가 결정적입니다. 이것이 없으면 코딩 에이전트는 도움이 되지 않는 "최적화"를 자신 있게 수행하다가 코드를 자주 망가뜨립니다. 프로파일링 데이터는 어떤 부분에 작업이 필요한지 알려 주는 신호가 되어 주고, 그 작업이 성공했는지 검증할 수단도 제공합니다.
JProfiler MCP 페이지에서 직접 시도해 보시기 바랍니다. 애플리케이션에 코드를 읽어서는 볼 수 없는 핫스팟이 있다면, 에이전트가 찾아낼 것입니다.