티스토리 뷰

728x90
반응형

인텔리제이에서 내부 클래스를 선언할 때, static으로 선언하지 않으면 경고 메세지가 표시된다.

그냥 그런가 보다 하고 있었는데, 어느날 궁금해서 찾아보다가 간과하기에는 큰 문제점이 있다.

내부 클래스는 외부 클래스를 참조

비정적(non-static) 내부 클래스는 외부 클래스와 강하게 연결되어 있기 때문에 외부 클래스의 맴버 변수나 메서드에 접근할 수 있다. 특히 비정적(non-static) 맴버 클래스의 인스턴스는 외부 클래스의 인스턴스 없이는 독립적으로 존재할 수 없기 때문에 암묵적으로 연결된다.

public class OuterClass {

    private int outerNum = 10;

    public void func1() {
        InnerClass innerClass = new InnerClass();
        innerClass.func2();
    }

    class InnerClass {

        private int num = 20;

        public void func2() {
            System.out.println("outerNum = " + outerNum);
            System.out.println("num = " + num);
        }
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        outerClass.func1();
    }
}

main 메서드를 실행한 결과

 

위 예시처럼 OuterClassouterNum 멤버 변수가 private로 선언되어 있지만, 내부 클래스에서는 접근할 수 있다. 이는 내부 클래스가 외부 클래스의 인스턴스를 참조하고 있기 때문에 가능한 일이다. 컴파일 시점에, 내부 클래스는 외부 클래스의 인스턴스를 참조하는 필드와 그 필드를 통해 접근할 수 있는 코드를 추가한다.

OuterClass$InnerClass.class 디컴파일

 

InnerClass.java가 컴파일된 OuterClass$InnerClass.class 파일을 인텔리제이에서 디컴파일 시켜준 코드를 보자.

먼저 OuterClass$InnerClass(OuterClass this$0)라는 생성자를 추가시켜 줬다. 여기서 this$0는 외부 클래스를 참조하고 있는 숨겨진 필드로, 내부 클래스가 외부 클래스를 참조하기 위해 컴파일러가 추가한 코드다. 이를 사용하고 싶어도 컴파일이 되기 전에는 사용할 수 없다.

 

this$0를 사용하면 컴파일 에러가 발생

 

그럼 만약에 필드 명이 같을 경우에는 어떻게 접근할 수 있을까? 이 때는 정규화된 this 문법을 사용하면 된다.

 

정규화된 this클래스명.this 형태로 외부 클래스의 이름을 명시하는 용법

 

public class OuterClass {

    private int num = 100;

    public void func1() {
        InnerClass innerClass = new InnerClass();
        innerClass.func2();
    }

    class InnerClass {

        private int num = 20;

        public void func2() {
            System.out.println("Inner class num = " + num);
            System.out.println("Outer class num = " + OuterClass.this.num);
        }
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        outerClass.func1();
    }
}

정규화된 this로 외부 클래스 필드 접근 결과

 

OuterClass.this.num으로 외부 클래스의 필드를 내부 클래스에서 접근할 수 있는 방법을 제공해 준다. 내부 클래스가 외부 클래스를 참조할 수 있게 해주는 이 숨겨진 필드 때문에 역설적이게도 내부 클래스를 static으로 선언하는 것이 유리하다. 왜 그럴까?

비정적(non-static) 내부 클래스의 메모리 누수

먼저 다음 예시를 보자.

import java.util.ArrayList;
import java.util.List;

public class OuterClass {

    private int[] arr;

    OuterClass(int size) {
        arr = new int[size];
    }

    InnerClass getInnerObject() {
        return new InnerClass();
    }

    class InnerClass {
    }

    public static void main (String[]args){
        List<InnerClass> innerList = new ArrayList<>();
        int iter = 100;

        for (int i = 0; i < iter; i++) {
            System.out.println("i = " + i);
            innerList.add(new OuterClass(100_000_000).getInnerObject());
        }
    }
}

위 코드의 main 메서드에서 예상되는 실행 흐름은 다음과 같다.

  1. new OuterClass(100_000_000)OuterClass 인스턴스 생성하기 위해 생성자 호출
  2. size = 100_000_000인 int 타입 배열 arr 필드를 생성 (4 byte * 100_000_000 = 약 400MB)
  3. 생성된 임시 인스턴스의 getInnerObject() 메서드 호출
  4. InnerClass 인스턴스를 생성 후 반환
  5. innerList에 생성된 InnerClass 인스턴스를 추가
  6. 위 과정을 100회 반복

 

