JAVA

중첩 클래스, 내부 클래스 2

경딩 2024. 12. 24. 01:35

지역 클래스 - 시작

 

지역 클래스 (Local class)는 내부 클래스의 특별한 종류의 하나이다. 따라서 내부 클래스의 특징을 그대로 가진다. 예를 들어서 지역 클래스도 내부 클래스이므로 바깥 클래스의 인스턴스 멤버에 접근할 수 있다.

 

지역 클래스 예

class Outer {
    public void process() {
        //지역 변수
        int localVar = 0;
        //지역 클래스
        class Local {...}
        Local local = new Local();
    }
 }

 

지역 클래스의 특징

  • 지역 클래스는 지역 변수처럼 코드 블록 안에 클래스를 선언한다.
  • 지역 클래스는 지역 변수에 접근할 수 있다.
package nested.local;

public class LocalOuterV1 {

    private int outInstanceVar = 3;

    public void  process(int paramVar) {
        int localVar = 1;

        class LocalPrinter {
            int value = 0;
            public void printData() {
                System.out.println("value = " + value);
                System.out.println("localVar = " + localVar);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outInstanceVar = " + outInstanceVar);
            }
        }

        LocalPrinter printer = new LocalPrinter();
        printer.printData();
    }

    public static void main(String[] args) {
        LocalOuterV1 localOuter = new LocalOuterV1();
        localOuter.process(2);
    }
}

 

 

지역 클래스의 접근 범위

  • 자신의 인스턴스 변수인 value에 접근할 수 있다.
  • 자신이 속한 코드 블럭인 지역변수 localVar에 접근할 수 있다.
  • 자신이 속한 코드 블럭의 매개변수인 paramVar에 접근할 수 있다. 참고로 매개변수도 지역 변수의 한 종류이다.
  • 바깥 클래스의 인스턴스 멤버인 outInstanceVar에 접근할 수 있다. (지역 클래스도 내부 클래스의 한 종류이다.)

 

지역 클래스는 지역 변수처럼 접근 제어자를 사용할 수 없다. 

 

지역 클래스 예제2

당연한 이야기지만 내부 클래스를 포함한 중첩 클래스들도 일반 클래스처럼 인터페이스를 구현하거나, 부모 클래스를 상속할 수 있다. 지역 클래스를 통해 사용 예를 알아보자.

package nested.local;

public interface Printer {
    void print();
}

 

package nested.local;

public class LocalOuterV2 {
    private int outInstanceVar = 3;

    public void process(int paramVar) {
        int localVar = 1;

        class LocalPrinter implements Printer {
            int value = 0;

            @Override
            public void print() {
                System.out.println("value = " + value);
                System.out.println("localVar = " + localVar);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outInstanceVar = " + outInstanceVar);
            }
        }
        LocalPrinter printer = new LocalPrinter();
        printer.print();
    }

    public static void main(String[] args) {
        LocalOuterV2 localOuter = new LocalOuterV2();
        localOuter.process(2);
    }
}

단순히 ` Printer ` 인터페이스만 추가하고 구현했기 때문에 이해하는데 어려움은 없을 것이다

 

 

 

지역 클래스 - 지역 변수 캡처 1

참고 ** ** 지금부터 설명할 지역 변수 캡처에 관한 내용은 너무 깊이 있게 이해하지 않아도 된다. 이해가 어렵다면 단순하게 지역 클래스가 접근하는 지역 변수의 값은 변경하면 안 된다 정도로 이해하면 충분하다.

 

지역 클래스를 더 자세히 알아보기 전에 잠시 변수들의 생명 주기에 대해서 정리해 보자

변수의 생명 주기

  • 클래스 변수 : 프로그램 종료까지. 가장 길다 (메서드 영역)
    • 클래스 변수(static 변수)는 메서드 영역에 존재하고, 자바가 클래스 정보를 읽어 들이는 순간부터 프로그램 종료까지 존재한다.
  • 인스턴스 변수: 인스턴스의 생존 기간(힙영역)
    • 인스턴스 변수는 본인이 소속된 인스턴스가 GC 되기 전까지 존재한다. 생존 주기가  긴 편이다.
  • 지역 변수 : 메서드 호출이 끝나면 사라짐 (스택 영역)
    • 지역 변수는 스택 영역의 스택 프레임 안에 존재한다. 따라서 메서드가 호출되면 생성되고, 메서드 호출이 종료되면 스택프레임이 제거되면서 그 안에 있는 지역 변수도 모두 제거된다. 생존 주기가 짜다. 참고로 매개변수도 지역변수의 한 종류이다.

 

 

지역 클래스 예제 3

지금까지 작성한 지역 클래스 예제를 약간 수정해서 새로 만들어보자.

