먼저 알아 둘 것 — 이 글의 결과는 반증(주장이 틀렸다는 판정)이지만, 실험에 쓴 자식 객체(주문 항목
OrderItem)가 표준이 금한 모양이었다. 값을 담는 필드가final이 아니었고, 그 필드를 직접 바꾸는void메서드가 있었다. 그래서 이 결과는 표준을 반증하지 않는다. 표준대로 불변 자식으로 다시 잰 실험은 불변 자식 실험에 있다(2026-09-16). 자식 객체가 무엇인지, 왜 이 모양이 문제였는지는 아래에서 차례로 나온다.
애그리거트는 「주문」과 그 안의 「주문 항목들」처럼 늘 함께 바뀌어야 하는 객체들을 한 묶음으로 본 것이다. 묶음의 대표인 주문을 애그리거트 루트라 하고, 루트가 목록(List)으로 들고 있는 주문 항목들을 자식이라 부른다. 상속의 자식 클래스가 아니라 「루트가 들고 있는 객체」라는 뜻이다.
내가 정해 둔 애그리거트 경계 표준(이 블로그의 코딩 규칙 글)은 그 자식 목록을 이렇게 다루기로 했다.
생성자에서 List.copyOf()로 복사하고, 변경은 루트의 의도 메서드로만 한다.
뒤 절반은 「주문 항목을 바꾸고 싶으면 주문(루트)의 changeQuantity 같은 의도 메서드를 불러라」는 뜻이다. 루트 한 곳을 거쳐야 묶음 전체의 규칙을 루트가 지킬 수 있다.
List.copyOf()는 지금 넘어온 리스트를 보고 새 불변 리스트를 만들어 돌려주는 자바 표준 라이브러리 메서드다. 원본 리스트를 나중에 바꿔도 이 복사본은 영향받지 않고, 이 복사본 자체에 원소를 더하거나 빼려 하면 예외가 난다. 정확히 어디까지 막고 어디는 못 막는지는 뒤의 「예상과 달랐던 점」에서 다시 짚는다.
앞 절반(copyOf로 복사한다)은 수단이고 뒤 절반(변경은 루트를 거친다)이 목적이다. 수단 하나로 목적이 지켜지는지를 재봤다.
주장 — copyOf로 받으면 밖에서 내부 상태를 바꿀 수 없다
생성자에서 List.copyOf로 받은 컬렉션은 애그리거트 밖에서 내부 상태를 바꿀 수 없다.
「애그리거트 밖」은 루트 클래스가 아닌 다른 코드다. 그 코드가 목록이나 자식 객체의 참조를 손에 들고 있을 때, 루트의 메서드를 거치지 않고 루트 안쪽 값을 바꿀 수 있는지가 질문이다.
근거 종류는 1(기술 사실)이다. 같은 코드를 copyOf가 있을 때와 없을 때로 돌려 비교하면 증명된다. 프레임워크를 쓰지 않았다 — 자바 컬렉션의 성질이라 Spring이 끼면 결과에 영향을 줄 수 있는 조건만 는다.
반증 조건 — 세 경로 중 하나라도 안쪽 값이 바뀌면 반증이다
반증 조건은 돌리기 전에 적었다. (가)(나)(다)는 다음 절 「설계」에서 정한, 밖에서 건드리는 세 경로다 — (가) 루트에서 꺼낸 목록에 더하기, (나) 생성자에 넘겼던 원본 목록에 더하기, (다) 목록 안 자식 객체의 값 바꾸기.
- refuted — 세 경우 중 하나라도 애그리거트 안에서 읽은 값이 밖에서 바꾼 대로 바뀐다
- supported — (가)와 (나)가 막히고 (다)에서도 안 바뀐다
- inconclusive — 대조군(리스트를 그대로 보관한 애그리거트)에서 (다)가 바뀌지 않는다
대조군을 둔 이유가 (다) 때문이다. 실험이 「값이 바뀌는 것」을 애초에 관측할 수 있는지 먼저 보여야 한다. (가)(나)는 copyOf가 막는다고 이미 알려진 경로라, 실험군에서 안 바뀌어도 이상할 게 없다. (다)가 진짜 질문인데, 실험군에서 (다)가 안 바뀌었다고 해서 곧바로 「copyOf가 막았다」로 읽을 수는 없다. 값 읽는 코드가 잘못돼서 아무것도 못 보고 있을 수도 있기 때문이다. 막는 장치가 아예 없는 대조군에서 (다)가 바뀌는 것을 먼저 봐야, 실험군의 결과를 믿을 수 있다.
설계 — 밖에서 건드리는 방법을 셋으로 나눈다
(가) 꺼내서 더한다 root.getItems().add(...)
(나) 넘긴 원본을 바꾼다 생성자에 넘겼던 리스트에 add
(다) 요소를 바꾼다 원본이 갖고 있던 자식 객체의 값을 바꾼다
실험에 쓴 클래스는 셋이다. 먼저 자식인 주문 항목이다.
public class OrderItem {
private final String name;
private int quantity; // final 이 아니다
public OrderItem(String name, int quantity) {
this.name = name;
this.quantity = quantity;
}
public void changeQuantity(int quantity) { // 필드를 직접 바꾼다
this.quantity = quantity;
}
public int quantity() { return quantity; }
}
quantity는final이 아니라서 만든 뒤에도 바뀔 수 있다. 이런 객체를 가변 객체라 한다changeQuantity는 setter 이름 대신 의도를 드러낸 이름으로, 받은 값으로quantity를 덮어쓴다
다음은 루트 둘이다. 실험군 Order는 copyOf로 받고, 대조군 RawOrder는 받은 리스트를 그대로 둔다.
// 실험군
public Order(List<OrderItem> items) {
this.items = List.copyOf(items);
}
// 대조군
public RawOrder(List<OrderItem> items) {
this.items = items;
}
Order는 넘어온 리스트의 복사본을 필드에 넣는다RawOrder는 넘어온 리스트의 참조를 그대로 넣는다. 밖과 안이 같은 리스트를 본다- 둘 다
getItems()로 필드의 목록을 그대로 돌려주고,readSize()·readFirstQuantity()로 안쪽의 목록 크기와 첫 항목 수량을 읽는다. 「안에서 읽은 값」은 이 두 메서드가 돌려준 값이다
(다) 경우를 예로, 밖에서 어떻게 건드렸는지 따라간다.
List<OrderItem> source = seed(); // pen 7개, note 3개가 든 ArrayList
Aggregate root = factory.apply(source); // Order 또는 RawOrder 를 만든다
int before = root.readFirstQuantity(); // 7
source.get(0).changeQuantity(99); // 밖에서 들고 있던 원본 리스트로 첫 항목을 바꾼다
int after = root.readFirstQuantity(); // 안에서 다시 읽는다
seed()가 주문 항목 둘을 담은ArrayList를 만든다. 밖의 코드는 이 리스트를source변수로 들고 있다- 그 리스트를 생성자에 넘겨 루트를 만든다.
Aggregate는Order와RawOrder가 함께 구현한 인터페이스이고,factory는 실험군을 돌 때는Order::new, 대조군을 돌 때는RawOrder::new가 들어 있는 변수다.factory.apply(source)는 그 생성자에source를 넘겨 부른 것과 같다. 같은 코드로 두 루트를 번갈아 두드리려고 이렇게 했다 - 바꾸기 전에 루트 안에서 첫 항목 수량을 읽는다
- 루트를 거치지 않고, 밖에 남은
source에서 첫 항목을 꺼내changeQuantity(99)를 부른다 - 루트 안에서 다시 읽어 3번 값과 비교한다
(가)는 4번 자리에서 root.getItems().add(...)를, (나)는 source.add(...)를 부른다. 밖에서 건드릴 때 예외가 나면 잡아서 그 이름을 적는다.
실험을 설계할 때 나는 이 자식 모양이 표준이 Domain에 쓰라고 한 모양 그대로라고 보았다. 이 판단은 틀렸다. 표준은 Domain의 필드를 전부 final로 두고, 변경 메서드는 필드를 바꾸는 대신 바뀐 값을 담은 새 객체를 돌려주라고 한다. 그러면 자식 객체는 만들어진 뒤 값이 바뀌지 않으니, 밖에서 같은 자식의 참조를 들고 있어도 루트 안의 그 자식을 바꿀 수 없다. (다) 경로가 뚫릴 자리가 처음부터 없어지는 것이다. 그래서 설계를 바꿔 불변 자식 실험을 새로 돌렸다. 이 글은 처음 설계 그대로 남겨 둔다.
경우마다 애그리거트를 새로 만들어 앞 경우가 뒤 경우를 오염시키지 않게 했다.
결과 — (가)(나)는 막혔고 (다)는 대조군과 같았다
proofs/aggregate-immutable-collection-copy/run.sh 셸 스크립트 하나를 돌려 얻었다. Docker로 JDK 21이 든 이미지(gradle:8.14.3-jdk21, 이름의 Gradle은 이 실험에서 쓰지 않았다)를 띄워 그 안에서 위 코드를 컴파일하고 한 번 실행한다. 반복은 1회다. 첫 줄 PROBE_ENV는 프로그램이 시작할 때 실행 환경을 찍은 줄이다 — 자바 버전, copyOf가 만든 리스트의 클래스, 대조군(raw, 원본 그대로)이 보관한 리스트의 클래스다. 아래 여섯 줄은 프로그램이 찍은 원본 줄을 읽기 쉽게 옮긴 것이다.
PROBE_ENV java=21.0.9 copyOfClass=List12 rawClass=ArrayList
copyOf (가) UnsupportedOperationException size 2→2 안 바뀜
copyOf (나) 예외없음 size 2→2 안 바뀜
copyOf (다) 예외없음 quantity 7→99 바뀜
대조군 (가) 예외없음 size 2→3 바뀜
대조군 (나) 예외없음 size 2→3 바뀜
대조군 (다) 예외없음 quantity 7→99 바뀜
판정은 refuted다. (다)에서 copyOf 쪽과 대조군의 결과가 완전히 같다.
copyOfClass=List12의 List12는 JDK가 원소 1~2개짜리 List.copyOf 결과를 만들 때 내부적으로 쓰는 구현 클래스 이름이다. List는 인터페이스이고, 실제로 만들어지는 객체는 그 약속을 채운 구현체 중 하나다. 원소가 더 많아지면 ListN이라는 다른 클래스를 쓰는데, 이름만 다를 뿐 밖에서 바꿀 수 없다는 성질은 같다.
예상과 달랐던 점 — copyOf가 막는 것은 구조뿐이다
List.copyOf는 얕은 복사라 자식 객체의 참조를 그대로 넘긴다. 그래서 막히는 것과 안 막히는 것이 이렇게 갈린다.
| 밖에서 하는 일 | copyOf가 막나 |
|---|---|
| 목록에 더하거나 뺀다 | 막는다 |
| 생성자에 넘겼던 원본 목록을 바꾼다 | 막는다 |
| 목록 안 객체의 값을 바꾼다 | 못 막는다 |
여기서 「막는다」는 안쪽 값이 안 바뀐다는 뜻이다. (나)에서 원본 목록에 더하는 것 자체는 예외 없이 성공한다. 다만 copyOf가 만든 복사본은 원본과 다른 리스트라 그 변경이 안쪽에 비치지 않는다.
표준 글의 「변경은 루트의 의도 메서드로만」은 자식이 불변일 때만 copyOf 하나로 지켜진다. 자식이 가변이면 밖에서 잡고 있던 참조로 그냥 바꿀 수 있다.
이 실험은 자식을 어떻게 해야 하는지는 재지 않았다. 자식까지 복사할지, 자식을 불변으로 만들지, 꺼낼 때 방어 복사를 할지는 각각 다른 대가가 있고 이 실험 밖이다.
이 결과가 표준에 남기는 것 — 수단을 하나 더 정해야 한다
규칙(변경은 루트를 거친다)은 이 실험이 판정하지 않는다. 깨진 것은 그 규칙을 copyOf 하나로 강제할 수 있다는 설명이다.
규칙은 바로 고치지 않는다. 실험 한 번이 틀렸을 수도 있어서다. 대신 둘을 한다. 표준 글의 그 원칙 밑에 > 반증:으로 시작하는 한 줄을 달아(이 블로그 글은 마크다운으로 쓰고, >로 시작하는 줄은 화면에 인용 상자로 보인다), 이 규칙에 불리한 결과가 있다는 것을 표준을 읽는 사람이 보게 한다. 그리고 이슈를 열고 kind: 모순 라벨을 붙인다. 모순 이슈는 실험의 결함인지 규칙의 결함인지를 따지는 자리이고, 여기서 자식 객체를 무엇으로 막을지를 정한다.
정리
copyOf는 목록의 구조만 막는다. 추가·제거는 막히고 원본 목록의 변경도 안 비친다- 원소의 상태는 못 막는다. 자식의 의도 메서드로 값을 바꾸면 안쪽 값이 그대로 바뀐다 — 대조군과 같은 결과다
- 자식이 불변일 때만 「생성자에서
copyOf로 복사하면 변경이 루트를 거친다」는 지금 표준 문장이 성립한다 - 재지 않은 것 — 자식까지 복사하거나 불변으로 만들었을 때의 비용
자신만의 철학을 만들어가는 중입니다.
댓글남기기