📘 OEA

GC가 문제인 줄 알았는데, 시작은 swap이었다

운영 중 Java 서비스가 느려지기 시작하면 대부분 가장 먼저 GC를 의심합니다.

그럴 만한 이유가 있습니다.

Java는 메모리를 직접 해제하지 않고, 더 이상 쓰지 않는 객체를 JVM이 나중에 정리하는 구조입니다. 그래서 응답이 느려지거나, 메모리 사용량이 커지거나, pause가 보이기 시작하면 자연스럽게 “GC가 너무 자주 도는 것 아닐까?”라는 생각으로 이어집니다.

이번 이슈도 처음에는 그렇게 보였습니다.

  • Java consumer가 느려졌다
  • swap 사용이 보였다
  • 체감 성능도 나빠졌다
  • 그래서 처음엔 GC 쪽을 먼저 의심했다

그런데 실제로 끝까지 따라가 보니, 문제의 출발점은 GC가 아니었습니다.

오히려 더 먼저 봐야 했던 건 이것이었습니다.

“OS 메모리는 남아 보이는데, 왜 Java 프로세스가 swap을 쓰고 있지?”

이번 글은 바로 그 질문에서 시작한 이야기입니다.

겉으로는 GC 문제처럼 보였지만, 실제로는 서버 전체에서 메모리를 어떻게 나눠 쓰고 있었는지를 다시 봐야 했던 사례였습니다.

처음 보였던 현상

운영 중 이상했던 건 단순했습니다.

서버를 보면 메모리가 완전히 바닥난 것처럼 보이지는 않았습니다.

freeavailable도 아주 0은 아니었습니다. 그런데도 Java consumer 프로세스에서 swap 사용이 나타났습니다.

이 상태를 처음 보면 당연히 헷갈립니다.

“정말 메모리가 부족해서 swap을 쓴 건가?”

“아직 메모리가 남아 보이는데 왜 swap이 생기지?”

“이게 GC가 밀리면서 생긴 문제인가?”

이 질문은 아주 자연스럽습니다.

저도 처음에는 GC 관점에서 보기 쉬운 문제라고 생각했습니다.

하지만 조금 더 들여다보니, 이건 “GC가 먼저 나빠져서 생긴 문제”라고 보기보다,

운영체제가 어떤 메모리를 남기고 어떤 메모리를 뒤로 밀어냈는지를 먼저 봐야 하는 상황이었습니다.

왜 메모리가 남아 있는데도 swap을 쓸까

이 부분이 가장 많이 오해되는 지점입니다.

많은 분들이 swap을 이렇게 이해합니다.

“RAM이 다 차면 그때 swap을 쓴다.”

완전히 틀린 말은 아니지만, 운영에서는 이 설명만으로는 부족합니다.

리눅스는 그렇게 단순하게 움직이지 않습니다. 운영체제는 서버 전체를 보면서, 지금 당장 자주 안 쓰는 메모리를 뒤로 미뤄두고, 필요한 메모리를 확보하려고 합니다.

즉, 어떤 시점에는 이런 일이 가능합니다.

  • 메모리가 완전히 0이 된 것은 아니다
  • 그런데도 운영체제가 “지금 덜 쓰는 메모리 일부는 swap으로 보내도 되겠다”고 판단한다
  • 그 대상 중 하나가 Java 메모리가 될 수 있다

특히 이 현상은 여러 프로세스가 한 서버에서 같이 메모리를 쓸 때 더 잘 나타납니다.

예를 들면 이런 구조입니다.

  • PostgreSQL이 같은 서버에서 돌고 있고
  • Java consumer가 여러 개 떠 있고
  • 로그 수집기나 에이전트도 같이 돌고 있고
  • OS는 파일 캐시까지 함께 유지하고 있는 경우

이 상황에서는 Java 하나만 봐서는 답이 안 나옵니다.

문제는 “Java가 지금 몇 GB를 쓰고 있느냐” 하나가 아니라, 서버 전체에서 누가 메모리를 어떻게 나눠 쓰고 있느냐입니다.

즉, 이번 이슈의 첫 번째 핵심은 이것이었습니다.

문제는 JVM 안에서만 생긴 게 아니라, 서버 전체 메모리 사용 구조에서 시작됐다.

그럼 GC는 왜 같이 의심됐을까

