중첩 클래스(Nested Class)는 말 그대로 클래스 안에 클래스를 중첩해서 정의한 것을 말한다. 중첩 클래스는 클래스를 정의하는 위치에 따라 여러가지가 있다.
“정적 중첩 클래스” 가 있고, 내부 클래스 종류에는 “내부 클래스”, “지역 클래스”, “익명 클래스” 가 있다. 중첩 클래스를 정의하는 위치는 변수의 선언 위치와 같다. 변수는 알다시피, 클래스 변수, 인스턴스 변수, 지역 변수가 있다.
중첩 클래스의 선언 위치는,
class Outer {
...
// 정적 중첩 클래스
static class StaticNested {
...
}
// 내부 클래스
class Inner {
...
}
}
구조를 보면, 정적 중첩 클래스는 클래스 변수와 같이 static 키워드가 붙어 있고, 내부 클래스는 인스턴스 변수와 동일하게 static 키워드가 붙어 있지 않은 것을 볼 수 있다.
class Outer {
public void process() {
// 지역 변수
int lcoalVar = 0;
// 지역 클래스
class Local {...}
Local local = new Local();
}
}
지역 클래스는 지역 변수처럼 코드 블록 안에서 클래스를 정의해야 한다.
중첩이라는 말은, 내 안에 있지만 내 것은 아닌 것을 말한다. 단순히 위치만 안에 있는 것이다. 반면, 내부는 나의 내부에서 나를 구성하는 요소를 말한다. 예를 한번 들어보자. 큰 파란색 박스 안에 작은 초록색 박스를 넣는 것, 둘은 아무 관련이 없는 완전히 다른 박스다. 이런 걸 중첩이라고 하고, 내 몸 안에 심장은 나를 구성하는 요소, 나의 내부에 있는 것이다. 이를 클래스에 대입해보면, "정적 중첩 클래스는 껍데기 클래스 안에 있지만 그 껍데기와는 아무 상관없는 전혀 다른 클래스" 고, "내부 클래스는 껍데기 클래스 내부에 있으면서 그 껍데기 클래스를 구성하는 요소" 를 말한다. 중첩과 내부 클래스를 분류하는 핵심은 껍데기 클래스의 입장에서 볼 때, “안에 있는 클래스가 나의 인스턴스에 소속이 되는가 되지 않는가” 에 있다.
<중간 점검>
정적 중첩 클래스
static이 붙는다.내부 클래스
static이 붙지 않는다.내부 클래스의 종류
내부 클래스: 껍데기 클래스 인스턴스의 멤버에 접근지역 클래스: 내부 클래스의 특징 + 지역 변수에 접근익명 클래스: 지역 클래스의 특징 + 클래스의 이름이 없는 특별한 클래스
참고로 실무에서는, 중첩과 내부라는 단어를 명확히 구분하지 않고, 중첩 클래스 또는 내부 클래스라고 얘기한다. 왜냐하면 클래스 안에 클래스가 있는 것을 중첩 클래스라고 하기 때문이다. 그리고 내부 클래스도 중첩 클래스의 한 종류이기도 하다. 추가로, static이 붙어 있는 정적 중첩 클래스는 내부 클래스라고 하면 안 된다.
내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 특별한 경우에만 사용해야 한다. 외부에 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안 된다.
중첩 클래스를 사용하는 이유는...
논리적 그룹화를 위함이다. 어떤 클래스가 다른 클래스에 의존적이고, 단독으로 사용될 일이 없다면, 그 둘을 하나로 묶어서 표현하는 것이 더 읽기 쉽고 의미가 명확하다. 패키지를 열었을 때, 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는 장점도 있다.
외부 클래스에서만 사용할 내부 구현 클래스를 외부로부터 격리할 수 있도록 캡슐화하기 위함이다. 중첩 클래스는 바깥 클래스의 private 멤버에 접근할 수 있다. 이렇게 해서 둘을 긴밀하게 연결하고 불필요한 public 메서드를 제거할 수 있다.
바로 코드를 보자.
package nested.nested;
public class NestedOuter {
private static int outClassValue = 3; // 클래스 변수
private int outInstanceValue = 2; // 인스턴스 변수
static class Nested { // 정적 클래스는 static이 붙는다...
// 정적 중첩 클래스의 인스턴스 변수
private int nestedInstanceValue = 1;
public void print() {
// 자신의 멤버에 접근 가능
System.out.println(nestedInstanceValue);
// 껍데기 클래스의 인스턴스 멤버에는 접근할 수 없다.
// Non-static field 'outInstanceValue' cannot be referenced from a static context
// System.out.println(outInstanceValue);
// 껍데기 클래스의 클래스 멤버에는 접근이 가능하다.
System.out.println(outClassValue);
}
}
}
정적 중첩 클래스는 자신의 멤버(nestedInstanceValue)에는 당연히 접근할 수 있다. 껍데기 클래스의 클래스 멤버(outClassValue)에는 접근할 수 있지만, 클래스에 static이 붙어 있기 때문에 당연히 껍데기 클래스의 인스턴스 멤버(outInstanceValue)에는 접근할 수 없다.
참고로, NestedOuter.outClassValue를 outClassValue와 같이 줄여서 사용해도 된다. 이 경우에는 껍데기 클래스에 있는 필드를 찾아서 사용한다. 그리고 private 접근 제어자는 같은 클래스 안에 있을 때만 접근이 가능했다. 위의 코드를 보면, 중첩 클래스도 같은 클래스에 있기 때문에 private이 붙어도 접근 가능한 것이다.
package nested.nested;
public class NestedOuterMain {
public static void main(String[] args) {
NestedOuter outer = new NestedOuter();
NestedOuter.Nested nested = new NestedOuter.Nested(); // 중첩 클래스만 따로 만드는 것도 가능
nested.print();
System.out.println("nestedClass = " + nested.getClass());
}
}
/*
1
3
nestedClass = class nested.nested.NestedOuter$Nested
*/
정적 중첩 클래스는 new 껍데기 클래스.중첩 클래스()와 같이 생성할 수 있다. 그리고 NestedOuter.Nested와 같이 껍데기 클래스.중첩 클래스로 접근 가능하다. 여기서 "new NestedOuter()로 만든 껍데기 클래스의 인스턴스" 와 "new NestedOuter.Nested()로 만든 정적 중첩 클래스의 인스턴스" 는 서로 아무 관계가 없는 인스턴스다. 단지 클래스 구조상 중첩한 것일 뿐이다.
그림을 통해 더 자세히 살펴보자.


