SSARTEL-10th / JPTS_bookstudy

"개발자가 반드시 알아야 할 자바 성능 튜닝 이야기" 완전 정복
7 stars 0 forks source link

"GC" 끝난 줄 알았지? #28

Open Yg-Hong opened 11 months ago

Yg-Hong commented 11 months ago

👍 문제

24장은 우리가 지나온 모든 발자취를 다시 걸어오는 경험을 할 수 있는 챕터였다. 이번 주제도 우리가 배웠던 지식을 다시 한번 점검하는 취지를 가지기 위해 가져왔다. 자 다시 한번 거인의 어깨 위에 올라서보자. 하나의 메모리 누수를 잡기까지 위 URL은 네이버 기술팀이 메모리 릭을 잡는 모든 트러블슈팅 과정을 서술하고 있다.

이 내용을 1~24장의 내용과 함께 정리해보자~ 너무 어려운 건 건너뛰고 책을 보고 배울 수 있었던 사실 내에서 재정리하는 식으로 하자. 새로운 기술 스택에 대한 구체적인 설명은 생략하자.

✈️ 선정 배경

장장 6주의 과정의 마무리이다. 이 모든 걸 정리할 필요가 있다 생각했다.

📺 관련 챕터 및 레퍼런스

1~24장 총정리!

🐳 비고

일타강사 조다민과 함께하는 JPTS 하루 완성

daminzzi commented 11 months ago

시작하며

세상에 벌써 책 한 권이 끝났다! 이번 이슈에서는 참고자료인 하나의 메모리 누수를 잡기까지 를 함께 살펴보며 주요 개념들을 다시 한 번 살펴볼 수 있도록 노력해보자!

에러 발생의 시작

  1. Out of Memory 오류 발생 https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/6

    자바는 Java8부터 PermGen 영역을 없애고 이를 Meta Space로 변경해 메모리 관리에 대한 개발자의 부담을 줄여주고자 노력했다. 그럼에도 불구하고 우리는 Out of Memory 에러를 만날 수 있다.

    이러한 OOME의 경우 대부분 개발이 대부분 완료된 후 사용자 테스트 혹은 인수 테스트 단계에서 많이 발생한다. 즉 개발 단계에서 수행하는 단위 테스트의 경우 목적 기능에 대한 검증 위주로 진행 되기 때문에 식별이 어렵고 가동 초기 단계 혹은 이와 유사한 테스트 환경에서 주로 발생하게 되는 것이다. 때문에 OOME가 발생하는 시점에서는 빠르게 대응해야 하는데 경험에 의한 JVM Option을 통한 처리 방법Dump 파일의 분석을 통해 대응을 하게 된다.

    JVM Option을 통한 처리 방법에서는 실제로 서비스가 안정적으로 돌아가기 위해서 필요한 메모리 사이즈에 비해서 설정되어 있는 메모리 사이즈가 작은 경우에 JVM의 옵션에서 디폴트 사이즈 옵션이 아니라 더 큰 용량의 메모리를 할당할 수 있다. 예시로 command-line option들을 사용해서 힙 메모리를 늘릴 수 있다.

    -Xms : To set an initial java heap size
    -Xmx: To set maximum java heap size
    -Xss: To set the Java thread stack size
    -Xmn : For setting the size of young generation, rest of the space goes for old generation

    또 다른 해결 방법으로는 Dump를 분석해 해결하는 방법이 있다. Thread dump, heap dump 분석을 통해서 문제가 발생하는 위치(쓰레드나 객체)를 추적하고 원인이 되는 부분에 대해서 수정할 수 있도록 할 수 있다. 이를 위해서는 jstack과 jmap 명령어를 이용해서 각각 thread dump와 heap dump를 얻을 수 있다.

    JVM 옵션을 통해서 OutOfMemory를 해결하는 방식은 서비스 초기에는 도움이 될 수 있으나, 결정적인 해결책이 되기는 어려울 수 있다. 구현 로직이 잘못되어 있다면 근본적인 해결은 하지 못하고 눈가리고 아웅하는 꼴이 되기 쉽상이다. 계속해서 발생하는 OOME에 대해서는 반드시 덤프를 분석해서 그 원인을 찾는 것이 중요하다.

  2. DBCP(DataBase Connection Pool) https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/12

    참고자료에서는 1번의 과정을 DBCP에서 관리하는 커넥션의 수가 부족한 것에 문제가 있다는 것을 확인하고 DBCP 라이브러리와 Apache Commons Pool 라이브러리를 업데이트했다고 한다. 여기서 이야기하는 DBCP는 Database Connection Pool, 즉 DB에 연결해 SQL문을 실행할 수 있는 Statement 객체인 Connection을 관리하는 Pool을 의미한다.

    DBCP를 이용해 우리는 미리 생성된 Connection을 쓸 수 있어 생성에 필요한 시간을 소비하지 않을 수 있다. 또한 DB Connection 수를 제한을 통한 과도한 접속으로 인한 서버 자원 고갈 방지, DB 서버 환경 변화에 대한 쉬운 유지보수, Connection 객체 생성에 대한 비용 절감의 효과를 누릴 수 있다.

    하지만 이렇게 참고자료에서도 확인할 수 있듯이, 커넥션 풀에서의 커넥션은 한정된 자원이기 때문에 동시 접속자가 많은 경우 클라이언트는 커넥션을 얻지 못하고 대기 상태로 기다려야 한다는 단점이 존재하기도 한다.

    이러한 대기 상태나 데드락을 방지하기 위해서는 적절한 커넥션 풀 사이즈를 유지하는 것이 중요하다. 특히 멀티 쓰레드 환경에서 하나의 쓰레드가 여러 개의 커넥션 풀 사이즈를 사용한다고 할 때, 최대 동시 사용 개수만큼의 커넥션이 필요할 것이다.(이와 관련해서는 이슈 12에서 더 자세히)

