이펙티브 자바 완벽 공략 2부

아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라.

아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라.


아이템 21. 핵심 정리

아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라

Java 8부터 인터페이스에 default 메서드를 정의할 수 있게 되면서 기존 인터페이스를 훨씬 유연하게 확장할 수 있게 되었다.

예를 들어 이미 널리 사용되고 있는 인터페이스가 있다고 하자.

public interface MyService {

    void execute();
}

시간이 지나 새로운 기능이 필요해졌다고 가정해보자.

기존 방식대로 추상 메서드를 추가하면

public interface MyService {

    void execute();

    void validate();
}

기존의 모든 구현 클래스가 validate()를 구현해야 한다.

하지만 default 메서드를 사용하면 기존 구현체를 깨뜨리지 않고 기능을 추가할 수 있다.

public interface MyService {

    void execute();

    default void validate() {
        // 기본 검증
    }
}

겉으로 보면 굉장히 편리하다.

하지만 바로 이 편리함 때문에 새로운 문제가 생길 수도 있다.

default 메서드를 기존 인터페이스에 추가한다는 것은 사실상

그 인터페이스를 구현하고 있는 모든 클래스에 새로운 동작을 강제로 주입하는 것

과 비슷하기 때문이다.

그래서 인터페이스를 설계하거나 이미 공개된 인터페이스에 default 메서드를 추가할 때는 인터페이스 자체만 볼 것이 아니라 그 인터페이스를 구현하고 있는 클래스들의 특성까지 고려해야 한다.


default 메서드는 기존 구현체를 깨뜨리지 않는 것처럼 보인다

default 메서드의 가장 큰 장점은 기존 구현 클래스가 새로운 메서드를 직접 구현하지 않아도 된다는 것이다.

public interface Worker {

    void work();

    default void stop() {
        System.out.println("stop");
    }
}

기존 구현체는 그대로 유지된다.

public class Developer
        implements Worker {

    @Override
    public void work() {
        System.out.println("develop");
    }
}

다음 코드도 바로 사용할 수 있다.

Worker worker =
        new Developer();

worker.stop();

컴파일 오류가 발생하지 않는다.

이 점만 보면 매우 안전한 변경처럼 느껴진다.

하지만 중요한 문제는 다음이다.

"컴파일된다."

≠

"기존 구현체의 의미와도 잘 맞는다."

기존 구현체가 특별한 불변식이나 동기화 정책을 가지고 있다면 새롭게 추가된 default 메서드가 그 규칙을 알지 못한 채 동작할 수 있다.


대표적인 예가 Collection.removeIf()

Collection에는 다음과 같은 default 메서드가 있다.

default boolean removeIf(
        Predicate<? super E> filter
) {
    Objects.requireNonNull(filter);

    boolean removed = false;

    final Iterator<E> each =
            iterator();

    while (each.hasNext()) {

        if (filter.test(each.next())) {
            each.remove();
            removed = true;
        }
    }

    return removed;
}

개념적으로는 컬렉션의 요소를 순회하면서 조건에 맞는 요소를 삭제한다.

사용 방법도 매우 편리하다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

numbers.removeIf(
        number -> number % 2 == 0
);

결과는 다음과 같다.

1
3
5

Collection이라는 인터페이스의 관점에서 보면 굉장히 자연스럽고 유용한 기능이다.


문제는 구현체마다 특별한 규칙이 있을 수 있다는 것이다

인터페이스는 자신을 구현하는 모든 클래스의 내부 정책을 알 수 없다.

예를 들어 어떤 Collection 구현체가 모든 메서드 호출을 동기화한다고 생각해보자.

public class SynchronizedCollection<E>
        implements Collection<E> {

    private final Collection<E> collection;

    private final Object lock =
            new Object();

    @Override
    public boolean add(E e) {

        synchronized (lock) {
            return collection.add(e);
        }
    }

    @Override
    public boolean remove(Object o) {

        synchronized (lock) {
            return collection.remove(o);
        }
    }

    // ...
}

이 클래스의 중요한 정책은 다음과 같다.

모든 Collection 연산

↓

동일한 Lock 획득

↓

한 번에 하나의 스레드만 실행

즉 단순히 Collection을 구현했다는 것이 전부가 아니다.

이 클래스에는

"모든 접근은 동기화되어야 한다."

라는 추가적인 불변식이 존재한다.


default 메서드는 구현 클래스의 특별한 규칙을 모른다

만약 Collection에 새로운 default 메서드가 추가된다면 기존 SynchronizedCollection은 별도의 코드를 작성하지 않아도 해당 메서드를 자동으로 가지게 된다.

collection.removeIf(
        value -> ...
);

문제는 인터페이스의 기본 구현이 SynchronizedCollection의 락 정책을 알지 못할 수 있다는 점이다.

기존 클래스의 메서드들은 다음처럼 작성되어 있을 수 있다.

synchronized (lock) {
    // 실제 작업
}

하지만 새로 들어온 default 메서드에는 그러한 동기화가 없다.

구조적으로 보면 다음과 같다.

기존 메서드

add()
remove()
clear()
...

↓

동기화 적용

반면 새 default 메서드는

removeIf()

↓

동기화 정책을 모름

이 될 수 있다.

이 경우 클래스가 원래 보장하려던 멀티스레드 안정성이 훼손될 수 있다.


인터페이스 관점에서는 정상인데 구현체 관점에서는 위험할 수 있다

이것이 아이템 21의 핵심 중 하나다.

인터페이스를 작성하는 입장에서는 다음과 같이 생각하기 쉽다.

Collection이면 요소를 순회할 수 있다.

Iterator가 있으니 삭제할 수 있다.

따라서 removeIf() 기본 구현을 제공할 수 있다.

인터페이스 계약만 생각하면 완벽하게 합리적이다.

하지만 특정 구현 클래스에서는 다음과 같은 추가 조건이 있을 수 있다.

모든 연산은 동기화되어야 한다.

모든 변경은 특정 검증을 거쳐야 한다.

모든 변경에는 로그가 기록되어야 한다.

모든 접근에는 권한 검사가 필요하다.

default 메서드는 이러한 구현체별 규칙을 자동으로 알 수 없다.


