MyList 인터페이스로 목록 구현 바꾸기
목록을 쓰는 코드가 ArrayList 같은 구체 클래스 대신 MyList 인터페이스에 의존하게 만들어, 배열 목록과 연결 목록을 main에서 바꿔 끼우는 방법과 그때 두 구현이 지켜야 할 조건을 배웁니다.
이 장에서는 지금까지 배열에 원소를 담는 MyArrayList와 노드를 이어 붙이는 MyLinkedList를 따로 만들었습니다. 내부 구조는 전혀 다르지만, 목록을 쓰는 쪽에서 보면 두 클래스가 하는 일은 같습니다. 원소를 끝에 추가하고, 인덱스로 꺼내고, 개수를 알려 줍니다. 그런데 목록을 쓰는 코드가 변수나 매개변수의 타입을 ArrayList처럼 구체 클래스로 적어 두면, 나중에 연결 목록으로 바꾸고 싶을 때 그 코드를 모두 고쳐야 합니다.
이 글에서는 두 구현이 함께 제공하는 연산을 MyList<E> 인터페이스로 묶고, 목록을 쓰는 처리기가 그 인터페이스에만 의존하게 만듭니다. 그러면 어떤 구현을 쓸지는 main 한곳에서 정할 수 있습니다. 이어서 타입만 맞춘다고 바꿔 쓸 수 있는 것은 아니라는 점과, get만으로 순회하면 연결 목록에서 비용이 원소 수의 제곱에 비례해 늘어나는 이유를 살펴봅니다. 마지막으로 자바 표준 라이브러리의 List가 바로 이 구조라는 것을 확인합니다.
구체 클래스 타입에 묶인 목록 선택
다음 프로그램은 연결 목록을 쓰라는 요청("linked")을 받았지만, 목록은 ArrayList로 직접 만듭니다. getClass().getSimpleName()은 변수에 실제로 들어 있는 객체의 클래스 이름을 돌려주므로, 출력의 actual= 뒤를 보면 어떤 구현이 만들어졌는지 알 수 있습니다.
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=ArrayListrequested는 출력에만 쓰일 뿐 객체를 만드는 코드와는 아무 관계가 없습니다. 그래서 요청이 무엇이든 만들어지는 것은 ArrayList입니다. 그렇다면 요청이 "linked"일 때 LinkedList를 대입하도록 고치면 될까요? 변수 타입이 ArrayList<String>으로 정해져 있어서 그것도 되지 않습니다.
ArrayList<String> hardWired = new ArrayList<>();
if (requested.equals("linked")) {
hardWired = new LinkedList<String>();
}main을 이렇게 바꾸고 java.util.LinkedList도 import해서 컴파일하면 다음 오류가 납니다. 앞에 붙는 파일 이름과 줄 번호는 생략했습니다.
error: incompatible types: LinkedList<String> cannot be converted to ArrayList<String>LinkedList는 ArrayList의 하위 클래스가 아니므로 ArrayList 타입 변수에 넣을 수 없습니다. 변수 타입을 구체 클래스로 적는 순간, 그 변수를 쓰는 코드는 모두 그 구현 하나에 묶입니다. 이 변수가 메서드의 매개변수였다면 호출하는 쪽도 모두 ArrayList를 넘겨야 하니, 구현을 바꾸려면 그 경로를 전부 고쳐야 합니다. 구체 클래스에 의존하면 생기는 문제에서 본 상황과 같고, 해결 방법도 같습니다. 두 구현이 함께 따르는 역할을 인터페이스로 정하고, 쓰는 쪽은 그 인터페이스 타입만 알게 합니다.
쓰는 쪽에 필요한 연산만 모은 MyList
인터페이스에 어떤 메서드를 넣을지는 구현이 아니라 쓰는 쪽에서 출발해 정합니다. 이 글의 예제에서 목록을 쓰는 쪽은 게시글 항목을 모아 조회 수 합계를 내는 BatchProcessor입니다. 이 처리기는 항목을 끝에 추가하고(add), 합계를 낼 때 인덱스로 하나씩 꺼내며(get), 몇 개인지 묻습니다(size). 그래서 MyList<E>에는 이 세 메서드만 둡니다.
interface MyList<E> {
void add(E value);
E get(int index);
int size();
}MyArrayList에는 용량을 늘리는 메서드가 있고, 연결 목록에는 Node가 있습니다. 이런 것까지 인터페이스에 넣으면 문제가 생깁니다. 처리기가 용량을 묻기 시작하면 용량이라는 개념이 없는 연결 목록은 의미 있는 답을 줄 수 없고, Node를 돌려주는 메서드가 생기면 처리기가 연결 구조에 직접 손댈 수 있게 됩니다. 어느 쪽이든 처리기가 특정 구현의 내부에 기대게 되어 인터페이스를 만든 목적이 사라집니다.
두 구현이 마침 함께 가진 메서드를 모두 모으는 것도 답이 아닙니다. 공통으로 있다는 이유만으로 넣은 메서드는 쓰는 쪽이 필요로 하지 않아도 앞으로 모든 구현이 계속 지원해야 하는 짐이 됩니다. 반대로 메서드가 적을수록 새 구현이 채워야 할 것도 적어서 구현을 바꾸기가 쉬워집니다.
두 구현을 같은 처리기에 넣어 보기
다음 프로그램은 MyList를 구현한 배열 목록 ArrayMyList와 연결 목록 LinkedMyList를 만들고, 같은 처리기에 차례로 넣어 결과를 비교합니다. 두 구현은 앞 글들에서 만든 목록을 이 글에 필요한 메서드만 남겨 간단히 다시 쓴 것입니다. 연결 목록에는 끝 추가를 바로 할 수 있도록 마지막 노드를 가리키는 tail을 두었습니다.
import java.util.Arrays;
import java.util.Objects;
public final class MyListSwap {
public static void main(String[] args) {
run(new ArrayMyList<>());
run(new LinkedMyList<>());
}
private static void run(MyList<Entry> storage) {
BatchProcessor processor = new BatchProcessor(storage);
processor.add("array", 40);
processor.add("linked", 50);
System.out.println(storage.getClass().getSimpleName() + "=" + processor.total());
}
private interface MyList<E> {
void add(E value);
E get(int index);
int size();
}
private static final class BatchProcessor {
private final MyList<Entry> entries;
BatchProcessor(MyList<Entry> entries) {
this.entries = Objects.requireNonNull(entries);
}
void add(String title, int viewCount) {
entries.add(new Entry(title, viewCount));
}
int total() {
int total = 0;
for (int i = 0; i < entries.size(); i++) {
total += entries.get(i).viewCount();
}
return total;
}
}
private static final class ArrayMyList<E> implements MyList<E> {
private Object[] elements = new Object[2];
private int size;
public void add(E value) {
if (size == elements.length) {
elements = Arrays.copyOf(elements, size * 2);
}
elements[size++] = value;
}
@SuppressWarnings("unchecked")
public E get(int index) {
Objects.checkIndex(index, size);
return (E) elements[index];
}
public int size() {
return size;
}
}
private static final class LinkedMyList<E> implements MyList<E> {
private Node<E> head;
private Node<E> tail;
private int size;
public void add(E value) {
Node<E> added = new Node<>(value);
if (head == null) head = added;
else tail.next = added;
tail = added;
size++;
}
public E get(int index) {
Objects.checkIndex(index, size);
Node<E> current = head;
for (int i = 0; i < index; i++) {
current = current.next;
}
return current.value;
}
public int size() {
return size;
}
}
private static final class Node<E> {
private final E value;
private Node<E> next;
Node(E value) {
this.value = value;
}
}
private record Entry(String title, int viewCount) {}
}ArrayMyList=90
LinkedMyList=90run은 목록을 MyList<Entry> 타입으로 받아 처리기에 넘깁니다. 처리기는 받은 목록이 배열인지 노드의 연결인지 모른 채 add, get, size만 호출합니다. 첫 줄은 ArrayMyList를, 둘째 줄은 LinkedMyList를 넣은 실행이고, 두 번 모두 조회 수 40과 50을 더한 90이 나옵니다. 줄 앞의 클래스 이름은 어떤 구현이 들어갔는지 보여 주려고 run에서 출력한 것일 뿐, 처리기의 계산에는 쓰이지 않습니다. ArrayMyList의 get이 Object 칸의 값을 E로 캐스팅하고 그 경고를 메서드 안에서만 억제하는 이유는 제네릭 목록과 타입 안전성에서 다뤘습니다.
처리기는 목록을 직접 만들지 않고 생성자로 받습니다. 필요한 객체를 바깥에서 만들어 생성자로 넘겨주는 이 방식을 생성자 주입(constructor injection)이라고 합니다. 처리기 안에 new ArrayMyList<>()가 한 줄이라도 있으면 처리기는 다시 그 구현에 묶이기 때문입니다. 생성자의 Objects.requireNonNull은 null이 들어오면 그 자리에서 NullPointerException을 던집니다. 이 검사가 없으면 처리기가 만들어진 뒤 처음 add를 호출할 때에야 예외가 나서, 원인이 된 코드를 찾기가 어려워집니다.
구현을 고르는 코드는 main에만 있습니다. 옵션에 맞는 구현 고르기와 객체지향 설계 정리에서 구현 객체를 만들고 연결하는 곳을 조립점이라고 불렀는데, 이 프로그램에서는 main이 조립점입니다. 목록 구현을 하나 더 만들더라도 처리기는 고칠 필요가 없고, main에서 그 구현을 만들어 run에 넘기는 한 줄만 추가하면 됩니다. 처리기 같은 기존 코드는 그대로 두고 새 구현을 더할 수 있으며, 바뀌는 곳은 조립점 한곳뿐인 이 구조가 개방-폐쇄 원칙이 말하는 모습입니다.
타입이 맞는다고 바꿔 쓸 수 있는 것은 아니다
ArrayMyList와 LinkedMyList가 MyList를 구현했다고 컴파일러가 확인해 주는 것은 메서드의 이름과 매개변수, 반환 타입뿐입니다. 하지만 처리기는 그보다 많은 것을 기대합니다. get(i)가 i번째로 추가한 원소를 돌려준다는 것, 범위를 벗어난 인덱스에는 예외를 던진다는 것, null을 넣을 수 있는지가 두 구현에서 같다는 것입니다. 이런 동작까지 같아야 처리기 입장에서 두 구현을 정말로 바꿔 쓸 수 있습니다. 인터페이스로 역할과 구현 분리하기에서 본 리스코프 치환 원칙이 바로 이 조건입니다.
인덱스 검사를 예로 들어 보겠습니다. 앞 예제의 두 get은 모두 Objects.checkIndex(index, size)로 시작합니다. 이 줄을 두 구현에서 모두 빼고 원소 세 개가 든 목록에 get(3)을 호출하면 결과가 갈립니다. ArrayMyList는 용량이 2에서 4로 늘어난 상태라서 비어 있는 3번 칸을 읽고 null을 조용히 돌려줍니다. LinkedMyList는 세 번째 노드의 next인 null까지 걸어간 뒤 그 값을 읽으려다 NullPointerException을 던집니다. 같은 호출에 한쪽은 틀린 값을, 다른 쪽은 엉뚱한 예외를 내는 셈이라, 한 구현으로만 시험한 처리기는 다른 구현에서 다르게 동작합니다. 검사를 넣어 두면 두 구현 모두 같은 예외 IndexOutOfBoundsException: Index 3 out of bounds for length 3을 던집니다.
이런 동작은 메서드 선언만으로는 드러나지 않으므로 인터페이스의 문서 주석에 적어 둡니다. 표준 라이브러리의 List도 메서드마다 어떤 경우에 어떤 예외를 던지는지를 문서로 정해 두고, 모든 구현이 그대로 따르게 합니다.
/**
* 원소를 추가한 순서대로 보관하는 목록. null 원소도 보관한다.
*/
interface MyList<E> {
/** value를 목록 끝에 추가한다. */
void add(E value);
/**
* index번째 원소를 돌려준다.
*
* @throws IndexOutOfBoundsException index가 0보다 작거나 size() 이상일 때
*/
E get(int index);
/** 보관한 원소 개수를 돌려준다. */
int size();
}null도 마찬가지입니다. 한 구현은 null을 받고 다른 구현은 거부한다면, 컴파일은 되지만 어떤 입력에서는 한쪽만 실패합니다. 그래서 null을 허용할지도 문서에 적고, 두 구현을 같은 입력으로 실행해 순서와 예외까지 같은지 비교해 봅니다.
get으로 순회하면 연결 목록이 느려지는 이유
앞 예제의 total은 i를 0부터 size() - 1까지 늘려 가며 get(i)를 차례로 호출합니다. 결과는 두 구현에서 같았지만 드는 비용은 크게 다릅니다. ArrayMyList의 get은 배열 칸 하나를 읽고 끝나므로, 전체 순회는 원소 수 n에 비례합니다. 반면 LinkedMyList의 get(i)는 매번 head에서 출발해 next를 i번 따라갑니다.
원소가 4개라면 get(0)은 0번, get(1)은 1번, get(2)는 2번, get(3)은 3번 이동하므로 모두 0 + 1 + 2 + 3 = 6번입니다. 원소가 n개이면 0부터 n−1까지의 합인 n(n−1)/2번이 되어, 이동 횟수가 n²에 비례해 늘어납니다. 바로 앞에서 도착한 노드를 기억해 두면 한 칸만 더 가면 될 텐데, get은 호출마다 처음부터 따로 시작하므로 그럴 수가 없습니다. 지금 위치를 기억하는 일은 반복자(iterator)가 맡습니다. 반복자는 current 노드 하나를 들고 있다가 원소를 하나 내줄 때마다 한 칸만 앞으로 옮기므로, 전체 순회에 n번만 이동합니다.
다음 프로그램은 이동 횟수를 직접 셉니다. get과 반복자가 next를 따라갈 때마다 moves를 1씩 늘리고, 같은 목록을 두 방식으로 순회해 비교합니다.
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
public final class LinkedTraversalCost {
public static void main(String[] args) {
for (int n : new int[] {4, 1_000}) {
CountingLinkedList<Integer> list = new CountingLinkedList<>();
for (int i = 1; i <= n; i++) {
list.add(i);
}
list.moves = 0;
long byIndex = 0;
for (int i = 0; i < list.size(); i++) {
byIndex += list.get(i);
}
long indexMoves = list.moves;
list.moves = 0;
long byIterator = 0;
for (int value : list) {
byIterator += value;
}
System.out.println("n=" + n + ", get=" + indexMoves + ", iterator=" + list.moves
+ ", sum=" + byIndex + "/" + byIterator);
}
}
private static final class CountingLinkedList<E> implements Iterable<E> {
private Node<E> head;
private Node<E> tail;
private int size;
long moves; // next를 따라간 횟수
void add(E value) {
Node<E> added = new Node<>(value);
if (head == null) head = added;
else tail.next = added;
tail = added;
size++;
}
E get(int index) {
Objects.checkIndex(index, size);
Node<E> current = head;
for (int i = 0; i < index; i++) {
current = current.next;
moves++;
}
return current.value;
}
int size() {
return size;
}
@Override
public Iterator<E> iterator() {
return new Iterator<>() {
private Node<E> current = head;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public E next() {
if (current == null) throw new NoSuchElementException();
E value = current.value;
current = current.next;
moves++;
return value;
}
};
}
}
private static final class Node<E> {
private final E value;
private Node<E> next;
Node(E value) {
this.value = value;
}
}
}n=4, get=6, iterator=4, sum=10/10
n=1000, get=499500, iterator=1000, sum=500500/500500원소가 4개일 때 get 순회는 앞에서 계산한 대로 6번 이동합니다. 반복자는 4번 이동하는데, 마지막 원소를 내줄 때 current가 끝의 null로 옮겨 가는 이동까지 세었기 때문입니다. 원소가 1000개가 되면 차이가 분명해집니다. get 순회는 1000 × 999 / 2 = 499,500번을 이동하지만 반복자는 원소 수와 같은 1000번입니다. 원소가 250배 늘었을 때 반복자의 이동도 250배 늘었지만, get 순회의 이동은 약 8만 배 늘었습니다. 두 방식의 합계(sum)가 같으므로 결과는 같고 비용만 다릅니다.
표준 LinkedList의 get은 인덱스가 앞쪽 절반에 있으면 첫 노드에서, 뒤쪽 절반에 있으면 마지막 노드에서 출발합니다. 그래서 원소 1000개를 get으로 순회하면 이동이 249,500번으로 절반쯤 줄지만, 여전히 n²에 비례합니다. LinkedList를 인덱스 for문과 get(i)로 순회하는 코드를 피해야 하는 이유가 이것입니다.
이 문제를 고치려면 MyList가 순회 방법도 제공해야 합니다. 자바에서는 Iterable<E>를 확장하면 됩니다. 각 구현이 iterator()를 구현해서, 배열 목록은 다음에 읽을 인덱스를, 연결 목록은 다음에 내줄 노드를 기억하는 반복자를 돌려줍니다. 앞 프로그램의 CountingLinkedList가 구현한 iterator()가 연결 목록 쪽의 예입니다. 반복자의 두 메서드에는 지켜야 할 규칙이 있습니다. hasNext()는 위치를 옮기지 않고 다음 원소가 있는지만 알려 줍니다. next()는 원소 하나를 돌려주면서 위치를 한 칸 옮기고, 더 돌려줄 원소가 없으면 NoSuchElementException을 던집니다.
interface MyList<E> extends Iterable<E> {
void add(E value);
E get(int index);
int size();
}MyList가 Iterable이 되면 처리기의 합계도 향상된 for문으로 쓸 수 있습니다. 컴파일러는 향상된 for문을 iterator()를 한 번 호출한 뒤 hasNext()와 next()를 번갈아 부르는 코드로 바꾸므로, 어떤 구현이 들어와도 원소 수에 비례하는 시간에 끝납니다.
int total() {
int total = 0;
for (Entry entry : entries) {
total += entry.viewCount();
}
return total;
}반복자를 만드는 방법은 Iterable과 Iterator에서 더 자세히 다룹니다.
더 깊이: 표준 라이브러리가 순회 방식을 고르는 방법
표준 라이브러리도 같은 고민을 안고 있습니다. List를 받는 범용 알고리즘은 들어온 목록이 ArrayList인지 LinkedList인지 알 수 없기 때문입니다. 그래서 자바는 RandomAccess라는 표시용 인터페이스(marker interface)를 둡니다. 메서드가 하나도 없고, 구현 클래스가 인덱스 접근이 빠르다(보통 상수 시간)는 사실을 알리는 데만 쓰입니다. ArrayList는 이 인터페이스를 구현하지만 LinkedList는 구현하지 않습니다.
알고리즘 쪽에서는 instanceof RandomAccess로 순회 방식을 고릅니다. 예를 들어 Collections.binarySearch는 목록이 RandomAccess이거나 크기가 작으면 get(i)로 가운데 원소를 바로 읽고, 그렇지 않으면 ListIterator로 노드를 따라가며 탐색합니다. 인터페이스 하나로 두 구현을 함께 받으면서도 구현마다 비용이 다른 연산을 피해 가는 방법입니다.
표준 List도 같은 구조다
자바 표준 라이브러리의 java.util.List는 MyList와 같은 역할을 하는 인터페이스이고, ArrayList와 LinkedList는 그 구현입니다. 그래서 처리기가 List<Entry>에 의존하게 하면 앞에서 한 일을 표준 클래스로 그대로 할 수 있습니다. 다음 프로그램은 MyListSwap과 구조가 같고, 목록 타입만 표준 List로 바꾼 것입니다.
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
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 = 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) {}
}ArrayList=90
LinkedList=90main이 ArrayList와 LinkedList를 차례로 만들어 넘기고, 처리기는 List<Entry>의 add와 스트림만 사용합니다. 스트림은 목록을 처음부터 차례로 훑기 때문에 앞에서 본 get 순회 문제가 없고, 두 구현 모두 원소 수에 비례하는 시간에 합계를 구합니다.
두 줄이 모두 90인 것은 main이 run을 부를 때마다 새 빈 목록을 만들어 넘겼기 때문입니다. 목록 하나를 만들어 두 번 넘기면 두 번째 실행은 앞에서 넣은 두 항목 위에 두 항목을 더 넣으므로 180이 나옵니다. 구현만 바꿔 결과를 비교하려면 입력과 시작 상태도 같게 맞춰야 합니다.
이제 처음의 ConcreteListChoiceBug도 고칠 수 있습니다. 변수 타입을 List<String>으로 바꾸고, 요청 문자열을 보고 구현을 만드는 일을 메서드 하나에 모읍니다.
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public final class ListChoiceFixed {
public static void main(String[] args) {
String requested = "linked";
List<String> storage = createStorage(requested);
System.out.println(
"requested=" + requested + ", actual=" + storage.getClass().getSimpleName());
}
private static List<String> createStorage(String requested) {
return switch (requested) {
case "array" -> new ArrayList<>();
case "linked" -> new LinkedList<>();
default -> throw new IllegalArgumentException("unknown list: " + requested);
};
}
}requested=linked, actual=LinkedListcreateStorage의 반환 타입이 인터페이스인 List<String>이므로, 요청에 따라 ArrayList와 LinkedList 중 무엇이든 돌려줄 수 있습니다. 모르는 이름이 오면 예외를 던져 잘못된 설정을 바로 알립니다. 구현 이름을 아는 코드는 이 메서드뿐이고, 돌려받은 목록을 쓰는 쪽은 List만 압니다. 옵션에 맞는 구현 고르기와 객체지향 설계 정리의 select처럼 어떤 구현을 만들지 골라 돌려주는 메서드를 팩터리(factory)라고 부릅니다.
List에는 MyList보다 훨씬 많은 메서드가 있습니다. 위치를 지정하는 add(int, E), remove, contains, subList 같은 것들입니다. 처리기에 필요한 것이 그중 일부뿐이라면 매개변수 타입을 더 작게 잡을 수도 있습니다. 예를 들어 원소를 차례로 읽기만 하는 코드라면 Iterable<E>나 Collection<E>로 충분하고, 그만큼 더 많은 종류의 객체를 받을 수 있습니다. 연습 문제 1에서 이 방법을 써 봅니다.
인터페이스가 비용을 회수하는 때
인터페이스는 공짜가 아닙니다. 읽고 관리할 타입이 하나 늘고, 앞에서 본 것처럼 모든 구현이 같은 동작을 지키도록 문서를 쓰고 확인하는 일이 따라붙습니다. 그래서 언젠가 바꿀지도 모른다는 이유만으로 인터페이스를 미리 만들지는 않습니다. 실제로 바꿔 쓸 구현이 둘 이상 있고 쓰는 쪽이 요구하는 연산이 안정되어 있을 때, 인터페이스는 그 비용을 회수합니다. 구현이 하나뿐이고 바뀔 계획도 없다면 구체 타입을 그대로 쓰는 편이 더 단순합니다.
이 장에서 키워 온 게시글 저장소가 그런 예입니다. 다음 CLI의 PostRepository는 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) {}
}0:list-role=44
1:dependency-injection=56
total=100두 항목은 추가한 순서대로 0번과 1번 칸에 들어갑니다. print와 totalViewCount는 인덱스 for문으로 사용 중인 칸, 즉 0번부터 size - 1번까지만 훑으므로 비어 있는 칸을 읽지 않습니다. 처음 용량이 2라서 이 실행에서는 배열을 늘리는 일이 일어나지 않고, 합계는 44 + 56 = 100입니다. 나중에 저장 방식을 바꿔야 하는 요구가 생기면, 그때 필드 타입을 MyList<Entry>나 List<Entry>로 바꾸고 생성자로 받게 하면 됩니다.
아래 그림은 이 글의 예제 가운데 세 프로그램, 네 번의 실행을 나란히 비교합니다. 구현을 고정한 ConcreteListChoiceBug, 표준 List를 주입한 ListStrategyBatch의 두 실행, 배열을 직접 쓰는 CLI가 각각 어디서 저장소를 만들고 무엇을 출력하는지 보여 줍니다. 주입 예제의 합계 90과 CLI의 합계 100이 다른 것은 입력한 조회 수가 다르기 때문입니다.
고정 ArrayList 예제, List 주입 두 실행, 별도 Entry 배열 CLI의 생성 위치와 계산 결과를 비교한다.
저장소 선택 · 생성 위치와 입력
생성 방식과 합계가 다른 네 실행
요청 문자열만 바꾼 코드와 실제 목록을 주입한 코드를 비교합니다. 주입 예제의 합계 90과 별도 CLI의 100은 입력값이 다릅니다.
ConcreteListChoiceBug고정 구현 생성
ConcreteListChoiceBug- 저장소 생성과 소비
main: new ArrayList<>()요청 문자열만linked로 설정- 관찰 결과
requested=linked, actual=ArrayList
ListStrategyBatchArrayList 실행
ListStrategyBatch- 저장소 생성과 소비
main → run → BatchProcessorList<Entry>주입; 스트림 합계- 관찰 결과
- 조회수
40 + 50ArrayList=90
ListStrategyBatchLinkedList 실행
ListStrategyBatch- 저장소 생성과 소비
main → run → BatchProcessor같은List<Entry>계약과 스트림 합계- 관찰 결과
- 조회수
40 + 50LinkedList=90
PostRepositoryCliCh147별도 배열 CLI
PostRepositoryCliCh147- 저장소 생성과 소비
- 저장소 내부
Entry[2]주입 없이 인덱스for순회 - 관찰 결과
- 조회수
44 + 56total=100
ListStrategyBatch의 두 행은 같은 main이 각각 새 목록으로 실행한 run입니다. 첫 프로그램의 출력은 requested=linked, actual=ArrayList 한 줄입니다.
인터페이스를 도입할지 판단할 때는 다음 질문을 차례로 던져 봅니다.
| 질문 | 확인할 것 | 판단 |
|---|---|---|
| 쓰는 쪽에 필요한 연산은 무엇인가 | 실제로 호출하는 메서드 | 그 메서드만 담은 작은 인터페이스 |
| 구현은 어디서 정하는가 | 객체를 만드는 위치 | main 같은 조립점 한곳 |
| 두 구현의 동작이 같은가 | 순서, 예외, null 처리 | 인터페이스 문서에 적고 같은 입력으로 비교 |
| 구현을 바꿀 이유가 실제로 있는가 | 요구 사항, 작업 부하 | 없으면 구체 타입으로 충분 |
정리
- 변수와 매개변수의 타입을 구체 클래스로 적으면, 그 변수를 쓰는 코드가 모두 그 구현 하나에 묶입니다.
- 인터페이스의 메서드는 쓰는 쪽이 실제로 호출하는 연산에서 출발해 정합니다.
BatchProcessor에게는add,get,size면 충분했습니다. - 쓰는 쪽은 구현을 생성자로 받고, 어떤 구현을 쓸지는
main같은 조립점 한곳에서 정합니다. - 바꿔 쓰려면 타입뿐 아니라 순서, 예외,
null처리 같은 동작도 같아야 합니다. 이런 규칙은 인터페이스 문서에 적습니다. - 연결 목록을
get(i)로 순회하면 이동 횟수가 n(n−1)/2로 n²에 비례합니다. 반복자로 순회하면 n번에 끝나므로, 순회가 필요한 역할은Iterable<E>를 확장합니다. - 표준
List와ArrayList,LinkedList가 같은 구조입니다. 구현이 하나뿐이고 바뀔 이유가 없다면 인터페이스 없이 구체 타입을 써도 됩니다.
연습 문제
ArrayList와LinkedList에 같은 정수 30, 40, 50을 넣고, 향상된for문으로 합계를 구하는sum메서드를 작성하세요.sum의 매개변수 타입은 구체 클래스가 아니라Iterable<Integer>로 선언합니다.
정답과 해설
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public final class ListSubstitutionIteratorSolution {
public static void main(String[] args) {
var array = new ArrayList<>(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;
}
}array=120
linked=120sum은 원소를 처음부터 차례로 읽기만 하므로 add도 get도 필요 없습니다. 향상된 for문에 필요한 것은 iterator() 하나이고, 그것을 약속하는 가장 작은 타입이 Iterable입니다. ArrayList와 LinkedList는 모두 Iterable을 구현하고 같은 순서로 원소를 내주므로 두 줄 모두 120이 나옵니다. sum은 어떤 구현이 들어오는지 모르며, 반복자를 쓰기 때문에 LinkedList에서도 원소 수에 비례하는 시간에 끝납니다.
- 원소가 6개인 연결 목록에서
get이 항상head에서 출발한다고 합시다.get(0)부터get(5)까지 차례로 호출하면next를 모두 몇 번 따라가나요? 반복자로 순회하면 몇 번인가요? 양방향 연결 목록인 표준LinkedList처럼 가까운 끝에서 출발한다면 링크를 모두 몇 번 따라가는지도 구하세요.
정답과 해설
get(i)는 next를 i번 따라가므로 0 + 1 + 2 + 3 + 4 + 5 = 15번입니다. n(n−1)/2에 n = 6을 넣은 6 × 5 / 2 = 15와 같습니다. 반복자는 원소마다 한 번씩, 마지막에 null로 옮겨 가는 이동까지 6번 이동합니다.
가까운 끝에서 출발하면 앞쪽 절반인 인덱스 0, 1, 2는 첫 노드에서 각각 0, 1, 2번, 뒤쪽 절반인 인덱스 3, 4, 5는 마지막 노드에서 prev를 각각 2, 1, 0번 따라갑니다. 합은 6번으로 15번보다 크게 줄지만, 원소 수가 늘면 이 합도 n²에 비례해 늘어납니다. 그래서 어느 쪽이든 연결 목록 전체 순회는 반복자로 해야 합니다.
- 누군가 연결 목록의
get순회가 느리다는 것을 알고,BatchProcessor.total안에if (entries instanceof LinkedMyList)로 시작하는 분기를 넣어 연결 목록일 때만 노드를 직접 따라가게 고쳤습니다. 이 수정의 문제점과 더 나은 해결책을 설명하세요.
정답과 해설
처리기가 다시 구체 클래스 LinkedMyList를 알게 되었습니다. 연결 목록 구현을 하나 더 만들거나 이름을 바꾸면 처리기의 분기도 함께 고쳐야 하므로, 처리기를 고치지 않고 구현을 바꿔 끼운다는 목적이 깨집니다. 노드를 직접 따라가려면 처리기가 Node에도 접근해야 해서 구현 내부까지 드러납니다.
이런 분기가 필요해졌다는 것은 역할에 필요한 연산이 빠져 있다는 신호입니다. 처리기에 필요한 것은 처음부터 끝까지 순회하는 방법이므로, MyList가 Iterable<E>를 확장하고 각 구현이 자기 구조에 맞는 반복자를 제공하게 하면 됩니다. 그러면 처리기는 향상된 for문 하나로 두 구현을 모두 원소 수에 비례하는 시간에 순회합니다.
다음 글
ArrayList와 LinkedList의 연산 비용에서는 같은 List를 구현한 두 클래스가 조회, 앞 삽입, 끝 추가, 중간 삽입에서 각각 얼마의 비용을 내는지 비교합니다. 그리고 그 표를 실제 작업 부하에 맞춰 읽고, 측정하기 전에는 무엇을 기본으로 골라야 하는지 정리합니다.