티스토리 뷰

인프런에서 김영한님의 자바 중급1을 들으며, 지역 클래스의 지역 변수 캡처 라는 개념을 새롭게 알게되었다.
이상하다고 느꼈지만, 제대로 이해하고 있지 못했던 것을 정리해본다.

 

일단 클래스 안의 클래스의 구조를 이해하자

 

 


중첩 클래스

중첩 클래스(Nested Class) 또는 내부 클래스(Inner Class)는 말 그대로 클래스 안에 클래스를 겹쳐놓은 것을 말한다.

 

중첩 클래스의 분류

  • 정적 중첩 클래스 -> 정적 변수와 같은 위치 
public class NestedOuter {

    private static int outClassValue = 3; // 접근 가능
    private int outInstanceValue = 2; // 인스턴스 변수 접근 불가

    static class Nested {
        private int nestedInstanceValue = 1; // 내꺼 당연히 접근 가능

        public void print() {...}
           
    }
}

 

  • 내부 클래스 종류
    • 내부 클래스 -> 인스턴스 변수와 같은 위치
    • 지역 클래스 -> 지역 변수와 같은 위치
    • 익명 클래스 → 지역 클래스와 비슷함
public class InnerOuter {

    private static int outClassValue = 3; // 접근 가능
    private int outInstanceValue = 2; // private도 접근 가능

    class Inner {
        private int innerInstanceValue = 1; // 내꺼 당연히 접근 가능

        public void print() {...}
    }
}

 

 

의미를 나누어 생각하면 아래와 같이 구분할 수 있다.

  • 중첩(Nested) : 어떤 다른 것이 내부에 위치하거나 포함되는 구조적인 관계 (ex 마트료시카)
  • 내부(Inner) : 나의 내부에 있는 나를 구성하는 요소 (ex 나의 심장)

핵심은 바깥 클래스 입장에서 볼 때 안에 있는 클래스가 나의 인스턴스에 소속이 되는가 되지 않는 가의 차이이다. 

실제로 이 둘을 명확히 구분해서 지칭하지는 않으며, 상황에 따라 스스로 이해해야하는 부분도 있다.

 

 

중첩 클래스는 언제 사용하나?

내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나,
둘이 아주 긴밀하게 연결되어있는 특별한 경우에만 사용한다.

  • 논리적 그룹화 : 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는. 특정 클래스가 다른 하나의 클래스에서만 사용되는 경우
  • 캡슐화 : 중첩 클래스는 바깥 클래스의 private멤버에 접근 할 수 있다. 이렇게 둘을 긴밀하게 연결하고 불필요한 public 메서드를 제거할 수 있다.

코드로 캡슐화의 장점을 이해해보자

 

고양이 옷입히기

옷 입은 대운이

우리집 고양이는 옷을 잘 입고 있는다

‘옷 입은 고양이’를 생성해보자.

 

이름과 옷의 사이즈, 그리고 옷을 입히기 위한 클래스를 준비한다.

  • Cat.class
public class Cat {

  private String name;
  private String size;
  private Clothes clothes;

  public Cat(String name, String size) {
    this.name = name;
    this.size = size;
    this.clothes = new Clothes(this);
  }

  public String getName() {
    return name;
  }

  public String getSize() {
    return size;
  }

  public void dressing() {
    clothes.clothing();
    System.out.println("옷을 입었다. 냥");
  }
}
  • Clothes.class
public class Clothes {
  private Cat cat; 

  public Clothes(Cat cat) {
    this.cat = cat;
  }
  public void clothing() {
    String catName = cat.getName();
    String catSize = cat.getSize();
    System.out.println("고양이 " + catName + "에게 " + catSize + "사이즈의 옷을 입힙니다.");
  }
}

호출해서 실행한다

  • main
  public static void main(String[] args) {
    Cat cat = new Cat("대운이", "L");
    cat.dressing();
  }
  • 결과

 

성공적으로 대운이가 옷을 입었다.

 

근데 여기서 아쉬운 부분들을 확인할 수 있다.

Cat 클래스내에 get메서드들은 Clothe에서만 사용할 것이다.

public class Cat {

  private String name;
  private String size;
  private Clothes clothes;

  public Cat(String name, String size) {...}
  public String getName() {...} // Clothe에서만 사용하는 메서드
  public String getSize() {...} // Clothe에서만 사용하는 메서드
  public void dressing() {...}
}

