본문 바로가기
자바

순회 하면서 컬렉션 수정하지 않기

by 이상한나라의개발자 2023. 12. 12.

순회하면 컬렉션 수정하지 않기

 

아래 소스를 보면 Contaminated가 포함 되면 컬렉션에서 제거하는 로직이다.

만약 아래처럼 remove를 통해 삭제하면 어떻게 될까?

이렇게 실행하면 List 인터페이스의 표준 구현이나 Set이나 Queue 같은 Collection 인터페이스의 구현은 ConcurrentModificationException을 던진다. 

List를 순회 하면서 List를 수정할 수 없습니다.

 

public class Inventory {

    private List<Supply> supplies = new ArrayList<>();

    // 오염된 물품 폐기
    void disposeContaminatedSupplies() {

        for (Supply supply : supplies) {
            if (supply.isContaminated()) {
                supplies.remove(supply);
            }
        }
    }

    public static void main(String[] args) {
        Inventory inventory = new Inventory();
        inventory.supplies.add(new Supply("apple"));
        inventory.supplies.add(new Supply("banana"));
        inventory.supplies.add(new Supply("Contaminated apple"));
        inventory.supplies.add(new Supply("Contaminated banana"));
        inventory.disposeContaminatedSupplies();
        System.out.println(inventory.supplies.size());
    }
}

 

 

그렇다면 ConcurrentModificationException을 일으키지 않으면서 어떻게 올바르게 수행할까요?

 

public class InventoryCorrect {

    private final List<Supply> supplies = new ArrayList<>();

    void disposeContaminatedSupplies() {
        Iterator<Supply> iterator = supplies.iterator();
        while (iterator.hasNext()) {
            Supply supply = iterator.next();
            if (supply.isContaminated()) {
                iterator.remove();
            }
        }

        supplies.forEach(supply -> {
            System.out.println("supply = " + supply.getName());
        });
    }

    void disposeContaminatedSuppliesCollectionRemoveIf() {
        supplies.removeIf(Supply::isContaminated);
        System.out.println("supplies.size() = " + supplies.size());
        for (Supply supply : supplies) {
            System.out.println("supply.getName() = " + supply.getName());
        }
    }