구현 클래스가 default 메서드를 재정의해야 할 수도 있다

이 문제를 해결하려면 해당 구현체가 새로운 default 메서드를 직접 재정의하여 자신의 정책에 맞춰야 한다.

예를 들어 동기화가 필요하다면 다음과 같이 만들 수 있다.

@Override
public boolean removeIf(
        Predicate<? super E> filter
) {

    synchronized (lock) {
        return collection.removeIf(filter);
    }
}

이렇게 하면 기존 클래스의 동기화 정책을 유지할 수 있다.

문제는

"새로운 default 메서드가 추가되었다."

는 사실을 구현체 개발자가 알아야 한다는 것이다.


내가 관리하는 코드라면 대응할 수 있다

인터페이스와 구현체를 모두 직접 관리하고 있다면 상대적으로 쉽다.

예를 들어

my-library

├─ MyCollection
├─ MyArrayCollection
├─ MySynchronizedCollection
└─ MyReadOnlyCollection

을 모두 같은 팀에서 관리하고 있다고 하자.

MyCollection에 새로운 default 메서드를 추가하면서 관련 구현체를 모두 검사할 수 있다.

default method 추가

↓

모든 구현체 점검

↓

필요한 클래스에서 override

이렇게 대응할 수 있다.


공개된 인터페이스라면 상황이 다르다

하지만 public 인터페이스는 누가 구현하고 있는지 모두 알 수 없다.

public interface MyLibraryInterface {
}

이 인터페이스를 다음 사람들이 사용할 수 있다.

우리 서비스

다른 사내 프로젝트

외부 라이브러리

오픈소스 프로젝트

서드파티 애플리케이션

라이브러리 제공자는 모든 구현체를 확인할 수 없다.

그런 상황에서 다음 코드를 추가한다.

default void newOperation() {
}

컴파일 오류도 발생하지 않는다.

기존 구현 클래스에 자동으로 새로운 메서드가 추가된다.

바로 이 특성이 위험하다.


컴파일 오류 없이 동작이 바뀐다는 것이 더 위험하다

일반적인 인터페이스 메서드를 추가하면 기존 구현체가 컴파일되지 않는다.

public interface Worker {

    void work();

    void stop();
}

기존 클래스가 stop()을 구현하지 않았다면 컴파일러가 알려준다.

컴파일 실패

↓

인터페이스 변경 사실 발견

↓

구현체 수정

개발자 입장에서는 불편해 보이지만 오히려 안전한 측면이 있다.

문제를 빠르게 발견하기 때문이다.


default 메서드는 문제를 숨길 수 있다

반면 다음처럼 추가하면

default void stop() {
}

기존 구현 클래스가 컴파일된다.

인터페이스 변경

↓

default method 추가

↓

기존 구현체 정상 컴파일

↓

문제 없음처럼 보임

그러나 실제 실행 시점에는 클래스의 기존 규칙과 충돌할 수 있다.

이런 종류의 변화는 발견하기 더 어렵다.

따라서

컴파일 호환성을 유지한다고 해서 의미적 호환성까지 자동으로 유지되는 것은 아니다.

라는 사실을 기억해야 한다.


binary compatibility와 behavioral compatibility는 다르다

이 문제를 조금 더 일반적인 관점에서 보면 다음 두 가지를 구분할 수 있다.

코드가 계속 컴파일되는가?

vs

코드가 기존과 동일한 의미로 동작하는가?

default 메서드는 첫 번째 문제를 상당 부분 해결해준다.

하지만 두 번째 문제까지 자동으로 해결해주는 것은 아니다.

예를 들어

SynchronizedCollection

이 계속 컴파일된다고 하더라도

"모든 연산이 동기화된다."

라는 기존 클래스의 의미가 유지된다는 보장은 별개의 문제다.


default 메서드는 런타임 오류를 유발할 수도 있다

더 까다로운 경우도 있다.

기존에는 컴파일 오류가 발생했을 코드가 default 메서드 추가 이후에는 컴파일되지만, 실행 시점에 오류가 발생할 가능성이 생길 수 있다.

개념적인 구조를 살펴보자.


기존 상위 클래스

public class SuperClass {

    private void hello() {
        System.out.println(
                "SuperClass hello"
        );
    }
}

hello()는 private이므로 하위 클래스에서는 사용할 수 없다.


마커 성격의 인터페이스

처음에는 아무 메서드도 없다고 가정한다.

public interface MarkerInterface {
}

하위 클래스

public class SubClass
        extends SuperClass
        implements MarkerInterface {
}

이 상태에서 다음과 같이 호출한다면

SubClass subClass =
        new SubClass();

subClass.hello();

SuperClass.hello()는 private이므로 하위 클래스에서 보이지 않는다.

따라서 정상적인 접근 가능한 메서드로 사용할 수 없다.


인터페이스에 default hello()가 추가된다면?

시간이 지나 인터페이스가 다음처럼 변경되었다고 생각해보자.

public interface MarkerInterface {

    default void hello() {
        System.out.println(
                "Interface hello"
        );
    }
}

이제 SubClass에는 인터페이스를 통해 접근 가능한 hello()가 생긴 것처럼 보인다.

subClass.hello();

그런데 Java의 메서드 선택 규칙에는 앞에서 살펴본 중요한 원칙이 있다.

클래스가 인터페이스보다 우선한다.

즉 메서드를 찾는 과정에서 클래스 계층이 인터페이스 기본 구현보다 우선적으로 고려된다.

제시된 예제와 같은 특정 상황에서는 이러한 메서드 탐색과 접근성 사이의 충돌 때문에 컴파일 단계가 아니라 실행 과정에서 문제가 드러나는 경우가 있을 수 있다.

핵심은 구체적인 예외 종류 자체보다 다음 사실이다.

인터페이스에 default method 하나 추가

↓

기존 클래스의 메서드 탐색 구조 변화

↓

기존에는 존재하지 않았던 실행 경로 형성 가능

↓

런타임 문제 발생 가능

즉 default 메서드 추가가 항상 무해한 API 확장은 아니다.


default 메서드가 없었다면 더 일찍 발견할 수도 있다