Clothe 클래스는 Cat클래스와 긴밀이 연결되어, 오직 Cat 클래스 에서만 사용될 것이다.

public class Clothes {
  private Cat cat; // Cat 에서만 사용되는 클래스

  public Clothes(Cat cat) {...}
  public void clothing() {...}
}

이때 우리는 중첩 클래스를 활용하여 옮길 수 있다.

 

 

Clothes클래스를 Cat 옮기며서 무엇이 달라질까?

  • Cat 필드에 바로 접근이 가능하다. 동시에 get메서드가 필요없어진다.
  • Cat에 연결되어있기 때문에 Clothes에서 Cat을 생성할 필요가 없어진다.
  • Clothes의 접근제어자를 private로 변경하여 캡슐화할 수 있다.
public class ClotheCat {

  private String name;
  private String size;
  private Clothes clothes;

  public ClotheCat(String name, String size) {
    this.name = name;
    this.size = size;
    this.clothes = new Clothes(); // Cat 매개변수 필요없어짐
  }

  public void dressing() {
    clothes.clothing();
    System.out.println("옷을 바로 입었다. 냥"); // 예시 결과 구분을 위한 문구 수정
  }

  private class Clothes { // 접근제어자를 public -> private으로 변경
    // Cat 생성 삭제
    public void clothing() { // 필드에 접근
      System.out.println("고양이 " + name + "에게 " + size + "사이즈의 옷을 입힙니다.");
    }
  }
}

 

 

보기만 해도 코드가 매우 간결해졌다.

 

이제 다른 개발자가 새로운 옷 입은 고양이를 생성하려 할 때, 패키지와 클래스를 들락날락 할 필요도 없어졌다.

옷을 입히는 기능이 완전히 캡슐화되어, 어떻게 동작하는지 외부에서 알 수없고 알 필요도 없다.

 

또한 다른 동물은 옷을 입을 수도 없다.

이렇게 필요없는 코드를 줄이고, 기능을 안전하게 캡슐화하는 장점을 살펴 보았다.

 

그러면 다시 본론으로 돌아가서,

지역 클래스의 지역 변수 캡쳐를 알아보기전, 지역 클래스는 뭘까?


지역 클래스

class Outer {
    public void process() {
        //지역 변수
        int localVar = 0;

        //지역 클래스
        class Local {...}

        Local local = new Local();
    }
}

 

지역클래스는 내부 클래스의 특별한 종류 중 하나로 내부 클래스의 특징을 그대로 가진다. 지역 변수 처럼 코드 블럭 안에서 선언한다.

중첩 클래스와 동일하게 바깥 클래스의 인스턴스 멤버에 접근할 수 있다. 또한 지역 변수에 접근할 수 있다.

단, 지역 클래스는 접근 제어자를 사용할 수 없다.

 

중요한 것은, 지역 클래스가 접근하는 지역 변수의 값은 변경하면 안된다.
왜?

 

 변수의 생명 주기를 다시 이해해보자

 

변수의 생명 주기

  • 클래스 변수 : 메서드 영역 - 프로그램 종료까지 생존, 가장 길다.
  • 인스턴스 변수 : 힙 영역 - 인스턴스의 생존 기간
  • 지역 변수 : 스택 영역 - 메서드 호출이 끝나면 사라짐
public class OuterClass {

  private int outInstanceVar = 3;

  public Printer process(int paramVar) {

    int localVar = 1; //기초 지식 : 지역 변수는 스택 프레임이 종료되는 순간 함께 제거된다.

    class LocalPrinter implements Printer { //기초 지식: 인스스턴스는 지역 변수 보다 오래 살아남는다.

      int val = 0;

      @Override
      public void print() {
        System.out.println(paramVar); // 매개 변수 출력
        System.out.println(val); // 내부 클래스의 지역 변수 출력
      }

    }
    LocalPrinter localPrinter = new LocalPrinter();
    return localPrinter;
  }
}
public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        Printer printer = localOuter.process(2);
        
        printer.print(); // process()의 스택 프레임이 사라진 이후에 실행
    }

 

 

바깥 클래스 내의 지역변수를 생성하는 process()메서드를 통해, Printer 인스턴스를 생성했다.
이때, 지역 클래스가 '지역 변수인 paramVar과 localVar'을 성공적으로 출력시키는 걸 볼 수 있다.

 

