본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : 선형 자료구조 구현

MyList와 구현 교체

BatchProcessor가 구체 클래스 대신 제네릭 MyList 역할에 의존하게 만들어 저장 전략 변경을 조립 시점으로 옮깁니다.

배열 목록과 연결 목록의 메서드가 같아도 변수 타입이 구체 클래스면 호출자는 구현 선택에 묶입니다. add, get, size라는 최소 규칙을 MyList<E>로 선언합니다. BatchProcessor는 이 역할만 사용하고 실제 구현은 생성자에서 전달받습니다.

ArrayList 고정 결합

설정은 linked를 요청하지만 처리기 내부에서 ArrayList를 직접 생성합니다. 선택 값이 있어도 조립 위치가 고정되어 실제 구현이 바뀌지 않는 잘못된 결과를 냅니다.

lab/ConcreteListChoiceBug.java
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

클라이언트가 구체 객체 생성과 업무 처리를 동시에 맡았습니다. 인터페이스를 매개변수로 받고 애플리케이션 시작점이 구현을 선택해야 개방-폐쇄 원칙을 지킬 수 있습니다.

소비자에게 필요한 MyList 규칙

  • MyList<E>는 호출자가 필요한 add, get, size만 선언해 구현 세부를 숨깁니다.
  • ArrayMyList와 LinkedMyList는 같은 인덱스·예외 규칙을 지켜 행동까지 대체 가능해야 합니다.
  • BatchProcessor는 new로 구현을 만들지 않고 생성자 매개변수로 MyList를 받습니다.
  • 구현 선택은 main 같은 조립 지점 한 곳에 모아 환경 결정과 업무 로직을 분리합니다.
  • 인터페이스가 크면 구현이 사용하지 않는 메서드까지 떠안으므로 소비자 요구에서 시작합니다.
  • 성능 차이는 규칙 밖의 품질 속성이며 선택 기준으로 측정하되 기능 결과는 같아야 합니다.

생성자 주입과 외부 구현 선택

ListStrategyBatch는 두 표준 구현을 같은 run에 전달해 기능 결과가 모두 90인지 확인합니다. 처리기는 List<Entry>만 저장하고 생성자에서 null도 거부합니다. 구현 이름을 출력하는 코드는 조립 결과 확인용이며 업무 계산에는 사용하지 않습니다.

src/ListStrategyBatch.java
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 topic, int minutes) {
            entries.add(new Entry(topic, minutes));
        }

        int total() {
            return entries.stream().mapToInt(Entry::minutes).sum();
        }
    }

    private record Entry(String topic, int minutes) {}
}

소비자 요구의 최소 역할

BatchProcessor가 필요한 연산이 add, get, size라면 MyList에도 그 세 메서드부터 둡니다. 구현 편의를 위해 Node나 용량 조회를 추가하면 호출자가 내부 방식에 의존할 통로가 생깁니다. 역할은 두 구현의 공통 분모가 아니라 소비자의 실제 사용법에서 출발합니다.

ArrayMyList와 LinkedMyList는 반환값뿐 아니라 인덱스 구분과 순서도 같아야 합니다. 한 구현은 null을 받고 다른 구현은 거부한다면 단순 타입 대체만 가능할 뿐 행동 대체는 실패합니다. 인터페이스 문서에 예외와 변경 효과를 포함해야 하는 이유입니다.

반복자와 컬렉션 규칙

인덱스 get만으로 전체 순회를 만들면 연결 목록은 각 원소마다 head부터 다시 걸어 제곱 비용이 될 수 있습니다. Iterable<E>를 확장해 반복자를 제공하면 배열 구현은 커서 인덱스를, 연결 구현은 current Node를 한 번씩 전진시킬 수 있습니다.

Iterator의 hasNext()는 상태를 이동하지 않고 다음 값 존재 여부만 알려야 합니다. next()는 값 반환과 커서 이동을 한 번 수행하고, 끝에서 호출되면 NoSuchElementException을 던집니다. 향상된 for문은 이 규칙을 사용합니다.

조립 지점과 업무 로직 분리

main은 설정과 작업 부하를 보고 구현을 선택합니다. BatchProcessor는 생성자 주입으로 MyList를 받고 저장 방식 이름을 검사하지 않습니다. 새 구현이 추가되어도 처리기 코드는 닫혀 있고 조립 코드만 확장됩니다.

인터페이스가 하나의 구현만 가리고 변경 가능성도 없다면 추상화 비용이 더 클 수 있습니다. 실제로 교체할 두 전략과 안정된 소비자 규칙이 있을 때 도입해야 OCP라는 이름이 설계를 단순하게 만듭니다.

src/IterableListContract.java
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.minutes();
        }
        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 topic, int minutes) {}
}

저장 전략을 조립하는 시작점

CLI는 저장소 구현을 main에서 선택하고 명령 처리기는 addtotal만 호출합니다. 테스트 전용 코드를 만들지 않아도 두 구현을 번갈아 실행해 동일한 결과를 관찰할 수 있습니다.

app/StudyLogCliCH147.java
public final class StudyLogCliCH147 {
    public static void main(String[] args) {
        StudyLog log = new StudyLog();
        log.add("list-role", 44);
        log.add("dependency-injection", 56);
        log.print();
        System.out.println("total=" + log.totalMinutes());
    }

    private static final class StudyLog {
        private Entry[] entries = new Entry[2];
        private int size;

        void add(String topic, int minutes) {
            if (topic == null || topic.isBlank()) throw new IllegalArgumentException("topic");
            if (minutes <= 0) throw new IllegalArgumentException("minutes");
            ensureCapacity(size + 1);
            entries[size++] = new Entry(topic, minutes);
        }

        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 totalMinutes() {
            int total = 0;
            for (int i = 0; i < size; i++) {
                total += entries[i].minutes();
            }
            return total;
        }

        void print() {
            for (int i = 0; i < size; i++) {
                Entry entry = entries[i];
                System.out.println(i + ":" + entry.topic() + "=" + entry.minutes());
            }
        }
    }

    private record Entry(String topic, int minutes) {}
}

CLI의 main이 구현을 고르고 StudyLog는 목록 역할만 사용해야 합니다. 회귀 실행에서는 ArrayList와 LinkedList를 각각 주입해 입력 순서와 total 100이 같은지 봅니다. 반복자 기반 합계라면 연결 구현도 원소당 한 번의 전진으로 끝납니다.

인터페이스 도입 비용을 회수하는 조건

질문관찰할 값선택 또는 조치
호출자가 필요한 기능은 무엇인가사용 메서드 목록작은 역할 설계
구현을 언제 고르는가조립 시점main에서 결정
행동이 같은가예외·순서 결과규칙 비교
성능 선택이 바뀌는가운영 지표주입 구현 교체

인터페이스는 미래를 막연히 대비해 추가하지 않습니다. 이미 두 구현을 바꾸어 써야 하고 소비자가 동일한 연산을 요구할 때 비용을 회수합니다. 단 하나의 안정된 구현만 있다면 구체 타입이 더 단순할 수 있습니다.

연습 문제

ArrayList와 LinkedList에 같은 정수를 넣고 향상된 for문으로 합계를 구하세요. 계산 메서드의 매개변수는 구체 클래스가 아니라 Iterable<Integer>로 선언합니다.

정답과 해설

합계 소비자는 addindex 조회가 필요 없으므로 Iterable이 가장 작은 역할입니다. 두 구현의 반복자가 같은 순서를 제공하면 결과도 같습니다.

exercise/ListSubstitutionIteratorSolution.java
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 같은 조립 지점 한 곳에서 확인할 수 있어야 합니다.