기존 인터페이스에 hello()가 없었다면

subClass.hello();

와 같은 코드는 애초에 컴파일 단계에서 문제가 드러난다.

Compile Time

↓

문제 발견

그런데 인터페이스에 default hello()가 들어오면서 컴파일러가 호출 가능한 메서드가 존재한다고 판단하게 되는 구조가 만들어질 수 있다.

그 결과 문제가

Compile Time

이 아니라

Runtime

까지 밀려날 수 있다.

이것은 개발자에게 더 좋지 않은 상황이다.


컴파일 오류와 런타임 오류

두 오류의 차이를 명확하게 이해할 필요가 있다.

컴파일 오류

코드를 실행하기 전에 발견된다.

int number = "hello";

컴파일러가 즉시 잘못된 코드를 알려준다.

코드 작성

↓

Compile

↓

Error

↓

실행되지 않음

문제를 상대적으로 빠르게 발견할 수 있다.


런타임 오류

컴파일은 성공하지만 프로그램을 실제로 실행하면서 문제가 발생한다.

코드 작성

↓

Compile 성공

↓

배포 또는 실행

↓

특정 경로 실행

↓

Runtime Error

어떤 코드 경로가 드물게 실행된다면 문제가 오랫동안 발견되지 않을 수도 있다.


개발자는 가능하면 컴파일 타임 실패를 선호한다

개발자의 입장에서 보통 다음이 더 좋다.

컴파일 단계에서 실패

왜냐하면 문제를 가장 빠르게 발견할 수 있기 때문이다.

반면 런타임 문제는

테스트 중

운영 중

특정 데이터 처리 중

특정 사용자의 요청 중

에야 발견될 수 있다.

따라서 같은 문제라면 가능한 한 컴파일러가 잡아주는 구조가 더 안전하다.


default 메서드의 위험은 “조용히 들어온다”는 데 있다

일반적인 인터페이스 변경은 매우 시끄럽다.

새 추상 메서드 추가

↓

수많은 컴파일 에러

개발자가 즉시 변경 사실을 알 수 있다.

반면 default 메서드는 조용하다.

default method 추가

↓

기존 클래스 컴파일 성공

↓

아무 경고 없음

바로 이 점 때문에 더 조심해야 한다.


새로운 default 메서드는 구현체의 불변식을 깨뜨릴 수 있다

인터페이스의 구현 클래스들은 단순히 메서드만 구현하고 있는 것이 아니다.

각자 자신만의 불변식을 가질 수 있다.

예를 들어 캐시 구현체가 있다고 하자.

모든 put 연산

↓

용량 검사

↓

통계 기록

↓

데이터 저장

그런데 인터페이스에 새로운 데이터 변경 default 메서드가 추가되면서 이 과정을 우회한다면 문제가 된다.

또 다른 예로 보안 Wrapper가 있을 수 있다.

모든 변경

↓

권한 검사

↓

실제 변경

새로운 default 메서드가 권한 검사 없이 내부 상태를 바꿀 수 있다면 보안 정책이 깨질 수 있다.

즉 문제의 본질은 removeIf() 하나가 아니다.

인터페이스가 모르는
구현 클래스 고유의 규칙

이 존재할 수 있다는 것이 핵심이다.


인터페이스 설계자는 구현체를 예상해야 한다

새로운 인터페이스를 설계할 때는 다음 질문을 생각해야 한다.

이 인터페이스를 어떤 클래스들이 구현할까?

읽기 전용 구현체가 존재할 수 있는가?

동기화가 필요한 구현체가 존재할 수 있는가?

불변 구현체가 존재할 수 있는가?

특정 검증을 반드시 수행해야 하는 구현체가 있는가?

Wrapper 또는 Decorator 구현체가 존재할 수 있는가?

인터페이스가 단순하다고 해서 구현체까지 단순한 것은 아니다.


default 메서드를 추가하기 전 확인해야 할 것

기존 인터페이스에 default 메서드를 추가해야 한다면 최소한 다음을 검토하는 것이 좋다.

모든 구현체에서 의미적으로 자연스러운가?

특정 구현체에서만 의미가 있는 기능은 아닌가?

구현체의 상태 불변식을 깨뜨리지 않는가?

동기화

검증

권한

로깅

트랜잭션

같은 정책을 우회할 가능성이 없는지 살펴봐야 한다.


기존 구현체와 충돌하는 메서드가 없는가?

인터페이스 계층과 클래스 계층 전체에서 동일한 시그니처의 메서드가 존재할 가능성도 고려해야 한다.


공개된 인터페이스라면 외부 구현체도 생각했는가?

내 코드베이스에서 검색했는데 구현 클래스가 두 개밖에 없다고 해서 실제 구현체가 두 개뿐이라는 보장은 없다.

public API라면 외부에서 얼마든지 새로운 구현체를 작성했을 수 있다.


새 인터페이스를 설계할 때도 default 메서드를 과신하면 안 된다

이 이야기는 기존 인터페이스 변경에만 적용되는 것은 아니다.

처음부터 새로운 인터페이스를 설계할 때도 default 메서드가 많아지면 구현 클래스의 자유도가 예상보다 줄어들 수 있다.

public interface Processor {

    default void before() {
    }

    default void process() {
    }

    default void after() {
    }

    default void retry() {
    }

    default void validate() {
    }
}

처음에는 편리해 보여도 구현체 입장에서는 인터페이스의 기본 동작과 자신의 정책 사이의 관계를 계속 검토해야 한다.

따라서 default 메서드는 단순히

"코드를 재사용하기 위해"

추가하는 기능으로 생각하면 안 된다.


default 메서드는 인터페이스 진화를 위한 도구다

앞선 아이템에서도 살펴봤듯 default 메서드의 중요한 목적은 이미 존재하는 인터페이스를 진화시키는 것이다.

기존 인터페이스

↓

새로운 기능 필요

↓

기본 구현이 합리적으로 가능

↓

default method

하지만 이것은 안전성을 자동으로 보장해주는 기능이 아니다.

오히려 구현 클래스의 입장에서는 예상하지 못한 새로운 동작이 들어올 수 있다.

따라서

