중첩 클래스(Nested Class)
중첩 클래스는 다른 클래스 안에 정의된 클래스를 말한다. 중첩 클래스는 자신을 감싼 바깥 클래스에서만 사용되어야 하며, 그 외의 쓰임새가 있다면 톱레벨 클래스로 만들어야 한다.
중첩 클래스는 4가지 종류가 있다:
- 정적 멤버 클래스 (static member class)
- 비정적 멤버 클래스 (non-static member class)
- 익명 클래스 (anonymous class)
- 지역 클래스 (local class)
정적 멤버 클래스를 제외한 나머지는 내부 클래스(inner class)라고 한다.
정적 멤버 클래스
정적 멤버 클래스는 바깥 클래스의 private 멤버에도 접근할 수 있다는 점만 제외하고는 일반 클래스와 똑같다.
public class Calculator {
// 정적 멤버 클래스
public static class Operation {
public enum Type { PLUS, MINUS }
private final Type type;
public Operation(Type type) {
this.type = type;
}
public int apply(int x, int y) {
switch (type) {
case PLUS: return x + y;
case MINUS: return x - y;
default: throw new AssertionError();
}
}
}
}
// 사용
Calculator.Operation op = new Calculator.Operation(Calculator.Operation.Type.PLUS);
int result = op.apply(5, 3); // 8
사용 시기: 바깥 클래스와 함께 쓰일 때만 유용한 public 도우미 클래스로 사용한다. 예를 들어 Calculator.Operation 처럼 계산기가 지원하는 연산 종류를 정의할 때 유용하다.
비정적 멤버 클래스
비정적 멤버 클래스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다. 따라서 비정적 멤버 클래스의 인스턴스 메서드에서 바깥클래스.this 형태로 바깥 인스턴스의 메서드를 호출하거나 참조를 가져올 수 있다.
public class MySet<E> {
private List<E> elements = new ArrayList<>();
// 비정적 멤버 클래스
public class MyIterator implements Iterator<E> {
private int cursor = 0;
@Override
public boolean hasNext() {
// 바깥 클래스의 인스턴스 멤버에 직접 접근
return cursor < elements.size();
}
@Override
public E next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return elements.get(cursor++);
}
public void remove() {
// 바깥 클래스.this 형태로 바깥 인스턴스 참조
MySet.this.elements.remove(--cursor);
}
}
public Iterator<E> iterator() {
return new MyIterator();
}
}
사용 시기: 어댑터 패턴에서 많이 사용된다. 어떤 클래스의 인스턴스를 감싸 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용한다. 대표적인 예가 Map 인터페이스의 keySet, entrySet, values 메서드가 반환하는 컬렉션 뷰들이다.
익명 클래스
이름이 없는 클래스다. 바깥 클래스의 멤버도 아니고, 사용되는 시점에 선언과 동시에 인스턴스가 만들어진다.
// 익명 클래스 예시
public class AnonymousExample {
public void doSomething() {
// 익명 클래스로 Runnable 구현
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("익명 클래스 실행");
}
});
thread.start();
// 람다로 대체 가능 (Java 8 이후)
Thread thread2 = new Thread(() ->
System.out.println("람다로 간결하게")
);
thread2.start();
}
}
사용 시기:
- 과거에는 작은 함수 객체나 처리 객체를 만드는 데 사용했지만, 이제는 람다가 대체
- 정적 팩터리 메서드를 구현할 때 사용
제약사항:
- 선언한 지점에서만 인스턴스를 만들 수 있음
- instanceof 검사나 클래스 이름이 필요한 작업은 수행 불가
- 여러 인터페이스를 구현할 수 없고, 인터페이스 구현과 동시에 다른 클래스 상속 불가
- 짧지 않으면 가독성이 떨어짐
지역 클래스
지역 클래스는 4가지 중첩 클래스 중 가장 드물게 사용된다. 지역 변수를 선언할 수 있는 곳이면 어디서든 선언할 수 있고, 유효 범위도 지역 변수와 같다.
public class LocalClassExample {
private int outerField = 100;
public void doSomething() {
final int localVar = 50; // effectively final
// 지역 클래스
class LocalCalculator {
public int calculate() {
// 바깥 클래스의 멤버에 접근 가능
// 지역 변수에도 접근 가능 (final이거나 effectively final이어야 함)
return outerField + localVar;
}
}
LocalCalculator calc = new LocalCalculator();
System.out.println(calc.calculate());
}
}
특징:
- 멤버 클래스처럼 이름이 있고 반복해서 사용 가능
- 익명 클래스처럼 비정적 문맥에서 사용될 때만 바깥 인스턴스 참조 가능
- 정적 멤버는 가질 수 없음
- 가독성을 위해 짧게 작성해야 함
왜 정적으로 하는 것이 좋은가
비정적 멤버 클래스의 숨은 참조
비정적 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다. 그래서 비정적 멤버 클래스의 인스턴스 안에서 바깥 인스턴스에 접근하려면 바깥클래스.this를 사용한다.
public class Outer {
private String name = "Outer";
// 비정적 멤버 클래스
class Inner {
public void printOuter() {
// 컴파일러가 자동으로 바깥 클래스 참조를 저장
System.out.println(Outer.this.name);
}
}
}
컴파일 후 실제 코드:
class Outer$Inner {
// 컴파일러가 자동으로 추가하는 숨은 참조
final Outer this$0;
Outer$Inner(Outer outer) {
this.this$0 = outer; // 바깥 인스턴스 참조 저장
}
public void printOuter() {
System.out.println(this$0.name);
}
}
메모리 누수 위험
이 참조를 저장하려면 시간과 공간이 소비된다. 더 심각한 문제는 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 생길 수 있다는 것이다.
public class MemoryLeakExample {
private byte[] data = new byte[1024 * 1024]; // 1MB
// 비정적 멤버 클래스
class Task implements Runnable {
@Override
public void run() {
// 바깥 인스턴스를 사용하지 않지만
// 숨은 참조가 유지되어 MemoryLeakExample이 GC되지 않음
System.out.println("Task 실행");
}
}
public Runnable getTask() {
return new Task();
}
}
// 사용
MemoryLeakExample example = new MemoryLeakExample();
Runnable task = example.getTask();
example = null; // example을 null로 설정해도 GC 되지 않을 수 있음
해결책: static으로 만들기
public class MemoryLeakFixed {
private byte[] data = new byte[1024 * 1024];
// 정적 멤버 클래스로 변경
static class Task implements Runnable {
@Override
public void run() {
System.out.println("Task 실행");
}
}
public Runnable getTask() {
return new Task();
}
}
// 사용
MemoryLeakFixed example = new MemoryLeakFixed();
Runnable task = example.getTask();
example = null; // 이제 example은 GC될 수 있음
성능 문제
비정적 멤버 클래스는 인스턴스를 생성할 때마다 바깥 인스턴스와의 관계 정보를 만들어야 한다. 이는 시간과 메모리를 추가로 소비한다.
// 비정적 멤버 클래스 - 느림
class Outer {
class Inner { }
public void create1000Instances() {
for (int i = 0; i < 1000; i++) {
new Inner(); // 매번 바깥 인스턴스 참조를 저장
}
}
}
// 정적 멤버 클래스 - 빠름
class Outer {
static class Inner { }
public void create1000Instances() {
for (int i = 0; i < 1000; i++) {
new Inner(); // 참조 저장 오버헤드 없음
}
}
}
결론
멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면 무조건 static을 붙여서 정적 멤버 클래스로 만들자.
이유:
- 메모리 누수 방지
- 성능 향상 (참조 저장 오버헤드 제거)
- 명확한 의도 표현 (바깥 인스턴스와 독립적임을 명시)
각 중첩 클래스의 사용 시기 요약:
- 정적 멤버 클래스: 바깥 인스턴스와 독립적으로 존재할 수 있을 때
- 비정적 멤버 클래스: 바깥 인스턴스의 메서드를 호출하거나 참조가 필요할 때 (어댑터 패턴)
- 익명 클래스: 한 메서드 안에서만 쓰이고, 인스턴스를 생성하는 지점이 단 한 곳이며, 타입이 이미 있을 때 (람다로 대체 가능하면 람다 사용)
- 지역 클래스: 거의 사용하지 않음. 익명 클래스보다 복잡할 때 고려
static을 생략하면 바깥 인스턴스로의 숨은 외부 참조를 갖게 되어, 시간과 공간이 낭비되고 심각한 경우 메모리 누수가 발생할 수 있다. 따라서 의심스러울 때는 무조건 static을 붙이자