Image by vectorjuice
소프트웨어에 문제가 발생했을 때, 가장 어렵고 오랜 시간이 걸리는 작업은 정확한 원인을 찾는 일이라 할 수 있습니다. 원인만 찾는다면 고치는 일은 크게 어렵지 않은 경우가 많습니다. 이 때 크게 두 가지 접근 방법을 생각할 수 있습니다. 하나는 문제의 단서가 될 수 있는 로그를 바탕으로 코드를 살피며 원인을 찾는 귀납적 방법이 있습니다. 의미있는 로그를 수집할 수 있는 경우에 효과적입니다. 또 다른 접근법으로는 문제의 이유가 될 만한 이론적 배경을 찾고 이를 뒷받침해 줄 수 있는 구체적인 로그나 코드를 찾는 연역적 적근법이 있습니다. 연역적 접근법은 적절한 로그 수집이 어려운 경우 또는 경험에 근거해 확실한 예상 원인이 존재하는 경우 효과적으로 활용할 수 있습니다. 검색을 통해 찾은 예상되는 원인이 코드와 실제로 연관되는지 확인하는 과정도 이에 해당됩니다. 그러나 현실에서는 두 방법이 혼용되곤 합니다. 처음에는 로그를 기반으로 문제에 접근하다가 중간에 이론적 배경을 깨닫고 다시 코드를 찾기도 합니다.
이 글은 Java, Spring 기반 서비스에서 일어난 Heap 메모리 누수 문제를 연역적으로 분석하고 해결한 과정을 객관화한 기록입니다. 연역적으로 문제를 해결한 하나의 사례를 통해 각자의 다른 문제를 해결하는데 도움이 되면 좋겠습니다.
서비스 운용 중 Heap 메모리 사용량이 조금씩 증가하는 문제가 있어서 1주일 가량 Heap 메모리 증가 패턴을 JConsole로 모니터링해 보았습니다. 전체 Heap 메모리가 아래와 같은 패턴으로 점진적으로 증가하는 모습을 확인할 수 있었습니다.
문제를 한정하기 위해서 조금 더 세부적인 로그 수집이 필요했습니다. Heap 메모리 덤프를 떠서 어떤 객체들이 얼마나 생성되었고, 어떻게 증가하고 있는지 확인해 볼 필요가 있었습니다. 그래서 연관된 문제 코드도 찾아야했었죠. 그러나 그때 당시 Heap 덤프를 떠서 분석하는 일이 너무 크고, 조금은 막막하게 느껴졌습니다. 그래서 거대한 분석 작업을 시작하기 전에 조금 작게 시도해 볼 수 있는 의미있는 일이 없을지 고민했습니다. 문득 문제에서 핵심적인 역할을 담당하는 가비지 컬렉터의 동작 원리에 대해 먼저 이해해야 겠다는 생각이 들었습니다.
여러 자료를 찾아보던 중 다음 글(Memory Management in Java)에서 많은 도움을 받은 것 같습니다. 글에서 저의 문제 해결에 도움이 되었던 가비지 컬렉터의 원리를 단순화해서 정리해 보겠습니다.
우리가 프로그램에서 new 키워드를 통해 힙 메모리에 생성한 객체는 가비지 컬렉터(Garbage Collector)에 의해 Young Generation 영역과 Old Generation 영역으로 나뉘어 관리됩니다.
위에서 이해한 가비지 컬렉터의 힙 메모리 관리 원리에 의하면 힙 메모리는 Young Generation 영역과 Old Generation 영역이 가득 차기 전까지는 증가하는 것이 자연스러운 것임을 이해할 수 있습니다. 이 이론적 배경을 근거로 다시 저희 서비스의 힙 메모리 모니터링 결과를 분석해 보겠습니다.
처음에 문제 상황으로 인식했던 힙 메모리 증가 패턴은 사실 Young Generation 영역과 Old Generation 영역을 하나로 바라본 Heap 메모리 전체에 대한 모니터링 결과였습니다. 결과를 세부적으로 분석해 보기 이제 Young Generation 영역과 Old Generation 영역을 분리해서 모니터링해 보겠습니다.
새로운 객체가 생성되면 위치하는 Young Generation 영역 (정확하게는 Young Generation 내부의 Eden 영역)은 Minor GC에 의해 일정 수준의 메모리 사용량이 유지되고 있는 것을 확인할 수 있습니다.
반면에 Old Generation 영역은 메모리 사용량이 선형적으로 증가하고 있는 것을 확인할 수 있습니다. 그리고 Gabage Collector의 GC 실행 시간을 확인해 보면 Old Generation 영역은 GC가 한 번도 수행되지 않았습니다. 반면에 Young Generation 영역은 활발히 GC가 일어났습니다.
따라서 Old Generation 영역은 아직 가득 차지 않았기 때문에 사용량이 선형적으로 증가하고 있는 것이지 메모리 누수가 있는 것이 아닐 수 있다는 사실을 추론할 수 있습니다.
그럼 마지막으로 Old Generation 영역의 증가가 메모리 누수에 의한 것인지 아닌지 판단하기 위해 강제로 Major GC를 수행해 보도록 하겠습니다. 만약 GC 이후에도 메모리가 계속 살아있으면 메모리 누수가 있을 수 있다고 의심해 볼 수 있겠습니다. 반대로 GC에 의해 Old Generation 영역이 줄어든다면 아직 GC가 한 번도 수행되지 않아서 점점 증가 중인 정상 상황으로 판단할 수 있겠습니다. 결과는 강제로 GC를 수행하면 힙 메모리 사용량이 떨어지는 것을 확인할 수 있었습니다. (결과는 캡처하지 못했습니다)
지금까지 가비지 컬렉터의 동작 원리를 이해하고 이를 근거로 프로그램의 힙 메모리 증가 현상을 분석한 과정을 정리해 보았습니다. 만약 가비지 컬렉터가 동작하는 이론적 배경을 찾는 대신 힙 메모리 덤프를 뜨고, 코드를 뒤지며 문제를 해결하려 했다면 훨씬 어렵고 힘들게 문제에 접근했을 것 같습니다. 아마도 이론적 배경을 이해하지 못했다면 영영 문제의 원인을 찾지 못했을 수도 있었단 생각도 듭니다. 이후에 회사 책장을 살펴보다가 『자바 성능 튜닝 이야기』 (이상민 저자)라는 책을 우연히 접하게 되었는데 “story18 GC가 어떻게 수행되고 있는 보고 싶다” 에서 제가 분석했던 내용과 유사한 내용이 나와서 반가웠던 기억도 납니다. 감사합니다.
잘 보았습니다!