기존 코드를 컴파일되게 유지하는 것

과

기존 코드의 의미를 유지하는 것

을 반드시 구분해야 한다.


인터페이스 변경의 영향 범위

일반 클래스의 private 메서드를 변경한다고 생각해보자.

변경 영향

↓

해당 클래스 내부

반면 public 인터페이스는 다르다.

인터페이스 변경

↓

우리 구현체

↓

다른 모듈 구현체

↓

외부 라이브러리 구현체

↓

사용자가 직접 만든 구현체

영향 범위가 매우 넓을 수 있다.

특히 default 메서드는 이 모든 구현체에 자동으로 새로운 동작을 추가할 수 있다.

따라서 public 인터페이스는 클래스보다 훨씬 보수적으로 변경할 필요가 있다.


컴파일 호환성이 전부가 아니다

default 메서드가 추가되었는데 기존 코드가 모두 정상적으로 컴파일된다고 해보자.

그렇다고 해서 바로 안전하다고 판단해서는 안 된다.

확인해야 할 것은 다음과 같다.

컴파일되는가?

+

기존 의미가 유지되는가?

+

Thread-Safe한가?

+

불변식이 유지되는가?

+

메서드 충돌은 없는가?

+

기존 테스트가 여전히 통과하는가?

인터페이스의 진화에서는 컴파일 호환성보다 행동 호환성(Behavioral Compatibility) 이 더 어려운 문제가 될 수 있다.


실무에서 public 인터페이스를 설계한다면

예를 들어 결제 수단 인터페이스가 있다고 생각해보자.

public interface PaymentMethod {

    PaymentResult pay(
            PaymentRequest request
    );
}

이미 여러 구현체가 존재한다.

CardPayment

PayPalPayment

BankTransferPayment

PointPayment

어느 날 인터페이스에 다음 default 메서드를 추가한다고 해보자.

default void cancel(
        Payment payment
) {
    // 기본 취소
}

문법적으로는 편리하다.

하지만 실제로는 결제 수단별 취소 정책이 완전히 다를 수 있다.

카드
→ PG 승인 취소 필요

PayPal
→ 외부 API 호출 필요

계좌이체
→ 환불 프로세스 필요

포인트
→ 내부 잔액 복원

이런 기능에 무리하게 기본 구현을 제공하면 기존 구현체에서 잘못된 동작이 발생할 수 있다.

따라서 모든 구현체에 적용 가능한 기본 의미가 존재하는지를 먼저 고민해야 한다.


테스트를 통해 기존 구현체와의 호환성을 검증해야 한다

인터페이스에 새로운 default 메서드를 추가했다면 기존 구현체에 대해 테스트하는 것이 중요하다.

예를 들어

기본 구현체

읽기 전용 구현체

동기화 구현체

Wrapper 구현체

특수한 상태를 가진 구현체

를 각각 테스트해볼 수 있다.

특히 동시성이나 보안, 트랜잭션 같은 특수한 정책이 들어간 구현체는 새로운 기본 구현과 충돌하지 않는지 확인해야 한다.


아이템 20과 아이템 21을 함께 이해하자

아이템 20에서는 인터페이스가 매우 유연한 설계 도구라는 사실을 살펴봤다.

다중 구현 가능

Mixin 가능

Default Method 가능

Wrapper와 조합 가능

하지만 아이템 21에서는 그 강력함에 대한 주의사항을 이야기한다.

default method를 추가할 수 있다.

↓

그렇다고 마음대로 추가하면 안 된다.

즉 두 아이템을 함께 이해해야 한다.

아이템 20

인터페이스를 적극적으로 활용하라.


아이템 21

하지만 구현하는 쪽까지 생각해서
신중하게 설계하라.

핵심 정리

  • default 메서드는 기존 인터페이스를 진화시키는 강력한 기능이다.
  • 기존 구현체가 새로운 메서드를 직접 구현하지 않아도 되기 때문에 컴파일 호환성을 유지하기 쉽다.
  • 하지만 default 메서드를 기존 인터페이스에 추가한다는 것은 모든 기존 구현체에 새로운 동작을 자동으로 추가하는 것과 비슷하다.
  • 인터페이스 입장에서는 합리적인 기본 구현이라도 특정 구현 클래스의 규칙과 충돌할 수 있다.
  • 대표적인 예가 Collection.removeIf()와 동기화를 요구하는 컬렉션 구현체의 관계다.
  • 구현체가 모든 연산에 별도의 동기화 정책을 가지고 있다면 인터페이스 기본 구현이 이를 우회할 수 있다.
  • 이런 경우 구현 클래스가 default 메서드를 직접 재정의하여 자신의 정책을 유지해야 한다.
  • 자신이 관리하는 구현체라면 대응할 수 있지만 공개 인터페이스의 외부 구현체까지 모두 확인하는 것은 어렵다.
  • 일반 추상 메서드를 추가하면 컴파일 오류를 통해 변경 사실을 즉시 발견할 수 있지만 default 메서드는 아무 경고 없이 기존 구현체에 들어갈 수 있다.
  • 따라서 컴파일 호환성과 행동 호환성을 구분해야 한다.
  • default 메서드 추가로 기존 클래스의 메서드 탐색 구조가 달라지면서 특정 상황에서는 실행 과정에서 문제가 드러날 수도 있다.
  • 같은 문제라면 런타임보다 컴파일 시점에 발견되는 것이 개발자에게 더 유리하다.
  • 인터페이스 구현체는 동기화, 검증, 보안, 로깅, 트랜잭션 등 인터페이스가 알지 못하는 추가적인 불변식을 가질 수 있다.
  • default 메서드를 추가할 때는 이러한 구현체별 정책을 깨뜨릴 가능성이 없는지 검토해야 한다.
  • 특히 public 인터페이스는 외부에서 어떤 구현체가 만들어졌는지 알 수 없으므로 훨씬 보수적으로 변경해야 한다.
  • default 메서드는 단순한 코드 재사용 수단이 아니라 인터페이스 진화를 위한 기능으로 이해하는 것이 좋다.
  • 인터페이스를 설계할 때는 사용하는 쪽뿐 아니라 직접 구현하는 개발자가 어떤 제약과 문제를 만나게 될지까지 함께 고려해야 한다.

