중첩 클래스(Nested class)

분류

  • 중첩 클래스(nested class): 다른 클래스/인터페이스 안에 선언된 클래스 전체
  • 내부 클래스(inner class): static 이 아닌 중첩 클래스
    • 인스턴스 멤버 클래스(non-static member class)
    • 로컬 클래스(local class)
    • 익명 클래스(anonymous class)
  • 정적 멤버 클래스(static nested class)

 

왜 사용하는가?

  • 논리적 그룹화 : 특정 클래스에서만 사용되는 클래스가 있다면, 그 안에 포함시켜서 함께 두는 게 자연스럽다. "헬퍼 클래스" 같은 것을 중첩시키면 패키지 구조도 깔끔해진다.
  • 캡슐화 강화 : 클래스 B가 클래스 A의 멤버에 접근해야 하는데, 해당 멤버들을 private 으로 숨겨야 하는 상황이 있을 수 있다. B를 A 안에 중첩시키면, A의 멤버를 private 으로 유지하면서 B는 접근할 수 있고 B 자체도 외부에 노출되지 않게 숨길 수 있다.
  • 가독성과 유지보수성 향상을 이끌어낼 수 있음

 

멤버 클래스

인스턴스 멤버 클래스 (non-static nested class)

  • 바깥 클래스의 모든 필드와 메서드에 접근 가능 (private 포함)
  • 바깥 클래스 외부에서 인스턴스 멤버 클래스 객체를 생성하려면, 바깥 클래스 객체를 먼저 생성해야 한다.
  • Java 15까지는 inner class에서 static 멤버 선언이 금지였는데, Java 16부터 JEP 395(Records) 관련 변경으로 inner/local/anonymous에서도 static 멤버 선언이 가능해짐

 

class Outer {
    private int value = 10;

    class Inner {
        // Java 16+ 부터 가능
        static int count = 0;
        static void staticMethod() {}

        void access() {
            System.out.println(value);  // 바깥 클래스 private 멤버 접근 가능
        }
    }
}

// 외부에서 생성
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();

 

정적 멤버 클래스 (static nested class)

  • 모든 종류의 필드와 메서드 선언 가능
  • 바깥 클래스의 인스턴스 없이 접근할 수 있는 건 정적 멤버뿐이다.
  • 바깥 클래스의 인스턴스 멤버에 직접 접근 불가

 

class Outer {
    private int instanceVar = 10;
    private static int staticVar = 20;

    static class StaticNested {
        void access() {
            // System.out.println(instanceVar);  // 컴파일 에러
            System.out.println(staticVar);       // OK
        }
    }
}

// 바깥 클래스 인스턴스 없이 생성 가능
Outer.StaticNested nested = new Outer.StaticNested();

 

 

바이트코드 파일명 형식: 바깥클래스$멤버클래스.class

 

로컬 클래스

블록 내부에서 클래스를 선언할 수 있다. 메서드 내에서만 사용 가능해서 접근 제한자나 static 키워드는 붙일 수 없다.

 

변수 캡처 (Variable Capture)

컴파일 시 로컬 클래스에서 사용하는 매개 변수나 지역 변수의 값을 로컬 클래스 내부에 복사해 사용한다.

⇒ 지역 변수는 스택 프레임 생명주기가 끝나면 사라지는데, 로컬/익명 클래스 인스턴스는 힙에 남을 수 있음

  • final 또는 effectively final 지역 변수/매개변수만 캡처할 수 있다.
  • 캡처한 지역 변수는 컴파일 시 생성자 인자 + synthetic 필드(val$<name>)로 복사되어 저장된다.

 

바이트코드 분석

public class OuterClass {
    void outMethod(final int arg1, int arg2) {
        final int var1 = 1;
        int var2 = 2;
        class LocalClass {
            void method() {
                int result = arg1 + arg2 + var1 + var2;
            }
        }
    }
}

 

위의 클래스를 OpenJDK 23기준으로 컴파일하면 두 개의 바이트코드가 나온다.

  • OuterClass.class
  • OuterClass$1LocalClass.class

 

바이트코드: OuterClass$1LocalClass.class

javap -p -v OuterClass\$1LocalClass.class

class OuterClass$1LocalClass
  minor version: 0
  major version: 67
  flags: (0x0020) ACC_SUPER
  this_class: #2                          // OuterClass$1LocalClass
  super_class: #14                        // java/lang/Object
  interfaces: 0, fields: 3, methods: 2, attributes: 4