package nested.local;

public class LocalOuterV3 {
    private int outInstanceVar = 3;

    public Printer process(int paramVar) {
        int localVar = 1; // 지역 변수는 스택 프레임이 종료되는 순간 함께 제거된다.

        class LocalPrinter implements Printer {
            int value = 0;

            @Override
            public void print() {
                System.out.println("value = " + value);

                // 인스턴스틑 지역 변수보다 더 오래 살아 남는다.
                System.out.println("localVar = " + localVar);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outInstanceVar = " + outInstanceVar);
            }
        }
        LocalPrinter printer = new LocalPrinter();
        // printer.print(); 를 여기서 실행하지 않고 Printer 인스턴스만 반환한다.
        return printer;
    }

    public static void main(String[] args) {
        LocalOuterV3 localOuter = new LocalOuterV3();
        Printer printer = localOuter.process(2);
//        printer.print(); 를 나중에 실행한다. process() 의 스택 프레임이 사라진 이후에 실행
        printer.print();
    }
}

process()는 Printer 타입을 반환한다.  여기서 LocalPrinter 인스턴스를 반환한다.

여기서는 LocalPrinter.print() 메서드를 process() 안에서 실행하는 것이 아니라 process() 메서드가 종료된 main()에 메서드에 실행한다.

 

이 예제를 실행하면서 뭔가 이상한 느낌이 들었다면 제대로 공부를 하고 있는 것이다.

예제 코드를 메모리 그림과 함께 분석해 보자!

LocalPrinter 인스턴스 생성 직후 메모리 그림

 

지역 클래스 인스턴스의 생존범위

지역 클래스로 만든 객체도 인스턴스이기 때문에 힙 영역에 존재한다. 따라서 GC 전까지 생존한다.

LocalPrinter 인스턴은 process() 메서드 안에서 생성된다. 그리고 process()에서 main()으로 생성한 LocalPrinter 인스턴스를 LocalPrinter 인스턴스를 반환하고 printer 변수에 참조를 보관한다. 따라서 LocalPrinter 인스턴스는 main 이 종료될 때까지 생존한다.

paramVar , localVar와 같은 지역 변수는 process() 메서드를 실행하는 동안에만 스택 영역에서 생존한다. process() 메서드가 종료되면 process() 스택 프레임이 스택 영역에서 제거되면서 함께 제거된다.

 

 

LocalPrinter.print() 접근 메모리 그림

 

 

LocalPrint 인스턴스는 print() 메서드를 통해 힙영역에 존재하는 바깥 인스턴스의 변수인 outInstanceVar에 접근한다.

이 부분은 인스턴스의 필드를 참조하는 것이기  때문에 특별한 문제가 없다.

 

LocalPrinter 인스턴스는 print() 메서드를 통해 스택 영역에 존재하는 지역변수도 접근하는 것처럼 보인다. 하지만 스택영역에 존재하는 지역 변수를 힙 영역에 있는 인스턴스가 접근하는 것은 생각처럼 단순하지 않다.

 

 

지역 변수의 생명주기는 매우 짧다. 반면에 인스턴스의 생명주기는 GC 전까지는 생존할 수 있다.

지역 변수인 paramVar , localVar는 process() 메서드가 실행되는 동안에만 생존할 수 있다.

process() 메서드가 종료되면 process()의 스택 프레임이 제거되면서 두 지역 변수도 함께 제거된다.

여기서 문제는 process() 메서드가 종료되어도 LocalPrinter 인스턴스는 계속 생존할 수 있다는 점이다.

 

 

예제를 잘 보자.

여기서는 process() 메서드가 종료된 이후에 main() 메서드 안에서 LocalPrinter.print() 메서드를 호출한다.

하지만 process() 메서드가 이미 종료되었으므로 해당 지역 변수들도 이미 제거된 상태이다.

 

그런데 실행 결과를 보면 localVar, paramVar와 같은 지역 변수의 값들이 모두 정상적으로 출력되는 것을 확인할 수 있다.

 

어떻게 제거된 지역 변수들에 접근할 수 있는 것일까?

참고

여기서는 이해를 돕기 위해 설명을 단순화했지만, 더 정확히 이야기하면 LocalPrint.print() 메서드를 실행하면  이 메서드도 당연히 스택 프레임 위에 올라가서 실행된다. main()에서 print()를 실행했으므로 main() 스택 프레임 위에 print() 스택 프레임도 올라간다. 물론 process() 스택프레임은 이미 제거된 상태이므로 지역 변수인 localVar , parmVar 도 함께 제거되어서 접근할 수 없다.

 

 

지역 클래스 - 지역 변수 캡처 2