한 줄 요약

default 메서드는 기존 인터페이스를 깨뜨리지 않고 진화시키는 강력한 기능이지만 모든 구현체에 새로운 동작을 조용히 삽입하기 때문에, 구현 클래스의 동기화·검증·불변식·메서드 탐색 규칙을 훼손하지 않는지까지 고려해서 매우 신중하게 추가해야 한다.

아이템 21. 완벽 공략 - ConcurrentModificationException

Java 컬렉션을 사용하다 보면 한 번쯤 만나게 되는 예외가 있다.

ConcurrentModificationException

이름에 Concurrent가 들어 있기 때문에 처음에는 다음과 같이 생각하기 쉽다.

"멀티스레드 환경에서 동시에 수정해서 발생하는 예외인가?"

멀티스레드 환경에서도 발생할 수 있는 것은 맞지만, 반드시 여러 스레드가 있어야 발생하는 예외는 아니다.

싱글스레드에서도 충분히 발생할 수 있다.

대표적인 상황은 컬렉션을 순회하고 있는 도중에 해당 컬렉션의 구조를 예상하지 못한 방식으로 변경하는 경우다.

컬렉션 순회 중

↓

컬렉션 구조 변경

↓

Iterator가 변경 감지

↓

ConcurrentModificationException

이 예외를 이해하려면 먼저 Iterator와 fail-fast라는 개념을 알아야 한다.


ConcurrentModificationException이란?

ConcurrentModificationException은 객체가 현재 허용하지 않는 방식으로 동시에 또는 예기치 않게 수정되었음을 감지했을 때 발생할 수 있는 런타임 예외다.

우리가 가장 흔하게 접하는 사례는 컬렉션이다.

예를 들어 다음 리스트가 있다고 해보자.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

한 스레드가 이 리스트를 순회하고 있다고 생각해보자.

1 → 2 → 3 → 4 → 5

그런데 순회 도중 다른 곳에서 리스트의 요소가 삭제되었다.

1 → 2 → 3 → 4 → 5

        ↓

        3 삭제

Iterator 입장에서는 문제가 생긴다.

현재 어디까지 순회했는가?

다음 요소는 무엇인가?

인덱스를 어떻게 해석해야 하는가?

현재 순회 결과를 신뢰할 수 있는가?

순회하는 도중 컬렉션 구조가 예상하지 못하게 변경되면 결과가 모호해질 수 있다.

그래서 많은 Java 컬렉션의 Iterator는 이런 상황을 감지하면 계속 진행하기보다 빠르게 실패하도록 설계되어 있다.


Fail-Fast Iterator

이런 방식의 Iterator를 흔히 fail-fast iterator라고 부른다.

말 그대로

문제가 발생하면 빠르게 실패한다.

는 의미다.

Iterator가 컬렉션을 끝까지 순회한 다음

"아까 뭔가 이상했던 것 같은데?"

라고 판단하는 것이 아니다.

순회 과정에서 자신이 예상하지 못한 구조적 변경을 감지하면 가능한 빠르게 예외를 발생시킨다.

Iterator 생성

↓

순회 시작

↓

컬렉션 구조 변경 감지

↓

즉시 ConcurrentModificationException

이렇게 하면 예측할 수 없는 결과를 계속 만들어내는 것보다 문제를 빠르게 발견할 수 있다.


왜 Fail-Fast가 필요할까?

다음과 같은 리스트가 있다고 생각해보자.

[1, 2, 3, 4, 5]

스레드 A가 순회한다.

Thread A

1
2
3
...

그런데 Thread B가 동시에 3을 삭제한다.

Thread B

remove(3)

또 다른 Thread C가 4를 삭제할 수도 있다.

이런 상황에서 아무 보호 장치가 없다면 각각의 스레드가 어떤 결과를 보게 될지 예측하기 어려워질 수 있다.

Thread A
1, 2, 3, 4, 5

Thread B
1, 2, 4, 5

Thread C
1, 2, 3, 5

그래서 변경을 감지했을 때 작업을 중단시키는 것이 더 안전할 수 있다.


중요한 점: Fail-Fast는 동기화 장치가 아니다

여기서 한 가지 중요한 점이 있다.

ConcurrentModificationException이 발생한다고 해서 Iterator가 멀티스레드 동기화를 보장한다는 의미는 아니다.

Fail-Fast

≠

Thread-Safe

Fail-fast는 잘못된 구조 변경을 가능한 빠르게 발견하도록 돕는 방식이지, 여러 스레드가 컬렉션에 안전하게 접근하도록 만들어주는 락이나 동기화 기능이 아니다.

따라서 멀티스레드 환경에서는 별도의 동기화 정책이나 적절한 동시성 컬렉션을 사용해야 한다.


멀티스레드에서만 발생하는 예외가 아니다

ConcurrentModificationException에서 가장 많이 오해하는 부분이다.

이름 때문에

Concurrent
=
여러 스레드

라고 생각하기 쉽다.

하지만 싱글스레드에서도 발생할 수 있다.

예를 들어 다음 코드를 보자.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

for (Integer number : numbers) {

    if (number == 3) {
        numbers.remove(number);
    }
}

스레드는 하나뿐이다.

그럼에도 ConcurrentModificationException이 발생할 수 있다.

왜 그럴까?


향상된 for문은 내부적으로 Iterator를 사용한다

다음 코드를 보면 특별한 Iterator가 보이지 않는다.

for (Integer number : numbers) {
}

하지만 Iterable을 대상으로 하는 향상된 for문은 개념적으로 Iterator를 이용해 순회한다.

대략 다음과 같은 구조로 생각할 수 있다.

Iterator<Integer> iterator =
        numbers.iterator();

while (iterator.hasNext()) {

    Integer number =
            iterator.next();

    // ...
}

즉 실제 구조는 다음과 같다.

for-each

↓

Iterator 생성

↓

iterator.hasNext()

↓

iterator.next()

↓

반복

그런데 Collection을 직접 수정하고 있다

앞의 코드를 다시 보자.