그렇다고 처음에 GC를 의심한 게 이상한 건 아닙니다.

GC는 Java에서 더 이상 쓰지 않는 객체를 정리하고, 다시 쓸 수 있는 메모리 공간을 확보하는 역할을 합니다.

그래서 보통 아래 같은 현상이 보이면 GC가 자연스럽게 후보로 올라옵니다.

  • 메모리 사용량이 커진다
  • 응답이 느려진다
  • pause가 보인다
  • 처리량이 흔들린다

특히 대용량 데이터를 다루는 서비스에서는 더 그렇습니다.

데이터를 읽고, 가공하고, 문자열로 바꾸고, JSON으로 만들고, 큐에 쌓고, 다시 내보내는 과정에서 객체가 많이 생기기 때문입니다.

그래서 운영에서 Java가 느려지면 GC를 먼저 의심하는 것 자체는 잘못이 아닙니다.

문제는 거기서 바로 결론 내리는 것입니다.

중요한 건 항상 하나입니다.

GC가 원인인가, 아니면 결과인가?

이번 사례에서는 GC가 문제를 만든 쪽이라기보다,

swap이 이미 끼어든 상태에서 GC도 같이 느려져 보인 쪽에 가까웠습니다.

왜 swap이 끼면 Java와 GC가 같이 느려질까

이제 두 번째 핵심으로 넘어갑니다.

OS가 Java 메모리 일부를 swap으로 밀어낸 상태라고 해보겠습니다.

그다음 Java가 그 메모리를 다시 써야 하는 순간이 오면 어떻게 될까요?

원래라면 메모리에서 바로 읽고 끝났어야 할 작업이,

이제는 swap 쪽에서 다시 가져오는 과정을 거쳐야 합니다.

GC도 마찬가지입니다.

GC는 힙을 훑으면서 어떤 객체가 살아 있는지 보고, 필요 없는 객체를 정리합니다.

그런데 GC가 확인해야 할 메모리 일부가 swap에 나가 있으면, 그 작업은 더 이상 “메모리 안에서 끝나는 정리 작업”이 아닙니다.

이제는

  • 메모리를 읽고
  • 정리하고
  • 끝나는 작업이 아니라
  • swap으로 밀려난 메모리를 다시 가져오고
  • 그다음에 읽고
  • 정리해야 하는 작업이 됩니다

즉, GC가 나빠진 게 아니라, GC가 일하려고 보니 필요한 메모리가 이미 밖으로 밀려나 있어서 느려지는 것입니다.

그래서 운영에서는 이런 식으로 보일 수 있습니다.

  • JVM heap 사용량은 아주 높아 보이지 않는다
  • 그런데 응답이 느려진다
  • GC pause가 길어져 보인다
  • 처리량이 한 번 떨어지면 회복이 늦다

겉으로 보면 “GC가 문제다”처럼 보이지만,

실제로는 swap이 먼저 있었고, 그 결과 GC도 같이 느려져 보인 것일 수 있습니다.

여기서 이번 이슈의 두 번째 핵심이 나옵니다.

GC는 문제의 시작점이 아니라, swap 상태가 만들어낸 지연을 더 눈에 띄게 보여준 쪽에 가까웠다.

이번에 실제로 봐야 했던 것

이 지점에서 문제를 푸는 방향도 달라졌습니다.

처음처럼 GC만 보고 있었다면 아마 이런 생각으로 갔을 겁니다.

  • collector를 바꿔볼까
  • pause time 옵션을 조정해볼까
  • Xmx를 늘려야 하나, 줄여야 하나

물론 나중에는 이런 조정도 필요할 수 있습니다.

하지만 이번에는 그 전에 먼저 확인해야 할 것들이 있었습니다.

  • PostgreSQL이 실제로 얼마만큼의 메모리를 쓰고 있는가
  • Java consumer가 몇 개 떠 있고, 각 프로세스의 메모리 상한은 얼마인가
  • 로그 수집기나 다른 부가 프로세스도 같이 메모리를 차지하고 있지 않은가
  • swap 사용이 순간적인지, 계속 누적되는지
  • 지금 보고 있는 느려짐이 JVM 자체 문제인지, 서버 전체 메모리 사용 문제인지

이걸 보기 시작하면 관점이 바뀝니다.

문제는 “GC가 안 좋다”가 아니라,