    public static void main(String[] args) {
        InventoryCorrect inventory = new InventoryCorrect();
        inventory.supplies.add(new Supply("apple"));
        inventory.supplies.add(new Supply("banana"));
        inventory.supplies.add(new Supply("Contaminated apple"));
        inventory.supplies.add(new Supply("Contaminated banana"));
        inventory.disposeContaminatedSuppliesCollectionRemoveIf();
    }

 

 

문제를 해결할 직관적인 방법은 리스트를 순회하며 변질된 데이터를 찾고 그 후 앞에서 발견했던 제품을 모두 제거하는 것입니다.

먼저 순회하고 나중에 수정하는 두 단계 접근법 입니다.

 

잘 동작하지만 코드 몇줄이 더 필요합니다. 게다가 순회하는 동안 변질된 데이터를 임시 자료 구조에 저장해야 합니다.

위 해법은 supplies 컬렉션의 Iterator를 활용하는 while 루프라는 새로운 순회 방식을 사용합니다. 핵심은 Iterator 입니다.

Iterator는 첫 번째 원소부터 시작해 리스트 내 원소를 가리키는 포인터 처럼 동작합니다. hasNext() 를 통해서 원소가 남아 있는지 묻고 next() 로 다음 원소를 얻고 반환된 마지막 원소를 remove() 로 안전하게 제거 합니다.

 

List를 직접 수정할 수 없지만 iterator가 이것을 완벽하게 대신합니다. iterator는 순회 중에도 작업을 올바르게 수행합니다.

 

엄밀히 말해 이전 예제의 for-each 루프도 iterator에 기반이지만 프로그래머에게 그 사실을 숨깁니다. 코드의 복자도를 줄여주니 반가운 일이죠. 하지만 덕분에 List에서는 iterator의 remove() 메소드도 쓸 수 없습니다.

 

CopyOnWriteArrayList와 같은 특수 List 구현은 순회하며 수정하기도 합니다. 하지만 대가가 따릅니다. 리스트에 원소를 추가하거나 제거할 때마다 매번 전체 리스트를 복사하고 싶으신가요? 자바 8 부터는 람다를 사용하는 Collection.removeIf() 메서드도 사용할 수 있습니다.

 

void disposeContaminatedSuppliesCollectionRemoveIf() {
    supplies.removeIf(Supply::isContaminated);
    System.out.println("supplies.size() = " + supplies.size());
    for (Supply supply : supplies) {
        System.out.println("supply.getName() = " + supply.getName());
    }
}

 

2.5 순회하며 계산 집약적 연산하지 않기

 

자료 구조를 순회할 때는 수행할 연산 유형에 주의해야 합니다. 계산 집약적 연산을 수행하면 성능 위험이 쉽게 초래 될 수 있습니다. 

위 코드는 정규식으로 Supply 객체를 찾는 find() 메서드의 전형적인 예입니다.

 

자바를 비롯 다양한 프로그래밍 언어에서는 정규식 (regular expression), 짧게 줄여 regex로 쿼리 문자열을 만듭니다. 

정규식이 있으면 거대한 택스트 데이터 집합에 효율 적으로 질의할 수 있습니다.

 

아래 방법은 유용하지만 성능을 저하 시키는 요인도 포함되어 있습니다. 코드를 실행하면 자바는 String 표현식인 regex를 가져와 regex로 부터 특수한 목적의 오토마톤(automaton)을 만드는데요. 이 오토마톤은 패턴을 따르는 문자열만 허용하고 나머지는 거절합니다.

 

Pattern.matches(regex,supply.toString())는 오토마튼을 컴파일해 supply.toString() 과 부합시켜 봅니다.

정규식 오토마톤 컴파일은 클래스 컴파일 처럼 시간과 처리 전력을 소모합니다. 보통 일회성 동작이지만 예제는 반복할 때마다 정규식을 컴파일 하고 있습니다.

 

String.replaceAll() 처러 자바 API 내 매우 유명한 메서드도 똑같이 동작하는 여러 경우가 있습니다.

 

public class Inventory2 {
    private final List<Supply> supplies = new ArrayList<>();