Constant pool:
   #1 = Fieldref           #2.#3          // OuterClass$1LocalClass.val$arg1:I
   #2 = Class              #4             // OuterClass$1LocalClass
   #3 = NameAndType        #5:#6          // val$arg1:I
   #4 = Utf8               OuterClass$1LocalClass
   #5 = Utf8               val$arg1
   #6 = Utf8               I
   #7 = Fieldref           #2.#8          // OuterClass$1LocalClass.val$arg2:I
   #8 = NameAndType        #9:#6          // val$arg2:I
   #9 = Utf8               val$arg2
  #10 = Fieldref           #2.#11         // OuterClass$1LocalClass.val$var2:I
  #11 = NameAndType        #12:#6         // val$var2:I
  #12 = Utf8               val$var2
  #13 = Methodref          #14.#15        // java/lang/Object."<init>":()V
  #14 = Class              #16            // java/lang/Object
  #15 = NameAndType        #17:#18        // "<init>":()V
  #16 = Utf8               java/lang/Object
  #17 = Utf8               <init>
  #18 = Utf8               ()V
  #19 = Utf8               (LOuterClass;III)V
  #20 = Utf8               Code
  #21 = Utf8               LineNumberTable
  #22 = Utf8               LocalVariableTable
  #23 = Utf8               this
  #24 = Utf8               LOuterClass$1LocalClass;
  #25 = Utf8               this$0
  #26 = Utf8               LOuterClass;
  #27 = Utf8               MethodParameters
  #28 = Utf8               Signature
  #29 = Utf8               method
  #30 = Utf8               result
  #31 = Utf8               SourceFile
  #32 = Utf8               OuterClass.java
  #33 = Utf8               EnclosingMethod
  #34 = Class              #35            // OuterClass
  #35 = Utf8               OuterClass
  #36 = NameAndType        #37:#38        // outMethod:(II)V
  #37 = Utf8               outMethod
  #38 = Utf8               (II)V
  #39 = Utf8               NestHost
  #40 = Utf8               InnerClasses
  #41 = Utf8               LocalClass
{
  final int val$arg1;
    descriptor: I
    flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

  final int val$arg2;
    descriptor: I
    flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

  final int val$var2;
    descriptor: I
    flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

  OuterClass$1LocalClass();
    descriptor: (LOuterClass;III)V
    flags: (0x0000)
    Code:
      stack=2, locals=5, args_size=5
         0: aload_0
         1: iload_2
         2: putfield      #1                  // Field val$arg1:I
         5: aload_0
         6: iload_3
         7: putfield      #7                  // Field val$arg2:I
        10: aload_0
        11: iload         4
        13: putfield      #10                 // Field val$var2:I
        16: aload_0
        17: invokespecial #13                 // Method java/lang/Object."<init>":()V
        20: return
      LineNumberTable:
        line 5: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      21     0  this   LOuterClass$1LocalClass;
            0      21     1 this$0   LOuterClass;
    MethodParameters:
      Name                           Flags
      <no name>                      final mandated
      <no name>                      final synthetic
      <no name>                      final synthetic
      <no name>                      final synthetic
    Signature: #18                          // ()V

  void method();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=2, locals=2, args_size=1
         0: aload_0
         1: getfield      #1                  // Field val$arg1:I
         4: aload_0
         5: getfield      #7                  // Field val$arg2:I
         8: iadd
         9: iconst_1
        10: iadd
        11: aload_0
        12: getfield      #10                 // Field val$var2:I
        15: iadd
        16: istore_1
        17: return
      LineNumberTable:
        line 7: 0
        line 8: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  this   LOuterClass$1LocalClass;
           17       1     1 result   I
}
SourceFile: "OuterClass.java"
EnclosingMethod: #34.#36                // OuterClass.outMethod
NestHost: class OuterClass
InnerClasses:
  #41= #2;                                // LocalClass=class OuterClass$1LocalClass

 

여기서 컴파일러가 생성한 Synthetic Fields를 볼 수 있다.

final int val$arg1;
  descriptor: I
  flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

final int val$arg2;
  descriptor: I
  flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

final int val$var2;
  descriptor: I
  flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC

 

그리고 생성자를 보면

OuterClass$1LocalClass();
  descriptor: (LOuterClass;III)V
  flags: (0x0000)
  Code:
    stack=2, locals=5, args_size=5
       0: aload_0
       1: iload_2
       2: putfield      #1                  // Field val$arg1:I
       5: aload_0
       6: iload_3
       7: putfield      #7                  // Field val$arg2:I
      10: aload_0
      11: iload         4
      13: putfield      #10                 // Field val$var2:I
      16: aload_0
      17: invokespecial #13                 // Method java/lang/Object."<init>":()V
      20: return
  • descriptor: (LOuterClass;III)V
    → 생성자가 받는 인자는 (OuterClass, int, int, int)
  • val$arg1, val$arg2, val$var2 에 저장하는 부분이 있다.

그런데 final int var1 = 1; 와 관련된 내용이 보이지 않는다.

void method(); 바이트코드를 보면

 9: iconst_1
10: iadd

 

컴파일 타임 상수는 필드로 복사하지 않고 인라인 상수로 처리되는 것을 확인할 수 있다.

 