“DB와 Java와 OS와 다른 프로세스가 한 서버에서 같이 메모리를 쓰는 구조가 과연 맞게 잡혀 있었는가”가 됩니다.

즉, 이번 이슈는 JVM 내부 튜닝 이야기이기 전에

서버 전체 메모리 예산을 어떻게 잡고 있었는가의 문제였습니다.

그래서 실제 조치는 어떻게 갔나

해결 방향은 명확했습니다.

이 문제를 GC 문제로만 보면 collector부터 바꾸고 싶어집니다.

하지만 이번에는 그렇게 가지 않았습니다.

왜냐하면 문제의 시작점이 GC가 아니라,

서버 안에서 메모리를 나눠 쓰는 방식에 더 가까웠기 때문입니다.

그래서 실제 조치는 크게 두 가지 방향이었습니다.

첫째, Java consumer 쪽 메모리 상한을 더 보수적으로 다시 잡는 것입니다.

운영에서는 “지금 당장 얼마나 쓰고 있느냐”보다

“최대로 어디까지 커질 수 있게 열어뒀느냐”가 더 중요할 때가 많습니다.

consumer 수가 많고, 각 프로세스의 -Xmx 상한이 크면,

실제 사용량이 그보다 작아 보여도 서버 전체 입장에서는 부담이 됩니다.

같은 서버에 DB도 같이 있다면 더 그렇습니다.

그래서 이번 조치의 핵심은 단순히 Java를 덜 쓰게 한 게 아니라,

DB와 Java가 같은 서버에서 함께 버틸 수 있도록 Java 쪽 메모리 상한을 현실적으로 다시 그은 것이었습니다.

둘째, swap을 GC 문제로 보지 않고 서버 메모리 문제로 본 것입니다.

이 차이가 중요합니다.

문제를 GC 문제로만 보면 JVM 옵션 안에서만 답을 찾게 됩니다.

그런데 이번엔 먼저 서버 전체에서 메모리를 누가 얼마나 쓰고 있는지 봤고,

그래서 해결도 프로세스별 메모리 상한과 전체 메모리 배치를 다시 맞추는 방향으로 갔습니다.

즉, 이번 건의 핵심 조치는

GC 튜닝이 아니라

메모리 배치를 다시 정리한 것이었습니다.

이번 이슈에서 정리되는 결론

이 사례를 한 줄로 줄이면 이렇습니다.

처음엔 GC가 문제인 줄 알았지만, 실제로는 OS가 Java 메모리 일부를 swap으로 밀어낸 상태였고, 그 결과 Java와 GC가 같이 느려져 보인 것이었다.

그래서 중요한 포인트도 분명해집니다.

첫째, OS 메모리가 조금 남아 보인다고 해서 swap이 안 생기는 건 아니다.

운영체제는 서버 전체 상황을 보고 덜 쓰는 메모리를 swap으로 밀 수 있습니다.

둘째, GC가 보인다고 해서 항상 GC가 원인은 아니다.

GC는 종종 원인보다 결과 쪽에 더 가깝습니다.

셋째, DB와 Java가 같은 서버에서 메모리를 함께 쓰는 구조에서는 JVM 하나만 보면 안 된다.

서버 전체를 같이 봐야 합니다.

넷째, 이런 문제의 해결은 JVM 옵션 변경보다 먼저, 서버 전체 메모리 배치를 다시 잡는 것에서 시작해야 한다.

마무리

운영에서는 종종 기술 이름이 문제를 가립니다.

GC 로그가 보이면 GC를 문제라고 생각하고,

swap이 보이면 메모리가 그냥 부족하다고 생각하고,

응답이 느려지면 Java 자체가 무거워졌다고 생각하기 쉽습니다.

그런데 실제로는 그보다 한 단계 더 바깥을 봐야 풀리는 문제가 있습니다.

이번 건이 그랬습니다.

문제는 GC 자체가 아니었습니다.

DB와 Java consumer가 같은 서버에서 메모리를 같이 쓰는 구조 안에서, 일부 Java 메모리가 swap으로 밀렸고, 그 결과 Java와 GC가 같이 느려져 보인 것이었습니다.

그래서 해결도 GC 알고리즘을 바꾸는 방식이 아니라,

서버 전체에서 메모리를 어떻게 나눠 쓸지 다시 정리하는 방식으로 가는 게 맞았습니다.

태그:

카테고리:

업데이트: