JMX와 모니터링 — 실행 중인 JVM을 원격으로 관찰하고 제어하는 방법
운영 중인 서버의 JVM 상태를 확인하고 싶은데, 로그만으로는 한계가 있습니다. 실시간으로 힙 사용량이나 스레드 수를 볼 수는 없을까요?
JMX(Java Management Extensions) 는 실행 중인 JVM의 상태를 실시간으로 모니터링하고, 설정 변경이나 작업 트리거까지 가능한 표준 관리 기술입니다. JConsole, VisualVM, 그리고 Prometheus JMX Exporter가 모두 이 기술 위에서 동작합니다.
JMX 아키텍처
┌─────────────────────────────────────────┐
│ 관리 도구 (JConsole, VisualVM, Grafana) │
│ ← JMX 클라이언트 │
└──────────┬──────────────────────────────┘
│ JMX Remote (RMI/JMXMP)
┌──────────▼──────────────────────────────┐
│ MBean Server │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 플랫폼 │ │ 애플리케이션│ │ 프레임워크│ │
│ │ MXBean │ │ MBean │ │ MBean │ │
│ └──────────┘ └──────────┘ └─────────┘ │
└─────────────────────────────────────────┘
세 계층:
- MBean: 관리 대상 리소스 (속성, 오퍼레이션, 알림)
- MBean Server: MBean을 등록하고 관리하는 레지스트리
- **JMX 커넥터 **: 원격 접근을 가능하게 하는 통신 계층
플랫폼 MXBean — JDK가 제공하는 모니터링
JDK는 JVM 자체의 상태를 모니터링할 수 있는 MXBean을 기본 제공합니다. 별도 라이브러리 없이 ManagementFactory에서 바로 꺼내 쓸 수 있습니다.
메모리와 스레드 정보를 확인하는 코드입니다.
import java.lang.management.*;
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = memoryBean.getHeapMemoryUsage();
System.out.println("힙 사용량: " + heap.getUsed() / 1024 / 1024 + " MB");
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
System.out.println("활성 스레드: " + threadBean.getThreadCount());
long[] deadlocked = threadBean.findDeadlockedThreads();
if (deadlocked != null) {
System.out.println("데드락 감지! 스레드 수: " + deadlocked.length);
}
GC와 런타임 정보도 같은 방식으로 접근합니다.
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println(gc.getName() +
" - 수집 횟수: " + gc.getCollectionCount() +
", 총 시간: " + gc.getCollectionTime() + " ms");
}
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
System.out.println("JVM 가동 시간: " + runtime.getUptime() / 1000 + " 초");
이렇게 코드에서 직접 읽을 수도 있고, JConsole/VisualVM을 통해 GUI로 동일한 정보를 확인할 수도 있습니다.
주요 플랫폼 MXBean
| MXBean | 제공 정보 |
|---|---|
MemoryMXBean | 힙/논힙 메모리 사용량 |
ThreadMXBean | 스레드 수, 데드락 감지 |
GarbageCollectorMXBean | GC 횟수, 시간 |
RuntimeMXBean | JVM 인자, 가동 시간 |
OperatingSystemMXBean | CPU 사용량, 시스템 로드 |
ClassLoadingMXBean | 로드된 클래스 수 |
CompilationMXBean | JIT 컴파일 시간 |
MemoryPoolMXBean | 메모리 풀별 사용량 |
커스텀 MBean 만들기
Standard MBean
// 1. MBean 인터페이스 정의 (이름 규칙: 클래스명 + "MBean")
public interface AppConfigMBean {
// 속성 (getter/setter)
int getMaxConnections();
void setMaxConnections(int max);
String getAppVersion(); // 읽기 전용 (setter 없음)
// 오퍼레이션
void resetStatistics();
String getStatus();
}
// 2. 구현 클래스
public class AppConfig implements AppConfigMBean {
private volatile int maxConnections = 10;
private final String appVersion = "2.1.0";
private final AtomicLong requestCount = new AtomicLong();
@Override
public int getMaxConnections() { return maxConnections; }
@Override
public void setMaxConnections(int max) {
if (max < 1 || max > 100)
throw new IllegalArgumentException("1~100 범위");
this.maxConnections = max;
System.out.println("maxConnections 변경: " + max);
}
@Override
public String getAppVersion() { return appVersion; }
@Override
public void resetStatistics() {
requestCount.set(0);
System.out.println("통계 초기화됨");
}
@Override
public String getStatus() {
return "OK - 요청 수: " + requestCount.get();
}
// MBean 외부용 메서드
public void incrementRequest() {
requestCount.incrementAndGet();
}
}
MBean 등록
public class Application {
public static void main(String[] args) throws Exception {
// MBeanServer 획득
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
// MBean 등록
AppConfig config = new AppConfig();
ObjectName name = new ObjectName("com.myapp:type=Config,name=main");
mbs.registerMBean(config, name);
System.out.println("MBean 등록 완료. JConsole로 접속하세요.");
// 애플리케이션 실행 유지
Thread.currentThread().join();
}
}
MXBean — 표준 타입으로 자동 변환
// MXBean 인터페이스 (@MXBean 어노테이션 또는 이름에 MXBean 접미사)
@MXBean
public interface ServerStatsMXBean {
ServerInfo getServerInfo(); // 커스텀 타입도 CompositeData로 자동 변환
List<ConnectionInfo> getActiveConnections();
}
// 커스텀 타입 (record 또는 getter가 있는 클래스)
public record ServerInfo(String hostname, int port, long uptime) {}
public record ConnectionInfo(String clientIp, long connectedAt) {}
MXBean은 커스텀 타입을 자동으로 CompositeData로 변환하므로, JConsole 등 원격 클라이언트에서 특수 클래스 없이도 데이터를 표시할 수 있습니다.
알림 (Notification)
MBean에서 이벤트를 발생시킬 수 있습니다.
public class AppConfig extends NotificationBroadcasterSupport
implements AppConfigMBean {
private long sequenceNumber = 0;
@Override
public void setMaxConnections(int max) {
int old = this.maxConnections;
this.maxConnections = max;
// 변경 알림 발송
Notification notification = new AttributeChangeNotification(
this, // 소스
sequenceNumber++, // 시퀀스 번호
System.currentTimeMillis(), // 타임스탬프
"maxConnections 변경됨", // 메시지
"maxConnections", // 속성명
"int", // 타입
old, // 이전 값
max // 새 값
);
sendNotification(notification);
}
}
// 알림 리스너 등록
mbs.addNotificationListener(name,
(notification, handback) -> {
System.out.println("알림 수신: " + notification.getMessage());
},
null, // 필터 (null = 모두 수신)
null // handback
);
JMX Remote 설정
기본 원격 접속 (개발용)
java \
-Dcom.sun.management.jmxremote.port=9999 \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-jar myapp.jar
인증 설정 (운영용)
java \
-Dcom.sun.management.jmxremote.port=9999 \
-Dcom.sun.management.jmxremote.authenticate=true \
-Dcom.sun.management.jmxremote.password.file=/path/jmxremote.password \
-Dcom.sun.management.jmxremote.access.file=/path/jmxremote.access \
-Dcom.sun.management.jmxremote.ssl=true \
-Djavax.net.ssl.keyStore=/path/keystore.p12 \
-Djavax.net.ssl.keyStorePassword=password \
-jar myapp.jar
# jmxremote.access
admin readwrite
monitor readonly
# jmxremote.password
admin secretPassword
monitor readPassword
프로그래밍 방식 원격 접속
// JMX 클라이언트
String url = "service:jmx:rmi:///jndi/rmi://server:9999/jmxrmi";
JMXServiceURL serviceUrl = new JMXServiceURL(url);
Map<String, Object> env = new HashMap<>();
env.put(JMXConnector.CREDENTIALS,
new String[]{"admin", "secretPassword"});
try (JMXConnector connector = JMXConnectorFactory.connect(serviceUrl, env)) {
MBeanServerConnection mbsc = connector.getMBeanServerConnection();
// 속성 읽기
ObjectName configName = new ObjectName("com.myapp:type=Config,name=main");
int maxConn = (int) mbsc.getAttribute(configName, "MaxConnections");
// 오퍼레이션 호출
mbsc.invoke(configName, "resetStatistics", null, null);
// 속성 변경
mbsc.setAttribute(configName,
new Attribute("MaxConnections", 20));
}
JConsole과 VisualVM
JConsole
jconsole # 로컬 JVM 자동 감지
jconsole server:9999 # 원격 접속
JConsole은 JDK에 포함된 기본 도구로, 메모리/스레드/클래스/MBean 탭을 제공합니다.
VisualVM
visualvm # 별도 설치 필요
VisualVM은 JConsole보다 풍부한 기능을 제공합니다:
- 힙 덤프 분석
- CPU/메모리 프로파일링
- 스레드 덤프
- 플러그인 확장
Prometheus + JMX Exporter
운영 환경에서는 JMX 데이터를 Prometheus로 수집하는 것이 일반적입니다.
# JMX Exporter를 Java Agent로 실행
java -javaagent:jmx_prometheus_javaagent.jar=8080:config.yml \
-jar myapp.jar
# config.yml
rules:
- pattern: 'java.lang<type=Memory><HeapMemoryUsage>used'
name: jvm_memory_heap_used_bytes
type: GAUGE
- pattern: 'com.myapp<type=Config,name=main><>MaxConnections'
name: myapp_max_connections
type: GAUGE
이렇게 설정하면 http://server:8080/metrics에서 Prometheus 형식의 메트릭을 확인할 수 있고, Grafana 대시보드와 연결할 수 있습니다.
주의할 점
인증 없이 JMX Remote를 열면 서버가 통째로 노출된다
JMX는 속성 읽기뿐 아니라 GC 트리거, 힙 덤프 생성, 설정 변경, 오퍼레이션 실행 까지 가능합니다. authenticate=false, ssl=false로 프로덕션에 배포하면 외부에서 JVM을 마음대로 제어할 수 있습니다. 실제로 JMX 포트가 열린 서버를 통해 원격 코드 실행 공격이 발생한 사례가 있습니다.
MBean의 getAttribute가 무거우면 모니터링이 장애를 유발한다
Prometheus JMX Exporter는 주기적으로 모든 등록된 MBean의 속성을 읽습니다. 만약 getAttribute() 내부에서 DB 쿼리나 원격 호출을 하면, 모니터링 자체가 성능 저하의 원인이 됩니다. MBean 속성은 이미 계산된 값을 반환 하도록 설계해야 합니다.
RMI 기반 JMX의 포트 바인딩 문제
JMX Remote의 기본 프로토콜인 RMI는 지정한 포트 외에 랜덤 포트 를 추가로 사용합니다. 방화벽 환경에서 JMX 포트만 열어도 접속이 안 되는 이유가 이것입니다. com.sun.management.jmxremote.rmi.port를 동일 포트로 설정하면 해결됩니다.
정리
| 항목 | 설명 |
|---|---|
| JMX 핵심 | 실행 중인 JVM을 모니터링하고 제어하는 표준 기술 |
| MBean | 관리 대상 리소스. 속성 읽기/쓰기, 오퍼레이션 호출 가능 |
| 플랫폼 MXBean | JDK 기본 제공. 메모리, 스레드, GC 정보 |
| MXBean vs MBean | MXBean은 표준 타입으로 자동 변환되어 원격 클라이언트에서 사용 편리 |
| JMX Remote | RMI 기반 원격 접속. 프로덕션에서 SSL + 인증 필수 |
| Prometheus 연동 | JMX Exporter로 메트릭을 Prometheus 형식으로 노출 |