지역 클래스는 지역 변수에 접근할 수 있다.

그런데 앞서 본 것처럼 지역 변수의 생명주기는 짧고, 지역 클래스를 통해 생성한 인스턴스의 생명주기는 길다.

지역 클래스를 통해 생성한 인스턴스가 지역 변수에 접근해야 하는데, 둘의 생명 주기가 다르기 때문에 인스턴스는 살아있지만, 지역 변수는 이미 제거된 상태일 수 있다.

 

지역 변수 캡처

자바는 이런 문제를 해결하기 위해 지역 클래스의 인스턴스를 생성하는 시점에 필요한 지역 변수를 복사해서 생성한 인스턴스를 함께 넣어둔다. 이런 과정을 변수 캡처(Capture)라고 한다,

캡 처라는 단어는 스크린 캡처를 떠올려 보면 바로 이해가 될 것이다. 인스턴스를 생성할 때 필요한 지역변수를 복사해서 보관해 두는 것이다.

물론 모든 지역 변수를 캡처하는 것이 아니라 접근이 필요한 지역 변수만 캡처한다.

 

지역 클래스의 인스턴스 생성과 지역 변수 캡처 과정 1

1.LocalPrinter 인스턴스 생성 시도 : 지역 클래스의 인스턴스를 생성할 때 지역 클래스가 접근하는 지역 변수를 확인한다.

  • LocalPrinter 클래스는 paramVar, localVar 지역 변수에 접근한다.

 

2. 사용하는 지역 변수 복사: 지역 클래스가 사용하는 지역 변수를 복사한다. (매개 변수도 지역 변수의 한 종류이다)

  • 여기서는 paramVar, localVar 지역 변수를 복사한다.

 

지역 클래스의 인스턴스 생성과 지역 변수 캡처 과정 2

 

3. 지역 변수 복사 완료: 복사한 지역 변수를 인스턴스에 포함한다.

4. 인스턴스 생성 완료: 복사한 지역 변수를 포함해서 인스턴스 생성이 완료된다. 이제 복사한 지역 변수를 인스턴스를 통해 접근할 수 있다.

 

LocalPrinter 인스턴스에서 print() 메서드를 통해 paramVar, localVar에 접근하면 사실은 스택 영역에 있는 지역 변수에 접근하는 것이 아니다. 대신에 인스턴스에 있는 캡처한 변수에 접근한다.

캡처한 paramVar, localVar의 생명주기는 LocalPrinter 인스턴스의 생명주기와 같다. 따라서 LocalPrinter 인스턴스는 지역 변수의 생명주기와 무관하게 언제든지 paramVar , localVar 캡처 변수에 접근할 수 있다.

이렇게 해서 지역

변수와 지역 클래스를 통해 생성한 인스턴스의 생명주기가 다른 문제를 해결한다.

 

코드로 캡처 변수 확인

package nested.local;

import java.lang.reflect.Field;

public class LocalOuterV3 {

    public static void main(String[] args) {
        LocalOuterV3 localOuter = new LocalOuterV3();
        Printer printer = localOuter.process(2);
//        printer.print(); 를 나중에 실행한다. process() 의 스택 프레임이 사라진 이후에 실행
        printer.print();

        // 추가
        System.out.println("필드 확인");
        Field[] fields = printer.getClass().getDeclaredFields();
        for (Field field : fields) {
            System.out.println("field = " + field);
        }
    }
}

 

실행 결과 

필드 확인
// 인스턴스 변수
field = int nested.local.LocalOuterV3$1LocalPrinter.value

// 지역 변수
field = final int nested.local.LocalOuterV3$1LocalPrinter.val$localVar
field = final int nested.local.LocalOuterV3$1LocalPrinter.val$paramVar

// 바깥 클래스 참조
field = final nested.local.LocalOuterV3 
nested.local.LocalOuterV3$1LocalPrinter.this$0

실행 결과를 통해 LocalPrinter 클래스의 캡처 변수를 확인할 수 있다. 추가로 바깥 클래스를 참조하기 위한 필드도 확인할 수 있다. 참골로 이런 필드들은 자바가 내부에서 만들어 사용하는 필드들이다.

 

정리

지역 클래스는 인스턴스를 생성할 때 필요한 지역 변수를 먼저 캡처해서 인스턴스에 보관한다.

그리고 지역 클래스의 인스턴스를 통해 지역 변수에 접근하면, 실제로는 지역 변수에 접근하는 것이 아니라, 인스턴스에 있는 캡처한 캡처 변수에 접근한다.

 

 

지역 클래스 - 지역 변수 캡처 3

지역 클래스가 접근하는 지역 변수는 절대로 중간에 값이 변하면 안 된다.

