1. 중첩 클래스의 분류
중첩 클래스는 클래스를 정의하는 위치에 따라 다음과 같이 분류한다. 총 4가지가 있고, 크게 2가지로 분류할 수 있다.
- 정적 중첩 클래스 = static; 바깥 클래스의 인스턴스에 소속되지 않음
- 내부 클래스 종류 = non-static; 바깥 클래스의 인스턴스에 소속됨
- 내부 클래스 = 바깥 클래스의 인스턴스 멤버에 접근
- 지역 클래스 = 내부 클래스 + 지역 변수에 접근
- 익명 클래스 = 지역 클래스 + 클래스의 이름이 없는 특별한 클래스
실무에서는 중첩, 내부라는 단어를 명확히 구분하지 않고 중첩 클래스 또는 내부 클래스라고 부른다. 엄밀하게 이야기하면 static이 붙어있는 정적 중첩 클래스는 내부 클래스라고 하면 안 된다. 그러나 대부분의 개발자들이 둘을 구분해서 말하지 않기 때문에 상황과 문맥에 따라 이해하면 된다.
- 내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결돼 있는 경우에만 사용해야 한다. 외부의 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안 된다.
- 중첩 클래스를 사용하는 이유는 다음과 같다.
- 논리적 그룹화
- 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우, 해당 클래스 안에 포함하는 것이 논리적으로 더 그룹화가 된다고 볼 수 있다. 패키지를 열었을 때, 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는다는 장점도 존재한다.
- 캡슐화
- 중첩 클래스는 바깥 클래스의 private 멤버에 접근할 수 있다. 이렇게 해서 둘을 긴밀하게 연결하고 불필요한 public 메서드를 제거할 수 있다.
- 논리적 그룹화
2. 정적 중첩 클래스 (static nested class)
개념
정적 중첩 클래스는 앞에 static이 붙는다. 특징은 다음과 같다.
- 자신의 멤버와 바깥 클래스의 클래스 멤버에 접근할 수 있다.
- 바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
public class NestedOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
static class Nested {
private int nestedInstanceValue = 1;
public void print() {
// 자신의 멤버에 접근 O
System.out.println(nestedInstanceValue);
// 바깥 클래스의 인스턴스 멤버 접근 X
// System.out.println(outInstanceValue);
// 바깥 클래스의 클래스 멤버 접근 O (private도 가능)
// System.out.println(outClassValue); // 가능
System.out.println(NestedOuter.outClassValue);
}
}
}
생성 및 접근
private 접근 제어자는 같은 클래스 안에 있을 때만 접근할 수 있다. 중첩 클래스도 바깥 클래스와 같은 클래스 안에 있기 때문에 바깥 클래스의 private 접근 제어자에 접근할 수 있다.
- 정적 중첩 클래스는 new 바깥클래스.중첩클래스()로 생성할 수 있고, 바깥클래스.중첩클래스로 접근할 수 있다.
- 여기서 new NestedOuter()로 만든 바깥 클래스의 인스턴스와, new NestedOuter.Nested()로 만든 정적 중첩 클래스의 인스턴스는 서로 아무런 관계가 없는 인스턴스이다. 단지 클래스 구조상 중첩해 뒀을 뿐이다.
- 둘이 아무런 관련이 없으므로 정적 중첩 클래스의 인스턴스만 따로 생성해도 된다.
- 중첩 클래스를 출력해 보면, 중첩 클래스의 이름은 NestedOuter$Nested와 같이 바깥 클래스, $, 중첩 클래스의 조합으로 만들어진다.
public class NestedOuterMain {
public static void main(String[] args) {
// outer와 nested는 관련 없음
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
}
nested.print()를 살펴보자. 정적 중첩 클래스는 바깥 클래스의 정적 필드에는 접근할 수 있지만, 인스턴스 필드에는 바로 접근할 수 없다. 바깥 인스턴스의 참조가 없기 때문이다.
내 클래스에 포함된 중첩 클래스가 아닌, 다른 곳에 있는 중첩 클래스에 접근할 때는 바깥클래스.중첩클래스로 접근해야 한다. 내 클래스에 포함된 중첩 클래스에 접근할 때는 바깥 클래스의 이름을 적지 않아도 된다.
public class Network {
public void sendMessage(String text) {
// Network.NetworkMessage networkMessage = new Network.NetworkMessage(text);
NetworkMessage networkMessage = new NetworkMessage(text);
}
private static class NetworkMessage {...}
}
참고
내부 클래스를 포함한 중첩 클래스는 자신이 소속된 바깥 클래스 안에서 사용된다. 따라서 자신이 소속된 바깥 클래스가 아닌 외부에서 생성하고 사용하고 있다면, 이미 중첩 클래스의 용도에 맞지 않을 수 있다. 이때는 중첩 클래스를 밖으로 빼는 것이 더 나은 선택이다.
3. 내부 클래스 (inner class)
개념
정적 중첩 클래스와는 다르게, 내부 클래스는 static이 붙지 않으므로 바깥 클래스의 인스턴스를 이루는 요소가 된다. 특징은 다음과 같다.
- 자신의 멤버와 바깥 클래스의 인스턴스 멤버, 클래스 멤버에 모두 접근할 수 있다.
public class InnerOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
class Inner {
private int innerInstanceValue = 1;
public void print() {
// 자신의 멤버에 접근 O
System.out.println(innerInstanceValue);
// 바깥 클래스의 인스턴스 멤버 접근 O (private도 가능)
System.out.println(outInstanceValue);
// 바깥 클래스의 클래스 멤버 접근 O (private도 가능)
System.out.println(outClassValue);
}
}
}
생성 및 접근
내부 클래스도 바깥 클래스와 같은 클래스 안에 있으므로 바깥 클래스의 private 접근 제어자에 접근할 수 있다.
- 내부 클래스는 바깥 클래스의 인스턴스의 소속되므로 바깥 클래스의 인스턴스 정보를 알아야 생성할 수 있다.
- 생성할 땐 바깥클래스의 인스턴스 참조.new 내부클래스()로 생성한다.
- outer.new Inner()에서 outer는 바깥 클래스의 인스턴스 참조를 가진다.
- outer.new Inner()로 생성한 내부 클래스는 개념상 바깥 클래스의 인스턴스 내부에 생성된다. 따라서 바깥 클래스의 인스턴스를 먼저 생성해야 내부 클래스의 인스턴스를 생성할 수 있다.
public class InnerOuterMain {
public static void main(String[] args) {
// inner는 outer의 인스턴스 소속임
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
}
개념상 바깥 클래스의 인스턴스 내부에서 내부 클래스의 인스턴스가 생성된다. 따라서 내부 인스턴스는 바깥 클래스를 알기 때문에 바깥 인스턴스 멤버에 접근할 수 있다.
실제로 내부 인스턴스가 바깥 인스턴스 안에 생성되진 않는다. 내부 인스턴스는 바깥 인스턴스의 참조를 보관하며, 이 참조를 통해 바깥 인스턴스 멤버에 접근할 수 있다.
참고
바깥 클래스에서 내부 클래스의 인스턴스를 생성할 때, 내부 클래스의 인스턴스는 자신을 생성한 바깥 클래스의 인스턴스를 자동으로 참조한다. 따라서 바깥 클래스의 이름을 생략하고 생성할 수 있다.
같은 이름의 바깥 변수 접근
바깥 클래스의 인스턴스 변수 이름과 내부 클래스의 인스턴스 변수 이름이 같다면, 어떤 변수를 먼저 사용할지 우선순위가 필요하다. 프로그래밍에서 우선순위는 대부분 더 가깝거나, 더 구체적인 것이 우선권을 가진다.
- 메서드 go()의 경우, 지역 변수인 value가 가장 가깝다. 따라서 우선순위가 가장 높다.
- 이렇게 다른 변수들을 가려서 보이지 않게 하는 것을 섀도잉(Shadowing)이라 한다.
- 다른 변수를 가리더라도 인스턴스의 참조를 사용하면 외부 변수에 접근할 수 있다.
- this.value는 내부 클래스의 인스턴스에 접근하고, 바깥클래스.this는 바깥 클래스의 인스턴스에 접근할 수 있다.
public class ShadowingMain {
public int value = 1;
class Inner {
public int value = 2;
void go() {
int value = 3;
System.out.println("value = " + value);
System.out.println("this.value = " + this.value);
System.out.println("ShadowingMain.value = " + ShadowingMain.this.value);
}
}
public static void main(String[] args) {
ShadowingMain main = new ShadowingMain();
Inner inner = main.new Inner();
inner.go();
}
// 출력
// value = 3
// this.value = 2
// ShadowingMain.value = 1
}
참고
프로그래밍에서 가장 중요한 것은 명확성이다. 이렇게 이름이 같은 경우, 처음부터 이름을 서로 다르게 지어서 명확하게 구분하는 것이 좋다.
4. 지역 클래스 (local class)
개념
지역 클래스는 내부 클래스의 특별한 종류 중 하나로, 내부 클래스의 특징을 그대로 가진다. 예를 들어, 지역 클래스도 내부 클래스이므로 바깥 클래스의 인스턴스 멤버에 접근할 수 있다. 특징은 다음과 같다.
- 지역 클래스는 지역 변수처럼 코드 블럭 안에 클래스를 선언한다.
- 지역 클래스는 지역 변수에 접근할 수 있다.
- 자신의 인스턴스 변수인 value, 자신이 속한 코드 블럭의 지역/매개 변수인 localVar/paramVar, 바깥 클래스의 인스턴스 멤버인 outInstanceVar에 접근할 수 있다.
- 지역 클래스는 지역 변수처럼 접근 제어자를 사용할 수는 없다.
public class LocalOuter {
private int outInstanceVar = 3;
public void process(int paramVar) {
int localVar = 1;
class LocalPrinter {
int value = 0;
public void printData() {
System.out.println("value = " + value);
System.out.println("localvar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
}
LocalPrinter printer = new LocalPrinter();
printer.printData();
}
public static void main(String[] args) {
LocalOuter localOuter = new LocalOuter();
localOuter.process(2);
}
// 출력
// value = 0
// localVar = 1
// paramVar = 2
// outInstanceVar = 3
}
내부 클래스를 포함한 중첩 클래스도 일반 클래스처럼 인터페이스를 구현하거나, 부모 클래스를 상속할 수 있다. 지역 클래스를 통해 사용 예시를 알아보자.
public interface Printer {
void print();
}
///
public class LocalOuter {
private int outInstanceVar = 3;
public void process(int paramVar) {
int localVar = 1;
class LocalPrinter implements Printer {
int value = 0;
@Override
public void print() {
System.out.println("value = " + value);
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
}
Printer printer = new LocalPrinter();
printer.print();
}
public static void main(String[] args) {
LocalOuter localOuter = new LocalOuter();
localOuter.process(2);
}
// 출력
// value = 0
// localVar = 1
// paramVar = 2
// outInstanceVar = 3
}
지역 변수 캡처
a. 변수들의 생명 주기
지역 클래스를 더 자세히 학습하기 전, 변수들의 생명 주기에 대해 정리해 보자.
- 클래스 변수 (메서드 영역) = 프로그램 종료까지 생존하므로 가장 긺
- 클래스(static) 변수는 메서드 영역에 존재하고, 자바가 클래스 정보를 읽어 들이는 순간부터 프로그램 종료까지 존재한다.
- 인스턴스 변수 (힙 영역) = 인스턴스의 생존 기간
- 인스턴스 변수는 자신이 소속된 인스턴스가 GC 되기 전까지 존재한다.
- 지역 변수 (스택 영역) = 메서드 호출이 끝나면 사라짐
- 지역 변수는 스택 영역의 스택 프레임 안에 존재하기 때문에 메서드가 호출되면 생성되고, 메서드 호출이 종료되면 스택 프레임이 제거되면서 그 안에 있는 지역 변수도 모두 제거된다.
b. 예시 코드 분석
아래 예시 코드를 실행해 보면, process() 스택 프레임과 지역 변수가 스택 영역에서 사라진 뒤에도 LocalPrinter 인스턴스가 스택 영역에 존재하던 변수들에 접근하는 것을 확인할 수 있다. 이 부분이 이상하다는 생각이 든다면 변수들의 생명 주기를 잘 이해하고 있는 것이다. 밑에서 메모리 그림을 통해 더 자세하게 이해해 보자.
public class LocalOuter {
private int outInstanceVar = 3;
public Printer process(int paramVar) {
// 지역 변수는 스택 프레임이 제거될 때 함께 사라짐
int localVar = 1;
class LocalPrinter implements Printer {
int value = 0;
@Override
public void print() {
System.out.println("value = " + value);
// 생존 주기: 인스턴스 > 지역 변수
System.out.println("localVar = " + localVar);
System.out.println("paramVar = " + paramVar);
System.out.println("outInstanceVar = " + outInstanceVar);
}
}
Printer printer = new LocalPrinter();
// printer.print();
return printer;
}
public static void main(String[] args) {
LocalOuter localOuter = new LocalOuter();
Printer printer = localOuter.process(2);
// process()의 스택 프레임이 사라진 이후에 실행
printer.print();
}
// 출력
// value = 0
// localVar = 1
// paramVar = 2
// outInstanceVar = 3
}
지역 클래스로 만든 객체도 인스턴스이기 때문에 힙 영역에 존재한다. 따라서 GC 전까지 생존한다.
- LocalPrinter 인스턴스는 process() 메서드 안에서 생성된다. 그리고 process()에서 main()으로 생성한 LocalPrinter 인스턴스를 반환하고, printer 변수에 참조를 보관한다. 따라서 LocalPrinter 인스턴스는 main()이 종료될 때까지 생존한다.
- paramVar, localVar 같은 지역 변수는 process() 메서드를 실행하는 동안에만 스택 영역에서 생존한다. 이후 process() 메서드가 종료되면 process() 스택 프레임이 스택 영역에서 제거되면서 함께 제거된다.
LocalPrinter 인스턴스는 print() 메서드를 통해 힙 영역에 존재하는 바깥 인스턴스의 변수인 outInstanceVar에 접근한다. print() 메서드를 통해 스택 영역에 존재하는 지역 변수에도 접근하는 것처럼 보이지만, 스택 영역에 존재하는 지역 변수를 힙 영역에 있는 인스턴스가 접근하는 것은 생각처럼 단순하지 않다.
- 지역 변수의 생명 주기는 매우 짧은 반면, 인스턴스의 생명 주기는 GC 전까지라 매우 길다. 여기서 process() 메서드가 종료돼도 LocalPrinter 인스턴스는 계속 생존할 수 있다.
- 예제를 살펴보면, process() 메서드가 종료된 이후에 main() 메서드 안에서 LocalPrinter.print()를 호출한다.
- 따라서 LocalPrinter 인스턴스에 있는 print() 메서드는 지역 변수인 paramVar, localVar에 접근해야 한다. 그러나 process() 메서드가 이미 종료됐으므로 해당 지역 변수들도 이미 제거된 상태이다.
- 그런데 실행 결과를 보면 지역 변수들의 값이 모두 정상적으로 출력되는 것을 확인할 수 있다.
c. 지역 변수 캡처 과정
자바는 이런 문제를 해결하기 위해 지역 클래스의 인스턴스를 생성하는 시점에 접근이 필요한 지역 변수를 모두 복사해서 생성한 인스턴스에 함께 넣어둔다. 이런 과정을 캡처(Capture)라고 한다. 캡처 과정은 아래와 같다.
- LocalPrinter 인스턴스 생성 시도
- 지역 클래스의 인스턴스를 생성할 때, 지역 클래스가 접근하는 지역 변수를 확인한다.
- ex. LocalPrinter 클래스는 paramVar, localVar 지역 변수에 접근한다.
- 사용하는 지역 변수 복사
- 지역 클래스가 사용하는 지역 변수를 복사한다.
- ex. paramVar, localVar 지역 변수를 복사한다.
- 지역 변수 복사 완료
- 복사한 지역 변수를 인스턴스에 포함한다.
- 인스턴스 생성 완료
- 복사한 지역 변수를 포함해서 인스턴스 생성이 완료된다. 이제 복사한 지역 변수를 인스턴스를 통해 접근할 수 있다.
LocalPrinter 인스턴스에서 print() 메서드를 통해 paramVar, localVar에 접근하는 것은 인스턴스에 있는 캡처된 변수에 접근하는 것이라고 보면 된다.
- 캡처한 변수의 생명 주기는 LocalPrinter 인스턴스의 생명 주기와 같다. 따라서 지역 변수의 생명 주기와는 무관하게 캡처 변수에 접근할 수 있다.
d. 지역 변수 final 선언 - 사실상 final
지역 클래스가 접근하는 지역 변수는 절대로 중간에 값이 변하면 안 된다. 따라서 final로 선언하거나, 사실상 final이어야 한다. 이것은 자바 문법이고 규칙이다.
- 영어로 effectively final이라고 한다. 사실상 final 지역 변수는 지역 변수에 final 키워드를 사용하지는 않았지만, 값을 변경하지 않는 지역 변수를 뜻한다. 따라서 사실상 final 지역 변수는 final 키워드를 넣든 넣지 않든 동일하게 동작해야 한다.
사실상 final이 아닐 경우 다음과 같은 문제가 발생할 수 있다.
- 예를 들어, LocalPrinter를 생성하는 시점에 지역 변수인 localVar와 paramVar를 캡처한 상황이라고 하자. 이후에 캡처한 지역 변수의 값을 아래 코드처럼 변경하면 스택 영역에 존재하는 지역 변수의 값과 인스턴스에 캡처한 캡처 변수의 값이 서로 달라지는 동기화 문제가 발생한다.
Printer printer = new LocalPrinter();
//localVar = 10; // 컴파일 오류
//paramVar = 20; // 컴파일 오류
- 물론 자바 언어를 설계할 때, 지역 변수의 값이 변경되면 인스턴스에 있는 캡처 변수의 값도 함께 변경되도록 설계하면 되지만, 이로 인해 수많은 문제들이 파생될 수 있다.
캡처 변수의 값을 변경하지 못하는 이유를 정리해 보면 다음과 같다.
- 지역 변수 값 변경과 인스턴스 캡처 변수 값 변경을 원자적으로 동기화해야 한다.
- 멀티쓰레드 상황에서 이런 동기화는 매우 어렵고, 성능을 저하시킬 수 있다.
- 개발자 입장에서 보면 예상치 못한 곳에서 값이 변경될 수 있다. 이는 디버깅을 어렵게 한다.
5. 익명 클래스 (anonymous class)
개념
익명 클래스는 지역 클래스의 특별한 종류 중 하나이다. 지역 클래스면서, 클래스의 이름이 없다는 특징이 있다. 다른 특징들은 다음과 같다.
- 익명 클래스를 사용하면 클래스의 이름을 생략하고, 클래스의 선언과 생성을 한 번에 처리할 수 있다. 즉, 클래스의 본문을 정의하면서 동시에 생성한다.
- new 다음에 바로 상속받으면서 구현할 부모 타입을 입력하면 된다. Printer라는 이름의 인터페이스를 구현한 익명 클래스를 생성하는 것이다.
- 본문에 Printer 인터페이스를 구현한 코드를 작성하면 된다.
Printer printer = new Printer() {
...
}
- 부모 클래스를 상속받거나, 인터페이스를 구현해야 한다. 즉, 익명 클래스를 사용할 때는 상위 클래스나 인터페이스가 필요하다.
- 말 그대로 이름이 없기 때문에 생성자를 가질 수 없으며 기본 생성자만 사용된다.
- AnonymousOuter$1과 같이 자바 내부에서 바깥 클래스 이름 + $ + 숫자로 정의된다. 익명 클래스가 여러 개라면 $1, $2, $3처럼 숫자가 증가하면서 구분된다.
익명 클래스를 사용하면 클래스를 별도로 정의하지 않고도 인터페이스나 추상 클래스를 즉석에서 구현할 수 있어 코드가 간결해진다는 장점이 있다. 그러나, 재사용이 필요하거나 복잡한 경우에는 별도의 클래스를 정의하는 것이 좋다.
- 익명 클래스는 단 한 번만 인스턴스를 생성할 수 있다. 다음과 같이 여러 번 생성이 필요하다면 익명 클래스를 사용할 수 없다. 대신에 지역 클래스를 선언하고 사용하면 된다.
Printer p1 = new LocalPrinter();
p1.print();
Printer p2 = new LocalPrinter();
p2.print();
활용
지역 클래스를 간단히 한 번만 생성해서 사용하는 경우에는 익명 클래스로 변경하는 것이 좋다.
public class ExRefMain {
public static void hello(Process process) {
process.run();
}
public static void main(String[] args) {
Process dice = new Process() {
@Override
public void run() {
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
}
};
Process sum = new Process() {
@Override
public void run() {
for (int i=1; i<=3; i++) {
System.out.println("i = " + i);
}
}
};
hello(dice);
hello(sum);
}
// 출력
// 주사위 = 4
// i = 1
// i = 2
// i = 3
}
이 경우 익명 클래스의 참조값을 변수에 담아둘 필요 없이, 인수로 바로 전달할 수 있다.
public class ExRefMain {
public static void hello(Process process) {
process.run();
}
public static void main(String[] args) {
hello(new Process() {
@Override
public void run() {
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
}
});
hello(new Process() {
@Override
public void run() {
for (int i=1; i<=3; i++) {
System.out.println("i = " + i);
}
}
});
}
// 출력
// 주사위 = 4
// i = 1
// i = 2
// i = 3
}
람다(Lambda)
자바8에 들어서면서 큰 변화가 있었는데, 바로 메서드(더 정확히는 함수)를 인수로 전달할 수 있게 됐다. 이것을 간단히 람다(Lambda)라고 한다.
- 코드를 보면 클래스나 인스턴스를 정의하지 않고, 메서드(더 정확히는 함수)의 코드 블럭을 직접 전달하는 것을 확인할 수 있다.
public class ExRefMain {
public static void hello(Process process) {
process.run();
}
public static void main(String[] args) {
hello(() -> {
int randomValue = new Random().nextInt(6) + 1;
System.out.println("주사위 = " + randomValue);
});
hello(() -> {
for (int i=1; i<=3; i++) {
System.out.println("i = " + i);
}
});
}
// 출력
// 주사위 = 4
// i = 1
// i = 2
// i = 3
}