MyList와 구현 교체
BatchProcessor가 구체 클래스 대신 제네릭 MyList 역할에 의존하게 만들어 저장 전략 변경을 조립 시점으로 옮깁니다.
배열 목록과 연결 목록의 메서드가 같아도 변수 타입이 구체 클래스면 호출자는 구현 선택에 묶입니다.
직접 구현을 하나의 역할로 묶는다면 add, get, size를 MyList<E>의 최소 규칙으로 정할 수 있습니다.
아래 실행 예제의 BatchProcessor는 표준 List<Entry>를 생성자로 받아 add와 스트림 합계를 사용합니다.
ArrayList 고정 결합
요청 문자열은 linked이지만 main은 ArrayList를 직접 생성합니다.
선택 값이 있어도 조립 위치가 고정되어 실제 구현이 바뀌지 않는 잘못된 결과를 냅니다.
import java.util.ArrayList;
public final class ConcreteListChoiceBug {
public static void main(String[] args) {
ArrayList<String> hardWired = new ArrayList<>();
String requested = "linked";
System.out.println(
"requested=" + requested + ", actual=" + hardWired.getClass().getSimpleName());
}
}requested=linked, actual=ArrayList요청 문자열이 객체 생성에 연결되지 않아 실제 구현은 ArrayList로 고정됩니다.
인터페이스를 매개변수로 받고 애플리케이션 시작점이 구현을 선택해야 개방-폐쇄 원칙을 지킬 수 있습니다.
소비자에게 필요한 MyList 규칙
MyList<E>는 호출자가 필요한add,get,size만 선언해 구현 세부를 숨깁니다.- ArrayMyList와 LinkedMyList는 같은 인덱스·예외 규칙을 지켜 행동까지 대체 가능해야 합니다.
- 처리기는 저장 구현을 직접 만들지 않고 생성자로 받습니다. 실행 예제의 BatchProcessor가 받는 타입은
List<Entry>입니다. - 구현 선택은
main같은 조립 지점 한 곳에 모아 환경 결정과 업무 로직을 분리합니다. - 인터페이스가 크면 구현이 사용하지 않는 메서드까지 떠안으므로 소비자 요구에서 시작합니다.
- 성능 차이는 규칙 밖의 품질 속성이며 선택 기준으로 측정하되 기능 결과는 같아야 합니다.
생성자 주입과 외부 구현 선택
ListStrategyBatch는 두 표준 구현을 같은 run에 전달해 기능 결과가 모두 90인지 확인합니다.
처리기는 List<Entry>만 저장하고 생성자에서 null도 거부합니다.
구현 이름을 출력하는 코드는 조립 결과 확인용이며 업무 계산에는 사용하지 않습니다.
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public final class ListStrategyBatch {
public static void main(String[] args) {
run(new ArrayList<>());
run(new LinkedList<>());
}
private static void run(List<Entry> storage) {
BatchProcessor processor = new BatchProcessor(storage);
processor.add("array", 40);
processor.add("linked", 50);
System.out.println(storage.getClass().getSimpleName() + "=" + processor.total());
}
private static final class BatchProcessor {
private final List<Entry> entries;
BatchProcessor(List<Entry> entries) {
this.entries = java.util.Objects.requireNonNull(entries);
}
void add(String title, int viewCount) {
entries.add(new Entry(title, viewCount));
}
int total() {
return entries.stream().mapToInt(Entry::viewCount).sum();
}
}
private record Entry(String title, int viewCount) {}
}소비자 요구의 최소 역할
BatchProcessor가 필요한 연산이 add, get, size라면 MyList에도 그 세 메서드부터 둡니다.
구현 편의를 위해 Node나 용량 조회를 추가하면 호출자가 내부 방식에 의존할 통로가 생깁니다.
역할은 두 구현의 공통 분모가 아니라 소비자의 실제 사용법에서 출발합니다.
ArrayMyList와 LinkedMyList는 반환값뿐 아니라 인덱스 구분과 순서도 같아야 합니다.
한 구현은 null을 받고 다른 구현은 거부한다면 단순 타입 대체만 가능할 뿐 행동 대체는 실패합니다.
인터페이스 문서에 예외와 변경 효과를 포함해야 하는 이유입니다.
반복자와 컬렉션 규칙
인덱스 get만으로 전체 순회를 만들면 연결 목록은 각 원소마다 head부터 다시 걸어 제곱 비용이 될 수 있습니다.
Iterable<E>를 확장해 반복자를 제공하면 배열 구현은 커서 인덱스를, 연결 구현은 current Node를 한 번씩 전진시킬 수 있습니다.
Iterator의 hasNext()는 상태를 이동하지 않고 다음 값 존재 여부만 알려야 합니다.
next()는 값 반환과 커서 이동을 한 번 수행하고, 끝에서 호출되면 NoSuchElementException을 던집니다.
향상된 for문은 이 규칙을 사용합니다.
조립 지점과 업무 로직 분리
main은 설정과 작업 부하를 보고 구현을 선택합니다.
실행 예제의 BatchProcessor는 생성자 주입으로 List<Entry>를 받고 저장 방식 이름을 검사하지 않습니다.
새 구현이 추가되어도 처리기 코드는 닫혀 있고 조립 코드만 확장됩니다.
인터페이스가 하나의 구현만 가리고 변경 가능성도 없다면 추상화 비용이 더 클 수 있습니다.
실제로 교체할 두 전략과 안정된 소비자 규칙이 있을 때 도입해야 OCP라는 이름이 설계를 단순하게 만듭니다.
import java.util.Iterator;
import java.util.List;
public final class IterableListContract {
public static void main(String[] args) {
EntryList entries =
new EntryList(List.of(new Entry("role", 40), new Entry("iterator", 50)));
int total = 0;
for (Entry entry : entries) {
total += entry.viewCount();
}
System.out.println("total=" + total);
}
private static final class EntryList implements Iterable<Entry> {
private final List<Entry> values;
EntryList(List<Entry> values) {
this.values = List.copyOf(values);
}
public Iterator<Entry> iterator() {
return values.iterator();
}
}
private record Entry(String title, int viewCount) {}
}배열 저장소의 CLI 회귀 결과
별도 CLI는 PostRepository를 생성하고 두 Entry의 입력 순서와 합계를 확인합니다.
이 CLI의 저장 필드는 Entry[]이며 구현을 주입받지 않습니다.
public final class PostRepositoryCliCH147 {
public static void main(String[] args) {
PostRepository repository = new PostRepository();
repository.add("list-role", 44);
repository.add("dependency-injection", 56);
repository.print();
System.out.println("total=" + repository.totalViewCount());
}
private static final class PostRepository {
private Entry[] entries = new Entry[2];
private int size;
void add(String title, int viewCount) {
if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
ensureCapacity(size + 1);
entries[size++] = new Entry(title, viewCount);
}
private void ensureCapacity(int required) {
if (required <= entries.length) return;
Entry[] grown = new Entry[Math.max(required, entries.length * 2)];
System.arraycopy(entries, 0, grown, 0, size);
entries = grown;
}
int totalViewCount() {
int total = 0;
for (int i = 0; i < size; i++) {
total += entries[i].viewCount();
}
return total;
}
void print() {
for (int i = 0; i < size; i++) {
Entry entry = entries[i];
System.out.println(i + ":" + entry.title() + "=" + entry.viewCount());
}
}
}
private record Entry(String title, int viewCount) {}
}고정 ArrayList 예제, List 주입 두 실행, 별도 Entry 배열 CLI의 생성 위치와 계산 결과를 비교한다.
| 원문 실행 | 저장소 생성과 소비 | 관찰 결과 |
|---|---|---|
ConcreteListChoiceBug | main: new ArrayList<>()요청 문자열만 linked로 설정 | requested=linked,actual=ArrayList |
ListStrategyBatchArrayList 실행 | main → run → BatchProcessorList<Entry> 주입; 스트림 합계 | 조회수 40 + 50ArrayList=90 |
ListStrategyBatchLinkedList 실행 | main → run → BatchProcessor같은 List<Entry> 계약과 스트림 합계 | 조회수 40 + 50LinkedList=90 |
PostRepositoryCliCH147 | 저장소 내부 Entry[2]주입 없이 인덱스 for 순회 | 조회수 44 + 56total=100 |
ConcreteListChoiceBug- 저장소 생성과 소비:
main: new ArrayList<>()요청 문자열만linked로 설정관찰 결과:requested=linked,actual=ArrayList ListStrategyBatchArrayList 실행- 저장소 생성과 소비:
main → run → BatchProcessorList<Entry>주입; 스트림 합계관찰 결과:조회수40 + 50ArrayList=90 ListStrategyBatchLinkedList 실행- 저장소 생성과 소비:
main → run → BatchProcessor같은List<Entry>계약과 스트림 합계관찰 결과:조회수40 + 50LinkedList=90 PostRepositoryCliCH147- 저장소 생성과 소비:저장소 내부
Entry[2]주입 없이 인덱스for순회관찰 결과:조회수44 + 56total=100
ListStrategyBatch의 두 행은 같은 main이 각각 새 목록으로 호출한 run입니다. 주입 예제의 90과 CLI의 100은 입력부터 다릅니다.
두 표준 구현의 주입 비교는 앞의 ListStrategyBatch가 맡고, 이 CLI는 배열 저장소의 회귀 예제입니다.
CLI의 두 조회수 44와 56은 입력 순서대로 보존되고 total=100으로 합산됩니다.
이 CLI의 합계는 인덱스 for문으로 사용 중인 배열 슬롯만 순회합니다.
인터페이스 도입 비용을 회수하는 조건
| 질문 | 관찰할 값 | 선택 또는 조치 |
|---|---|---|
| 호출자가 필요한 기능은 무엇인가 | 사용 메서드 목록 | 작은 역할 설계 |
| 구현을 언제 고르는가 | 조립 시점 | main에서 결정 |
| 행동이 같은가 | 예외·순서 결과 | 규칙 비교 |
| 성능 선택이 바뀌는가 | 운영 지표 | 주입 구현 교체 |
인터페이스는 미래를 막연히 대비해 추가하지 않습니다.
이미 두 구현을 바꾸어 써야 하고 소비자가 동일한 연산을 요구할 때 비용을 회수합니다.
단 하나의 안정된 구현만 있다면 구체 타입이 더 단순할 수 있습니다.
연습 문제
ArrayList와 LinkedList에 같은 정수를 넣고 향상된 for문으로 합계를 구하세요.
계산 메서드의 매개변수는 구체 클래스가 아니라 Iterable<Integer>로 선언합니다.
정답과 해설
합계 소비자는 add나 index 조회가 필요 없으므로 Iterable이 가장 작은 역할입니다.
두 구현의 반복자가 같은 순서를 제공하면 결과도 같습니다.
import java.util.ArrayList;
import java.util.LinkedList;
public final class ListSubstitutionIteratorSolution {
public static void main(String[] args) {
var array = new ArrayList<>(java.util.List.of(30, 40, 50));
var linked = new LinkedList<>(array);
System.out.println("array=" + sum(array));
System.out.println("linked=" + sum(linked));
}
private static int sum(Iterable<Integer> values) {
int total = 0;
for (int value : values) {
total += value;
}
return total;
}
}두 줄 모두 120을 출력합니다.
sum은 구현 선택을 모르며 순회 역할에만 의존합니다.
추상화 검증 기준
처리기 안에 구체 구현 생성식이나 instanceof 분기가 없어야 합니다.
두 구현이 같은 입력·순서·예외 규칙을 만족해야 합니다.
전체 순회는 반복자를 통해 선형으로 끝나야 하며, 구현 선택은 main 같은 조립 지점 한 곳에서 확인할 수 있어야 합니다.