주요글: 도커 시작하기
반응형
TPTP를 이클립스에 설치하는 방법과 TPTP를 이용하여 메모리 누수 코드를 찾는 방법에 대해서 살펴본다.

TPTP 설치하기

메모리 누수를 찾는 것은 쉽지 않은데 그 이유는 메모리가 부족한 현상이 어플리케이션을 실행하자 마자 곧 바로 나타나는 것이 아니라 짧게는 1-2시간, 길게는 1주일 후에나 발생하기 때문이다. 예를 들어, 웹 어플리케이션을 구동하면 대략 48시간 정도 후에 시스템에서 OutOfMemoryError 와 같은 치명적 에러가 발생하면서 어플리케이션이 죽는 경우가 있을 것이다. 또한, 메모리가 부족하여 시스템이 죽는 걸 알게 되어도 어떤 코드 때문에 메모리 누수 현상이 발생하는 지 찾는 것 또한 쉽지 않다.

메모리 누수 현상이 발생할 경우 이를 해결하는 가장 좋은 방법은 어플리케이션을 프로파일링 하여 메모리 누수가 될 만한 코드를 찾는 것이다. 프로파일링을 통해서 메모리 누수가 발생하는 코드를 찾을 수 있도록 도와주는 다양한 툴이 존재하는데, 본 글에서는 TPTP가 제공하는 프로파일링 기능을 사용하여 메모리 누수가 예상되는 코드를 찾아내는 방법에 대해서 살펴볼 것이다.

TPTP 설치하기

TPTP는 이클립스 Test & Performance Tool Platform 프로젝트의 약자로서, 이클립스에서 오픈 소스로 진행중인 테스트 및 성능 관련 툴을 위한 플랫폼이다. TPTP는 모니터링, 테스트 자동화, 프로파일링 등 어플리케이션의 문제점을 찾고 해결하는 데 도움이 되는 기능을 제공하고 있다. TPTP는 이클립스 플러그인으로 제공되며, 설치 과정은 아래와 같다.

  1. 이클립스 3.2 설치: http://www.eclipse.org/downloads/
  2. EMF 2.2.1 SDK 버전(EMF, SDO, XSD가 함께 포함된 배포판)을 다운로드 하여 이클립스 폴더에 설치: http://www.eclipse.org/emf/downloads/
  3. UML2 2.0.1 SDK 버전을 다운로드하여 이클립스에 설치: http://download.eclipse.org/tools/uml2/scripts/downloads.php
  4. TPTP 4.2.0.x All Runtime 버전을 다운로드 하여 이클립스에 설치: http://www.eclipse.org/tptp/
EMF, UML2, TPTP 등은 모두 플러그인 형태로 제공되기 때문에, 압축을 풀면 plugins 폴더와 features 폴더가 생성된다. 이 두 폴더를 이클립스 디렉토리에 그대로 복사한 뒤 이클립스를 재실행하기만 하면 모든 설치가 완료된다. 이클립스를 실행한뒤, 'Run' 메뉴를 클릭하면, 아래 그림과 같이 프로파일과 관련된 메뉴가 추가된 것을 확인할 수 있다.

 
위에서 설명한 EMF, SDO, XSD, UML2의 버전은 이클립스 3.2에서 올바르게 동작하는 것으로 이클립스 3.1을 사용할 경우 그에 알맞은 버전을 설치해야 TPTP를 실행할 수 있으니 주의하기 바란다.

메모리 누수를 찾는 과정

자바는 개발자가 별도로 메모리 관리를 하지 않아도 가비지 콜렉터를 통해서 사용되지 않는 객체를 메모리에서 제거해준다. 가비지 콜렉터가 메모리 관리를 수행하는 알고리즘이 단순하진 않지만, 기본적으로 더 이상 참조되지 않는 객체를 가비지로 인식하여 해당 객체를 메모리에서 제거하게 된다. 예를 들어, 아래의 코드를 살펴보자.

    Human h = new Hunam(); // 새로운 Human 객체 생성
    ....
    h = null; // 더 이상 Human 객체를 참조하지 않음

위 코드에서 새로 생성한 Human 객체를 h 가 참조하도록 했는데, 어떤 처리를 수행한 후 h 에 null을 할당하였다. 이렇게 되면 생성된 Human 객체는 더 이상 참조되지 않으며, 가비지 콜렉터의 대상이 된다. 객체가 더 이상 참조되지 않는다고 해서 곧바로 메모리에서 제거되는 것은 아니며, 메모리가 부족하거나 인위적으로 가비지 콜렉터를 실행했을 때 비로서 참조되지 않는 객체가 메모리에서 제거된다.