모니터링 개선 https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/20

그럼 이제 문제가 해결됐는가? (아니다) 그렇다면 우리가 어떤 부분을 놓치고 있었는지 제대로 확인해보기 위해서는 모니터링 개선이 필요하다. 모니터링 개선에 대해서는 지난 이슈들에서 다뤘던 로그와 모니터링 도구에 대해서 생각해보고자 한다.

  1. 로그

    먼저 문제 발생 원인을 쉽게 파악하기 위해서는 로그를 잘 남기는 것이 중요하다. 이전 이슈였던 https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/8%EC%97%90%EC%84%9C 에서 우리는 로그 레벨에 대해서 살펴본 적이 있다. log4j 기준으로 ALL, DEBUG, INFO, WARN, ERROR, FATAL, OFF, TRACE의 8단계의 로그 레벨이 존재하는데, 로그를 남기면서 내가 어느 정도 선 까지 로그를 남겨야 하는지, 그리고 지금 내가 중점적으로 봐야 하는 부분은 어디인지 생각하면서 로그를 남겨야 한다. 깔끔한 로그를 위해서 Log4j, slf4j, Lock Back과 같은 로거를 사용하고 예외 처리 시 필요한 내용만을 처리할 수 있도록 노력하는 것이 중요하다.(참고: 책 ch10. 로그는 반드시 필요한 내용만 찍자)

  2. 모니터링 도구

    또한 모니터링 도구를 이용해서 일정 상황 발생 시 개발자에게 알림을 보내 빠른 트래킹 및 복구를 도울 수 있는데, https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/20 에서 살펴본 프로메테우스 를 이용해서도 이러한 알람을 사용할 수 있다. 자세한 과정은 이 글 을 참고하도록 하고, 결론만 이야기해보자면 프로메테우스의 Alertmanager와 서버에서의 알람 규칙 설정을 통해서 알람을 보낼 수 있다. 하지만 그 과정에서 운영되고 있는 환경에 맞춰 이를 적용하기 위해서 관련된 문법과 프로세스에 대해서 공부하는 것이 필요하더라….

문제 원인 파악

