JVM Heap Memory 아주 간단하게 모니터링 해보자 (Feat WebSocket, Vue.js)

Minu·2024년 4월 9일
2

Spring

목록 보기
2/3

배경

원래 저는 Scouter를 이용한 실시간 모니터링을 했었습니다. 하지만 배포 환경과 로컬 환경을 번갈아가면서 모니터링을 할때마다 Scouter의 설정 파일을 계속 변경해야하는 번거로움과 모바일에서도 내가 배포중인 서버의 메모리량을 확인해보고 싶어서 Scouter의 코드를 참고해서 WebSocket을 이용해 간단하게 구성해봤습니다.


WebSocket 이란?

WebSocket은 클라이언트와 서버를 실시간으로 통신이 가능하도록 하는 프로토콜 즉 양방향 통신으로 써 handshake 를 통해 연결이 끊길때까지 지속적으로 통신함

GET 과 같은 메소드랑 비교하면 이해하기 쉬움
GET 요청은 "/api/member/me" 엔드포인트에 요청하면, 요청할 때의 기준으로 정보를 가져와서 만약 member의 정보가 수정되면 다시 "/api/member/me" 에 요청해야 알 수 있지만 만약 WebSocket 으로 연결중이라면 연결이 끊길때까지 member 정보를 알 수 있음


환경

  • springboot
  • gradle
  • websocket
  • vuejs

SpringBoot

1. gradle 의존성

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'
}

2. JVM 힙 메모리 Util 클래스

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());
    }


}
  • Runtime 클래스에 대한 설명
    Runtime 클래스를 이용하여 힙 메모미를 가져와 총 힙 메모리, 사용 가능한 힙 메모리, 사용중인 힙 메모리를 계산하여 문자열로 변환시키는 클래스 입니다.

3. WebSocket 설정 클래스

@RequiredArgsConstructor
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{


    private final MemoryMonitorHandler memoryMonitorHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(memoryMonitorHandler, "/memory-monitor")
            .setAllowedOrigins("*");
    }

}
  • 클라이언트에서 "/memory-monitor" 로 요청할 경우, MemoryMonitorHandler 의 구성 정보가 실행됩니다.
  • setAllowedOrigins("*") 와일드카드로 모두 허용으로 했지만 실제 배포 환경에서는 변경 해주세요

4. Handler 클래스

@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();
    }

}

5. 테스트 컨트롤러

@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 환경 구성이 끝났습니다.


Vue.js

Monitor.vue - temaplate

<template>
  <h1> JVM Memory Monitor </h1>
  <p>{{ memoryInfo }}</p>
  <button @click="connectWebSocket">Connect</button>
</template>
  • Connect 버튼 클릭 시, connectWebSocket 함수를 호출 하도록 했고 스프링에서 받아온 메모리 정보를 memoryInfo 변수에 담아 출력하도록 했습니다.

Monitor.vue - script

<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>
  1. WebSocket 연결 정보를 3. WebSocket 설정 클래스 에서 설정한 엔드포인트를 적어줍니다.
  2. 받아온 정보를 memoryInfo.value 변수에 할당합니다.

결과 확인

  1. 요청 전

  1. 웹 소켓 연결 후

성공적으로 연결이 되면 위와 같이 JVM Heap 메모리 정보가 실시간으로 반영됩니다!

  1. springboot/test 요청

처음엔 사용 가능한 총 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 최소 힙 크기를 늘려야 할까1?

왜 JVM 최소 힙 크기를 늘려야 할까2?

  • JVM이 힙 크기를 늘릴 때마다 OS에 추가 메모리를 요청해야 하며, 이는 시간이 걸립니다
  • 힙 크기가 작을수록(-Xms로 시작) GC가 더 자주 발생한다는 것입니다. 따라서 처음에 더 큰 힙으로 시작하면 GC가 자주 발생하지 않습니다.
  • 프로덕션 환경에서는 일반적으로 OS당(또는 VM당) 하나의 앱 서버만 실행합니다. 따라서 앱 서버는 다른 앱과 메모리를 놓고 경쟁하지 않으므로 메모리를 미리 제공하는 것이 좋습니다.

즉 JVM 은 동적으로 메모리를 늘려가는데 이 때 메모리가 부족할 경우 swap이 이루어지게 되는데 swap 영역은 Disk가 되므로 오버헤드가 발생한다. 그러므로 최소 힙 (Xms)과 최대 힙(Xmx) 크기를 늘리는게 좋다라는 의견이다.


최소 힙, 최대 힙 변경 후 결과

jar 배포 시, 최소, 최대 힙을 변경하는 방법으로 했습니다.

  1. jar 파일
./gradlew build   
  1. 배포
 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 프리티어, 사용자가 매우 적은 서비스

아무래도 모니터링을 통해 서비스 환경에 맞게 힙 메모리를 조절하는게 가장 적절하다고 생각합니다

참고

0개의 댓글

관련 채용 정보