for (Integer number : numbers) {

    if (number == 3) {
        numbers.remove(number);
    }
}

Iterator가 numbers를 순회하고 있다.

그런데 Iterator가 아니라 리스트 자체를 통해서 구조를 변경한다.

numbers.remove(number);

호출 흐름을 보면 다음과 같다.

Iterator

↓

List 순회 중


그런데


List.remove()

↓

컬렉션 구조 변경

Iterator 입장에서는 자신이 관리하지 않는 곳에서 컬렉션이 변경된 것이다.

다음 순회 과정에서 이 변경을 감지하면 ConcurrentModificationException이 발생할 수 있다.


하나의 스레드에서도 충분히 발생한다

따라서 핵심은

다른 스레드가 수정했는가?

가 아니다.

더 중요한 것은

Iterator가 순회하고 있는데

Iterator가 예상하지 못한 방식으로

컬렉션 구조가 변경되었는가?

다.

그래서 다음 코드도 문제를 일으킬 수 있다.

for (String value : list) {

    if (condition(value)) {
        list.remove(value);
    }
}

스레드가 하나여도 마찬가지다.


List.of()와 ArrayList는 다르다

관련 예제를 만들 때 또 하나 주의해야 할 부분이 있다.

다음 두 코드는 같은 리스트처럼 보인다.

List<Integer> list =
        List.of(1, 2, 3);
List<Integer> list =
        new ArrayList<>(
                List.of(1, 2, 3)
        );

하지만 중요한 차이가 있다.

첫 번째는 List.of()가 반환한 리스트다.

List.of(1, 2, 3)

두 번째는 변경 가능한 ArrayList다.

new ArrayList<>(
        List.of(1, 2, 3)
)

List.of()는 수정할 수 없는 리스트를 반환한다

List.of()로 만든 리스트는 요소 추가나 제거 같은 구조 변경을 지원하지 않는다.

List<Integer> numbers =
        List.of(1, 2, 3);

numbers.remove(1);

이런 변경 연산을 시도하면

UnsupportedOperationException

이 발생한다.

즉 이 예제에서 ConcurrentModificationException을 확인하려면 먼저 수정 가능한 컬렉션을 사용해야 한다.


정확히는 unmodifiable이라고 이해하는 것이 좋다

List.of()를 흔히 불변 리스트라고 표현하기도 하지만, 정확한 의미를 구분하면 리스트 자체의 구조를 변경할 수 없는 unmodifiable list라고 이해하는 것이 좋다.

예를 들어 리스트 안에 들어 있는 객체 자체가 mutable하다면 그 객체의 내부 상태까지 불변으로 만들어주는 것은 아니다.

List<Member> members =
        List.of(member);

member.changeName("Yoon");

리스트에 새로운 요소를 넣거나 제거할 수는 없지만 Member 객체 자체가 mutable하다면 그 내부 상태는 변경될 수 있다.

즉 다음 두 가지는 다르다.

List 구조 변경 불가능

≠

List 안의 객체까지 모두 불변

변경 가능한 ArrayList를 만들어보자

이번 예제에서는 삭제를 수행해야 하므로 다음처럼 작성한다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

이제 numbers는 변경 가능한 ArrayList다.

numbers.remove(Integer.valueOf(3));

정상적으로 요소를 제거할 수 있다.


잘못된 삭제 방법

다음 코드는 피해야 한다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

for (Integer number : numbers) {

    if (number == 3) {
        numbers.remove(number);
    }
}

핵심 문제는 다음이다.

Iterator로 순회

+

Collection으로 직접 삭제

순회 주체와 변경 주체가 다르다.


해결 방법 1. Iterator.remove()

가장 전통적인 해결 방법은 Iterator를 직접 사용하는 것이다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

Iterator<Integer> iterator =
        numbers.iterator();

while (iterator.hasNext()) {

    Integer number =
            iterator.next();

    if (number == 3) {
        iterator.remove();
    }
}

결과는 다음과 같다.

[1, 2, 4, 5]

이번에는 문제가 발생하지 않는다.


왜 Iterator.remove()는 안전할까?

앞에서는 Iterator가 순회하는 동안 외부에서 컬렉션을 변경했다.

Iterator
   ↓
순회 중

Collection.remove()
   ↓
외부에서 구조 변경

하지만 이번에는 Iterator 자신이 변경을 수행한다.

Iterator
   ↓
순회

Iterator.remove()
   ↓
Iterator가 알고 있는 방식으로 변경

즉 Iterator가 현재 순회 상태와 삭제 작업을 함께 관리할 수 있다.

그래서 Iterator가 지원하는 삭제 연산을 사용하면 순회하면서 안전하게 현재 요소를 제거할 수 있다.


Iterator.remove() 사용 시 주의할 점

Iterator.remove()는 보통 가장 최근에 next()로 반환한 요소를 제거한다.

Integer value =
        iterator.next();

iterator.remove();

따라서 다음처럼 순서를 지켜 사용하는 것이 중요하다.

next()

↓

현재 요소 확인

↓

필요한 경우 remove()

Iterator 구현이나 사용 방식에 따라 잘못된 상태에서 remove()를 호출하면 별도의 예외가 발생할 수 있으므로 Iterator 계약에 맞게 사용해야 한다.


해결 방법 2. 인덱스를 이용한 반복

List처럼 인덱스를 지원하는 컬렉션이라면 Iterator를 사용하지 않고 인덱스로 처리할 수도 있다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

for (int i = 0;
     i < numbers.size();
     i++) {

    if (numbers.get(i) == 3) {

        numbers.remove(i);

        i--;
    }
}

Iterator를 사용하지 않기 때문에 앞에서 설명한 fail-fast Iterator 문제는 없다.


인덱스 삭제에서는 위치 이동을 고려해야 한다

하지만 인덱스 방식에는 또 다른 주의점이 있다.

다음 리스트를 생각해보자.

[1, 2, 3, 4, 5]

인덱스 2의 3을 삭제하면

[1, 2, 4, 5]

가 된다.

4가 앞으로 이동한다.

삭제 전

index 2 → 3
index 3 → 4


삭제 후

index 2 → 4

그런데 반복문의 i가 바로 증가하면 이동한 요소를 건너뛸 수 있다.