11번 째 반복에서 힙 메모리 초과 에러 발생

 

결과를 보면 11번의 순회만으로 힙 메모리 영역에서 OutOfMemoryError 예외가 발생한다. 단순히 400MB 크기의 배열을 담은 OuterClass를 계속 생성하니까 그렇다고 생각할 수 있다. 하지만 위 코드에서 InnerClassstatic을 붙여 정적 내부 클래스로 만들어 주면 아무 무리 없이 동작한다.

 

InnerClass를 static으로 선언 시 에러 없이 모든 반복 수행

 

자바를 제대로 공부하고 있는 사람이라면 위 예시에서 ‘GC(가비지 컬렉션)가 제대로 동작하지 않았나?’라는 의심을 먼저 해봐야 한다. 실행 흐름의 3번에서 임시로 생성된 OuterClass의 인스턴스는 getInnerObject() 메서드를 호출한 뒤 역할이 끝났다. 따로 변수에 저장된 것도 아니기 때문에 사실 자바의 GC에 의해 정리돼야 한다. 문제는 여기서 발생한다.

InnerClassOuterClass의 참조 값을 히든 필드로 가지고 있다는 걸 우리는 이전에 직접 확인했다. 따라서 GC 입장에서는 OuterClass의 인스턴스가 innerList에 담긴 InnerClass의 인스턴스의 필드 값으로 참조되고 있기 때문에 정리하지 못하는 것이다. 결과적으로 힙 메모리 영역에 불필요한 OuterClass의 인스턴스 메모리들이 쌓이게 되고 OutOfMemoryError가 발생한 것이다.

내부 클래스는 웬만하면 static으로 선언하자

내부 클래스를 static으로 선언하면 위와 같은 불상사를 막을 수 있다. static 클래스라는 것은 외부 클래스의 인스턴스가 존재하지 않더라도 컴파일 시 단독으로 메모리에 올라갈 수 있다는 의미다. 따라서 외부 클래스의 인스턴스를 참조하는 히든 필드도 필요 없다.

 

static으로 선언하면 정규화된 this 사용 불가

 

확인해 보면 위처럼 정규화된 this를 사용할 수 없고, 사용하고 싶다면 static을 빼라고 조언한다. 즉, 위 예시에서 임시로 생성된 OuterClass의 인스턴스를 어떤 인스턴스도 참조하고 있지 않아 GC의 정리 대상이 된다.

힙 메모리 관련 장애는 프로그램 실행에 있어 치명적이므로 웬만하면 내부 클래스는 static으로 선언하는 것이 바람직하다. 사실 내부 클래스를 많이 사용하는 경우도 없겠지만 말이다.

그리고 내부 클래스를 사용한다는 것은 외부 클래스와 의미적으로 깊은 연관이 있을 때 사용해야 한다. 내부 클래스를 static으로 선언하다고 해서 만능이 아니다. static 영역은 GC의 관리 영역 밖에 존재하므로, 무분별하게 사용하면 프로그램 종료시까지 남아있어 전체 시스템의 퍼포먼스에 악영향을 줄 수 있다.

또한, static으로 선언된 내부 클래스는 실제로 외부 클래스 도움 없이 외부에서 인스턴스를 생성할 수 있다.

// OuterClass.java
public class OuterClass {

    static class InnerClass {
        private int num = 100;

        public int getNum() {
            return num;
        }
    }
}

// Main.java
public class Main {

    public static void main(String[] args) {
        OuterClass outer = new OuterClass();
        OuterClass.InnerClass inner = new OuterClass.InnerClass();
        System.out.println(inner.getNum());
    }
}

이와 같은 코드도 사실 설계가 잘못된 것이다. 이럴거면 내부 클래스를 굳이 외부 클래스의 정적 멤버 클래스로 작성하는게 아니라 단독으로 설계하는 것이 맞다. 만약 이럴 의도가 아니라면 내부 클래스를 외부에서 접근하지 못하도록 private 접근 제어자를 붙여주는 것도 다른 방법이 된다.

 

private로 선언됐기 때문에 외부에서 직접 접근 불가

 

좋은 코드는 최대한 명확하게 의미가 보여야 한다. 애매하거나 모호한 코드는 후에 치명적인 결함을 만들 수 있다. 제한을 많이 둘 수록 명확해지는데 반해 유연함은 떨어질 수 있다. 언제나 이 중간지점을 찾는게 참 어려운 것 같다.

 


References

728x90
반응형
댓글