디컴파일 결과는 대략 이런 모양일 것 같다.

class LocalClass {
    final int val$arg1;
    final int val$arg2;
    final int val$var2;

    LocalClass(OuterClass outer, int arg1, int arg2, int var2) {
        this.val$arg1 = arg1;
        this.val$arg2 = arg2;
        this.val$var2 = var2;
    }

    void method() {
            int result = val$arg1 + val$arg2 + 1 + val$var2;
    }
}

 

스코프 규칙

class Global {
    class Cyclic {}

    void foo() {
        new Cyclic(); // Global.Cyclic 인스턴스 생성
        class Cyclic extends Cyclic {} // 순환 상속 에러 (컴파일 에러)

        {
            class Local {}
            {
                class Local {} // 컴파일 에러
            }
            class Local {} // 컴파일 에러
            class AnotherLocal {
                void bar() {
                    class Local {} // OK
                }
            }
        }
        class Local {} // OK, 이전 Local의 스코프 밖
    }
}

 

  • 스코프는 선언이 시작되는 순간부터 적용돼서 foo 함수 내의 로컬 클래스 Cyclic 는 자기 자신을 상속하려고 해서 컴파일 에러가 발생한다. (Global.Cyclic 으로 바꾸면 Ok)
  • 같은 로컬 클래스 이름은 같은 메서드(또는 생성자나 초기화 블록) 내에서 재선언될 수 없다.
  • 그런데 AnotherLocal 처럼 더 깊이 중첩된 다른 클래스 안에서는 Local을 다시 선언할 수 있다.

 

바이트코드 파일명 형식: 바깥클래스$1로컬클래스.class

 

익명 클래스 (Anonymous Class)

이름 없이 선언과 동시에 인스턴스를 생성하는 클래스

  • 생성자를 가질 수 없다 (이름이 없으므로)
  • 인스턴스 초기화 블록({})으로 초기화 로직 작성 가능
  • 로컬 클래스와 동일하게 effectively final 변수만 캡처 가능

 

예시:

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("작업 실행");
    }
});

thread.start();

 

 

바이트코드 파일명 형식: 바깥클래스$1.class (이름 없이 숫자만)

 

바깥 클래스 참조 얻기

중첩 클래스에서의 this 키워드는 바깥 클래스에 대한 객체 참조가 아닌, 중첩 클래스의 객체 참조이다.

중첩 클래스 객체 참조를 얻으려면 바깥클래스.this 를 사용한다.

 

class Outer {
    int value = 10;

    class Inner {
        int value = 20;

        void print() {
            System.out.println(this.value);        // 20 (Inner)
            System.out.println(Outer.this.value);  // 10 (Outer)
        }
    }
}

 

중첩 인터페이스 (Nested Interface)

클래스나 인터페이스의 멤버로 선언된 인터페이스를 지칭한다.

 

클래스 안에 중첩 인터페이스를 선언하면

  • 암묵적으로 static (인터페이스는 인스턴스 개념이 없다)
  • 접근 제한자 자유롭게 지정 가능

 

인터페이스 안에 중첩 인터페이스를 선언하면

  • 암묵적으로 public 이고 static 이다
  • 인터페이스 선언에 protectedprivate 제한자가 있으면 컴파일 에러

 

왜 인터페이스를 클래스 내부에 선언하는가?

  • 특정 클래스나 인터페이스에서만 사용되는 인터페이스면, 해당 타입 안에 중첩시켜서 논리적으로 긴밀한 관계를 맺도록 함 (예: Map.Entry<K, V>)
  • 네임스페이스 관리: 이름 충돌을 방지하고, 패키지를 깔끔하게 유지할 수 있다.

 

UI 프로그래밍에서 이벤트 처리 목적으로 많이 활용:

public class Button {
    // Button에 특화된 클릭 리스너
    public interface OnClickListener {
        void onClick(Button button);
    }
}

public class TextView {
    // TextView에 특화된 클릭 리스너
    public interface OnClickListener {
        void onClick(TextView view);
    }
}

 

사용 예시:

public class Button {
    public interface OnClickListener {
        void onClick(Button button);
    }

    private OnClickListener listener;

    public void setOnClickListener(OnClickListener listener) {
        this.listener = listener;
    }

    public void onClick() {
        if (listener != null) listener.onClick(this);
    }
}

// --------------------------------

class App {
    public static void main(String[] args) {
        Button loginBtn = new Button();

        loginBtn.setOnClickListener(new Button.OnClickListener() {
            @Override
            public void onClick(Button button) {
                System.out.println("로그인 버튼 클릭");
            }
        });

        loginBtn.onClick();
    }
}

 

 

 

[레퍼런스]

Oracle - Nested Classes

JLS 8.1.3 - Inner Classes and Enclosing Instances

JLS 8.5 - Member Class and Interface Declarations

JLS 14.3 - Local Class and Interface Declarations

JEP 395: Records