그래서 삭제 후 i--를 하거나 뒤에서부터 순회하는 방법을 사용할 수 있다.


뒤에서부터 삭제할 수도 있다

for (
        int i = numbers.size() - 1;
        i >= 0;
        i--
) {

    if (numbers.get(i) == 3) {
        numbers.remove(i);
    }
}

뒤에서부터 삭제하면 앞쪽 요소들의 인덱스에는 영향을 주지 않기 때문에 인덱스 이동 문제를 피하기 쉽다.

다만 단순히 조건에 맞는 요소를 삭제하는 목적이라면 더 좋은 방법이 있다.


해결 방법 3. removeIf()

Java 8 이후에는 조건에 따라 요소를 삭제할 때 removeIf()를 사용할 수 있다.

List<Integer> numbers =
        new ArrayList<>(
                List.of(1, 2, 3, 4, 5)
        );

numbers.removeIf(
        number -> number == 3
);

결과는 다음과 같다.

[1, 2, 4, 5]

코드가 훨씬 간결하다.


Predicate를 사용한다

removeIf()는 삭제 조건을 Predicate로 전달받는다.

개념적으로 다음과 같다.

boolean removeIf(
        Predicate<? super E> filter
)

Predicate<T>는 하나의 값을 받아 boolean을 반환하는 함수형 인터페이스다.

T

↓

조건 검사

↓

true / false

따라서

number -> number == 3

은 다음 의미다.

number가 3이면 true

↓

삭제

removeIf()의 개념적인 동작

Collection의 기본 removeIf() 동작은 개념적으로 Iterator를 이용한다.

Iterator<E> iterator =
        iterator();

while (iterator.hasNext()) {

    E element =
            iterator.next();

    if (filter.test(element)) {
        iterator.remove();
    }
}

즉 다음 구조다.

Iterator로 순회

↓

조건 확인

↓

Iterator.remove()

따라서 컬렉션을 직접 순회하며 collection.remove()를 호출하는 것보다 의도가 명확하고 안전한 방법이 된다.

다만 실제 구체 컬렉션 구현체는 성능이나 구현상의 이유로 removeIf()를 자체적으로 재정의할 수도 있다. 중요한 것은 호출자가 직접 순회와 구조 변경을 뒤섞지 않고 컬렉션 API가 제공하는 제거 연산을 이용한다는 점이다.


가장 권장하기 쉬운 코드는 removeIf()

단순히

"조건에 맞는 모든 요소를 삭제한다."

가 목적이라면 removeIf()가 가장 명확하다.

다음 코드를

Iterator<Integer> iterator =
        numbers.iterator();

while (iterator.hasNext()) {

    Integer number =
            iterator.next();

    if (number % 2 == 0) {
        iterator.remove();
    }
}

다음처럼 줄일 수 있다.

numbers.removeIf(
        number -> number % 2 == 0
);

의도도 바로 드러난다.

짝수이면 제거한다.

세 가지 삭제 방법 비교

방법특징주의점
Iterator.remove()순회하면서 안전하게 삭제 가능Iterator 계약을 이해해야 함
인덱스 기반 삭제Iterator를 사용하지 않음삭제 후 인덱스 이동 주의
removeIf()가장 간결하고 의도가 명확함조건 기반 일괄 제거에 적합

단순한 조건 제거라면 보통 다음 형태를 우선적으로 고려할 수 있다.

collection.removeIf(condition);

잘못된 코드와 올바른 코드 비교

문제가 될 수 있는 코드

for (Integer number : numbers) {

    if (number == 3) {
        numbers.remove(number);
    }
}

구조는 다음과 같다.

Iterator로 순회

+

Collection 직접 수정

↓

ConcurrentModificationException 가능

Iterator를 이용한 방법

Iterator<Integer> iterator =
        numbers.iterator();

while (iterator.hasNext()) {

    if (iterator.next() == 3) {
        iterator.remove();
    }
}
Iterator로 순회

+

Iterator가 삭제

↓

정상적인 순회 상태 유지

removeIf()를 이용한 방법

numbers.removeIf(
        number -> number == 3
);
삭제 조건 전달

↓

컬렉션이 제거 과정 처리

가장 간결하다.


ConcurrentModificationException이라는 이름의 Concurrent는 어떻게 이해할까?

이름만 보면

Thread A

vs

Thread B

만 생각하기 쉽다.

하지만 여기서 중요한 것은 더 넓은 의미에서 순회와 구조 변경이 서로 충돌하고 있다는 것이다.

따라서 다음 상황도 문제가 된다.

Thread A 하나뿐

↓

Iterator로 순회

↓

같은 Thread가 Collection.remove() 호출

↓

Iterator 관점에서는 예상하지 못한 구조 변경

즉

ConcurrentModificationException

=

반드시 멀티스레드에서 발생하는 예외

라고 이해하면 안 된다.


Structural Modification이란?

Iterator와 관련해 이해하면 좋은 개념이 구조적 변경(Structural Modification)이다.

일반적으로 컬렉션의 크기나 구조를 바꾸는 연산을 생각할 수 있다.

대표적으로 다음과 같다.

list.add(value);
list.remove(value);
list.clear();

이러한 변경은 Iterator가 추적하는 순회 구조에 영향을 줄 수 있다.

단순히 객체 내부의 상태를 변경하는 것과는 성격이 다르다.

예를 들어

for (Member member : members) {
    member.changeName("Yoon");
}

처럼 리스트의 요소 자체를 제거하거나 추가하지 않고 요소 객체의 내부 상태만 변경하는 것은 리스트의 구조를 변경한 것은 아니다.

다만 그 객체의 변경 가능성 자체가 다른 문제를 만들 수 있는지는 별도로 판단해야 한다.


ConcurrentModificationException을 동시성 제어 용도로 사용하면 안 된다

중요한 실무 포인트다.

다음과 같이 생각하면 안 된다.

ConcurrentModificationException이 안 나왔으니까
멀티스레드에서도 안전하다.

잘못된 판단이다.

Fail-fast 동작은 기본적으로 버그를 빨리 발견하는 데 도움을 주기 위한 것이지 동시성 제어 수단이 아니다.