    List<Supply> find(String regex) {
        List<Supply> result = new LinkedList<>();
        for (Supply supply : supplies) {
            if (supply.getName().matches(regex)) {
                result.add(supply);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        Inventory2 inventory = new Inventory2();
        inventory.supplies.add(new Supply("apple"));
        inventory.supplies.add(new Supply("banana"));
        inventory.supplies.add(new Supply("Contaminated apple"));
        inventory.supplies.add(new Supply("Contaminated banana"));
        List<Supply> contaminatedBanana = inventory.find("Contaminated banana");

        contaminatedBanana.forEach(supply -> {
            System.out.println("supply = " + supply.getName());
        });

        System.out.println(contaminatedBanana.size());
    }
}

 

 

그럼 정규식을 반복해 컴파일 하지 않으려면 ?

 

List<Supply> find(String regex) {
    List<Supply> result = new LinkedList<>();
    Pattern pattern = Pattern.compile(regex);
    for (Supply supply : supplies) {
        if (pattern.matcher(supply.getName()).matches()) {
            result.add(supply);
        }
    }
    return result;
}

잠재적 성능 저하를 막는 해법은 매우 간단합니다. 계산이 많이 필요한 연산은 가능하면 적게 하세요

위 예제에서는 메서드를 호출할 때 정규식을 딱 한번만 컴파일 하면 됩니다. 루프를 반복해도 표현식 문자열은 바뀌지 않으니까요.!

 

2.6 새 줄로 그루핑

 

-- 수정전
enum DistanceUnit {

    MILES, KILOMETERS;

    static final double MILE_IN_KILOMETERS = 1.60934;
    static final int IDENTITY = 1;
    static final double KILOMETER_IN_MILES = 1 / MILE_IN_KILOMETERS;

    double getConversionRate(DistanceUnit unit) {
        if (this == unit) {
            return IDENTITY;
        }
        if (this == MILES && unit == KILOMETERS) {
            return MILE_IN_KILOMETERS;
        } else {
            return KILOMETER_IN_MILES;
        }
    }
}

-- 수정후
enum DistanceUnit {

    MILES, KILOMETERS;

    static final int IDENTITY = 1;

    static final double MILE_IN_KILOMETERS = 1.60934;
    static final double KILOMETER_IN_MILES = 1 / MILE_IN_KILOMETERS;

    double getConversionRate(DistanceUnit unit) {
        if (this == unit) {
            return IDENTITY;
        }

        if (this == MILES && unit == KILOMETERS) {
            return MILE_IN_KILOMETERS;
        } else {
            return KILOMETER_IN_MILES;
        }
    }
}

 

 

코드 블록이 서로 붙어 있으면 보통 한 덩어리로 간주합니다. 별개 블록을 새 줄로 분리하면 코드 이해도를 향상 시킬수 있습니다.

위 코드는 마일과 킬로미터 간 변환률을 반환하는 enum을 보여 줍니다. 실제로 코드 시맨틱은 괜찮은데 문제가 숨어 있습니다.

바로 여백이 빠져 있습니다. 무엇보다 getConversionRate() 내 코드가 서로 붙어 있습니다. 

그런데 이 함수는 몇몇 코드 블록을 새 줄로 분리 했습니다.

먼저 IDENTITY 필드를 다른 상수들과 분리했습니다.  IDENTITY 필드는 특정 단위와는 독립적이고 마일과 킬로미터간 두 변환률보다 더 추상적이니 분리해야 합니다.

 

getConversionRate() 에서는 두 if 블록을 서로 분리 했습니다. 확인 하는 사항이 다르니까요. 첫 번째 if는 같은 단위 인지 확인하고  두 번째 if는 변환합니다. 빈 줄을 사용해 수직으로 분리하면 읽는 사람이 더 명확히 이해할 수 있습니다.

 

연관된 코드과 개념은 그루핑하고 서로 다른 그룹은 빈줄로 각각 분리해야 합니다.

 

수직 공간이라는 개념은 훨씬 더 확장된 개념입니다. 로버트 C. 마틴은 자신의 저서 <클린코드> 에서 수직 서식화를 신문에 비유 했습니다. 훌륭한 기사는 제목(클래스명) 으로  시작해 섹션 머릿말(공개 멤버, 생성자, 메서드)에 이어 세부 내용(private method)이 나온다고요. 코드를 이렇게 조직하면 코드를 읽어 내려가기만 해도 이미 클래스를 휠씬 더 쉽게 이해할 수 있습니다.

 

 

2.7 직접 만들지 말고 자바 API

 

public class Inventory4 {

    private final List<Supply> supplies = new ArrayList<>();

    int getQuantity(Supply supply) {
        requireNonNull(supply, "supply is null");
        return Collections.frequency(supplies, supply);
    }

    public static void main(String[] args) {
        Inventory4 inventory = new Inventory4();

        inventory.supplies.add(new Supply("apple"));
        inventory.supplies.add(new Supply("banana"));
        inventory.supplies.add(new Supply("Contaminated apple"));
        inventory.supplies.add(new Supply("Contaminated banana"));

        int quantity = inventory.getQuantity(new Supply("apple"));
        System.out.println("quantity = " + quantity);
    }
}

 

'자바' 카테고리의 다른 글

Stream  (0) 2023.12.12
슬기롭게 주석 사용하기  (0) 2023.12.12
SOLID  (0) 2023.12.12
의존성 ( dependency )  (0) 2023.12.12
if문 제거하기  (0) 2023.12.12