원래 저는 Scouter를 이용한 실시간 모니터링을 했었습니다. 하지만 배포 환경과 로컬 환경을 번갈아가면서 모니터링을 할때마다 Scouter의 설정 파일을 계속 변경해야하는 번거로움과 모바일에서도 내가 배포중인 서버의 메모리량을 확인해보고 싶어서 Scouter의 코드를 참고해서 WebSocket을 이용해 간단하게 구성해봤습니다.
WebSocket은 클라이언트와 서버를 실시간으로 통신이 가능하도록 하는 프로토콜 즉 양방향 통신으로 써 handshake 를 통해 연결이 끊길때까지 지속적으로 통신함
GET 과 같은 메소드랑 비교하면 이해하기 쉬움
GET 요청은 "/api/member/me" 엔드포인트에 요청하면, 요청할 때의 기준으로 정보를 가져와서 만약 member의 정보가 수정되면 다시 "/api/member/me" 에 요청해야 알 수 있지만 만약 WebSocket 으로 연결중이라면 연결이 끊길때까지 member 정보를 알 수 있음
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
public class RuntimeUtil {
// MB 단위로 메모리 크기를 포맷팅
final static DecimalFormat memoryFormat = new DecimalFormat("#,##0.00 Mb");
// 1MB를 나타내는 바이트 수 (1024 * 1024)
final static double MB_Numeral = 1024d * 1024d;
public RuntimeUtil() {
}
// 총 힙 메모리
public static double getTotalMemoryInMb() {
return Runtime.getRuntime().totalMemory() / MB_Numeral;
}
// 사용 가능한 힙 메모리
public static double getFreeMemoryInMb() {
return Runtime.getRuntime().freeMemory() / MB_Numeral;
}
// 사용중인 힙 메모리
public static double getUsedMemoryInMb() {
return getTotalMemoryInMb() - getFreeMemoryInMb();
}
// 총 힙 메모리 to 문자열
public static String getTotalMemoryStringInMb() {
return memoryFormat.format(getTotalMemoryInMb());
}
// 사용 가능한 힙 메모리 to 문자열
public static String getFreeMemoryStringInMb() {
return memoryFormat.format(getFreeMemoryInMb());
}
// 사용중인 힙 메모리 to 문자열
public static String getUsedMemoryStringInMb() {
return memoryFormat.format(getUsedMemoryInMb());
}
}
@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{
private final MemoryMonitorHandler memoryMonitorHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(memoryMonitorHandler, "/memory-monitor")
.setAllowedOrigins("*");
}
}
@Component
public class MemoryMonitorHandler extends TextWebSocketHandler {
// 스케줄러를 이용해 지속적으로 실행
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
// WebSocket 연결이 성공적으로 이루어진 후에 이 메소드 실행
@Override
public void afterConnectionEstablished(@NonNull WebSocketSession session){
// 스케줄러를 사용해 매초마다 메모리 정보를 클라이언트에 전송
executorService.scheduleAtFixedRate(() -> {
try {
// 실시간 메모리 사용량을 문자열로 만들어서 WebSocket 세션을 통해 클라이언트에게 전송
session.sendMessage(new TextMessage(getMemoryInfo()));
} catch (IOException e) {
e.printStackTrace();
}
}, 0, 1, TimeUnit.SECONDS); // 1초마다 전송
}
// RuntimeUtil 클래스에서 메모리 정보를 가져와 문자열 조합
private String getMemoryInfo() {
return "Total Memory: " + RuntimeUtil.getTotalMemoryStringInMb() + ", "
+ "Used Memory: " + RuntimeUtil.getUsedMemoryStringInMb() + ", "
+ "Free Memory: " + RuntimeUtil.getFreeMemoryStringInMb();
}
}
@RestController
public class TestController {
@GetMapping("/test")
public ResponseEntity<String[]> test() {
int size = 1_000_000;
String[] s = new String[size];
for (int i = 0; i < size; i++) {
s[i] = "a";
}
return ResponseEntity.ok(s);
}
}
실제로 힙 메모리의 변화가 있는지 테스트용으로 만든 컨트롤러 입니다.
이로써 Springboot 의 Heap 메모리 정보와 클라이언트와 통신할 WebSocket 환경 구성이 끝났습니다.
<template>
<h1> JVM Memory Monitor </h1>
<p>{{ memoryInfo }}</p>
<button @click="connectWebSocket">Connect</button>
</template>
<script setup>
import {ref, onUnmounted} from 'vue';
const memoryInfo = ref('');
let webSocket = null;
const connectWebSocket = () => {
// 1
const socket = new WebSocket('ws://localhost:8080/memory-monitor');
socket.onopen = () => {
console.log('WebSocket connection open')
};
// 2
socket.onmessage = (event) => {
memoryInfo.value = event.data;
};
socket.onclose = () => {
console.log('WebSocket connection closed');
}
webSocket = socket;
onUnmounted(() => {
if (webSocket) {
webSocket.close();
}
});
}
</script>
성공적으로 연결이 되면 위와 같이 JVM Heap 메모리 정보가 실시간으로 반영됩니다!
처음엔 사용 가능한 총 Heap은 88mb 였는데 158mb 로 변했다..?
그래서 초기 JVM가 몇으로 구성되는지 확인하는 아래 명령어를 입력 했을 InitialHeapSize=268435456 (256MB), MaxHeapSize=4294967296 (4GB)로 나왔다. 근데 왜 처음엔 88mb 였을까...
java -XX:+PrintFlagsFinal -version | grep -iE 'heapsize|permsize|threadstacksize'
찾아보니 아래와 같은 내용이 있다.
JVM은 메모리가 부족하게 되면 OS에 메모리를 추가 요청하는 방식으로 힙 사이즈를 조정합니다.
이때 GC 가 발생하게 되고, JVM은 필요한 만큼 힙사이즈를 늘려가게 됩니다.
이렇게 조정하다가 만약 머신의 물리 메모리 사이즈를 넘어가게 되면
가상 메모리를 사용하면서 swap space로 swap in - out 을 하게 됩니다.
그리고
아래 링크 글을 읽어보면
즉 JVM 은 동적으로 메모리를 늘려가는데 이 때 메모리가 부족할 경우 swap이 이루어지게 되는데 swap 영역은 Disk가 되므로 오버헤드가 발생한다. 그러므로 최소 힙 (Xms)과 최대 힙(Xmx) 크기를 늘리는게 좋다라는 의견이다.
jar 배포 시, 최소, 최대 힙을 변경하는 방법으로 했습니다.
./gradlew build
cd build/libs
java -Xms2048m -Xmx2048m -jar 파일이름.jar
위와 같이 성공적으로 Heap 메모리가 늘어난 걸 확인 할 수 있습니다.
여기서 또 든 의문점이 그럼 최소 힙(Xms)과 최대 힙(Xmx)를 같게 설정하면 스왑 영역을 사용하는 상황을 줄일 수 있어서 좋은게 아닌가? 입니다. 왜냐하면 처음부터 최대 힙과 같은 메모리를 할당해주기 때문에 최대 힙을 넘어서 메모리가 부족하게 되면 swap 이 실행되는게 아닌 out of memory java heap space가 발생하게 되는데 어차피 최소 메모리를 최대 힙 보다 적게 설정해도 결국 최소 메모리가 부족하면 swap이 이루어지게 될테고, 최대 힙에 도달하게 됐을 때 메모리가 부족할 경우 결국 out of memory java heap space가 발생하지 않나? 였습니다.
그러나 메모리가 제한적이거나 사용자가 적어 메모리 사용량이 적은데 힙 메모리를 더 사용해 OS 메모리를 낭비할 필요가 있을까...!
예) EC2 프리티어, 사용자가 매우 적은 서비스
아무래도 모니터링을 통해 서비스 환경에 맞게 힙 메모리를 조절하는게 가장 적절하다고 생각합니다