이펙티브 자바 완벽 공략 2부
아이템 24. 멤버 클래스는 되도록 static으로 만들어라.
아이템 24. 멤버 클래스는 되도록 static으로 만들어라.
- 아이템 24. 멤버 클래스는 되도록 static으로 만들어라.
- 아이템 24. 핵심 정리 - 네 종류의 클래스와 각각의 쓰임
- 아이템 24. 멤버 클래스는 되도록 static으로 만들라
- 중첩 클래스와 멤버 클래스는 같은 의미가 아니다
- 멤버와 지역의 차이는 선언 위치다
- 클래스 영역에 선언되면 멤버 클래스다
- 메서드 내부에 선언되면 지역 클래스다
- 네 가지 중첩 클래스
- 정적 멤버 클래스
- 정적 멤버 클래스는 바깥 클래스의 정적 멤버에 접근할 수 있다
- 정적 멤버 클래스는 Outer 인스턴스가 필요하지 않다
- 왜 Outer 안에 정의할까?
- 정적 멤버 클래스가 적합한 경우
- 비정적 멤버 클래스
- 비정적 멤버 클래스는 바깥 인스턴스를 참조할 수 있다
- 비정적 멤버 클래스에는 숨은 외부 인스턴스 참조가 있다
- 명시적으로 바깥 인스턴스를 참조할 수도 있다
- 비정적 멤버 클래스는 Outer 없이 만들 수 없다
- 실제로는 Outer 내부에서 생성하는 경우가 많다
- 비정적 멤버 클래스가 필요한 경우
- 바깥 인스턴스를 사용하지 않는다면 문제가 달라진다
- 이런 경우 static으로 만드는 것이 좋다
- 왜 static이 더 효율적인가?
- 공간적인 비용이 생긴다
- 생성 비용과 사용 구조도 복잡해진다
- 더 중요한 문제는 바깥 객체의 생명주기다
- 장기간 보관되는 Inner는 특히 조심해야 한다
- 멤버 클래스는 기본적으로 static부터 검토하자
- 대표적인 비정적 멤버 클래스 활용: 어댑터
- Iterator를 제공하는 어댑터
- 이 경우 비정적 멤버 클래스가 자연스럽다
- Adapter 관점에서 보면
- 세 번째 종류: 익명 클래스
- 별도의 이름이 필요 없다
- Java 8 이후 익명 클래스 사용은 많이 줄었다
- 메서드 레퍼런스로 더 줄일 수도 있다
- 그렇다고 익명 클래스가 사라진 것은 아니다
- 네 번째 종류: 지역 클래스
- 지역 클래스는 멤버 클래스가 아니다
- 지역 클래스는 사용 빈도가 높지 않다
- 네 가지 중첩 클래스를 다시 비교해보자
- 정적 멤버 클래스와 비정적 멤버 클래스 비교
- 비정적 멤버 클래스를 발견하면 확인할 것
- static을 붙이는 것은 단순한 스타일 문제가 아니다
- 실무에서 자주 볼 수 있는 정적 멤버 클래스
- Builder를 비정적으로 만들면 이상해진다
- 멤버 클래스 설계 기준
- 핵심 정리
- 한 줄 정리
- 아이템 24. 완벽 공략 - 어댑터 패턴
- 어댑터 패턴이 필요한 상황
- 기존 클래스를 직접 수정하면 되지 않을까?
- Adapter를 별도로 만든다
- 어댑터 패턴의 구성 요소
- 어댑터의 핵심은 인터페이스 변환이다
- 비정적 멤버 클래스가 어댑터에 잘 어울리는 이유
- MyIterator는 특정 MyCollection 인스턴스에 종속된다
- Iterator가 일종의 Adapter 역할을 한다
- 왜 static으로 만들지 않는가?
- 어댑터를 별도 클래스로 분리할 수도 있다
- 내부 클래스와 별도 Adapter 클래스 중 무엇을 선택할까?
- Object Adapter와 위임
- 기존 코드를 수정하지 않아도 된다
- 변경 이유도 분리된다
- Java I/O에서 볼 수 있는 대표적인 Adapter
- BufferedReader까지 연결할 수 있다
- 어댑터와 데코레이터의 차이
- 어댑터 패턴의 장점
- 어댑터 패턴의 단점
- 코드 구조를 한 단계 더 따라가야 한다
- Adapter 안에 비즈니스 로직을 너무 많이 넣지 않는 것이 좋다
- 실무 예제: 외부 API Adapter
- Adapter와 DIP를 함께 생각할 수 있다
- 비정적 멤버 클래스 형태가 좋은 경우
- 어댑터 패턴을 판단하는 질문
- 핵심 정리
- 한 줄 정리
- 아이템 24. 핵심 정리 - 네 종류의 클래스와 각각의 쓰임
아이템 24. 핵심 정리 - 네 종류의 클래스와 각각의 쓰임
아이템 24. 멤버 클래스는 되도록 static으로 만들라
Java에서는 하나의 클래스 내부에 또 다른 클래스를 정의할 수 있다.
이런 클래스를 일반적으로 중첩 클래스(Nested Class) 라고 한다.
그런데 중첩 클래스라고 해서 모두 같은 성격을 가지는 것은 아니다.
Java에서 중첩 클래스는 크게 다음 네 종류로 나눌 수 있다.
1. 정적 멤버 클래스
2. 비정적 멤버 클래스
3. 익명 클래스
4. 지역 클래스
이번 아이템에서 가장 중요하게 다루는 것은 이 중에서도 다음 두 가지다.
정적 멤버 클래스
vs
비정적 멤버 클래스
핵심 원칙은 단순하다.
바깥 클래스의 인스턴스를 참조할 필요가 없다면 비정적 멤버 클래스로 만들지 말고
static멤버 클래스로 만들어라.
왜 그런지 이해하려면 먼저 중첩 클래스와 멤버 클래스의 차이부터 알아야 한다.
중첩 클래스와 멤버 클래스는 같은 의미가 아니다
중첩 클래스는 말 그대로 다른 클래스 내부에 정의된 클래스다.
public class Outer {
class Inner {
}
}
Inner는 Outer 내부에 있으므로 중첩 클래스다.
하지만 중첩 클래스라고 해서 모두 멤버 클래스인 것은 아니다.
이 차이를 이해하려면 변수와 비교하면 쉽다.
멤버와 지역의 차이는 선언 위치다
다음 클래스를 보자.
public class Sample {
private int memberValue;
public void doSomething() {
int localValue = 10;
}
}
memberValue는 클래스 영역에 선언되어 있기 때문에 멤버 변수다.
private int memberValue;
반면 localValue는 메서드 내부에 선언되어 있다.
int localValue = 10;
따라서 지역 변수(Local Variable) 다.
클래스도 동일한 기준으로 생각할 수 있다.
클래스 영역에 선언되면 멤버 클래스다
public class Outer {
static class StaticMemberClass {
}
class NonStaticMemberClass {
}
}
두 클래스 모두 Outer의 클래스 영역에 선언되어 있다.
따라서 둘 다 멤버 클래스다.
차이는 static 여부다.
static 존재
→ 정적 멤버 클래스
static 없음
→ 비정적 멤버 클래스
메서드 내부에 선언되면 지역 클래스다
반면 다음 클래스는 다르다.
public class Outer {
public void doSomething() {
class LocalClass {
}
LocalClass local =
new LocalClass();
}
}
LocalClass는 Outer 내부에 존재하기 때문에 넓은 의미에서는 중첩 클래스다.
하지만 클래스의 멤버로 선언된 것이 아니다.
doSomething()이라는 메서드의 지역 영역에 선언되어 있다.
따라서 지역 클래스(Local Class) 라고 한다.
네 가지 중첩 클래스
전체 구조를 정리하면 다음과 같다.
| 종류 | 선언 위치 | 멤버 클래스인가? | 이름 |
|---|---|---|---|
| 정적 멤버 클래스 | 클래스 영역 | O | 있음 |
| 비정적 멤버 클래스 | 클래스 영역 | O | 있음 |
| 익명 클래스 | 표현식이 필요한 위치 | X | 없음 |
| 지역 클래스 | 메서드 등 지역 영역 | X | 있음 |
이번 아이템의 핵심은 앞의 두 가지다.
정적 멤버 클래스
먼저 정적 멤버 클래스부터 살펴보자.
public class Outer {
private static int number = 10;
public static class Inner {
public void print() {
System.out.println(number);
}
}
}
Inner는 다음과 같이 선언되어 있다.
public static class Inner {
}
클래스 영역에 있으므로 멤버 클래스이고, static이 붙었으므로 정적 멤버 클래스다.
정적 멤버 클래스는 바깥 클래스의 정적 멤버에 접근할 수 있다
다음 필드는 Outer의 정적 필드다.
private static int number = 10;
정적 멤버 클래스에서는 이를 사용할 수 있다.
public static class Inner {
public void print() {
System.out.println(number);
}
}
개념적으로 다음과 같다.
Outer.Inner
↓
Outer의 static 영역 사용 가능
정적 멤버 클래스는 Outer 인스턴스가 필요하지 않다
정적 멤버 클래스의 가장 중요한 특징이다.
다음처럼 바깥 클래스 객체를 생성하지 않고도 내부 클래스 객체를 생성할 수 있다.
Outer.Inner inner =
new Outer.Inner();
Outer 인스턴스는 필요하지 않다.
Outer 인스턴스
X
Outer.Inner 인스턴스
O
즉 두 객체가 인스턴스 수준에서 연결되어 있지 않다.
왜 Outer 안에 정의할까?
그렇다면 독립적으로 생성할 수 있는데 굳이 왜 다른 클래스 안에 넣을까?
주로 바깥 클래스와 논리적으로 강하게 연결되어 있지만 독립된 객체로 사용할 수 있는 경우에 적합하다.
예를 들어 계산기에 사용되는 연산 종류를 생각할 수 있다.
public class Calculator {
public enum Operation {
PLUS,
MINUS,
MULTIPLY,
DIVIDE
}
}
사용할 때도 의미가 자연스럽다.
Calculator.Operation.PLUS
Operation은 논리적으로 Calculator와 관련이 깊다.
하지만 특정 Calculator 인스턴스 하나에 종속될 필요는 없다.
이런 경우 정적 중첩 타입으로 두는 것이 자연스럽다.
정적 멤버 클래스가 적합한 경우
다음과 같은 특징이 있다면 정적 멤버 클래스를 고려할 수 있다.
바깥 클래스와 논리적으로 관련이 있다.
하지만 특정 바깥 객체와 연결될 필요는 없다.
바깥 객체의 인스턴스 필드나 인스턴스 메서드를 사용하지 않는다.
이런 클래스에 static을 붙이지 않을 이유가 별로 없다.
비정적 멤버 클래스
이번에는 static을 제거해보자.
public class Outer {
private int number = 10;
public class Inner {
public void print() {
System.out.println(number);
}
}
}
Inner는 이제 비정적 멤버 클래스다.
public class Inner {
}
비정적 멤버 클래스의 가장 중요한 특징은 특정 Outer 인스턴스와 연결된다는 것이다.
비정적 멤버 클래스는 바깥 인스턴스를 참조할 수 있다
다음 코드를 보자.
public class Outer {
private int number = 10;
public class Inner {
public void print() {
System.out.println(number);
}
}
}
Inner 안에서 다음 필드를 바로 사용하고 있다.
number
그런데 number는 static이 아니다.
private int number = 10;
즉 특정 Outer 객체가 가지고 있는 값이다.
Inner가 이 값에 접근할 수 있다는 것은 내부적으로 특정 Outer 인스턴스와 연결되어 있다는 뜻이다.
비정적 멤버 클래스에는 숨은 외부 인스턴스 참조가 있다
개념적으로 다음과 같은 구조라고 생각할 수 있다.
Outer 인스턴스
↑
│
숨은 참조
│
Inner 인스턴스
코드에는 명시적으로 다음 필드를 작성하지 않았다.
private final Outer outer;
하지만 비정적 멤버 클래스 객체는 자신을 생성한 바깥 클래스 인스턴스와 연결된다.
그래서 내부 클래스에서 바깥 인스턴스의 멤버에 접근할 수 있다.
명시적으로 바깥 인스턴스를 참조할 수도 있다
동일한 이름의 필드가 존재한다고 생각해보자.
public class Outer {
private int number = 10;
public class Inner {
private int number = 20;
public void print() {
System.out.println(number);
System.out.println(
Outer.this.number
);
}
}
}
여기서
Outer.this
는 현재 Inner와 연결되어 있는 바깥 Outer 인스턴스를 의미한다.
따라서 다음처럼 구분할 수 있다.
this
→ Inner 인스턴스
Outer.this
→ Inner를 감싸고 있는 Outer 인스턴스
비정적 멤버 클래스는 Outer 없이 만들 수 없다
정적 멤버 클래스는 다음처럼 생성할 수 있었다.
new Outer.Inner();
하지만 비정적 멤버 클래스는 다르다.
먼저 Outer 인스턴스가 필요하다.
Outer outer =
new Outer();
Outer.Inner inner =
outer.new Inner();
조금 낯설지만 문법적으로는 다음 구조다.
Outer 인스턴스 생성
↓
해당 Outer와 연결된 Inner 생성
즉 Inner가 독립적으로 존재하는 것이 아니라 특정 Outer와 함께 존재한다.
실제로는 Outer 내부에서 생성하는 경우가 많다
외부에서 다음과 같이 작성하는 코드는 자주 사용되는 형태는 아니다.
Outer outer =
new Outer();
Outer.Inner inner =
outer.new Inner();
비정적 멤버 클래스는 보통 바깥 객체의 인스턴스 메서드 안에서 직접 생성한다.
public class Outer {
public void execute() {
Inner inner =
new Inner();
inner.run();
}
private class Inner {
void run() {
System.out.println("run");
}
}
}
execute()는 인스턴스 메서드다.
즉 이 메서드가 실행되고 있다는 것은 이미 특정 Outer 객체가 존재한다는 의미다.
따라서 그 객체와 연결된 Inner를 자연스럽게 생성할 수 있다.
비정적 멤버 클래스가 필요한 경우
비정적 멤버 클래스가 무조건 나쁜 것은 아니다.
실제로 내부 클래스가 바깥 인스턴스의 상태를 적극적으로 사용한다면 자연스러운 설계가 될 수 있다.
public class Order {
private final List<String> items =
new ArrayList<>();
public class ItemViewer {
public void printItems() {
for (String item : items) {
System.out.println(item);
}
}
}
}
ItemViewer가 특정 Order의 items를 바라봐야 한다면 바깥 객체와 연결되어 있는 것이 의미가 있다.
Order A
↑
│
ItemViewer A
다른 Order가 있다면 다른 상태를 바라본다.
Order B
↑
│
ItemViewer B
바깥 인스턴스를 사용하지 않는다면 문제가 달라진다
다음 클래스를 보자.
public class Outer {
public class Inner {
public void calculate() {
System.out.println(
10 + 20
);
}
}
}
Inner가 Outer의 어떤 인스턴스 상태도 사용하지 않는다.
Outer.this 사용 안 함
Outer 인스턴스 필드 사용 안 함
Outer 인스턴스 메서드 사용 안 함
그런데 비정적 멤버 클래스로 선언되어 있다.
이 경우 특정 Outer 객체와 연결될 이유가 없다.
이런 경우 static으로 만드는 것이 좋다
public class Outer {
public static class Inner {
public void calculate() {
System.out.println(
10 + 20
);
}
}
}
이제 다음처럼 독립적으로 생성할 수 있다.
Outer.Inner inner =
new Outer.Inner();
불필요한 바깥 인스턴스 연결도 사라진다.
이것이 아이템 24의 핵심이다.
왜 static이 더 효율적인가?
비정적 멤버 클래스는 바깥 객체와 연결되므로 내부적으로 추가적인 참조를 유지해야 한다.
개념적으로는 다음과 같다.
NonStaticInner
├─ 자신의 상태
└─ Outer 인스턴스 참조
반면 정적 멤버 클래스에는 이런 연결이 없다.
StaticInner
└─ 자신의 상태
따라서 바깥 인스턴스를 사용할 이유가 없다면 정적 멤버 클래스가 구조적으로 더 단순하다.
공간적인 비용이 생긴다
비정적 멤버 클래스 객체는 바깥 객체에 대한 참조를 유지해야 한다.
Inner Object
↓
Outer Object Reference
객체마다 추가적인 참조가 필요하므로 정적 멤버 클래스보다 조금 더 많은 메모리가 필요할 수 있다.
개별 객체에서는 작은 차이지만 객체가 대량으로 생성되는 구조에서는 의미가 생길 수도 있다.
생성 비용과 사용 구조도 복잡해진다
비정적 멤버 클래스는 반드시 바깥 인스턴스와 연결되어야 한다.
따라서 바깥 객체가 필요하지 않은 상황에서도 생성 관계가 생긴다.
Outer 생성
↓
Inner 생성
정적 멤버 클래스라면 바로 만들 수 있다.
Inner 생성
클래스의 진짜 의미와 관계없는 객체 의존성이 하나 추가되는 셈이다.
더 중요한 문제는 바깥 객체의 생명주기다
실무적으로 더 주의할 부분은 숨은 참조 때문에 바깥 객체의 생명주기가 길어질 수 있다는 것이다.
다음 구조를 생각해보자.
어딘가에서 Inner 객체 보관
↓
Inner가 Outer를 참조
↓
Outer도 계속 도달 가능한 객체가 됨
외부에서는 Outer가 더 이상 필요 없다고 생각할 수 있다.
하지만 Inner가 살아 있고, Inner가 Outer를 참조한다면 GC 관점에서 Outer 역시 여전히 참조 가능한 객체일 수 있다.
즉 예상하지 못한 객체 보존이 발생할 수 있다.
장기간 보관되는 Inner는 특히 조심해야 한다
예를 들어 다음과 같은 상황을 생각할 수 있다.
Listener Registry
↓
Inner Listener 보관
↓
Inner Listener
↓
Outer
바깥 객체는 더 이상 필요하지 않아도 리스너가 등록된 상태로 남아 있다면 바깥 객체까지 함께 오래 살아남을 수 있다.
따라서 비정적 멤버 클래스를 사용할 때는 단순한 문법의 차이가 아니라 객체 참조 그래프가 달라진다는 것을 이해해야 한다.
멤버 클래스는 기본적으로 static부터 검토하자
새로운 멤버 클래스를 만든다고 생각해보자.
처음부터 다음처럼 만들기보다
class Inner {
}
먼저 다음 질문을 한다.
이 클래스가 특정 Outer 인스턴스의
상태를 사용해야 하는가?
필요 없다면
static class Inner {
}
로 선언한다.
즉 실무에서는 다음 순서로 생각하면 편하다.
멤버 클래스 필요
↓
Outer 인스턴스 필요?
├─ YES → 비정적 멤버 클래스 검토
└─ NO → static 멤버 클래스
대표적인 비정적 멤버 클래스 활용: 어댑터
비정적 멤버 클래스가 자연스럽게 사용될 수 있는 대표적인 사례 중 하나는 어댑터(Adapter) 역할이다.
어댑터는 기존 객체를 클라이언트가 원하는 다른 인터페이스처럼 사용할 수 있도록 연결해주는 역할을 한다.
개념적으로 다음과 같다.
기존 객체
↓
Adapter
↓
클라이언트가 원하는 타입
Iterator를 제공하는 어댑터
예를 들어 직접 만든 자료구조가 있다고 하자.
public class MyCollection<E> {
private final List<E> values =
new ArrayList<>();
public void add(E value) {
values.add(value);
}
}
이 객체를 순회하기 위해 Iterator를 제공하고 싶다.
public class MyCollection<E>
implements Iterable<E> {
private final List<E> values =
new ArrayList<>();
public void add(E value) {
values.add(value);
}
@Override
public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator
implements Iterator<E> {
private int index;
@Override
public boolean hasNext() {
return index
< values.size();
}
@Override
public E next() {
return values.get(index++);
}
}
}
MyIterator는 바깥의 MyCollection 객체가 가지고 있는 다음 상태를 사용한다.
values
즉 특정 컬렉션 객체의 데이터를 순회하기 위한 Iterator다.
이 경우 비정적 멤버 클래스가 자연스럽다
객체 관계는 다음과 같다.
MyCollection A
↑
│
MyIterator A
MyIterator A는 MyCollection A의 데이터만 순회해야 한다.
따라서 둘 사이에 인스턴스 연결이 존재하는 것이 의미적으로 자연스럽다.
Iterator가
바깥 객체의 상태를 사용한다.
↓
비정적 멤버 클래스 사용이 자연스러움
이런 경우까지 억지로 static으로 바꿀 필요는 없다.
Adapter 관점에서 보면
클라이언트는 MyCollection 내부 구현을 알 필요 없이
Iterator<E>
라는 타입으로 데이터를 순회할 수 있다.
for (E value : collection) {
...
}
MyIterator가 바깥 컬렉션의 구조를 Iterator라는 인터페이스로 변환해주는 일종의 어댑터 역할을 하는 것이다.
세 번째 종류: 익명 클래스
중첩 클래스에는 익명 클래스(Anonymous Class) 도 있다.
이름 그대로 클래스 이름이 없다.
다음과 같이 정의한다.
Runnable runnable =
new Runnable() {
@Override
public void run() {
System.out.println(
"run"
);
}
};
이 코드는 독특하다.
new Runnable() {
...
}
부분에서
클래스를 정의하면서
동시에
그 클래스의 인스턴스를 생성한다.
별도의 이름이 필요 없다
일반적으로 클래스를 만든다면 다음과 같다.
public class MyRunnable
implements Runnable {
@Override
public void run() {
System.out.println("run");
}
}
그리고 객체를 생성한다.
Runnable runnable =
new MyRunnable();
익명 클래스를 사용하면 이 두 과정을 한 위치에서 처리한다.
Runnable runnable =
new Runnable() {
@Override
public void run() {
System.out.println(
"run"
);
}
};
한 번만 사용하는 간단한 구현체에 유용할 수 있다.
Java 8 이후 익명 클래스 사용은 많이 줄었다
익명 클래스는 Java 8 이전까지 콜백이나 간단한 함수 객체를 만들 때 많이 사용되었다.
예를 들어
Runnable runnable =
new Runnable() {
@Override
public void run() {
System.out.println(
"run"
);
}
};
Java 8 이후 함수형 인터페이스라면 람다로 줄일 수 있다.
Runnable runnable =
() -> System.out.println(
"run"
);
더 간단하다.
메서드 레퍼런스로 더 줄일 수도 있다
이미 실행할 메서드가 있다면
Runnable runnable =
service::execute;
처럼 메서드 레퍼런스를 사용할 수도 있다.
따라서 Java 8 이후에는 단순 함수 객체를 표현하기 위한 익명 클래스 상당수가
Anonymous Class
↓
Lambda
또는
Method Reference
로 대체되었다.
그렇다고 익명 클래스가 사라진 것은 아니다
람다는 함수형 인터페이스를 구현할 때 사용할 수 있다.
즉 추상 메서드가 하나인 인터페이스에 적합하다.
반면 익명 클래스는 특정 클래스를 상속하면서 그 자리에서 일부 동작을 재정의할 수도 있다.
SomeClass instance =
new SomeClass() {
@Override
public void execute() {
...
}
};
따라서 람다가 익명 클래스를 모든 경우에 대체하는 것은 아니다.
다만 예전보다 사용 빈도가 크게 줄었다고 이해하면 된다.
네 번째 종류: 지역 클래스
마지막은 지역 클래스(Local Class) 다.
지역 클래스는 메서드 안에서 이름을 가지고 정의되는 클래스다.
public void doSomething() {
class LocalProcessor {
void process() {
System.out.println(
"processing"
);
}
}
LocalProcessor processor =
new LocalProcessor();
processor.process();
}
LocalProcessor의 범위는 doSomething() 내부다.
지역 클래스는 멤버 클래스가 아니다
다음 코드를 구분해서 보자.
public class Outer {
private int number;
public void doSomething() {
class LocalClass {
}
}
}
number는 클래스 멤버다.
private int number;
doSomething() 역시 클래스 멤버다.
public void doSomething() {
}
하지만 LocalClass는 doSomething()의 지역 영역 안에 존재한다.
따라서 Outer의 멤버 클래스가 아니다.
지역 클래스는 사용 빈도가 높지 않다
지역 클래스가 필요한 경우도 존재하지만 코드가 길어지기 쉽다.
public void execute() {
class Processor {
// 여러 필드
// 여러 메서드
// 여러 로직
}
// execute() 나머지 로직
}
클래스 정의가 메서드 안에 들어가면서 메서드 자체가 매우 길어질 수 있다.
로직이 충분히 복잡하다면 별도의 클래스나 멤버 클래스로 빼는 것이 더 읽기 좋은 경우가 많다.
따라서 실제 코드에서는 네 종류 중 상대적으로 보기 드문 형태다.
네 가지 중첩 클래스를 다시 비교해보자
| 종류 | static | 이름 | Outer 인스턴스 연결 | 대표적인 용도 |
|---|---|---|---|---|
| 정적 멤버 클래스 | O | O | X | 바깥 타입과 논리적으로 관련된 독립 타입 |
| 비정적 멤버 클래스 | X | O | O | 바깥 객체와 연결된 Adapter, Iterator 등 |
| 익명 클래스 | 상황에 따라 다름 | X | 선언 위치에 따라 주변 문맥 사용 | 일회성 구현 객체 |
| 지역 클래스 | X | O | 지역 문맥 사용 가능 | 메서드 내부 전용 구현 |
실무적으로 이번 아이템에서 가장 중요한 구분은 다음 두 가지다.
static member class
vs
non-static member class
정적 멤버 클래스와 비정적 멤버 클래스 비교
| 구분 | 정적 멤버 클래스 | 비정적 멤버 클래스 |
|---|---|---|
| 선언 | static class | class |
| 바깥 인스턴스 필요 | X | O |
| 바깥 인스턴스 상태 접근 | 직접 불가 | 가능 |
| 숨은 외부 참조 | X | O |
| 독립 생성 | 가능 | 특정 Outer 필요 |
| 메모리 구조 | 상대적으로 단순 | 외부 참조 추가 |
| Outer 생명주기에 영향 가능 | 적음 | 있음 |
| 권장 기준 | 기본 선택 | Outer 참조가 실제로 필요할 때 |
비정적 멤버 클래스를 발견하면 확인할 것
다음과 같은 클래스가 있다고 하자.
public class Outer {
private class Inner {
void execute() {
...
}
}
}
다음 질문을 해보면 된다.
Inner가 Outer.this를 사용하는가?
Outer의 인스턴스 필드를 사용하는가?
Outer의 인스턴스 메서드를 사용하는가?
특정 Outer 인스턴스와
논리적으로 연결될 필요가 있는가?
모두 아니라면 다음처럼 바꾸는 것을 고려해야 한다.
private static class Inner {
void execute() {
...
}
}
static을 붙이는 것은 단순한 스타일 문제가 아니다
다음 두 선언은 겉으로 한 단어 차이다.
private class Inner {
}
private static class Inner {
}
하지만 객체 관계는 크게 다르다.
비정적 멤버 클래스는
Inner
↓
Outer 참조
를 가진다.
정적 멤버 클래스는
Inner
만 존재한다.
즉 static은 단순히 코딩 스타일을 맞추는 것이 아니라 객체 사이의 숨은 연관 관계를 제거하는 역할을 한다.
실무에서 자주 볼 수 있는 정적 멤버 클래스
정적 멤버 클래스는 여러 곳에서 자연스럽게 사용할 수 있다.
예를 들어 Builder를 생각할 수 있다.
public class Member {
private final String name;
private final int age;
private Member(Builder builder) {
this.name = builder.name;
this.age = builder.age;
}
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 Member build() {
return new Member(this);
}
}
}
사용할 때는 다음과 같다.
Member member =
new Member.Builder()
.name("Yoon")
.age(30)
.build();
Builder는 논리적으로 Member와 관련이 깊지만 기존 Member 객체 하나를 감싸고 있을 필요는 없다.
따라서 정적 멤버 클래스로 두는 것이 자연스럽다.
Builder를 비정적으로 만들면 이상해진다
만약 Builder가 비정적이라면 Builder를 만들기 위해 이미 Member 객체가 필요해진다.
Member를 만들기 위한 Builder인데
Builder를 만들려면
Member가 먼저 필요함
목적과 구조가 뒤집힌다.
따라서 이런 경우는 정적 멤버 클래스가 적합하다는 것을 쉽게 알 수 있다.
멤버 클래스 설계 기준
새로운 내부 클래스를 만들 때 다음 순서로 판단할 수 있다.
다른 클래스 내부에 둘 이유가 있는가?
↓
없다
→ 최상위 클래스로 분리
있다
↓
클래스 멤버로 둘 것인가?
↓
Outer 인스턴스를 사용해야 하는가?
├─ YES
│ ↓
│ 비정적 멤버 클래스
│
└─ NO
↓
정적 멤버 클래스
그리고 단 한 번만 필요한 매우 작은 구현이라면 익명 클래스나 람다를 고려할 수 있다.
메서드 내부에만 필요한 이름 있는 작은 타입이라면 지역 클래스도 가능하지만 복잡해지면 별도 클래스로 분리하는 편이 읽기 좋을 수 있다.
핵심 정리
- 다른 클래스 내부에 정의된 클래스를 중첩 클래스라고 한다.
- 모든 중첩 클래스가 멤버 클래스인 것은 아니다.
- 클래스 영역에 직접 선언된 중첩 클래스만 멤버 클래스라고 볼 수 있다.
- Java의 중첩 클래스는 정적 멤버 클래스, 비정적 멤버 클래스, 익명 클래스, 지역 클래스로 구분할 수 있다.
- 아이템 24에서 가장 중요한 것은 정적 멤버 클래스와 비정적 멤버 클래스의 차이다.
- 정적 멤버 클래스는 특정 바깥 클래스 인스턴스를 필요로 하지 않는다.
- 정적 멤버 클래스는 바깥 클래스와 논리적으로 관련되지만 인스턴스 수준에서는 독립적인 타입을 표현하기 좋다.
- 비정적 멤버 클래스는 특정 바깥 클래스 인스턴스와 연결된다.
- 비정적 멤버 클래스에서는
Outer.this를 통해 연결된 바깥 인스턴스를 명시적으로 참조할 수도 있다. - 비정적 멤버 클래스 객체를 외부에서 만들려면 먼저 특정 바깥 객체가 필요하다.
- 비정적 멤버 클래스에는 바깥 객체에 대한 숨은 참조가 존재하기 때문에 추가적인 공간과 객체 관계가 생긴다.
- 내부 객체가 오래 살아남으면 숨은 참조 때문에 바깥 객체의 생명주기까지 예상보다 길어질 수 있다.
- 바깥 인스턴스의 상태를 전혀 사용하지 않는 멤버 클래스라면
static으로 만드는 것이 좋다. - 바깥 객체와 실제로 밀접하게 연결되어야 하는 Iterator나 Adapter 구현 등에서는 비정적 멤버 클래스가 자연스러울 수 있다.
- 익명 클래스는 이름 없이 선언과 인스턴스 생성을 동시에 수행하는 클래스다.
- Java 8 이후 단순 함수 객체 목적의 익명 클래스 상당수는 람다와 메서드 레퍼런스로 대체할 수 있다.
- 익명 클래스가 람다로 항상 대체되는 것은 아니다.
- 지역 클래스는 메서드 같은 지역 영역에서 이름을 가지고 선언하는 클래스이며 멤버 클래스가 아니다.
- 지역 클래스는 사용할 수 있지만 메서드가 지나치게 길어질 수 있어 실제 사용 빈도는 상대적으로 낮다.
static을 붙일지 말지는 단순한 스타일 문제가 아니라 객체 사이의 숨은 참조 관계를 만들 것인지 결정하는 설계 문제다.- 멤버 클래스를 만들 때는 우선 정적 멤버 클래스를 고려하고, 특정 바깥 인스턴스에 접근해야 하는 명확한 이유가 있을 때만 비정적 멤버 클래스를 선택하는 것이 좋다.
한 줄 정리
멤버 클래스가 바깥 클래스의 특정 인스턴스 상태를 실제로 사용하지 않는다면 비정적으로 둘 이유가 없으며, 불필요한 외부 객체 참조와 생명주기 결합을 제거하기 위해
static멤버 클래스로 선언하는 것이 더 단순하고 안전한 설계다.
아이템 24. 완벽 공략 - 어댑터 패턴
아이템 24에서는 비정적 멤버 클래스가 유용하게 사용될 수 있는 대표적인 사례로 어댑터 패턴(Adapter Pattern) 을 생각해볼 수 있다.
어댑터 패턴은 이미 존재하는 클래스를 수정하지 않으면서 클라이언트가 원하는 인터페이스에 맞게 변환해서 사용할 수 있도록 만드는 디자인 패턴이다.
핵심은 이름 그대로 현실의 어댑터와 비슷하다.
예를 들어 전기 플러그의 모양이 콘센트와 맞지 않는다고 생각해보자.
기존 플러그
→ 모양이 맞지 않음
→ 어댑터 장착
→ 원하는 콘센트에 연결 가능
플러그 자체를 뜯어고치거나 콘센트를 변경하는 것이 아니다.
중간에 하나의 변환 장치를 넣어서 서로 다른 규격을 연결한다.
소프트웨어의 어댑터 패턴도 동일하다.
기존 클래스
↓
Adapter
↓
클라이언트가 원하는 인터페이스
어댑터 패턴이 필요한 상황
클라이언트 코드가 다음 인터페이스를 사용한다고 가정해보자.
public interface MyIterator<E> {
boolean hasNext();
E next();
}
클라이언트는 오직 MyIterator 타입만 알고 있다.
public void printAll(
MyIterator<String> iterator
) {
while (iterator.hasNext()) {
System.out.println(
iterator.next()
);
}
}
그런데 우리가 이미 가지고 있는 코드는 전혀 다른 형태라고 해보자.
public class LegacyIterator<E> {
private final List<E> values;
private int index;
public LegacyIterator(
List<E> values
) {
this.values = values;
}
public boolean hasMore() {
return index < values.size();
}
public E getNext() {
return values.get(index++);
}
}
기능 자체는 비슷하다.
MyIterator.hasNext()
↔
LegacyIterator.hasMore()
MyIterator.next()
↔
LegacyIterator.getNext()
하지만 인터페이스가 다르다.
따라서 다음 코드는 사용할 수 없다.
LegacyIterator<String> legacy =
new LegacyIterator<>(values);
printAll(legacy);
printAll()은 MyIterator를 요구하고 있지만 LegacyIterator는 그 타입이 아니기 때문이다.
기존 클래스를 직접 수정하면 되지 않을까?
가장 단순한 방법은 기존 클래스를 수정해서 인터페이스를 구현하는 것이다.
public class LegacyIterator<E>
implements MyIterator<E> {
@Override
public boolean hasNext() {
return hasMore();
}
@Override
public E next() {
return getNext();
}
}
이 방법도 상황에 따라 가능하다.
하지만 현실에서는 기존 클래스를 직접 수정하기 어려운 경우가 많다.
예를 들어 다음과 같은 경우다.
외부 라이브러리의 클래스
수정하면 안 되는 레거시 코드
여러 곳에서 이미 사용 중인 클래스
소스 코드 자체를 가지고 있지 않은 클래스
또한 기존 클래스가 담당하는 역할에 단순히 다른 인터페이스를 맞추기 위한 코드까지 섞고 싶지 않을 수도 있다.
이럴 때 어댑터를 별도의 객체로 만든다.
Adapter를 별도로 만든다
public class LegacyIteratorAdapter<E>
implements MyIterator<E> {
private final LegacyIterator<E> adaptee;
public LegacyIteratorAdapter(
LegacyIterator<E> adaptee
) {
this.adaptee = adaptee;
}
@Override
public boolean hasNext() {
return adaptee.hasMore();
}
@Override
public E next() {
return adaptee.getNext();
}
}
이제 기존 클래스는 수정하지 않았다.
LegacyIterator<String> legacy =
new LegacyIterator<>(
List.of("A", "B", "C")
);
Adapter로 감싸기만 한다.
MyIterator<String> iterator =
new LegacyIteratorAdapter<>(
legacy
);
그러면 기존 클라이언트에게 전달할 수 있다.
printAll(iterator);
구조는 다음과 같다.
Client
│
│ MyIterator 사용
▼
LegacyIteratorAdapter
│
│ 위임
▼
LegacyIterator
클라이언트는 LegacyIterator가 어떻게 구현되어 있는지 알 필요가 없다.
어댑터 패턴의 구성 요소
어댑터 패턴을 설명할 때 보통 세 가지 역할을 구분한다.
Target
클라이언트가 사용하고 싶어 하는 인터페이스다.
public interface MyIterator<E> {
boolean hasNext();
E next();
}
즉 클라이언트가 기대하는 규격이다.
Adaptee
이미 존재하지만 클라이언트가 원하는 인터페이스와 맞지 않는 클래스다.
public class LegacyIterator<E> {
public boolean hasMore() {
...
}
public E getNext() {
...
}
}
기존 기능은 정상적으로 동작하지만 인터페이스 형태가 다르다.
Adapter
Adaptee를 Target 인터페이스에 맞게 변환한다.
public class LegacyIteratorAdapter<E>
implements MyIterator<E> {
private final LegacyIterator<E> adaptee;
@Override
public boolean hasNext() {
return adaptee.hasMore();
}
@Override
public E next() {
return adaptee.getNext();
}
}
전체 흐름은 다음과 같다.
Target
MyIterator
▲
│ implements
│
Client → Adapter
│
│ delegates
▼
Adaptee
LegacyIterator
어댑터의 핵심은 인터페이스 변환이다
어댑터가 기존 객체의 기능을 완전히 새롭게 구현하는 것은 아니다.
이미 기능은 존재한다.
adaptee.hasMore();
adaptee.getNext();
Adapter는 그것을 클라이언트가 이해하는 형태로 변환한다.
hasNext()
next()
따라서 핵심은 다음과 같다.
기존 기능을 재사용하면서 클라이언트가 원하는 인터페이스로 변환한다.
비정적 멤버 클래스가 어댑터에 잘 어울리는 이유
아이템 24와 연결되는 부분이다.
다음과 같은 컬렉션이 있다고 하자.
public class MyCollection<E> {
private final List<E> values =
new ArrayList<>();
public void add(E value) {
values.add(value);
}
}
이 객체를 Java의 Iterator<E>로 사용할 수 있도록 만들고 싶다.
Iterator는 다음과 같은 인터페이스를 제공한다.
public interface Iterator<E> {
boolean hasNext();
E next();
// ...
}
MyCollection 내부에 Iterator 구현체를 만들 수 있다.
public class MyCollection<E>
implements Iterable<E> {
private final List<E> values =
new ArrayList<>();
@Override
public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator
implements Iterator<E> {
private int index;
@Override
public boolean hasNext() {
return index < values.size();
}
@Override
public E next() {
return values.get(index++);
}
}
}
여기서 MyIterator가 주목할 부분이다.
private class MyIterator
implements Iterator<E> {
}
비정적 멤버 클래스다.
MyIterator는 특정 MyCollection 인스턴스에 종속된다
다음 코드를 보자.
@Override
public boolean hasNext() {
return index < values.size();
}
values는 어디에 있을까?
MyIterator 자체의 필드가 아니다.
바깥의 MyCollection 객체가 가지고 있다.
private final List<E> values =
new ArrayList<>();
next()도 마찬가지다.
@Override
public E next() {
return values.get(index++);
}
즉 MyIterator는 특정 MyCollection 인스턴스의 내부 데이터를 사용한다.
객체 관계는 다음과 같다.
MyCollection A
│
├─ values
│
└── MyIterator A
│
└─ Collection A의 values 사용
따라서 MyIterator가 바깥 객체에 대한 참조를 가지고 있는 것이 자연스럽다.
이런 상황은 비정적 멤버 클래스의 적절한 사용 사례가 된다.
Iterator가 일종의 Adapter 역할을 한다
MyCollection은 내부적으로 다음 데이터를 관리한다.
List<E> values
하지만 외부 클라이언트는 그 내부 구현을 알 필요가 없다.
클라이언트는 표준 인터페이스인
Iterator<E>
만 사용하면 된다.
Iterator<String> iterator =
collection.iterator();
while (iterator.hasNext()) {
System.out.println(
iterator.next()
);
}
MyIterator는 MyCollection의 내부 구조를 Iterator라는 클라이언트가 원하는 형태로 제공한다.
MyCollection 내부 표현
↓
MyIterator
↓
Iterator 인터페이스
이런 관점에서 비정적 멤버 클래스가 어댑터 역할을 수행할 수 있다.
왜 static으로 만들지 않는가?
앞선 아이템의 핵심 원칙은
바깥 인스턴스가 필요 없다면
static으로 만들어라.
였다.
그런데 지금 MyIterator는 바깥 객체의 values를 사용한다.
values.size()
values.get(index)
따라서 특정 MyCollection 인스턴스에 대한 연결이 실제로 필요하다.
이 경우에는 비정적 멤버 클래스가 자연스럽다.
Outer 상태 사용
O
↓
Non-static Member Class
반대로 바깥 인스턴스를 전혀 사용하지 않는다면 static으로 만드는 것이 더 적절하다.
어댑터를 별도 클래스로 분리할 수도 있다
어댑터가 반드시 내부 클래스여야 하는 것은 아니다.
다음과 같이 별도의 클래스로 분리할 수도 있다.
public class MyIteratorAdapter<E>
implements Iterator<E> {
private final MyCollection<E> collection;
private int index;
public MyIteratorAdapter(
MyCollection<E> collection
) {
this.collection = collection;
}
@Override
public boolean hasNext() {
return index
< collection.size();
}
@Override
public E next() {
return collection.get(index++);
}
}
이 방식에서는 바깥 객체에 대한 참조가 암묵적인 것이 아니라 명시적으로 나타난다.
private final MyCollection<E> collection;
이 역시 충분히 가능한 구조다.
내부 클래스와 별도 Adapter 클래스 중 무엇을 선택할까?
Adapter가 특정 클래스 내부 구현에 매우 밀접하게 연결되어 있고 외부에서는 사용할 이유가 없다면 내부 클래스로 두는 것이 자연스러울 수 있다.
MyCollection 전용 Iterator
↓
private inner class
반면 여러 클래스에서 재사용해야 하거나 Adapter 자체가 독립적인 의미를 가진다면 별도 클래스로 분리하는 것이 더 자연스럽다.
여러 곳에서 사용
독립된 테스트 필요
복잡한 변환 로직
↓
별도 Adapter Class
Object Adapter와 위임
앞에서 작성한 코드는 기존 객체를 필드로 가지고 있다.
private final LegacyIterator<E> adaptee;
그리고 실제 기능은 기존 객체에 위임한다.
@Override
public boolean hasNext() {
return adaptee.hasMore();
}
@Override
public E next() {
return adaptee.getNext();
}
이 방식의 핵심은 Composition + Delegation이다.
Adapter
│
│ has-a
▼
Adaptee
기존 클래스의 내부 구현을 상속할 필요가 없다.
아이템 18에서 살펴본 합성과 위임의 장점도 그대로 얻을 수 있다.
기존 코드를 수정하지 않아도 된다
어댑터를 별도의 클래스로 만들었을 때 중요한 장점이다.
기존 코드는 그대로 둔다.
public class LegacyPaymentClient {
public void requestPayment() {
...
}
}
새로운 시스템이 다음 인터페이스를 사용한다고 하자.
public interface PaymentGateway {
void pay();
}
Adapter만 추가한다.
public class LegacyPaymentAdapter
implements PaymentGateway {
private final LegacyPaymentClient client;
public LegacyPaymentAdapter(
LegacyPaymentClient client
) {
this.client = client;
}
@Override
public void pay() {
client.requestPayment();
}
}
기존 LegacyPaymentClient는 한 줄도 수정하지 않는다.
변경 이유도 분리된다
Adapter를 별도로 만들면 책임이 명확해진다.
LegacyPaymentClient
→ 실제 기존 결제 기능
LegacyPaymentAdapter
→ PaymentGateway 규격으로 변환
기존 기능 자체와 인터페이스 변환 책임을 분리할 수 있다.
이것은 유지보수 측면에서도 장점이 있다.
Java I/O에서 볼 수 있는 대표적인 Adapter
Java의 I/O API에서도 서로 다른 형태를 연결하는 구조를 자주 볼 수 있다.
대표적으로 InputStreamReader를 생각할 수 있다.
InputStream은 바이트 기반 입력을 다룬다.
InputStream inputStream =
new FileInputStream(
"data.txt"
);
하지만 어떤 코드는 문자 기반 API인 Reader를 사용하고 싶을 수 있다.
이때 InputStreamReader가 중간에서 변환한다.
Reader reader =
new InputStreamReader(
inputStream,
StandardCharsets.UTF_8
);
구조를 보면 다음과 같다.
InputStream
Byte 기반 입력
│
▼
InputStreamReader
│
▼
Reader
Character 기반 입력
즉 InputStream이라는 바이트 기반 입력을 Reader라는 문자 기반 인터페이스처럼 사용할 수 있도록 연결한다는 점에서 Adapter의 대표적인 사례로 볼 수 있다.
BufferedReader까지 연결할 수 있다
그다음 Reader를 BufferedReader로 감쌀 수도 있다.
try (
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
new FileInputStream(
"data.txt"
),
StandardCharsets.UTF_8
)
)
) {
String line;
while (
(line = reader.readLine())
!= null
) {
System.out.println(line);
}
}
구조는 다음과 같다.
FileInputStream
↓
InputStreamReader
↓
BufferedReader
다만 패턴의 역할을 조금 더 엄밀하게 구분하면 InputStreamReader는 서로 다른 입력 모델인 InputStream과 Reader를 연결하므로 Adapter 성격이 강하다.
반면 BufferedReader는 이미 Reader인 객체를 감싸서 버퍼링이나 readLine() 같은 추가 기능을 제공한다는 점에서 Decorator에 더 가까운 구조로 이해할 수 있다.
즉 Java I/O에는 여러 패턴이 함께 조합되어 있다.
FileInputStream
│
│ Adapter
▼
InputStreamReader
│
│ Decorator 성격
▼
BufferedReader
어댑터와 데코레이터의 차이
아이템 18에서 데코레이터 패턴도 살펴봤기 때문에 두 패턴이 비슷하게 느껴질 수 있다.
둘 다 내부 객체를 감싸고 위임하기 때문이다.
하지만 목적이 다르다.
Adapter
인터페이스가 맞지 않는다.
↓
클라이언트가 원하는 인터페이스로 변환한다.
Decorator
인터페이스는 이미 맞는다.
↓
기존 객체에 새로운 기능을 추가한다.
비교하면 다음과 같다.
| 구분 | Adapter | Decorator |
|---|---|---|
| 핵심 목적 | 인터페이스 변환 | 기능 확장 |
| 기존 타입과 Target | 다른 경우가 많음 | 보통 동일한 인터페이스 |
| 내부 객체 사용 | 위임 | 위임 |
| 기존 코드 수정 | 보통 필요 없음 | 보통 필요 없음 |
| 대표 사례 | InputStreamReader | BufferedReader |
둘 다 Composition과 Delegation을 활용하지만 의도(Intent) 가 다르다.
어댑터 패턴의 장점
기존 코드를 재사용할 수 있다
클라이언트가 원하는 인터페이스와 기존 클래스가 맞지 않더라도 기존 구현을 버릴 필요가 없다.
기존 코드
+
Adapter
=
새로운 인터페이스에서 재사용
기존 클래스를 수정하지 않아도 된다
외부 라이브러리처럼 수정할 수 없는 코드도 사용할 수 있다.
Third-party Library
↓
Adapter
↓
우리 애플리케이션 인터페이스
클라이언트와 기존 구현을 분리한다
클라이언트는 Adapter 뒤의 구체 구현을 알 필요가 없다.
PaymentGateway gateway =
new LegacyPaymentAdapter(
legacyClient
);
클라이언트는 PaymentGateway에만 의존할 수 있다.
변환 책임을 분리할 수 있다
기존 클래스는 본래 기능에 집중한다.
Adapter는 인터페이스 변환에 집중한다.
기존 클래스
→ 기존 비즈니스 기능
Adapter
→ 타입/인터페이스 변환
역할이 명확해진다.
레거시 시스템과 신규 시스템을 연결하기 좋다
실무에서는 기존 API와 새 인터페이스를 연결해야 하는 일이 자주 발생한다.
예를 들어
Legacy PG API
↓
Adapter
↓
PaymentProvider
또는
외부 배송사 SDK
↓
Adapter
↓
ShippingClient
처럼 사용할 수 있다.
어댑터 패턴의 단점
가장 명확한 단점은 클래스가 하나 더 생긴다는 것이다.
기존에는
Client
↓
LegacyClass
였다면
Client
↓
Adapter
↓
LegacyClass
가 된다.
중간 단계가 하나 추가된다.
현실의 콘센트 어댑터와 비슷하다.
기기
↓
어댑터
↓
콘센트
항상 중간 부품이 필요하다.
코드 구조를 한 단계 더 따라가야 한다
디버깅할 때도
Client
↓
Adapter
↓
실제 구현
순서로 따라가야 할 수 있다.
Adapter가 지나치게 많아지면 클래스 수가 증가하고 객체 관계도 복잡해질 수 있다.
하지만 기존 코드를 수정하지 않고 책임을 분리할 수 있다는 장점 때문에 많은 경우 이 비용을 감수할 가치가 있다.
Adapter 안에 비즈니스 로직을 너무 많이 넣지 않는 것이 좋다
Adapter의 주 역할은 변환이다.
다음과 같은 역할은 자연스럽다.
메서드 이름 변환
파라미터 타입 변환
반환값 변환
외부 객체를 내부 인터페이스로 변환
하지만 Adapter 안에
복잡한 주문 정책
결제 승인 판단
재고 차감 규칙
대규모 비즈니스 상태 변경
등이 계속 들어가기 시작하면 Adapter가 단순한 변환 객체가 아니라 또 하나의 서비스 객체가 될 수 있다.
따라서 Adapter가 어떤 책임을 가지고 있는지 명확하게 유지하는 것이 중요하다.
실무 예제: 외부 API Adapter
애플리케이션에서는 외부 서비스에 직접 의존하지 않기 위해 자체 인터페이스를 정의하는 경우가 많다.
public interface NotificationSender {
void send(
String receiver,
String message
);
}
외부 라이브러리는 다음과 같은 API를 제공한다고 하자.
public class ExternalMessageClient {
public void push(
ExternalMessage message
) {
...
}
}
둘의 인터페이스는 다르다.
Adapter를 만든다.
public class ExternalNotificationAdapter
implements NotificationSender {
private final ExternalMessageClient client;
public ExternalNotificationAdapter(
ExternalMessageClient client
) {
this.client = client;
}
@Override
public void send(
String receiver,
String message
) {
ExternalMessage request =
new ExternalMessage(
receiver,
message
);
client.push(request);
}
}
클라이언트는 외부 SDK를 모른다.
public class OrderService {
private final NotificationSender sender;
public OrderService(
NotificationSender sender
) {
this.sender = sender;
}
public void completeOrder() {
sender.send(
"user",
"주문이 완료되었습니다."
);
}
}
외부 라이브러리가 바뀌더라도 Adapter 뒤에서 처리할 수 있다.
Adapter와 DIP를 함께 생각할 수 있다
이 구조는 의존성 역전 원칙과도 자연스럽게 연결된다.
비즈니스 코드는 외부 라이브러리에 직접 의존하지 않는다.
OrderService
↓
NotificationSender
실제 외부 API를 사용하는 것은 Adapter다.
ExternalNotificationAdapter
↓
ExternalMessageClient
전체 구조는 다음과 같다.
Business Logic
OrderService
↓
Application Interface
NotificationSender
↑ implements
Adapter
ExternalNotificationAdapter
↓
External Library
ExternalMessageClient
외부 기술을 애플리케이션 내부 모델에 맞춰주는 경계 객체가 된다.
비정적 멤버 클래스 형태가 좋은 경우
Adapter를 반드시 독립 클래스로 만들 필요는 없다.
다음 조건이라면 비정적 멤버 클래스가 적합할 수 있다.
특정 Outer 객체에 강하게 종속되어 있다.
Outer의 인스턴스 상태를 사용해야 한다.
외부에서 Adapter 구현 자체를 알 필요가 없다.
Adapter가 매우 작다.
Outer와 함께 있을 때 의미가 가장 명확하다.
대표적인 것이 Iterator 같은 경우다.
private class MyIterator
implements Iterator<E> {
}
반면 독립적인 재사용과 테스트가 필요하다면 별도 클래스로 빼는 것이 자연스럽다.
어댑터 패턴을 판단하는 질문
코드를 보면서 다음 상황을 발견했다면 Adapter를 고려할 수 있다.
기능은 이미 있는데
인터페이스가 맞지 않는가?
기존 클래스를 수정할 수 없는가?
클라이언트가 특정 인터페이스에
계속 의존하도록 만들고 싶은가?
외부 라이브러리 타입을
애플리케이션 안으로 퍼뜨리고 싶지 않은가?
데이터나 메서드 호출 형식만
변환하면 기존 기능을 재사용할 수 있는가?
이런 경우 Adapter가 좋은 선택이 될 가능성이 높다.
핵심 정리
- 어댑터 패턴은 기존 코드를 클라이언트가 원하는 인터페이스에 맞게 변환해서 사용할 수 있도록 만드는 디자인 패턴이다.
- 현실에서 서로 다른 규격의 플러그와 콘센트를 연결하는 어댑터와 비슷한 개념이다.
- 어댑터 패턴은 일반적으로
Target,Adaptee,Adapter의 역할로 이해할 수 있다. Target은 클라이언트가 원하는 인터페이스다.Adaptee는 이미 존재하지만 클라이언트가 원하는 인터페이스와 맞지 않는 클래스다.Adapter는Target을 구현하면서 내부적으로Adaptee에 작업을 위임한다.- Composition과 Delegation을 이용하면 기존 클래스를 수정하지 않고도 새로운 인터페이스로 사용할 수 있다.
- 특정 바깥 객체의 상태를 이용해 외부 인터페이스를 제공하는 작은 Adapter는 비정적 멤버 클래스로 구현하기 좋다.
- Iterator가 자신을 감싸고 있는 Collection의 상태를 이용한다면 비정적 멤버 클래스가 자연스러운 구조가 될 수 있다.
- 바깥 객체를 사용할 필요가 없다면 아이템 24의 원칙에 따라 정적 멤버 클래스나 독립 클래스를 고려해야 한다.
- Adapter가 여러 곳에서 재사용되거나 독립적인 의미가 크다면 별도의 클래스로 분리할 수 있다.
- Java I/O의
InputStreamReader는 바이트 기반InputStream을 문자 기반Reader로 연결하는 대표적인 Adapter 사례로 이해할 수 있다. BufferedReader역시 객체를 감싸는 형태지만 이미Reader인 객체에 버퍼링과 편의 기능을 추가한다는 점에서는 Decorator 성격이 더 강하다.- Adapter와 Decorator 모두 합성과 위임을 활용할 수 있지만 Adapter의 핵심 목적은 인터페이스 변환이고 Decorator의 핵심 목적은 기능 확장이다.
- Adapter를 사용하면 기존 코드를 수정하지 않고 재사용할 수 있고 외부 라이브러리와 애플리케이션 코드 사이의 결합을 줄일 수 있다.
- 단점은 중간 Adapter 클래스가 추가되면서 객체와 클래스 수가 늘어난다는 것이다.
- 실무에서는 외부 SDK, 레거시 API, 결제 모듈, 알림 API처럼 외부 인터페이스를 애플리케이션 내부 인터페이스에 맞추는 경계에서 특히 유용하다.
- Adapter는 가능하면 변환 책임에 집중하고 복잡한 비즈니스 로직까지 떠안지 않도록 설계하는 것이 좋다.
한 줄 정리
어댑터 패턴은 이미 존재하는 코드의 기능은 그대로 재사용하면서 클라이언트가 원하는 인터페이스로 변환해주는 패턴이며, 특정 바깥 객체에 종속된 작은 어댑터라면 비정적 멤버 클래스를 활용할 수 있고 독립적인 변환 책임이 필요하다면 합성과 위임을 사용하는 별도 Adapter 클래스로 분리하는 것이 좋다.