애그리거트 경계 표준은 자식 컬렉션을 이렇게 다루기로 했다.
생성자에서 List.copyOf()로 복사하고, 변경은 루트의 의도 메서드로만 한다.
먼저 돌린 실험은 이 문장을 반증했다. 밖에서 잡고 있던 자식 객체의 값을 바꾸자 루트 안에서 읽은 값이 따라 바뀌었다. 그런데 그 실험의 자식은 표준이 허용하지 않는 모양이었다. OrderItem의 quantity는 final이 아니었고, void changeQuantity()가 필드를 직접 바꿨다.
표준은 그 자식을 세 곳에서 금한다.
- DTO 생성 표준 — Domain을 포함해 모든 필드를
final로 선언하고 setter를 만들지 않는다 - 애그리거트 경계 규칙 — 변경 메서드는 새 객체를 반환한다
- 표준 글 원칙 4의
OrderItemDomain—productId·quantity·unitPrice가 전부private final이다
그래서 옛 실험이 깬 것은 「표준대로 만든 애그리거트」가 아니라 「표준을 어긴 자식을 넣은 애그리거트」였다. 설계가 바뀌므로 옛 실험은 고치지 않고 남겨두고, 자식까지 표준 모양으로 만든 새 실험을 돌렸다.
주장 — 루트는 copyOf, 자식은 불변이면 밖에서 안쪽을 못 바꾼다
표준대로 루트가 자식 컬렉션을 List.copyOf로 보관하고 자식이 불변(모든 필드 final,
변경은 새 객체 반환)이면, 밖에서 루트 안쪽의 목록 구조와 자식 값을 모두 바꿀 수 없다.
근거 종류는 1(기술 사실)이다. 옛 실험과 같이 프레임워크를 쓰지 않았다. Lombok도 빼고 @Getter는 필드를 그대로 돌려주는 getter로, @Builder(access = PRIVATE)는 private 생성자와 정적 팩토리로 손으로 적었다. 주장이 자바 컬렉션과 final의 성질이라, 어노테이션 처리기가 끼면 변수만 는다.
반증 조건 — 네 경로 중 하나라도 안쪽이 바뀌면 반증이다
- refuted — 실험군에서 (가)(나)(다-1)(다-2) 중 하나라도 루트 안쪽 size나 안쪽 자식 quantity가 바뀐다
- supported — 네 경우 모두 안쪽이 그대로이고, (다)의 변경 메서드가 99를 담은 새 객체를 돌려줬고, 대조군에서 (가) 또는 (나)의 size가 바뀐다
- inconclusive — 대조군 (가)(나)가 모두 안 새거나, 변경 메서드가 99를 돌려주지 않았거나, 출력 줄이 8개가 아니다
조건을 옛 실험보다 하나 늘렸다 — 변경 메서드가 돌려준 값이다. 자식이 새 객체를 반환하면 안쪽 값이 그대로인 것이 당연해 보인다. 그런데 변경 메서드가 아무것도 안 했어도 안쪽은 그대로다. 99를 담은 새 객체가 실제로 나왔는지를 같이 봐야 「안 바뀌었다」가 「막았다」로 읽힌다.
설계 — 루트와 자식을 표준 글 예시 그대로 만들고 네 경로로 건드린다
실험군 OrderDomain은 원칙 3 예시를 그대로 옮겼다. 생성자에서 this.items = List.copyOf(items)로 복사하고, getter는 필드를 그대로 반환한다. 대조군 RawOrderDomain은 루트만 다르다 — 넘어온 목록을 복사하지 않고 보관한다. 자식은 둘 다 같은 불변 OrderItemDomain이다.
(가) 꺼낸 목록에 add root.getItems().add(...)
(나) 생성 때 넘긴 원본에 add source.add(...)
(다-1) 꺼낸 자식의 변경 메서드 root.getItems().get(0).changeQuantity(99)
(다-2) 원본이 가진 자식의 변경 메서드 source.get(0).changeQuantity(99)
(다-2)는 옛 실험의 (다)가 뚫린 경로다. 같은 경로를 불변 자식으로 다시 밟는다.
(라) 자식 값을 바꾸는 다른 경로는 재지 않았다. 리플렉션 없이 컴파일되는 범위에서 따져보니 표준 모양에는 남은 경로가 없었다. 자식이 컬렉션 필드를 가지면 그것도 copyOf 규칙에 걸려 (가)(나)가 한 층 아래서 반복될 뿐이고, private 생성자라 가변 하위 클래스를 밖에서 만들 수 없고, final 필드라 재대입이 컴파일되지 않는다. 하나는 열려 있다. final 필드가 Date나 배열 같은 가변 타입이면 값이 바뀔 수 있는데, 표준 문장이 그걸 기계적으로 막지는 않는다. 이 실험 밖이다.
결과 — 실험군은 네 경로 모두 막혔고 대조군은 구조만 샜다
PROBE_ENV java=21.0.9 copyOfClass=List12 rawClass=ArrayList
copyOf (가) UnsupportedOperationException size 2→2 안 바뀜
copyOf (나) 예외없음 size 2→2 안 바뀜
copyOf (다-1) 예외없음 quantity 7→7 (반환 99) 안 바뀜
copyOf (다-2) 예외없음 quantity 7→7 (반환 99) 안 바뀜
대조군 (가) 예외없음 size 2→3 바뀜
대조군 (나) 예외없음 size 2→3 바뀜
대조군 (다-1) 예외없음 quantity 7→7 (반환 99) 안 바뀜
대조군 (다-2) 예외없음 quantity 7→7 (반환 99) 안 바뀜
판정은 supported다. 대조군이 (가)(나)에서 새므로 실험이 구조 변경을 관측할 수 있고, 같은 경로가 실험군에서는 막혔다.
예상과 달랐던 점 — 자식 값은 copyOf가 아니라 자식 불변이 막았다
대조군의 (다-1)(다-2)도 안 바뀌었다. 목록을 복사하지 않은 루트에서도 자식 값은 그대로였다. 그러니 자식 값을 지킨 것은 List.copyOf가 아니라 자식이 새 객체를 돌려준다는 것 하나다.
두 실험을 겹쳐 놓으면 역할이 깨끗하게 갈린다.
| 밖에서 하는 일 | 막는 것 |
|---|---|
| 목록에 더하거나 뺀다 | List.copyOf (대조군은 샜다) |
| 넘긴 원본 목록을 바꾼다 | List.copyOf (대조군은 샜다) |
| 자식의 값을 바꾼다 | 자식 불변 (옛 실험은 copyOf가 있어도 샜고, 이번 대조군은 copyOf가 없어도 막혔다) |
옛 실험은 틀린 실험이 아니었다. 「copyOf 하나로 자식 값까지 막히지는 않는다」는 사실은 그대로 맞다. 틀린 곳은 그 결과를 표준의 반증으로 읽은 것이다 — 표준은 copyOf 하나에 기대지 않고 자식 불변까지 요구하고 있었다.
(나)가 예외 없이 막힌 것도 짚어둔다. copyOf가 만든 목록은 원본과 떨어진 사본이라, 원본에 더하는 일 자체는 성공하고 루트 쪽에만 비치지 않는다.
이 결과가 표준에 남기는 것 — 막는 범위를 나눠 적는다
규칙은 바꿀 것이 없다. 바꿀 것은 원칙 3의 설명이다. 지금 표준 글은 옛 반증을 받아 「지금 문장은 자식이 불변일 때만 성립한다 … 자식을 불변으로 만들지, 깊은 복사를 할지는 아직 안 정했다」고 적고 있다. 그런데 자식 불변은 이미 다른 규칙이 정해두었다.
제안은 하나다 — 원칙 3의 설명을 「List.copyOf는 구조만 막고, 자식 값은 자식 불변 규칙(모든 필드 final, 변경은 새 객체 반환)이 막는다」로 범위를 좁혀 적는다. 반증 줄과 이슈 #162를 어떻게 정리할지는 이 글에서 정하지 않는다.
정리
- 표준대로 만든 루트는 네 경로 모두 안쪽이 그대로였다 — 꺼낸 목록 add, 원본 add, 꺼낸 자식 변경, 원본이 가진 자식 변경
List.copyOf는 구조를 막는다. 대조군은 (가)(나)에서 size가 2→3으로 샜다- 자식 값은 자식 불변이 막는다. copyOf가 없는 대조군에서도 자식 값은 안 바뀌었다
- 옛 반증은 표준이 금한 가변 자식에서 나왔다. 사실로는 맞고, 표준의 반증으로는 무효다
- 재지 않은 것 — final 필드가 가변 타입(
Date, 배열 등)인 자식, 자식이 컬렉션 필드를 가질 때의 한 층 아래 경로, 리플렉션
자신만의 철학을 만들어가는 중입니다.
댓글남기기