하지만, 개발자의 실수로 더 이상 사용되지 않는 객체를 어디선가 참조하게 되면, 그 객체는 가비지 콜렉터의 대상이 되지 않으며 따라서 지속해서 메모리에 남아 있게 된다. 이런 객체가 많아질수록 메모리는 불필요한 객체로 공간을 낭비하게 되며, 결국 새로운 객체를 생성해서 저장할 메모리 공간이 부족한 상태가 발생하게 된다.

메모리 누수를 발견하는 과정

메모리 누수가 발생하는 이유는 가비지 콜렉터에 의해 메모리에서 제거되어야 할 객체가 계속해서 누군가에 의해 참조되고 있기 때문일 것이다. 따라서, 어떤 객체가 메모리 누수를 일으키는 지 알아내기 위해서는 특정 시점에 메모리에 저장된 객체들의 상태를 알아내는 것이 중요하다. 즉, 현재 어떤 클래스의 객체가 몇개 만들어졌고, 그 중에 몇개가 가비지 콜렉터에 의해 제거되었고, 또, 객체들 사이의 참조 관계는 어떻다라는 것을 알아야 하는 것이다.

대부분의 프로파일링 툴은 특정 시점의 메모리 상태를 알아낼 수 있는 기능을 제공하고 있으며, TPTP가 제공하는 프로파일링 툴 역시 이 기능을 제공하고 있다. 이 기능을 사용하면 특정 시점의 메모리 스냅샵을 생성할 수 있는데, 툴 마다 다르지만 TPTP의 경우는 다음과 같은 메모리 스냅샵을 제공한다.


위 그림을 보면 현재까지 생성된 객체의 개수와 살아있는(active) 객체, 그리고 제거된(collected) 객체의 수 뿐만 아니라 바이트 단위로 현재 사용중인 메모리 사용량까지 표시되어 있다. TPTP 이외의 프로파일링 툴 역시 이와 비슷한 정보를 제공하며, 아래와 같은 절차에 따라 메모리 누수가 발생하는 객체를 찾을 수 있게 된다.


메모리 누수를 발생시킬거라고 의심되는 코드를 실행하기에 앞서 메모리 스냅샵을 구한다. 그리고 나서, 해당 코드를 실행하고 그 다음에 다시 메모리 스냅샷을 구한다. 만약 GC 되어야 할 객체가 잘못된 코드에 의해 GC 되지 않는다면 스냅샷1에서는 존재하지 않았던 (또는 개수가 매우 적었던) 객체의 수가 스냅샷2에서는 증가되어 있을 것이다. 메모리 스냅샷을 구하기에 앞서 GC를 실행하는 이유는 더 이상 참조되지 않는 객체를 메모리에서 제거함으로써 좀더 정확하게 의심 객체를 찾기 위함이다.

TPTP를 사용한 메모리 누수 찾기

TPTP를 사용할 때에도 앞서 설명한 메모리 스냅샷을 사용해서 메모리 누수를 발생시키는 객체를 찾을 수 있으며, TPTP가 제공하는 추가적인 기능을 사용해서 메모리 누수를 발생시키는 메소드까지도 발견해낼 수 있다. 본 절에서는 TPTP를 사용하여 메모리 누수 코드를 발견하는 과정을 살펴보도록 하겠다.

메모리 누수가 발생하는 예제 어플리케이션

일단 메모리 누수를 일으키는 코드부터 작성해보도록 하자.

    package test.memoryleak.human;
    
    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.util.HashMap;
    import java.util.Map;
    
    public class HumanTest {
        
        private Map<Integer, Human> tempMap;
        
        public  HumanTest() {
            tempMap = new HashMap<Integer, Human>();
        }
        
        public void testHuman(int i) {
            Human human = new Human(i);
            tempMap.put(human.getId(), human);
            
            if (i > 0 && i % 3 != 0) { // 의도적으로 i가 3의 배수일 때 tempMap에서 제거되지 않도록 함
                tempMap.remove(human.getId());
            }
        }
        
        public static void main(String[] args) throws IOException {
            HumanTest test = new HumanTest();
            
            System.out.println("press enter ....");
            BufferedReader br = null;
            br = new BufferedReader(new InputStreamReader(System.in));
            br.readLine();
            
            try {
                for (int i = 1 ; i <= 10000 ; i++) {
                    test.testHuman(i);
                    if (i == 10) {
                        System.out.println(i +" humans processed");
                        System.out.println("press enter ....");
                        br.readLine();
                    }
                    if (i > 0 && i % 5000 == 0) {
                        try {
                            Thread.sleep(2000);
                        } catch(Throwable ex) {}
                        System.out.println(i+" humans processed");
                        br.readLine();
                    }
                }
                System.out.println("completed.");
            } catch(OutOfMemoryError ex) {
                System.out.println("error: "+ex.getMessage());
            }
            System.out.println("press any key....");
            br.readLine();
        }
    }