Nested.print()를 살펴보면, 정적 중첩 클래스는 껍데기 클래스의 클래스 변수(outClassValue)에는 접근할 수 있다. 하지만, 껍데기 인스턴스의 참조를 Nested 클래스가 알 수 없기 때문에 껍데기 클래스의 인스턴스 변수(outInstanceValue)에는 접근할 수 없다.
정리하자면, 다소 무리가 있을 수는 있지만 사실 정적 중첩 클래스는 다른 클래스를 그냥 중첩해 둔 것일 뿐이다. "둘은 아무런 관계가 없다는 것이다." NestedOuter.outClassValue와 같은 클래스 변수에 접근하는 것은 중첩 클래스가 아니어도 클래스명.클래스 변수명으로 충분히 접근할 수 있다. 그냥 클래스 2개를 따로 만든 것과 같다.
class NestedOuter {
}
class Nested {
}
이렇게 찢어 놓은 코드와 정적 중첩 클래스의 유일한 차이점은 "private 접근 제어자에 접근할 수 있냐 없냐" 정도이다.
정적 중첩 클래스를 어떤 식으로 활용하는지 코드를 리팩토링 하면서 알아보도록 하자.
오직 Network 객체 안에서만 사용되는 NetworkMessage 객체가 있고, text를 입력 받아서 NetworkMessage를 생성하고 출력하는 기능이 있다. Network 객체를 생성하고 network.sendMessage()를 통해 메시지를 전달하도록 했다. NetworkMain은 오직 Network 클래스만 사용하도록 하며, NetworkMessage 클래스는 전혀 사용하지 않고 있다고 해보자.
package nested.nested.ex1;
public class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print() {
System.out.println(content);
}
}
package nested.nested.ex1;
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
}
package nested.nested.ex1;
public class NetworkMain {
public static void main(String[] args) {
Network network = new Network();
network.sendMessage("Hello, Java!");
}
}
// Hello, Java!
여기 네트워크 기능을 사용하려고 ex1 패키지를 열어본 어느 개발자는 main을 제외한 클래스 2개(Network, NetworkMessage)를 볼 수 있을 것이다. 이 클래스들을 처음 본 개발자는 당연히 2개를 모두 사용하려고 할 것이다. 최소 어딘가에서 NetworkMessage가 사용될 것이라고 생각하는게 당연하다. 하지만, 실제로 돌려보고 나서야 Network 클래스만 사용하면 된다는 것을 깨닫는다. 그 누가 보더라도 의미를 쉽게 이해할 수 있도록 코드를 리팩토링 해보자.
package nested.nested.ex2;
public class Network {
// sendMessage만 사용하면 되겠다.
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
networkMessage.print();
}
// NetworkMessage는 private이니 안에서만 쓰겠네? 내부 중첩이구나...
private static class NetworkMessage {
private String content;
public NetworkMessage(String content) {
this.content = content;
}
public void print() {
System.out.println(content);
}
}
}
위처럼 NetworkMessage 클래스를 Network 클래스 안에 중첩해서 만들었다. NetworkMessage에 private도 붙여서 외부에서 접근할 수 없도록 설정했다.
package nested.nested.ex2;
public class NetworkMain {
public static void main(String[] args) {
Network network = new Network();
network.sendMessage("Hello, Java!");
}
}
// Hello, Java!
이제 ex2 패키지를 열어본 개발자는 해당 클래스(Network)만 확인할 것이고, NetworkMessage가 중첩 클래스에 있는 private 접근 제어자로 되어 있는 것을 보고, Network 내부에서만 단독으로 사용하는 클래스라고 바로 인지할 수 있을 것이다.
내 클래스에 포함된 중첩 클래스가 아니라 다른 곳에 있는 중첩 클래스에 접근할 때는 껍데기 클래스.중첩 클래스로 접근해야 한다.
NestedOuter.Nested nested = new NestedOuter.Nested();
내 클래스에 포함된 중첩 클래스에 접근할 때는 껍데기 클래스 이름을 적지 않아도 된다.
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
}
private static class NetworkMessage {...}
}
내부 클래스를 포함한 중첩 클래스는 그 용도가 자신이 소속된 껍데기 클래스 안에서 사용되는 것이다. 따라서 자신이 소속된 껍데기 클래스가 아닌 외부에서 생성하고 사용하고 있다면, 중첩 클래스로 설계한 의미가 없다고 봐야 할 것이다. 이럴 때는 중첩 클래스를 밖으로 빼는 것이 옳은 선택이다.
정적 중첩 클래스는 껍데기 클래스와 아무 상관이 없다고 했다. 하지만, 내부 클래스는 껍데기 클래스의 인스턴스를 이루는 요소가 된다. 쉽게 말해, 내부 클래스는 껍데기 클래스의 인스턴스에 소속된다는 것이다.
<정적 중첩 클래스>
static이 붙는다.<내부 클래스>
static이 붙지 않는다.
아래 코드를 보자.
package nested.inner;
public class InnerOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
// 내부 클래스 (static이 붙지 않음)
// 그냥 InnerOuter 클래스의 인스턴스 멤버와 같은 취급
class Inner {
private int innerInstanceValue = 1;
public void print() {
// 자기 자신에 접근
System.out.println(innerInstanceValue);
// 외부 클래스의 인스턴스 멤버, private에 접근 가능
System.out.println(outInstanceValue);
// 외부 클래스의 클래스 멤버, private에 접근 가능
System.out.println(outClassValue);
}
}
}
"내부 클래스는
static키워드가 붙지 않는다. 그냥 껍데기 클래스의 인스턴스 멤버가 된다는 것이다!" 그래서 내부 클래스는 자신의 멤버를 포함한 껍데기 클래스의 인스턴스 변수와 클래스 변수 모두에 접근할 수 있는 것이다.
package nested.inner;
public class InnerOuterMain {
public static void main(String[] args) {
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner(); // 내부 클래스 생성
inner.print();
System.out.println("innerClass = " + inner.getClass());
}
}
/*
1
2
3
innerClass = class nested.inner.InnerOuter$Inner
*/
내부 클래스는 껍데기 클래스의 인스턴스에 소속된다. 따라서 껍데기 클래스의 인스턴스 정보(인스턴스의 참조값)를 알아야 생성할 수 있다. 내부 클래스는 "껍데기 클래스의 인스턴스 참조.new 내부 클래스()"와 같이 생성할 수 있다. 위와 같이 outer.new Inner()로 생성한 내부 클래스는 개념상 껍데기 클래스의 인스턴스 내부에 생성된다. 따라서 껍데기 클래스의 인스턴스를 먼저 생성해야 내부 클래스의 인스턴스를 생성할 수 있다.
보다시피 outer.new Inner()로 생성한 Inner 인스턴스는 개념상 껍데기 클래스의 인스턴스 내부에 생성된다. 따라서 내부 인스턴스는 껍데기 클래스를 알기 때문에 껍데기 클래스의 인스턴스 변수(outInstanceValue)에 접근 가능한 것이다.
위 그림을 보면 알겠지만, 실제로는 내부 인스턴스가 껍데기 인스턴스 안에 생성되는 것은 아니고, 껍데기 인스턴스의 참조가 내부 인스턴스에 보관되는 것이다. 해당 참조를 통해 껍데기 인스턴스 변수에 접근할 수 있는 것이다. 편의상 그냥 인스턴스 안에 생성된다고 이해하도록 하자.
간단한 예제를 리팩토링 하면서 내부 클래스가 어떻게 활용되는지 보자.
package nested.inner.ex1;
// Car에서만 사용
public class Engine {
private Car car;
public Engine(Car car) {
this.car = car;
}
public void start() {
System.out.println("연료 레벨 확인: " + car.getFuelLevel());
System.out.println(car.getModel() + "의 엔진을 구동합니다...");
}
}
엔진은 Car 클래스에서만 사용된다. 엔진을 가동하기 위해서는 차의 연료 레벨과 차량의 이름이 필요하다. Car 인스턴스의 참조를 생성자에서 보관하도록 한다. 추가로, 엔진의 연료 레벨을 확인하기 위한 getFuelLevel()과 이름을 확인하기 위한 getModel()을 필요로 한다.
package nested.inner.ex1;
public class Car {
private String model;
private int fuelLevel;
private Engine engine;
public Car(String model, int fuelLevel) {
this.model = model;
this.fuelLevel = fuelLevel;
this.engine = new Engine(this);
}
// Engine에서만 사용하는 메서드
public String getModel() {
return model;
}
// Engine에서만 사용하는 메서드
public int getFuelLevel() {
return fuelLevel;
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
}
getModel(), getFuelLevel() 메서드는 엔진에 필요한 메서드들이다. 오직 엔진에서만 사용하고, 다른 곳에서는 사용하지 않는다.
package nested.inner.ex1;
public class CarMain {
public static void main(String[] args) {
Car myCar = new Car("Model Y", 100);
myCar.start();
}
}
/*
연료 레벨 확인: 100
Model Y의 엔진을 구동합니다...
Model Y 시작 완료
*/
결과적으로 Car 클래스는 엔진에서만 사용하는 기능을 위해 메서드를 추가해서, 모델 이름과 연료 레벨을 외부에 노출해야 한다.
위와 같이 엔진은 차 내부에서만 사용되기 때문에, 엔진을 차의 내부 클래스로 만들도록 했다. 또한 엔진은 차의 연료 레벨과 모델명에 접근해야 한다.
package nested.inner.ex2;
public class Car {
private String model;
private int fuelLevel;
private Engine engine;
public Car(String model, int fuelLevel) {
this.model = model;
this.fuelLevel = fuelLevel;
this.engine = new Engine();
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
public class Engine {
public void start() {
System.out.println("연료 레벨 확인: " + fuelLevel);
System.out.println(model + "의 엔진을 구동합니다...");
}
}
}
위와 같이 Engine을 내부 클래스로 만들면, Car의 인스턴스 변수인 fuelLevel, model에 직접 접근할 수 있다.
껍데기 클래스에서 내부 클래스의 인스턴스를 생성할 때는 껍데기 클래스 이름을 생략할 수 있다. 껍데기 클래스에서 내부 클래스의 인스턴스를 생성할 때 내부 클래스의 인스턴스는 자신을 생성한 껍데기 클래스의 인스턴스를 자동으로 참조한다. 여기서는 new Engine()로 생성된 Engine 인스턴스는 자신을 생성한 껍데기 Car 인스턴스를 자동으로 참조하고 있는 것이다.
package nested.inner.ex2;
public class CarMain {
public static void main(String[] args) {
Car myCar = new Car("Model Y", 100);
myCar.start();
}
}
/*
충전 레벨 확인: 100
Model Y의 엔진을 구동합니다...
Model Y 시작 완료
*/
비교를 해보면, 리팩토링 전에는 결과적으로 모델 이름과 연료 레벨을 외부에 노출한다는 문제가 있었다. 이건 불필요한 Car 클래스의 정보들이 추가로 외부에 노출되는 것이기 때문에 캡슐화를 무너뜨리는 행위다. 반면, 리팩토링을 거치면서 getModel(), getFuelLevel()과 같은 메서드를 모두 제거하고, 결과적으로 꼭 필요한 메서드만 외부에 노출시킴으로써 Car의 캡슐화를 더 견고하게 만들었다.
바람직하지 않지만, 만약 껍데기 클래스의 인스턴스 변수와 내부 클래스의 인스턴스 변수 이름이 같다면 어떻게 구분할까?
package nested;
public class ShadowingMain {
public int value = 1;
class Inner {
public int value = 2;
void go() {
int value = 3;
System.out.println("value = " + value); // value = 3
System.out.println("this.value = " + this.value); // this.value = 2
System.out.println(ShadowingMain.this.value); // 1
}
}
public static void main(String[] args) {
ShadowingMain shadowingMain = new ShadowingMain();
Inner inner = shadowingMain.new Inner();
inner.go();
}
}
현재 변수의 이름이 같기 때문에 우선순위가 필요하다. 보통 프로그래밍에서의 우선순위는 현재 위치와 더 가깝거나, 더 구체적인 것이 우선권을 가진다. 위 코드의 go() 메서드는 지역 변수인 value와 가장 가깝다. 따라서 우선순위가 가장 높다. 이런 식으로 다른 변수들을 가려서 보이지 않게 하는 것을 섀도잉(Shadowing)이라고 한다. 다른 변수를 가리더라도 인스턴스의 참조를 사용하면 외부 변수에 접근할 수 있다. this.value로 내부 클래스의 인스턴스에 접근하고, 껍데기 클래스 이름.this로 껍데기 클래스의 인스턴스에 접근할 수 있다.
프로그래밍에서 가장 중요한 것은 “명확성” 이다. 위와 같이 이름이 같은 경우에는 처음부터 이름을 서로 다르게 지어서 명확하게 구분하는 것이 현명하다.