멀티스레드 환경에서 공유 컬렉션을 사용한다면 목적에 맞는 방식을 선택해야 한다.

예를 들어 다음과 같은 선택지가 있을 수 있다.

외부 synchronized 사용

동기화 Wrapper

ConcurrentHashMap

CopyOnWriteArrayList

기타 java.util.concurrent 컬렉션

어떤 방식을 선택할지는 읽기/쓰기 비율과 필요한 일관성 수준 등에 따라 달라진다.


아이템 21과 왜 연결될까?

앞에서 Collection.removeIf()가 아이템 21에서 등장했던 이유를 다시 생각해보자.

removeIf()는 Collection의 default 메서드로 추가된 기능이다.

일반적인 컬렉션에서는 편리하게 사용할 수 있다.

collection.removeIf(
        value -> condition(value)
);

하지만 특수한 동기화 정책을 가지고 있는 구현체에서는 단순한 기본 구현이 그 구현체의 동기화 규칙과 맞지 않을 수 있다.

즉 문제는 removeIf() 자체가 나쁜 기능이라는 것이 아니다.

인터페이스 관점의 합리적인 기본 구현

≠

모든 구현 클래스에서 항상 적절한 구현

이 될 수 있다는 것이다.

이것이 바로 아이템 21에서

인터페이스는 구현하는 쪽을 생각해 설계하라.

라고 말하는 이유다.


ConcurrentModificationException을 통해 알 수 있는 설계 원칙

이 예외를 단순히

"for문에서 remove하면 나는 예외"

정도로 외우면 아쉽다.

더 중요한 것은 Iterator와 컬렉션 사이에 존재하는 계약을 이해하는 것이다.

Iterator가 컬렉션을 순회한다.

↓

Iterator는 일정한 구조를 전제로 한다.

↓

외부에서 구조가 예상하지 못하게 바뀐다.

↓

Iterator의 전제가 깨진다.

↓

Fail-Fast

즉 예외의 본질은 순회 중에 Iterator의 계약을 깨뜨리는 구조 변경이다.


실무에서 기억할 기준

컬렉션을 순회하면서 삭제해야 한다면 다음 순서로 생각해볼 수 있다.

단순한 조건 삭제인가?

가능하면 다음을 먼저 고려한다.

collection.removeIf(condition);

순회 과정에서 세밀한 제어가 필요한가?

Iterator를 직접 사용한다.

Iterator<E> iterator =
        collection.iterator();

while (iterator.hasNext()) {

    E value =
            iterator.next();

    if (condition(value)) {
        iterator.remove();
    }
}

List이고 인덱스 자체가 중요한가?

인덱스 기반 처리를 고려할 수 있다.

다만 삭제에 따른 인덱스 이동을 주의해야 한다.


멀티스레드에서 공유하고 있는가?

단순히 ConcurrentModificationException만 피하려고 해서는 안 된다.

컬렉션의 동시 접근 정책 자체를 다시 설계해야 한다.


핵심 정리

  • ConcurrentModificationException은 Java 컬렉션을 다룰 때 자주 접할 수 있는 런타임 예외다.
  • 이름에 Concurrent가 있지만 반드시 멀티스레드 환경에서만 발생하는 것은 아니다.
  • 싱글스레드에서도 Iterator로 순회하는 동안 컬렉션을 예상하지 못한 방식으로 구조 변경하면 발생할 수 있다.
  • 많은 컬렉션의 Iterator는 문제를 빠르게 발견하기 위해 fail-fast 방식으로 동작한다.
  • fail-fast는 문제가 감지되었을 때 가능한 빠르게 실패하도록 하는 방식이다.
  • fail-fast Iterator는 멀티스레드 안전성을 제공하는 동기화 장치가 아니다.
  • 향상된 for문으로 Iterable 컬렉션을 순회하면 내부적으로 Iterator를 이용하게 된다.
  • Iterator로 순회하면서 collection.remove()로 직접 구조를 변경하면 ConcurrentModificationException이 발생할 수 있다.
  • 순회하면서 삭제하려면 Iterator.remove()를 사용할 수 있다.
  • Iterator.remove()는 Iterator 자신이 순회와 삭제를 함께 관리하기 때문에 정상적인 순회 상태를 유지할 수 있다.
  • List에서는 인덱스를 이용해 삭제할 수도 있지만 삭제 후 요소의 인덱스 이동을 주의해야 한다.
  • 단순한 조건 기반 삭제라면 removeIf()가 가장 간결하고 명확한 방법이다.
  • Collection의 기본적인 removeIf()는 Iterator를 이용한 제거와 같은 개념으로 이해할 수 있으며 구체 구현체가 자체 구현을 제공할 수도 있다.
  • List.of()가 반환하는 리스트는 추가나 삭제 같은 구조 변경을 지원하지 않으므로 변경을 시도하면 UnsupportedOperationException이 발생한다.
  • 정확히는 List.of()의 결과를 단순히 모든 의미에서 불변이라고 생각하기보다 리스트 구조가 수정 불가능한 리스트라는 점과 내부 요소 객체의 변경 가능성을 구분하는 것이 좋다.
  • ConcurrentModificationException이 발생하지 않았다는 사실만으로 코드가 Thread-Safe하다고 판단하면 안 된다.
  • 멀티스레드 환경에서는 동기화나 java.util.concurrent의 적절한 컬렉션을 별도로 고려해야 한다.
  • 아이템 21의 관점에서는 removeIf()처럼 인터페이스에서 합리적으로 보이는 default 구현도 특정 구현체의 동기화 정책이나 불변식과 충돌할 수 있다는 사실이 중요하다.

한 줄 정리

ConcurrentModificationException은 단순한 멀티스레드 예외가 아니라 Iterator가 순회하는 동안 컬렉션이 예상하지 못한 방식으로 구조 변경되었음을 빠르게 알리는 fail-fast 예외이며, 순회 중 삭제가 필요하다면 Iterator.remove()나 removeIf()처럼 해당 변경을 안전하게 관리하는 API를 사용하는 것이 핵심이다.


© 2020. All rights reserved.

SIKSIK