[Java] 중첩 클래스(Nested class) 정리 - 바이트코드 분석을 곁들인
중첩 클래스(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이다 - 인터페이스 선언에
protected나private제한자가 있으면 컴파일 에러
왜 인터페이스를 클래스 내부에 선언하는가?
- 특정 클래스나 인터페이스에서만 사용되는 인터페이스면, 해당 타입 안에 중첩시켜서 논리적으로 긴밀한 관계를 맺도록 함 (예:
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();
}
}
[레퍼런스]
JLS 8.1.3 - Inner Classes and Enclosing Instances
JLS 8.5 - Member Class and Interface Declarations
'CS > Java' 카테고리의 다른 글
| [Java] 제네릭/와일드카드/타입 소거 정리 (0) | 2026.01.28 |
|---|---|
| [Java] try/catch/finally: 바이트코드 분석 (1) | 2026.01.21 |
| [Java] 생성자 초기화 순서 정리: 5단계에서 7단계로 (feat. JEP 513) (0) | 2026.01.13 |