"애초에 Inner Class를 왜 만들지? 그냥 그걸 포장한 객체를 포함해버리면 되는 거 아닌가?"
이런 의문을 가진 적이 있다.
특히 내부 클래스를 사용할 땐 거의 static 을 사용한다.
심지어 Static Nested Class를 사용하게 되면, 사실상 다른 클래스로 분리하는 것과 큰 차이가 없어보이기도 한다.
static 멤버, Inner Class와 Static Nested Class를 언제, 왜 쓰는지 알아보았다.
static 필드나 메서드는 인스턴스를 생성하지 않고도 클래스 자체를 통해 직접 접근할 수 있다.
대표적인 예시로 main 메서드가 있다.
public static void main(String[] args) { ... }
main은 어떤 클래스의 인스턴스도 없는 상태에서 JVM이 직접 호출하므로 static이 될 수밖에 없다.
아래 코드에서 Engine을 별도 클래스로 분리하면 안 될까?
// 방법 1: Inner Class
class Car {
class Engine {
void start() {
System.out.println("엔진 시작");
}
}
}
// 방법 2: 다른 클래스로 분리하기
class Engine { }
class Car {
private Engine engine;
}
대부분의 경우, 방법 2로도 충분히 가능하다.
하지만 Inner Class만으로 얻을 수 있는 가치도 있다.
1. 논리적으로 강하게 묶여있는 경우
Engine이 Car 밖에서 아무 의미가 없다면, Car.Engine으로 표현하는 것이 훨씬 자연스럽다.
실제로 Java 표준 라이브러리의 Map.Entry를 예로 들 수 있다.
Map.Entry<String, String> entry = ...;
Entry만 단독으로 존재한다면 어느 Entry인지 모호해진다.
Map.Entry 라는 표현 자체로 개념의 소속 관계를 명확이 드러낼 수 있다.
2. Outer Class의 private 멤버에 접근
Inner Class는 외부 클래스의 private 필드에 직접 접근할 수 있다.
class Car {
private String vin;
class Engine {
void printVin() {
System.out.println(vin); // Car의 private 필드에 직접 접근
}
}
}
일반 클래스로 분리하면
class Engine {
void printVin(Car car) {
System.out.println(car.getVin()); // getter가 필요
}
}
컴파일러가 내부적으로 outer 참조를 처리해주기 때문에 Inner Class에서는 가능하다.
일반 Inner Class는 반드시 Outer 객체가 있어야 생성할 수 있다.
Car car = new Car();
Car.Engine engine = car.new Engine(); // car 객체 필수
이는 컴파일러가 내부적으로 이런 구조를 만들기 때문이다.
class Engine {
Car outer; // 숨겨진 참조
}
즉, Inner Class 인스턴스는 항상 Outer 인스턴스와 함께 묶인다.
Outer 객체가 필요 없는 상황에서도 강제로 생성해야 하는 것은 낭비다.
static을 붙이면 달라진다.
class Car {
static class Engine {
void start() { ... }
}
}
이제 Car 객체 없이도 생성할 수 있게 되었다.
Car.Engine engine = new Car.Engine(); // Car 인스턴스 불필요
인스턴스 독립성 측면에서는 일반 Top-Level 클래스처럼 동작한다.
하지만 완전히 동일하지는 않다. Top-Level 클래스와 비교했을 때 두 가지 차이가 있다.
Car 라는 네임스페이스(namespace)를 공유한다.Car.Engine 으로만 참조 가능private static 멤버에 접근할 수 있다.class Car {
private static String brand = "Hyundai";
static class Engine {
void printBrand() {
System.out.println(brand); // Outer의 private static 멤버 접근
}
}
}
즉, Static Nested Class는 "Outer의 네임스페이스와 private 영억을 공유하되, 인스턴스는 독립적인 클래스"다.
Static Nested Class = "Outer 객체와는 독립적이지만, 개념적으로는 Outer에 속하는 타입"
Static Nested Class가 가장 자주 등장하는 패턴이 바로 Builder Pattern이다.
public class User {
private String name;
private int age;
private User() {} // 외부 직접 생성 차단
public static class Builder {
private String name;
private int age;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public User build() {
User user = new User();
user.name = this.name;
user.age = this.age;
return user;
}
}
}
User user = new User.Builder()
.name("kim")
.age(20)
.build();
Builder는 User와 개념적으로 매우 밀접하다.
그런데 User 객체가 만들어지기 전에 Builder가 먼저 존재해야 한다.
따라서 static nested class가 정확히 맞는 선택이다.
일반 Inner Class를 쓸 때 반드시 알아야 할 문제가 있다. 메모리 누수다.
Inner Class 인스턴스는 내부적으로 Outer 인스턴스에 대한 숨겨진 참조(this$0)를 항상 들고 있다. 이 구조 때문에 예상치 못한 문제가 발생할 수 있다.
누수가 발생하는 시나리오
import java.util.ArrayList;
class EnclosingClass {
private int[] data;
public EnclosingClass(int size) {
data = new int[size];
}
class EnclosedClass {
// data에 실제로 접근 — this$0를 통해 Outer 참조
int firstElement() {
return data[0];
}
}
EnclosedClass getEnclosedClassObject() {
return new EnclosedClass();
}
}
public class MemoryLeakDemo {
public static void main(String[] args) {
ArrayList<Object> al = new ArrayList<>();
Runtime rt = Runtime.getRuntime();
for (int i = 0; ; i++) {
al.add(new EnclosingClass(10_000_000).getEnclosedClassObject());
long used = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
System.out.printf("iter %3d | heap used: %dMB%n", i, used);
}
}
}
EnclosedClass가 아무 필드도 없더라도, data에 실제로 접근하는 메서드가 있으면 JVM은 this$0 참조를 통해 EnclosingClass 전체를 살려둔다. 배열 하나당 약 40MB(int 4바이트 × 10,000,000)이므로, 힙 제한 없이도 수백 번 안에 OOM이 난다.
iter 95 | heap used: 3845MB
iter 96 | heap used: 3885MB
...
iter 101 | heap used: 4083MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
new EnclosingClass(100_000)은 변수에 저장되지 않으니 곧 GC될 것 같다. 하지만 그렇지 않다.
ArrayList → EnclosedClass 인스턴스 → (this$0) → EnclosingClass 인스턴스 → int[100_000]
EnclosedClass가 ArrayList에 살아있는 한, this$0 참조 때문에 EnclosingClass도, 그 안의 배열도 GC되지 않는다. Inner 객체가 Outer 객체의 수명을 강제로 연장시킨다.
static class는 Outer 인스턴스 참조 자체를 갖지 않는다.
위 예시에서 class EnclosedClass를 static class EnclosedClass로 바꾸기만 해도 참조 체인이 끊어진다.
사실 항상 문제가 발생하는 것은 또 아니다.
Inner Class 자체가 항상 OOM을 유발하지는 않는다.
"Outer는 버리고 Inner만 컬렉션에 장기 보관"하는 극단적인 패턴에서 발생하는 구조다.
// Android / GUI에서 흔한 패턴
class MyActivity {
class NetworkTask extends AsyncTask {
// 네트워크 작업이 끝나기 전에 Activity가 종료돼도
// NetworkTask가 this$0로 MyActivity 전체를 붙잡고 있음
// → Activity 관련 뷰, 컨텍스트 전체가 GC 불가 상태가 됨
}
}
결국에 Inner 객체가 Outer 객체보다 오래 살아남는 상황이 위험하다.
같은 수명으로 함께 쓰이는 경우, 일반적인 Inner Class에서 이 문제는 잘 발생하지는 않는다.
가능하면 그렇다.
다만 Inner Class가 Outer의 인스턴스 상태에 반드시 접근해야 하는 경우라면 static으로 만들 수 없다. 억지로 static으로 바꾸면 Outer 객체를 매번 파라미터로 넘겨야 해서 오히려 코드가 복잡해질 수 있다.
이 때는 Inner Class를 써야 하지만, Inner 객체가 Outer 객체보다 오래 살아남지 않도록 수명을 관리해야 한다.
Outer 객체의 상태에 직접 접근해야 한다
-> Inner Class (수명 관리 주의)
Outer 객체 없이 독립적으로 생성되어야 한다
-> Static Nested Class
굳이 내부에 둘 이유가 없다
-> Top-level Class (별도 파일)
일반적으로는 Inner Class는 드물고,
Static Nested Class가 Builder, DTO, Helper 등에서 종종 쓰이는 듯하다.
처음의 질문으로 돌아가서,
"그래서 그냥 클래스 분리하면 되지 않나?"
사실 대부분은 분리해도 된다.
Map.Entry, User.Builder)이 세 가지 중 하나도 해당하지 않는다면, 그냥 분리하는 것이 나을 것 같다.