메서드가 종료 될 때, 스택 프레임이 종료되고 해당 지역 변수의 생명주기는 끝나게 된다.
하지만 Printer는 참조되는 곳이 있기 때문에 GC가 삭제하지 않고, 메모리에 남아있는다.

 

 

그런데 어떻게 다른 인스턴스에서 사라졌어야하는 지역 변수를 출력할 수 있을까?



 

지역 변수의 생명 주기는 짧고, 지역 클래스를 통해 생성한 인스턴스의 생명 주기는 길다.
지역 클래스를 통해 생성한 인스턴스가 지역 변수에 접근해야 하는데, 둘의 생명 주기가 다른 문제가 발생한다.

 

이런 문제를 해결하기 위해

지역 클래스 인스턴스를 생성하는 시점에 필요한 지역 변수를 복사해서 생성한 인스턴스와 함께 넣어둔다.

이런 과정을 변수 캡처라고 한다.

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

지역 클래스의 인스턴스 캡처 과정

  1. LocalPrinter 인스턴스 생성 시도 시점에 지역 클래스가 접근하는 지역 변수를 확인한다.
  2. LocalPrinter 인스턴스 내부에, 필요한 지역 변수를 복사해서 저장한다. (paramVar, localVar)
  3. LocalPrinter의 print() 메서드를 통해 paramVar, localVar 에 접근한건, 사실 인스턴스에 있는 캡처된 변수에 접근한 것이다.
  • 캡처한 지역 변수의 생명 주기는 인스턴스의 생명주기와 같아진다.

 

참조값 확인하기

Field[] fields = printer.getClass().getDeclaredFields();

 

LocalPrinter 인스턴스의 필드를 출력하면 아래와 같이 확인할 수 있다.

  • 필드 확인
// 인스턴스 변수
field = int section09.LocalOuterV3$1LocalPrinter.value
// 캡처 변수 
field = final int section09.LocalOuterV3$1LocalPrinter.val$localVar 
field = final int section09.LocalOuterV3$1LocalPrinter.val$paramVar    
// 바깥 클래스 참조 
field = final section09.LocalOuterV3 section09.LocalOuterV3$1LocalPrinter.this$0

 

 

지역 클래스가 참조하는 지역 변수를 변경하지마!

지역 변수인 int localVar = 1;의 값을 변경하는 코드를 작성하면 컴파일 에러가 발생한다. (paramVar도 마찬가지)

 

사실상 final - effectively final
final로 선언하지 않았지만 선언뒤 한번도 변경되지 않음.

  • 캡처한 이후에 변경하더라도 동기화 문제가 발생하여 컴파일 오류가 발생한다.
  • 동기화 오류 : 스택 영역에 존재하는 지역 변수의 값과 인스턴스에 캡처한 캡처 변수의 값이 서로 달라짐

 

  • OuterClass 내에서 변경하려해도 컴파일 오류 발생
public Printer process(int paramVar) {

    int localVar = 1; 
    // paramVar = 22; // 컴파일 오류
    
    class LocalPrinter implements Printer { 

      int val = 0;
      // localVar = 2; // 컴파일 오류
      // val = 2; // 컴파일 오류

      @Override
      public void print() {...}

    }
    LocalPrinter localPrinter = new LocalPrinter();
    return localPrinter;
  }
  • Main 클래스에서 값을 변경해도 컴파일 오류가 발생
main() {
    Printer printer = new LocalPrinter();
    // localVar = 10; // 컴파일오류
    // paramVar = 10; // 컴파일오류
}

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

지역 변수의 값을 변경하면 인스턴스에 캡처한 변수의 값도 변경해야 한다.

반대로 인스턴스에 있는 캡처 변수의 값을 변경하면 해당 원본 지역 변수의 값도 다시 변경해야 한다.

개발자 입장에서 보면, 예상하지 못한 곳에서 값이변경 될 수 있고 이는 디버깅을 어렵게 한다.

지역 변수의 값과 인스턴스에 있는 캡처 변수의 값을 서로 동기화 해야 하는데, 멀티 쓰레드 상황에서 이런 동기화는 매우 어렵고 성능 좋지 않다.

 

자바 언어를 설계할 때 열심히 수정하면 서로 변경 되게 할 수 있을 것이다. 

하지만 아예 변경 할 수 없게 하므로서 복잡한 문제들을 근본적으로 차단한다.


정리: 지역 클래스가 접근하는 지역 변수의 값은 변경하면 안된다.


옷 입은 대운이2

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함