위 코드에서 testHuman() 메소드는 Human 객체를 생성한 뒤 임시로 tempMap에 저장한다. 그리고 Human 객체가 사용된 뒤에 마지막에 임시로 저장한 tempMap에서 Human 객체를 제거한다. 하지만, 메모리 누수 현상을 설명하기 위해 의도적으로 i의 값이 3의 배수인 경우에는 tempMap에서 Human 객체를 제거하지 않았다. 따라서, tempMap에 저장된 Human 객체는 tempMap이 Human 객체를 참조하고 있으므로, GC의 대상이 되지 않게 되며, 곧 메모리 누수가 발생하게 된다.

프로파일 시작하기

메모리 스냅샷을 구하기 위해서는 자바 어플리케이션을 프로파일이 가능한 상태로 실행해야 한다. 프로파일이 가능하도록 어플리케이션을 수행하는 과정은 다음과 같다.

  1. 'Run' > 'Profile As' > 'Java Application' 을 실행
  2. Monitor 탭을 선택해서 프로파일 할 옵션을 선택

     
    • 'Java Profiling' 옵션을 선택한 뒤, 'Edit Options'을 클릭한 뒤, 아래와 같이 조사할 클래스의 범위 선택


      [Next] 버튼을 클릭한 다음에 아래 그림과 같이 어플리케이션이 구동될 때 자동적으로 프로파일을 시작하도록 설정

    • 'Basic Memory Analysis' 옵션을 선택
    • 'Execution Time Analysis' 옵션 선택한 뒤, 'Edit Options'를 클릭하여 'Show execution flow graphical details'를 선택
조사할 클래스의 범위를 지정해주지 않으면 모든 클래스에 대해서 프로파일링 하게 되는데, 이 경우 프로파일링에 따른 부하 때문에 어플리케이션을 실행하는 데 방해를 줄 수도 있다. 따라서, 분석해야만 하는 클래스에 대해서만 프로파일링 하도록 클래스 범위를 지정해주는 것이 좋다.

설정이 완료되었다면 'Ok' 버튼을 눌러서 프로파일을 시작해보자. 그럼, 아래와 같은 대화창이 뜰 것이다.


이 대화창은 'Profiling and Logging' 퍼스펙티브로 전환할지 묻는 것으로 'Yes' 버튼을 클릭하면 아래 그림과 같이 프로파일링을 위한 퍼스펙티브로 전환된다.


위 화면에서 좌측 영역은 프로파일과 관련된 기능을 제공하며, 우측 영역은 프로파일링 정보를 보여준다. 이제 어플리케이션에서 메모리가 누수되는 지점을 찾을 준비가 완료되었다.

메모리 스냅샵으로부터 메모리 누수되는 객체 추측

메모리 누수가 발생하는 지점을 찾기 위해서는 먼저 어떤 객체가 메모리 누수의 범인인지 찾아내야 한다. 이를 위해서는 앞서 설명했듯이 메모리 스냅샷 정보가 필요하다. 예제 어플리케이션은 testHuman() 메소드를 10,000번 실행하는 데, 처음 10번째 까지 실행한 뒤 엔터키 입력을 기다린다. 콘솔 화면에서 엔터키를 눌러보면 아래 그림과 같이 10번 실행된 뒤 키 입력을 기다릴 것이다.


이 상태에서 메모리 스냅샷을 살펴보자. 메모리 스냅샵을 구하기 전에 GC를 먼저 실행해야 하는데, GC는  아이콘을 클릭하거나 팝업 메뉴를 통해서 실행할 수 있다. GC를 실행한 뒤, 'Basic Memory Analysis'를 더블 클릭해서 메모리 스냅샷을 구해보자. 아래와 비슷한 메모리 스냅샷 결과가 출력될 것이다.


위 그림을 보면 Human 객체가 총 10개 생성되었고 그 중에 4개가 여전히 메모리에 존재하고, 6개는 GC에 의해 메모리에서 제거되었음을 알 수 있다. 4개의 Human 객체가 메모리 존재한다는 사실을 통해서 Human 객체가 메모리 누수를 일으킬 수 있는 가능성을 확인할 수 있다. 이제 콘솔에서 다시 한번 엔터키를 눌러보자. 그럼, 아래 그림과 같이 메모리 부족 현상이 발생함을 알 수 있다.