따라서 final로 선언하거나 사실상 final 이어야 한다. 이것은 자바 문법이고 규칙이다.

 

용어 - 사실상 final

영어로는 effectively final이라 한다. 사실상 final 지역 변수는 지역 변수에 final 키워드를 사용하지는 않았지만, 값을 변경하지 않는 지역 변수를 뜻한다. final 키워드를 넣지 않았을 뿐이지, 실제로는 final 키워드를 넣은 것처럼 중간에 값을 변경하지 않은 지역 변수이다. 따라서 사실상 final 지역 변수는 final 키워드를 넣어도 동일하게 작동해야 한다.

 

지역 클래스가 접근하는 지역 변수는 왜 final 또는 사실상 final 이어야 할까? 왜 중간에 값이 변하면 안 될까? 

 

드를 통해 확인해 보자.

 

package nested.local;

public class LocalOuterV4 {
    private int outInstanceVar = 3;

    public Printer process(int paramVar) {
        int localVar = 1; // 지역 변수는 스택 프레임이 종료되는 순간 함께 제거된다.

        class LocalPrinter implements Printer {
            int value = 0;

            @Override
            public void print() {
                System.out.println("value = " + value);

                // 인스턴스틑 지역 변수보다 더 오래 살아 남는다.
                System.out.println("localVar = " + localVar);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outInstanceVar = " + outInstanceVar);
            }
        }
        LocalPrinter printer = new LocalPrinter();
        // printer.print(); 를 여기서 실행하지 않고 Printer 인스턴스만 반환한다.
        //localVar = 10;  // 컴파일 오류
      //  paramVar = 20;  // 컴파일 오류
        return printer;
    }

    public static void main(String[] args) {
        LocalOuterV4 localOuter = new LocalOuterV4();
        Printer printer = localOuter.process(2);
//        printer.print(); 를 나중에 실행한다. process() 의 스택 프레임이 사라진 이후에 실행
        printer.print();

    }
}

 

실행결과

value = 0
localVar = 1
paramVar = 2
outInstanceVar = 3

 

Printer printer = new LocalPrinter();

LocalPrinter를 생성하는 시점에 지역 변수인 localVar , paramVar를 캡처한다.

 

그런데 이후에 캡처한 지역 변수의 값을 다음과 같이 변경하면 어떻게 될까?

        LocalPrinter printer = new LocalPrinter();
        // 만약 localVar 의 값을 변경한다면? 다시 캡처해야 하나?
        // localVar = 10;  // 컴파일 오류
        // paramVar = 20;  // 컴파일 오류

이렇게 되면 스택 영역에 존재하는 지역 변수의 값과 인스턴스에 캡처한 변수의 값이 서로 달라지는 문제가 발생한다. 이것을 동기화 문제라고 한다.

 

물론 자바 언어를 설계할 때 지역변수의 값이 변경되면 인스턴스에 캡처한 변수의 값도 함께 변경하도록 설계하면 된다.

하지만 이로 인해 수많은 문제들이 파생될 수 있다.

 

캡처 변수의 값을 변경하지 못하는 이유

  • 지역 변수의 값을 변경하면 인스턴스에 캡처한 변수의 값도 변경해야 한다.
  • 반대로 인스턴스에 있는 캡처 변수의 값을 변경하면 해당 지역 변수의 값도 다시 변경해야 한다.
  • 개발자 입장에서 예상하지 못한 곳에서 값이 변경될 수 있다. 이는 디버깅을 어렵게 한다.
  • 지역 변수의 값과 인스턴스에 있는 캡처 변수의 값을 서로 동기화해야 하는데, 멀티 스레드 상황에서 이런 동기화는 매우 어렵고 , 성능에 나쁜 영향을 줄 수 있다. 

이 모든 문제는 캡처한 지역 변수의 값이 변하기 때문에 발생한다. 자바는 캡처한 지역 변수의 값을 변하지 못하게 막아서 이런 복잡한 문제들을 근본적으로 차단한다.

 

참고:  변수 캡처에 대한 내용이 이해가 어렵다면 단순하게 지역 클래스가 접근하는 지역 변수의 값은 변경하면 안 된다. 정도로 이해하면 충분하다.

 

 

참고자료 : 김영한의 실전 자바 - 중급 1편

'JAVA' 카테고리의 다른 글

익명 클래스  (0) 2024.12.24
중첩 클래스, 내부 클래스 1  (0) 2024.12.23
자바 정규식 Reqular Expression  (0) 2024.12.23
[JAVA] 리플렉션  (2) 2024.11.27
[JAVA] 자원 정리 (try-catch , try-with-resources) 2  (1) 2024.11.26