빌드노트
subList를 만든 뒤 원본에 add하자 조회가 실패한다
subList는 원본과 연결된 범위 뷰다. 원본 add 뒤 조회 실패와 변경 전 ArrayList 복사를 짧게 비교하고, set·뷰 remove의 차이와 복사 시점을 설명한다.
ArrayList에서 subList(1, 3)으로 부분 목록을 얻은 뒤 원본에 원소를 추가하면, 기존 부분 목록의 get(0)에서 ConcurrentModificationException이 날 수 있다. subList는 원본과 연결된 범위 뷰다. 나중에 원본을 바꿔도 처음 고른 원소 구성을 유지하려면, 원본을 바꾸기 전에 new ArrayList<>(original.subList(1, 3))으로 복사한다.
시작 인덱스는 포함하고 끝 인덱스는 제외하므로 [A, B, C, D]의 subList(1, 3)은 [B, C]를 가리킨다. Java 17의 List.subList 문서 (새 탭에서 열림)는 그 뷰를 거치지 않고 원본을 구조적으로 바꾸면 기존 뷰의 동작이 정의되지 않는다고 명시한다. 아래 예외는 로컬 ArrayList의 관찰 결과이며, 모든 List 구현이 같은 예외를 낸다는 보장은 아니다.
선택한 범위 밖에 추가해도 실패한다
합성 문자열 목록으로 확인한 예제다. JDK 17의 java와 jshell을 사용할 수 있는 빈 폴더에서 다음 명령을 순서대로 실행한다. 버전 확인이 실패하면 JDK 설치와 PATH부터 확인한다. 외부 라이브러리는 필요 없다.
java --version
jshell --class-path . --no-startup --feedback concise
JShell에 아래 내용을 순서대로 입력한다.
import java.util.*;
var original = new ArrayList<>(List.of("A", "B", "C", "D"));
var part = original.subList(1, 3);
System.out.println(part.get(0));
original.add("E");
System.out.println(original);
System.out.println(part.get(0));
Windows의 OpenJDK·JShell 17.0.2에서 첫 조회는 B, 원본 출력은 [A, B, C, D, E]였다. 마지막 조회에서는 java.util.ConcurrentModificationException과 스택이 출력됐다. add는 성공했고, 그 뒤 옛 뷰를 읽을 때 실패했다.
추가한 위치는 선택한 [B, C] 뒤의 맨 끝이다. 범위 밖에 추가했어도 원본의 크기를 바꾸었으므로 기존 뷰를 계속 쓸 수 있다고 가정하면 안 된다. 앞서 성공한 인덱스 0의 조회가 실패한 것이어서, 조회 인덱스만 고쳐서는 해결되지 않는다. 목록 변경과 조회는 순서대로 실행했다. 이 예외를 봤다는 이유만으로 다른 스레드가 개입했다고 판단할 수도 없다.
복사는 원본의 구조가 바뀌기 전에 한다
같은 JShell에서 다음 코드를 이어 입력한다. 첫 줄은 원본 변수에 새 목록을 넣는다. 앞서 실패한 뷰는 재사용하지 않는다. 고른 범위를 보관하는 코드를 원본의 add보다 먼저 실행한다.
original = new ArrayList<>(List.of("A", "B", "C", "D"));
var copy = new ArrayList<>(original.subList(1, 3));
original.add("E");
System.out.println(copy);
출력은 [B, C]였다. 복사본의 조회는 원본의 추가 뒤에도 성공했다. 컬렉션을 받는 ArrayList 생성자 (새 탭에서 열림)는 전달받은 원소를 순서대로 담은 새 목록을 만든다. 별도의 새 목록 실험에서는 여기서 copy.remove(0)을 해도 원본은 [A, B, C, D, E]를 유지하고 복사본만 [C]가 됐다.
복사를 뒤로 미루면 같은 해결책이 되지 않는다. 새 원본에서 part 생성 → 원본 add → new ArrayList<>(part) 순서로 실행했을 때도 ConcurrentModificationException이 났다. 복사하려면 기존 뷰에서 원소를 읽어야 하기 때문이다. 예외를 잡은 다음 복사를 시도하기보다, 처음 범위를 고른 자리로 복사를 옮긴다. 변경 뒤 원본에서 새로 범위를 고르면 당시 인덱스의 원소를 얻으므로 처음 선택한 원소를 보존하는 것과 달라질 수 있다.
복사로 분리되는 것은 목록의 원소 구성이다. 원소 객체 자체까지 복제하지는 않는다. 여기서는 문자열만 사용했으며, 가변 객체의 내부 상태를 따로 보관하는 방법은 검증하지 않았다.
원본에 반영할 작업이라면 뷰를 유지한다
모든 변경이 뷰를 못 쓰게 만드는 것은 아니다. 각각 새 원본 [A, B, C, D]와 새 subList(1, 3)으로 실행한 대조 결과는 다음과 같다.
- 원본의
set(1, "B*")뒤 뷰는[B*, C]였다. 이어 뷰에서set(1, "C*")을 하자 원본이[A, B*, C*, D]가 됐다. 값 교체가 양방향으로 보였다. - 새 뷰에서
remove(0)을 하자B가 반환되고 원본은[A, C, D], 뷰는[C]가 됐다. 삭제 뒤 같은 뷰도 읽을 수 있었다.
ArrayList 문서 (새 탭에서 열림)는 기존 원소를 교체하는 set을 구조적 변경으로 보지 않는다. 뷰의 remove는 크기를 바꾸지만 그 뷰를 통해 수행한 변경이다. 따라서 크기가 바뀌었는지만 보지 말고, 원본을 별도로 바꿨는지, 해당 뷰를 통해 바꿨는지도 확인한다.
범위 삭제나 값 교체를 원본에 반영하려면 뷰를 사용한다. 이후 원본을 따로 추가·삭제할 예정이고 처음 고른 원소 구성을 유지해야 한다면 범위 생성 직후 복사한다. 고쳤는데도 문제가 남으면 원본의 실제 목록 구현과 뷰를 만든 뒤의 호출 순서를 확인한다. 외부 구조 변경 뒤 예외가 없었다는 사실만으로 기존 뷰를 계속 사용해도 된다고 판단하지 않는다.
직접 실행한 범위는 OpenJDK 17.0.2의 ArrayList와 문자열 목록이다. 다른 JDK·목록 구현, 중첩 뷰, 여러 스레드의 동시 수정은 검증하지 않았다. 실습을 마치면 JShell에 /exit를 입력한다.