그럼 이제 앞의 단계를 통해서 예외 처리나 서비스에서 제외된 장비나 쓰레드 등의 로그를 분석할 수 있다.

  1. 스레드 덤프 분석

    웹 서버에서는 많은 수의 동시 사용자를 처리하기 위해 수십 ~ 수백 개정도의 스레드를 사용한다. 두 개 이상의 스레드가 같은 자원을 이용할 때는 필연적으로 스레드 간에 경합이 발생하고, 경우에 따라서는 데드 락이 발생할 수도 있다.

    스레드 경합때문에 다양한 문제가 발생할 수 있으며, 이런 문제를 분석하기 위해서는 스레드 덤프를 이용해야 한다. 이를 통해 각 스레드의 상태를 정확히 알 수 있다.

    따라서 jps -v 를 통해서 Java 애플리케이션 프로세스의 PID를 확인하고, jstack [PID] 를 통해서 스레드 덤프를 획득한다. 분석 과정에서 덤프를 그대로 읽기 어려운 경우 분석툴을 이용하기도 한다.

    • 경합과 데드락

      경합은 어떤 스레드가 다른 스레드가 획득하고 있는 락(lock)이 해제되기를 기다리는 상태를 말한다. 웹 애플리케이션에서 여러 스레드가 공유 자원에 접근하는 일은 매우 빈번하다. 대표적으로 로그를 기록하는 것도 로그를 기록하려는 스레드가 락을 획득하고 공유 자원에 접근하는 것이다.

      데드락은 스레드 경합의 특별한 경우인데, 두 개 이상의 스레드에서 작업을 완료하기 위해서 상대의 작업이 끝나야 하는 상황을 말한다.

  2. 메모리 릭 발생 확인 https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/16

    앞에서도 이야기 했듯이 JVM의 Option을 조정하는 것으로는 완벽한 해결이 되지 않을 수도 있다. 이러한 메모리 릭 상황이 있기 때문이다. 아무리 큰 메모리를 가지고 있어도 메모리 릭이 발생하고 있다면 언젠가는 OOME가 발생하는 시한폭탄을 안고 있는 것일지도 모른다.

    하지만 메모리 릭이 어디서 발생하는지 쉽게 알 수 있다면 처음에 짤 때부터 그렇게 안짰겠지..?

    그래서 우리는 FULL GC가 일어난 뒤 메모리 공간의 변화를 확인하기 위해서 -Xloggc 옵션을 사용해서 메모리 변화를 확인할 수도 있다.

    java -Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps YourMainClass

    프로메테우스 경고 규칙을 설정해 이 로그가 발생했을 때 alert manager에 해당 메세지를 전달하고 사용자에게 알람을 보낼 수 있다.

    이러한 방식 외에도 jstat -gc 옵션, JVM -verbosegc 시작 옵션을 통해서 GC를 모니터링 해볼 수 있다. (참고: https://d2.naver.com/helloworld/6043, 책 ch18.GC가 어떻게 수행되고 있는지 보고 싶다 p.354~358)

    비 정상적인 메모리 변화가 관찰된다면 이후 힙 덤프 분석을 통해 어떤 객체가 비정상적으로 행동하는지 확인할 수 있다.

  3. 힙 덤프 분석

    앞에서 GC 로그를 통해서 메모리 릭이 발생하는 장치를 찾았다. 이제는 Object의 변동을 추적하고 힙 영역의 내용을 확인해야 한다.

    • jmap: 힙 덤프나 히스토그램을 출력하는 프로그램
    • jhat: 힙 덤프를 이용해 각 객체를 볼 수 있는 프로그램
    • VisualVM: 실행되고 있는 JVM의 힙 내용을 볼 수 있는 프로그램

    jmap 실행 결과(히스토그램이나 힙 덤프를 분석)

    $ jmap -histro:live 8825 | more
    num    # instances    #bytes    class name  
    ----------------------------------------------
    1:    3062256    677810312    [C  
    2:    3176949    76246776        java.lang.String  
    3:    29959    32072704        [I  
    4:    380080    27365760        xxx.xxx.common.model.xxxx  
    5:    100476    12792648        <constMethodKlass>  
    6:    113714    11254840        [Ljava.lang.Object;  
    7:    453459    11883016        java.util.HashMap$Entry  
    8:    100476    8043896        <methodKlass>  
    9:    16052        6917504        [B  
    10:    412877    6606032        java.lang.Integer  
    11:    141665    6546672        <symbolKlazss>  
    12:    10410    5282344        <const PoolKlass>  
    13:    12577    4451720        [Ljava.util.HashMap$Entry;  
    14:    10410    4351624        <instanceKlassKlass>  
    15:    9166    3134352        <constantPoolCacheKlass>  
    16:    105039    2520936        java.util.ArrayList  
    17:    58627    1876064        xx.xxx.xxx.xxxListEntry  
    18:    58027    1856864        java.util.LinkedHashMap$Entry  
    19:    21897    1751760        java.lang.reflect.Method

→ 하지만 이러한 방식은 서비스 중인 서버의 히스토그램을 확보해야 하는데, 서비스 중인 서버에서 받지 못했고, 숫자로는 접근이 어려울 수 있어 분석이 어려울 수 있다.

jhat을 이용한다면 다음과 같이 class meta 정보와 생성된 객체 정보를 확인할 수 있다.

Untitled c8/469865d0-daa0-467c-abf7-be29e61967dc/Untitled.png)

Untitled 1 e9e8c76-9ba9-4b71-a2ea-e2f2a046d32f/Untitled.png)

Untitled 2 98-3cbe21a09c85/Untitled.png)

하지만 jhat의 경우 힙 덤프 파일이 크다면 분석이 어려울 수 있다는 단점이 있다.(아예 응답이 없을 수도 있음) (참고: https://soft.plusblog.co.kr/51)

이외에도 VisualVM을 통한 heap dump 분석에서는 프로세스가 진행 중에 Monitoring 에서 Heap Dump 버튼을 클릭하면 실제 Heap Dump 가 생기고 아래 그림과 같이 CPU, Memory, Classes, Thread를 확인할 수 있으며 Visual GC 플러그인을 통해 GC에 대한 현황을 볼 수 있다.

출처: https://liltdevs.tistory.com/167

출처: https://liltdevs.tistory.com/167

각 클래스 별 정보도 확인할 수 있으며 비정상적으로 메모리를 많이 차지하고 있는 클래스에 대해서 먼저 접근해 원인을 확인해볼 수 있다.

문제 해결

위의 과정을 통해서 문제가 발생한 Class 를 찾을 수 있었다. 지금부터는 내부 구현에서 어떤 부분에 문제가 있는지를 찾아야 한다.

  1. 메모리 릭을 발생 시키는 패턴 https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/16

    • GC 메모리 영역 밖의 잘못 설계된 객체 참조
      • Static 변수에 의한 객체 참조
      • 현재 자바 스레드 스택 내의 모든 지역 변수 및 매개 변수에 의한 객체 참조
      • JNI 프로그램에 의해 동적으로 만들어지고 제거되는 JNI global 객체 참조
    • Autoboxing
      • Integer, Long 같은 래퍼 클래스(Wrapper)를 이용하여, 무의미한 객체를 생성하는 경우
    • Using Cache
      • 맵에 캐쉬 데이터를 선언하고 해제하지 않는 경우
    • Closing Connections
      • 스트림 객체를 사용하고 닫지 않는 경우
    • Using CustiomKey
      • 맵의 키를 사용자 객체로 정의하면서 equals(), hashcode()를 재정의 하지 않아서 같은 키로 착각하여 데이터가 계속 쌓이게 되는 경우
    • Mutable Custiom Key
      • 맵의 키를 사용자 객체로 정의하면서 equals(), hashcode()를 재정의 하였지만, 키값이 불변(Immutable) 데이터가 아니라서 데이터 비교시 계속 변하게 되는 경우
    • Internal Data Structure
      • 자료구조를 생성하여 사용하면서, 구현 오류로 인해 메모리를 해제하지 않는 경우

    위의 패턴들은 주로 발생하는 메모리릭을 발생시키는 패턴이다. 알아두면 이러한 부분이 있는지 먼저 의심해볼 수 있다.

  2. Synchronized (참고: ch08. synchronized는 제대로 알고 써야 한다)

    참고자료에서는 멀티 쓰레드 환경에서 Cache 관리에 대해서 Synchronized의 중요성을 보여준다.

    웹 기반의 시스템에서 스레드 관련 부분 중 가장 많이 사용하는 것은 synchronized일 것이다. synchronized를 통해서 한번에 하나의 스레드에서만 해당 기능(함수나 블록)에 접근할 수 있도록 만들어준다. synchronized가 제대로 적용되지 않으면 다른 객체를 접근하는 등 참조 및 메모리 해제에 문제가 생길 수 있다. 하지만 Synchronized 키워드를 너무 남발하면 오히려 프로그램 성능저하를 일으킬 수 있으므로 꼭 필요한 부분에서만 Synchronized를 사용해야 한다.

    • 하나의 객체를 여러 스레드에서 동시에 사용할 경우
    • static으로 선언한 객체를 여러 스레드에서 동시에 사용할 경우

    (동기화 적용에 대한 예시: https://coding-start.tistory.com/68)

이러한 문제 해결의 과정은 경험에 의해 학습하는 부분이 크다. 따라서 많은 사례를 접하고 적용해볼 수 있는 능력이 필요하다.

마무리

이렇게 서비스에서 에러발생의 시작에서부터 해결까지 일련의 과정을 살펴보았다. 물론 성능테스트나 부하 테스트를 통해서 이러한 에러 발생을 미연에 방지할 수 있다면 좋겠지만(https://github.com/SSARTEL-10th/JPTS_bookstudy/issues/21) 언제나 클라이언트는 개발자의 생각을 벗어날 수 있다. 그리고 그런 상황에서 성능 개선 및 트러블 슈팅에 대해서 지금까지의 학습 내용이 도움이 될 수 있으면 좋겠다!