추상화 수준 고르기

mongBrown·2026년 4월 15일

API 파라미터에 List 대신 Iterable을 쓰는 이유

아래 두 메서드 시그니처를 보자.

void send(ArrayList<String> emails)

void send(Iterable<String> emails)

둘 다 이메일 목록을 받아서 처리하는 메서드다.

첫 번째는 ArrayList를 직접 받는다. ArrayList 그 자체이거나 이를 상속한 경우만 넘길 수 있다. LinkedList를 쓰고 있다면 이 메서드를 호출하기 위해 새 ArrayList를 만들어 복사해야 한다. 호출하는 쪽이 구현체 선택의 자유가 없다.

두 번째는 Iterable을 받는다. Iterable은 "순회할 수 있다"는 의미의 인터페이스로, ArrayList, LinkedList, HashSet, 심지어 직접 만든 커스텀 컬렉션까지 Iterable을 구현하고 있다면 모두 넘길 수 있다. 호출하는 쪽이 어떤 컬렉션을 쓰든 상관없다.

이 둘은 단순히 호환되는 타입의 범위 차이일까. 아니면 그 선택이 메서드의 의미와 설계 의도까지 달라지게 만드는 걸까.


Collection 계층부터 정리하고 가자

선택지를 이해하려면 Java 컬렉션 계층이 어떻게 생겼는지 알아야 한다.

Iterable
└── Collection
    ├── List   — 순서 있음, 중복 허용
    │   ├── ArrayList    — 배열 기반, 인덱스 접근 빠름
    │   └── LinkedList   — 노드 연결 구조, 앞뒤 삽입/삭제 빠름
    ├── Set    — 순서 없음, 중복 불허
    │   ├── HashSet      — 가장 빠른 검색, 순서 없음
    │   ├── LinkedHashSet — 삽입 순서 유지
    │   └── TreeSet      — 정렬된 순서 유지
    └── Queue  — FIFO 구조
        └── Deque — 양쪽 끝에서 삽입/삭제
            ├── ArrayDeque   — 배열 기반 덱
            └── LinkedList   — List와 Deque 동시 구현

Collections(s가 붙은 것)는 인터페이스가 아니라 sort(), shuffle(), unmodifiableList() 같은 정적 유틸 메서드만 모아놓은 클래스다. 컬렉션 계층과는 별개의 존재다.


왜 구체 타입보다 추상 타입을 선호하는가

ArrayList를 파라미터로 받으면 호출하는 쪽도 반드시 ArrayList를 넘겨야 한다. LinkedList를 쓰고 싶어도 못 쓴다.

List로 받으면 ArrayListLinkedList든 상관없다. 호출하는 쪽이 구현체를 자유롭게 선택할 수 있다. 이게 추상 타입을 선호하는 첫 번째 이유 — 유연성이다.

그런데 무조건 최상위 인터페이스인 Iterable로 받는 게 항상 좋은 건 아니다. 추상화 수준을 어떻게 고를지는 다른 기준이 필요하다.


어떤 기준으로 추상화 수준을 고르는가

추상 타입을 선택하는 두 번째 이유는 설계 의도를 코드로 표현하는 것이다.

파라미터 타입은 "이 자리에 어떤 타입이 올 수 있는가"를 제한한다.

// 반복 가능하면 뭐든 받겠다
// ArrayList, LinkedList, HashSet, 커스텀 이터러블까지
void process(Iterable<String> items)

// 컬렉션 계열이면 받겠다
// List든 Set이든 Queue든 상관없다
void process(Collection<String> items)

// List 계열만 받겠다
// 순서가 있고 중복이 허용되는 것만
void process(List<String> items)

타입이 구체적으로 좁아질수록 호출자가 넘길 수 있는 범위도 좁아진다. Collection으로 받으면 List도, Set도, Queue도 모두 허용하는 것이다. List로 받는 순간 Set이나 Queue는 넘길 수 없다. 이 선택이 "이 메서드는 어떤 성격의 데이터를 기대하는가"를 시그니처에서 드러낸다.

기준은 결국 하나다 — 호출자가 어떤 타입을 넘겨야 하는가. 어떤 컬렉션이든 받겠다면 Iterable, List/Set/Queue를 모두 허용하겠다면 Collection, 순서와 중복이 보장된 것만 받겠다면 List다.


그러면 그냥 항상 Iterable로 받으면 되는 걸까

추상 타입이 좋다면, 가장 상위인 Iterable로 받으면 가장 좋은 설계 아닐까. 어떤 컬렉션이든 다 받을 수 있고, 호출하는 쪽도 자유롭다.

그런데 Iterable은 범위가 너무 넓다. 반복 가능하면 무엇이든 올 수 있다 보니, 호출하는 쪽이 어떤 성격의 데이터를 넘겨야 하는지 시그니처에서 알 수가 없다. 순서가 보장되어야 하는지, 중복이 허용되는지, FIFO 방식인지 — 아무것도 드러나지 않는다.

Collection으로 받으면 최소한 컬렉션 계열이 와야 한다는 것이 보이고, List로 받으면 순서와 중복이 보장된 데이터를 기대한다는 것이 보인다. 타입이 구체적일수록 "이 메서드가 어떤 성격의 데이터를 다루는가"가 시그니처에서 드러난다.

add()가 없다고 불변 컬렉션이 아니다

Iterableadd(), remove()가 없으니 수정이 막힌다고 착각하기 쉽다. 그렇지 않다. Iterableadd()를 정의하지 않은 건 수정을 금지하는 게 아니라, 단순히 Iterable의 관심사가 아니기 때문이다.

ArrayListIterable로 넘겨도 그 객체는 여전히 ArrayList다. 메서드 안에서 iterator()만 쓸 수 있을 뿐, 바깥에서는 원본 참조로 add()를 얼마든지 호출할 수 있다.

수정을 막을 목적으로 Iterable을 선택하는 건 잘못된 방향이다. 불변이 필요하다면 Collections.unmodifiableList() 같은 별도 래핑을 써야 한다.

결국 추상 타입 선택은 어떤 판단인가

파라미터 타입은 "이 자리에 어떤 성격의 데이터가 와야 하는가"를 표현하는 수단이다. 순서가 보장되어야 하는지, 중복을 허용하는지, 컬렉션 계열이기만 하면 되는지 — 그 기준에 맞는 타입을 골라야 시그니처만 봐도 의도를 알 수 있다.

무조건 상위 타입이 유연해서 좋은 게 아니다. 범위가 넓을수록 호출자도, 이 코드를 읽는 사람도 어떤 데이터를 넘겨야 하는지 알기 어려워진다. 필요한 성격에 딱 맞는 수준을 고르는 것이 좋은 설계다.

profile
화이팅!

0개의 댓글