이펙티브 자바 완벽 공략 2부
아이템 25. 톱 레벨 클래스는 한 파일에 하나만 담으라
아이템 25. 톱 레벨 클래스는 한 파일에 하나만 담으라
- 아이템 25. 톱 레벨 클래스는 한 파일에 하나만 담으라
- 아이템 25. 핵심 정리
- 아이템 25. 톱레벨 클래스는 한 파일에 하나만 담아라
- 톱레벨 클래스와 중첩 클래스의 차이
- 왜 여러 톱레벨 클래스를 한 파일에 두면 문제가 될까?
- Utensil.java
- Dessert.java
- Main 클래스
- Utensil.java를 기준으로 컴파일하면
- Dessert.java가 선택되면 결과가 달라질 수 있다
- 세 소스 파일을 동시에 컴파일하면 중복 정의 문제가 발생한다
- 문제의 본질은 클래스와 소스 파일의 관계가 불명확하다는 것이다
- 하나의 파일에는 하나의 톱레벨 클래스만 둔다
- 클래스 탐색도 쉬워진다
- 서로 강하게 종속된 클래스라면 어떻게 할까?
- 정적 멤버 클래스로 만들 수 있다
- 왜 static 멤버 클래스가 적절할까?
- 중첩 클래스로 만들면 접근 범위도 줄일 수 있다
- 구현 세부사항은 최대한 숨긴다
- 가독성 측면에서도 의미가 분명해진다
- 그렇다고 모든 관련 클래스를 중첩시켜야 하는 것은 아니다
- 톱레벨 클래스로 만들지 중첩 클래스로 만들지 판단하는 기준
- 소스 파일과 클래스의 관계를 단순하게 유지하자
- 빌드 시스템에서도 예측하기 쉬워진다
- 클래스 이름과 파일 이름의 관계도 명확해진다
- 컴파일 순서에 따라 결과가 달라지는 코드는 피해야 한다
- 작은 규칙이지만 얻는 효과는 크다
- 아이템 24와 함께 이해하기
- 핵심 정리
- 한 줄 정리
- 아이템 25. 핵심 정리
아이템 25. 핵심 정리
아이템 25. 톱레벨 클래스는 한 파일에 하나만 담아라
Java에서는 하나의 소스 파일 안에 여러 개의 톱레벨 클래스를 선언하는 것이 가능하다.
여기서 톱레벨 클래스(Top-Level Class) 란 다른 클래스 내부에 중첩되어 있지 않고 소스 파일의 가장 바깥 영역에 직접 선언된 클래스를 의미한다.
예를 들어 다음 코드를 보자.
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
Utensil과 Dessert는 모두 다른 클래스 내부에 포함되어 있지 않다.
따라서 둘 다 톱레벨 클래스다.
Utensil.java
├─ Utensil
└─ Dessert
이러한 코드는 경우에 따라 문법적으로 작성할 수 있다.
하지만 이번 아이템에서는 이러한 구조를 사용하지 말고
하나의 소스 파일에는 하나의 톱레벨 클래스만 정의하라.
고 권장한다.
단순한 코드 스타일 문제가 아니라 컴파일 과정에 따라 프로그램의 의미가 달라질 수 있는 위험이 있기 때문이다.
톱레벨 클래스와 중첩 클래스의 차이
앞선 아이템에서 중첩 클래스를 살펴봤다.
다음 구조에서는 Utensil만 톱레벨 클래스다.
class Utensil {
static class Dessert {
}
}
구조는 다음과 같다.
Utensil
│
└─ Dessert
Dessert는 Utensil 내부에 포함되어 있으므로 중첩 클래스이며, 정확히는 static 멤버 클래스다.
반면 다음 코드는 다르다.
class Utensil {
}
class Dessert {
}
두 클래스 모두 가장 바깥 영역에 선언되어 있으므로 둘 다 톱레벨 클래스다.
Utensil
Dessert
이번 아이템에서 경계하는 것은 바로 이런 구조다.
왜 여러 톱레벨 클래스를 한 파일에 두면 문제가 될까?
단순히 클래스가 두 개 있다는 것만 보면 특별한 문제가 없어 보인다.
class Utensil {
}
class Dessert {
}
문제는 동일한 클래스 이름을 가진 다른 소스 파일들이 존재할 때 발생할 수 있다.
예를 들어 두 개의 파일이 있다고 생각해보자.
Utensil.java
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
이 파일 하나에 두 개의 톱레벨 클래스가 들어 있다.
Utensil
→ "pan"
Dessert
→ "cake"
Dessert.java
이번에는 다른 파일에도 동일한 두 클래스가 있다고 해보자.
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
이번에는 값이 다르다.
Utensil
→ "pot"
Dessert
→ "pie"
즉 프로젝트에는 사실상 동일한 이름을 가진 톱레벨 클래스가 두 번 정의되어 있는 셈이다.
Main 클래스
그리고 다음 코드가 있다고 하자.
public class Main {
public static void main(
String[] args
) {
System.out.println(
Utensil.NAME
+ Dessert.NAME
);
}
}
Main은 다음 두 클래스의 값을 출력한다.
Utensil.NAME
+
Dessert.NAME
문제는 어떤 소스 파일이 컴파일 과정에서 선택되는가에 따라 결과가 달라질 수 있다는 것이다.
Utensil.java를 기준으로 컴파일하면
Utensil.java에는 다음 값이 들어 있다.
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
따라서 결과는 개념적으로 다음과 같다.
pancake
즉
pan + cake
가 된다.
Dessert.java가 선택되면 결과가 달라질 수 있다
반면 다른 파일에는 다음 정의가 있다.
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
이쪽의 클래스 정의가 사용되면 결과는 다음과 같이 달라질 수 있다.
potpie
즉
pot + pie
다.
동일한 Main 코드를 가지고 있는데도 어떤 파일이 컴파일 대상이 되는지에 따라 서로 다른 클래스 정의가 사용될 수 있는 것이다.
세 소스 파일을 동시에 컴파일하면 중복 정의 문제가 발생한다
이번에는 다음 세 파일을 모두 함께 컴파일한다고 생각해보자.
Main.java
Utensil.java
Dessert.java
그런데 Utensil.java와 Dessert.java 양쪽에 모두 다음 클래스가 있다.
Utensil
Dessert
즉 같은 이름의 톱레벨 클래스가 중복으로 존재한다.
Utensil.java
├─ Utensil
└─ Dessert
Dessert.java
├─ Utensil
└─ Dessert
이 경우에는 중복 클래스 정의 때문에 컴파일 오류가 발생할 수 있다.
결국 다음 세 가지 상황이 만들어진다.
특정 파일 조합으로 컴파일
→ 결과 A
다른 파일 조합으로 컴파일
→ 결과 B
모두 함께 컴파일
→ 컴파일 오류
소스 코드는 동일하지만 컴파일 방식에 따라 결과가 달라질 수 있다는 것은 매우 위험한 구조다.
문제의 본질은 클래스와 소스 파일의 관계가 불명확하다는 것이다
일반적인 Java 프로젝트에서는 다음 관계를 기대한다.
Utensil.java
→ Utensil
Dessert.java
→ Dessert
파일 이름만 보고도 어느 클래스가 정의되어 있는지 쉽게 알 수 있다.
하지만 한 파일에 여러 톱레벨 클래스를 정의하면 다음처럼 된다.
Utensil.java
→ Utensil
→ Dessert
이제 Dessert가 어디에 정의되어 있는지 파일 이름만으로 파악할 수 없다.
프로젝트 규모가 커질수록 이러한 구조는 코드 탐색과 유지보수를 어렵게 만든다.
하나의 파일에는 하나의 톱레벨 클래스만 둔다
가장 간단한 해결책은 클래스를 각각의 파일로 분리하는 것이다.
Utensil.java
class Utensil {
static final String NAME = "pan";
}
Dessert.java
class Dessert {
static final String NAME = "cake";
}
구조가 명확하다.
Utensil.java
└─ Utensil
Dessert.java
└─ Dessert
이제 소스 파일과 톱레벨 클래스가 일대일 관계를 가진다.
클래스 탐색도 쉬워진다
개발자가 Dessert 클래스를 찾고 싶다고 생각해보자.
한 파일에 하나의 톱레벨 클래스만 있으면 자연스럽게 다음 파일을 찾는다.
Dessert.java
코드와 파일 구조가 서로 일치한다.
물론 IntelliJ IDEA 같은 IDE에서는 클래스 이름을 검색해서 바로 이동할 수 있다.
하지만 IDE가 강력하다고 해서 소스 코드 구조를 불필요하게 복잡하게 만들 이유는 없다.
좋은 프로젝트 구조는 개발 도구 없이 보더라도 이해하기 쉬워야 한다.
서로 강하게 종속된 클래스라면 어떻게 할까?
때로는 두 클래스가 논리적으로 매우 강하게 연결되어 있어서 굳이 별도의 톱레벨 클래스로 분리할 필요가 없는 경우도 있다.
예를 들어 다음과 같은 상황이다.
Utensil
└─ Utensil을 위해서만 사용하는 Helper
Helper가 외부에서 독립적인 타입으로 사용할 필요가 없다면 별도의 톱레벨 클래스로 만들기보다 중첩 클래스로 넣는 방법을 생각할 수 있다.
정적 멤버 클래스로 만들 수 있다
public class Utensil {
private static class Helper {
void doSomething() {
// ...
}
}
}
이제 톱레벨 클래스는 Utensil 하나뿐이다.
Utensil.java
└─ Utensil
│
└─ Helper
Helper는 Utensil의 구현 세부사항으로 명확하게 들어간다.
왜 static 멤버 클래스가 적절할까?
앞선 아이템에서 살펴봤듯 멤버 클래스가 바깥 클래스의 특정 인스턴스를 사용할 필요가 없다면 static으로 만드는 것이 좋다.
public class Utensil {
private static class Helper {
}
}
Helper가 특정 Utensil 객체의 상태를 참조하지 않는다면 비정적 멤버 클래스로 만들 이유가 없다.
즉 아이템 24와 자연스럽게 연결된다.
아이템 24
바깥 인스턴스가 필요 없다면
static 멤버 클래스로 만들어라.
아이템 25
독립적인 톱레벨 타입이 아니라면
중첩 클래스로 넣는 것을 고려하라.
중첩 클래스로 만들면 접근 범위도 줄일 수 있다
톱레벨 클래스로 분리하면 최소한 패키지 수준에서 접근할 수 있는 형태가 될 수 있다.
class Helper {
}
하지만 특정 클래스 내부에 넣으면 private으로 제한할 수 있다.
public class Utensil {
private static class Helper {
}
}
이제 외부에서는 Helper의 존재 자체를 사용할 수 없다.
외부 코드
X
Helper 직접 접근
Utensil 내부
O
Helper 사용
정보 은닉 측면에서도 더 좋은 구조가 될 수 있다.
구현 세부사항은 최대한 숨긴다
예를 들어 어떤 클래스의 내부 계산에만 필요한 타입이 있다고 하자.
public class PriceCalculator {
private static class CalculationResult {
private final long amount;
private final long discount;
private CalculationResult(
long amount,
long discount
) {
this.amount = amount;
this.discount = discount;
}
}
}
CalculationResult가 PriceCalculator 내부에서만 의미가 있다면 굳이 다음처럼 별도의 톱레벨 클래스로 공개할 이유가 없다.
CalculationResult.java
대신 관련된 클래스 바로 안에 두면 관계가 명확해진다.
PriceCalculator
│
└─ CalculationResult
가독성 측면에서도 의미가 분명해진다
다음 두 구조를 비교해보자.
별도의 톱레벨 클래스
PriceCalculator.java
CalculationResult.java
코드를 처음 보는 사람은 두 클래스 사이의 관계를 따로 파악해야 한다.
반면
PriceCalculator
│
└─ CalculationResult
처럼 정의하면 CalculationResult가 PriceCalculator의 구현을 돕기 위한 타입이라는 사실이 즉시 드러난다.
그렇다고 모든 관련 클래스를 중첩시켜야 하는 것은 아니다
여기서 중요한 점은
한 파일에 하나의 톱레벨 클래스
라는 원칙이
관련된 클래스를 전부
하나의 파일 안에 집어넣어라.
라는 의미는 아니라는 것이다.
독립적인 의미가 있는 클래스라면 각자의 파일로 분리하는 것이 좋다.
예를 들어
Order
Customer
Product
는 서로 관계가 깊어도 각자 독립적인 도메인 타입이다.
따라서 일반적으로 다음처럼 두는 것이 자연스럽다.
Order.java
Customer.java
Product.java
반면 오직 Order 구현을 위해 존재하는 작은 도우미 타입이라면
Order
│
└─ OrderCalculation
처럼 중첩 클래스로 둘 수 있다.
톱레벨 클래스로 만들지 중첩 클래스로 만들지 판단하는 기준
다음 질문을 해볼 수 있다.
이 클래스가 독립적인 개념인가?
다른 클래스에서도 사용할 가능성이 있는가?
외부에서 이 타입을 알아야 하는가?
별도로 테스트하고 재사용할 가치가 있는가?
그렇다면 별도의 톱레벨 클래스로 분리하는 것이 자연스럽다.
반대로
특정 클래스의 구현에서만 사용되는가?
외부에서 알 필요가 없는가?
바깥 클래스와 함께 있을 때 의미가 명확한가?
라면 멤버 클래스를 고려할 수 있다.
소스 파일과 클래스의 관계를 단순하게 유지하자
한 파일에 하나의 톱레벨 클래스만 두면 프로젝트 구조도 자연스럽다.
src/
└─ main/
└─ java/
└─ example/
├─ Order.java
├─ Customer.java
├─ Product.java
└─ Payment.java
각 파일과 타입 사이의 관계가 명확하다.
Order.java
→ Order
Customer.java
→ Customer
Product.java
→ Product
클래스를 추가하거나 삭제할 때도 파일 단위로 변경을 파악하기 쉽다.
빌드 시스템에서도 예측하기 쉬워진다
소스 파일 하나에 여러 톱레벨 클래스가 존재하면 컴파일러가 어떤 클래스 정의를 만나게 되는지에 따라 복잡한 상황이 만들어질 수 있다.
반면
하나의 톱레벨 클래스
=
하나의 소스 파일
이라는 구조를 유지하면 컴파일 입력과 클래스 정의의 관계가 단순해진다.
빌드 시스템, IDE, 코드 분석 도구 입장에서도 훨씬 예측 가능한 프로젝트 구조가 된다.
클래스 이름과 파일 이름의 관계도 명확해진다
특히 public 톱레벨 클래스는 Java에서 파일 이름과 클래스 이름의 관계가 명확하다.
public class Order {
}
파일은 다음과 같이 구성한다.
Order.java
이러한 Java의 일반적인 구조를 프로젝트 전체에서 일관되게 유지하는 것이 좋다.
같은 파일에 여러 package-private 톱레벨 클래스를 추가할 수 있다고 해서 굳이 그 기능을 활용할 필요는 없다.
컴파일 순서에 따라 결과가 달라지는 코드는 피해야 한다
소프트웨어에서 중요한 것은 결정성이다.
같은 소스 코드를 컴파일했으면 언제나 같은 결과가 나오는 것이 바람직하다.
Source Code
↓
Compile
↓
항상 동일한 결과
그런데 소스 파일 배치나 컴파일 대상 순서에 따라
pancake
가 나오기도 하고
potpie
가 나오기도 하거나
Duplicate Class Error
가 발생한다면 유지보수하기 매우 어려운 코드가 된다.
이 아이템의 규칙은 이런 불필요한 불확실성을 처음부터 제거하는 간단한 방법이다.
작은 규칙이지만 얻는 효과는 크다
이번 아이템의 원칙은 매우 단순하다.
하나의 소스 파일
↓
하나의 톱레벨 클래스
하지만 이 간단한 규칙만으로도 다음과 같은 장점을 얻을 수 있다.
컴파일 결과의 예측 가능성
파일과 클래스 관계 명확화
코드 탐색 용이
중복 클래스 정의 방지
유지보수성 향상
구현 세부사항의 접근 범위 축소
특별한 설계 패턴이나 복잡한 기술이 필요한 것도 아니다.
처음부터 파일을 적절하게 나누면 된다.
아이템 24와 함께 이해하기
아이템 24에서는 중첩 클래스를 사용할 때 바깥 인스턴스가 필요하지 않다면 정적 멤버 클래스로 만들라고 했다.
아이템 25에서는 한 파일에 여러 톱레벨 클래스를 넣지 말라고 한다.
두 원칙을 함께 사용하면 클래스 위치를 결정하는 기준이 명확해진다.
새로운 클래스 필요
↓
독립적인 타입인가?
├─ YES
│
│ 별도의 파일
│ +
│ 톱레벨 클래스
│
└─ NO
↓
특정 클래스에 종속적인가?
├─ YES
│
│ 멤버 클래스 고려
│
│ Outer 인스턴스 필요?
│
├─ YES → 비정적 멤버 클래스
└─ NO → 정적 멤버 클래스
이 기준만 기억해도 클래스 구조를 상당히 깔끔하게 유지할 수 있다.
핵심 정리
- 톱레벨 클래스는 다른 클래스 내부가 아니라 소스 파일의 가장 바깥 영역에 직접 정의된 클래스다.
- 하나의 Java 소스 파일에 여러 package-private 톱레벨 클래스를 작성할 수 있는 경우가 있지만 권장하지 않는다.
- 한 파일에 여러 톱레벨 클래스가 존재하면 동일한 클래스 이름을 다른 파일에서도 정의했을 때 컴파일 대상과 방식에 따라 예상하지 못한 결과가 발생할 수 있다.
- 특정 파일 조합을 컴파일했을 때 서로 다른 클래스 정의가 선택되어 실행 결과가 달라질 수 있다.
- 중복 정의가 포함된 모든 파일을 함께 컴파일하면 중복 클래스 오류가 발생할 수도 있다.
- 같은 소스 코드가 컴파일 방식에 따라 다른 결과를 만드는 구조는 유지보수성과 예측 가능성을 떨어뜨린다.
- 가장 간단한 해결 방법은 하나의 소스 파일에 하나의 톱레벨 클래스만 정의하는 것이다.
Utensil.java에는Utensil,Dessert.java에는Dessert를 두는 식으로 파일과 타입의 관계를 명확하게 유지하는 것이 좋다.- 특정 클래스에 완전히 종속된 보조 클래스라면 별도의 톱레벨 클래스로 만들지 않고 중첩 클래스로 둘 수 있다.
- 바깥 클래스의 특정 인스턴스를 참조할 필요가 없다면 아이템 24의 원칙에 따라 정적 멤버 클래스로 만드는 것이 좋다.
- 중첩 클래스로 만들면
private접근 제어자를 이용해 외부에 노출할 필요가 없는 구현 세부사항을 숨길 수 있다. - 독립적인 도메인 개념이나 다른 곳에서 재사용되는 타입이라면 별도의 파일과 톱레벨 클래스로 분리하는 것이 더 적절하다.
- 단순히 관련이 있다는 이유만으로 모든 클래스를 한 파일에 넣어서는 안 된다.
- 파일 하나와 주요 타입 하나가 대응하도록 유지하면 코드 탐색, 빌드, 유지보수 측면에서 구조가 단순해진다.
- 핵심은 Java 문법상 가능하다는 이유로 여러 톱레벨 클래스를 한 파일에 넣지 않고 파일과 타입 사이의 관계를 최대한 명확하고 결정적으로 유지하는 것이다.
한 줄 정리
Java에서는 하나의 파일에 여러 톱레벨 클래스를 선언할 수 있는 경우가 있지만 컴파일 과정에 따라 다른 클래스 정의가 선택되거나 중복 정의 오류가 발생하는 불필요한 위험을 만들 수 있으므로, 독립적인 톱레벨 클래스는 파일별로 하나씩 두고 특정 클래스에 종속된 구현 타입만 필요하다면 정적 멤버 클래스로 감추는 것이 좋다.