메모리 스냅샷을 확인하기 위해 GC를 실행하고, 'Memory Statistics' 화면을 'Refresh' 해보자. 그럼, 아래와 같이 메모리 상태에 변화가 생겼을 것이다.


위 그림을 보면 Human 객체가 904개가 생겼는데 이 중에 302개나 메모리에 존재한다는 것을 알 수 있으며, 이를 통해 Human 객체가 누수되고 있다는 사실을 확신할 수 있게 되었다.

객체 참조 그래프로부터 GC 방해 요소 추측

Human 객체가 메모리 누수의 범인이라는 점을 밝혀냈으므로, 이제 어떤 객체가 Human 객체를 참조하고 있는지를 알아내야 한다. 가비지 콜렉터는 기본적으로 어떤 객체에 의해서도 참조되지 않는 객체에 대해서 메모리 반환을 수행하기 때문에, Human 객체가 메모리에 남아 있다는 것은 곧 Human 객체를 누군가가 참조하고 있다는 뜻이된다.

객체 참조 상태를 확인하기 위해서는 먼저 객체 참조 정보를 수집해야 한다. 아래 그림과 같이 팝업 메뉴 또는 해당 아이콘을 클릭함으로써 현재 상태의 객체 참조 그래프를 구할 수 있다.


객체 참조 정보를 구했다면, 이제 아래와 같이 실행하여 객체 참조 정보를 확인하도록 하자.


객체 참조 정보에서 Human 객체의 참조 정보를 확인하면 아래 그림과 같은 그래프를 확인할 수 있다. 아래 그래프를 보면 Human 객체를 HashMap 객체가 참조하고 있고, 이 HashMap 객체를 HumanTest 가 참조하고 있다는 것을 알 수 있다. 즉, HumanTest 객체의 (HashMap 타입의) tempMap 필드가 Human 객체를 저장하고 있다는 것을 확인할 수 있다.



클래스 인터액션 그래프로부터 누수 예상 메소드 추측

HumanTest 클래스의 tempMap 필드가 Human 객체를 참조하기 때문에 Human 객체가 메모리에서 제거되지 않고 메모리 누수를 일이키고 있다는 것을 알게 되었다. 이쯤 되면 대충 어떤 부분에서 잘못된 코드를 작성했는 지 유추해 낼 수 있다. 하지만, 많은 클래스가 존재하고 참조 문제를 일으키는 범인 클래스가 다양한 곳에서 사용되는 경우가 있다. 이런 경우에는 클래스들 간의 인터액션을 살펴봄으로써 어떤 메소드에서 문제가 있는 지를 찾아낼 수 있다. 아래 그림과 같이 'UML2 Class Interaction' 메뉴를 실행해보자.


그럼, 클래스 사이의 메소드 호출 관계 및 실행 순서를 시퀀스 다이어그램을 통해서 확인할 수 있다. 이 그래프를 살펴보면 아래와 같은 부분을 발견할 수 있을 것이다.


위 그림에서 파란색 박스 부분은 올바르게 실행된 testHuman() 메소드의 경우이다. 파란색 부분은 HashMap의 put과 remove가 호출된 것을 확인할 수 있다. 즉, Human 객체를 생성한 뒤 HashMap에 저장했다가 메소드를 종료하기 전에 HashMap에 제거했다는 것을 유추할 수 있다. 반면에 빨간색 박스 부분은 메소드를 리턴하기 전에 remove 메소드가 호출되지 않는 것을 볼 수 있는데, 이는 HashMap에 저장된 Human 객체가 메소드가 testHuman() 메소드가 리턴되기 전에 HashMap에서 제거되지 않는다는 것을 유추할 수 있다. 즉, TestHuman 클래스의 testHuman() 메소드가 메모리 누수를 발생시키는 코드를 포함하고 있다는 것을 알아낸 것이다.

이제 남은 작업은 메모리 누수를 발생시키는 코드를 찾아서 문제를 제거하는 것만 남았다!!

결론

본 글에서는 TPTP가 제공하는 프로파일링 기능을 사용하여 누수되고 있는 객체를 찾아내고, 해당 객체를 누수 시키는 코드를 찾아나가는 과정을 살펴보았다. 본 글에서 소개한 방식을 통해서 여러분은 메모리 누수를 일으키는 문제의 코드를 비교적 빠르게 찾아낼 수 있게 되었을 것이다. 다음 글에서는 TPTP를 사용하여 어플리케이션의 병목(bottleneck) 부분을 찾는 방법에 대해서 살펴볼 것이며, 이를 통해 어플리케이션의 실행 속도를 향상시키는 방법을 배우게 될 것이다.

관련링크:

+ Recent posts