제목 끝의 「지지」는 이 실험의 판정이 supported(주장이 뒷받침됐다)라는 뜻이다.
애그리거트는 「주문」과 그 안의 「주문 항목들」처럼 함께 바뀌어야 하는 객체 묶음이다. 묶음의 대표인 주문을 루트라 하고, 루트가 List로 들고 있는 주문 항목들을 자식이라 부른다. 이 글에서 「루트의 안쪽」은 루트가 들고 있는 그 자식 목록과 자식들의 값을 말한다. 태그의 DDD는 애그리거트라는 말이 나온 설계 방식인 도메인 주도 설계(Domain-Driven Design)의 줄임말이다.
애그리거트 경계 표준은 자식 컬렉션을 이렇게 다루기로 했다.
생성자에서 List.copyOf()로 복사하고, 변경은 루트의 의도 메서드로만 한다.
List.copyOf(목록)은 넘겨받은 목록과 같은 순서로 원소를 담은 바꿀 수 없는 새 목록을 돌려준다. 나중에 원래 목록을 바꿔도 돌려받은 목록에는 비치지 않는다. 다만 새로 만드는 것은 목록뿐이고, 목록 안의 자식 객체는 원래 목록과 같은 객체를 함께 가리킨다(얕은 복사). 의도 메서드는 setQuantity 같은 setter 대신 changeQuantity처럼 무엇을 하려는지가 드러나는 이름의 변경 메서드다.
먼저 돌린 실험은 이 문장을 반증했다(판정 refuted — 미리 정한 조건에 걸려 주장이 틀렸다고 나왔다). 밖에서 잡고 있던 자식 객체의 값을 바꾸자 루트 안에서 읽은 값이 따라 바뀌었다. copyOf가 목록은 새로 만들었어도 그 안의 자식은 밖의 목록과 같은 객체라서, 밖에서 그 객체를 고치면 루트 안에서도 고쳐진 값이 보인 것이다. 그런데 그 실험의 자식은 표준이 허용하지 않는 모양이었다. OrderItem의 quantity는 final이 아니었고, void changeQuantity()가 필드를 직접 바꿨다.
옛 실험은 자식 클래스를 OrderItem이라 불렀고, 이번 실험은 표준 글 예시 이름 그대로 OrderItemDomain이라 부른다. 끝의 Domain은 업무 규칙을 담는 객체라는 표시다(Domain). 두 자식의 변경 메서드를 나란히 놓으면 차이가 이렇다.
// 옛 실험의 자식 OrderItem — 표준이 금한 모양. 자기 필드를 덮어쓴다
private int quantity;
public void changeQuantity(int quantity) {
this.quantity = quantity;
}
// 이번 실험의 자식 OrderItemDomain — 표준 모양. 필드는 final이고, 바뀐 값을 담은 새 객체를 돌려준다
private final Long productId; // 상품 번호
private final int quantity; // 수량
private final BigDecimal unitPrice; // 주문 시점의 개당 가격
public OrderItemDomain changeQuantity(int quantity) {
return new OrderItemDomain(productId, quantity, unitPrice);
}
- 옛 자식의
changeQuantity는this.quantity에 새 값을 넣는다. 루트 안에 든 바로 그 객체의 값이 바뀐다 - 새 자식의
quantity는final이라 생성자 밖에서는 다시 대입할 수 없다 - 그래서 새 자식의
changeQuantity는 자기를 고치지 않고new로 새 객체를 만들어 돌려준다. 원래 객체는 그대로이고, 새 값이 필요한 쪽이 돌려받은 객체를 쓴다 - 바뀐 자식을 애그리거트에 반영하는 일은 루트의 의도 메서드가 맡는다. 루트도 자기 목록을 고치지 않고, 새 자식을 담은 새 목록으로 새 루트를 만들어 돌려준다. 이번 실험의 루트에 있는
addItem도 그렇게 새OrderDomain을 돌려준다 - 그리고
OrderItemDomain의 생성자는private이다. 밖에서는new를 못 쓰고 정적 팩토리 메서드create로만 만든다. 이 점은 아래 「(라)」에서 다시 쓰인다
표준은 옛 실험의 자식을 세 곳에서 금한다.
- DTO 생성 표준(글) — 이름은 DTO지만 Domain 객체를 만드는 규칙까지 함께 정한다(그 글의 예시도
OrderDomain이다). Domain을 포함해 모든 필드를final로 선언하고 setter를 만들지 않는다 - 애그리거트 경계 표준의 규칙 파일 — 표준마다 글과 따로, 코드를 짤 때 따를 문장만 모아 둔 파일이 있다. 위에 인용한 한 문장은 그중 하나이고, 같은 파일에 변경 메서드는 새 객체를 반환한다는 문장도 있다
- 표준 글 원칙 4의
OrderItemDomain—productId(상품 번호) ·quantity(수량) ·unitPrice(주문 시점의 개당 가격)가 전부private final이다
그래서 옛 실험이 깬 것은 「표준대로 만든 애그리거트」가 아니라 「표준을 어긴 자식을 넣은 애그리거트」였다. 설계가 바뀌므로 옛 실험은 고치지 않고 남겨두고, 자식까지 표준 모양으로 만든 새 실험을 돌렸다. 옛 실험을 고쳐 다시 돌리지 않은 이유는 이 저장소가 표준을 실험으로 증명할 때 따르는 규칙에 있다 — 원하는 결과가 나올 때까지 같은 실험의 조건을 바꾸면, 돌리기 전에 판정 기준을 적어둔 의미가 사라진다(표준을 증명하는 방식).
주장 — 루트는 copyOf, 자식은 불변이면 밖에서 안쪽을 못 바꾼다
표준대로 루트가 자식 컬렉션을 List.copyOf로 보관하고 자식이 불변(모든 필드 final,
변경은 새 객체 반환)이면, 밖에서 루트 안쪽의 목록 구조와 자식 값을 모두 바꿀 수 없다.
「목록 구조」는 목록에 자식이 몇 개, 어떤 객체가 들어 있는지다 — 자식을 더하거나 빼거나 다른 객체로 바꾸면 구조가 바뀐다. 「자식 값」은 목록에 든 자식 객체 하나하나의 필드 값(여기서는 quantity)이다.
근거 종류는 1(기술 사실)이다. 옛 실험과 같이 프레임워크를 쓰지 않았다. Lombok도 빼고 @Getter는 필드를 그대로 돌려주는 getter로, @Builder(access = PRIVATE)는 private 생성자와 정적 팩토리 메서드로 손으로 적었다. 주장이 자바 컬렉션과 final의 성질이라, 어노테이션 처리기가 끼면 결과에 영향을 줄 수 있는 요인만 는다. Lombok은 컴파일할 때 코드를 대신 만들어 넣는다. 넣으면 결과가 나와도 「Lombok이 만든 코드가 손으로 쓴 것과 정말 같은가」를 따로 확인해야 하는데, 손으로 적으면 그 확인이 필요 없다.
반증 조건 — 네 경로 중 하나라도 안쪽이 바뀌면 반증이다
반증 조건은 돌리기 전에 적었다. 아래 말들은 다음 섹션 「설계」에서 자세히 나오니, 먼저 짧게 적어둔다.
- 실험군은 표준대로
List.copyOf로 복사하는 루트, 대조군은 복사하지 않는 루트다 - (가)(나)(다-1)(다-2)는 밖에서 루트 안쪽을 건드려 보는 네 가지 방법이다. (가)(나)는 목록에 자식을 더해 보고, (다-1)(다-2)는 자식의 변경 메서드를 불러 본다. 둘을 묶어 (다)라 부른다
- 99는 (다)에서 변경 메서드에 넘기는 새 수량이다. 원래 수량은 7이다
- 실험 프로그램은 두 루트 × 네 경로마다 한 줄씩, 모두 8줄을 찍는다
-
「샌다」는 밖에서 한 일 때문에 루트 안쪽(size나 quantity)이 바뀐다는 뜻이다
- refuted — 실험군에서 (가)(나)(다-1)(다-2) 중 하나라도 루트 안쪽 size나 안쪽 자식 quantity가 바뀐다
- supported — 네 경우 모두 안쪽이 그대로이고, (다)의 변경 메서드가 99를 담은 새 객체를 돌려줬고, 대조군에서 (가) 또는 (나)의 size가 바뀐다
- inconclusive — 대조군 (가)(나)가 모두 안 새거나, 변경 메서드가 99를 돌려주지 않았거나, 출력 줄이 8개가 아니다
조건을 옛 실험보다 하나 늘렸다 — 변경 메서드가 돌려준 값이다. 자식이 새 객체를 반환하면 안쪽 값이 그대로인 것이 당연해 보인다. 그런데 변경 메서드가 아무것도 안 했어도 안쪽은 그대로다. 99를 담은 새 객체가 실제로 나왔는지를 같이 봐야 「안 바뀌었다」가 「막았다」로 읽힌다.
설계 — 루트와 자식을 표준 글 예시 그대로 만들고 네 경로로 건드린다
실험군 OrderDomain은 원칙 3 예시를 그대로 옮겼다. 생성자에서 this.items = List.copyOf(items)로 복사하고, getter는 필드를 그대로 반환한다. 필드에 든 것이 이미 copyOf가 만든 바꿀 수 없는 목록이라, getter가 그것을 그대로 내줘도 받은 쪽은 그 목록에 더하거나 뺄 수 없다. 대조군 RawOrderDomain은 루트만 다르다 — 넘어온 목록을 복사하지 않고 보관한다. 자식은 둘 다 같은 불변 OrderItemDomain이다.
(가) 꺼낸 목록에 add root.getItems().add(...)
(나) 생성 때 넘긴 원본에 add source.add(...)
(다-1) 꺼낸 자식의 변경 메서드 root.getItems().get(0).changeQuantity(99)
(다-2) 원본이 가진 자식의 변경 메서드 source.get(0).changeQuantity(99)
source는 루트를 만들 때 넘긴 원래 목록(ArrayList)이다. 자식 둘이 들어 있고, 첫 자식의 수량은 7이다root는 그source를 넘겨 만든 루트다. 경로마다source와root를 새로 만들어서, 앞 경로의 결과가 뒤 경로에 섞이지 않는다- (가)는 getter로 꺼낸 목록에, (나)는 루트 밖에 남은 원래 목록에 자식을 하나 더한다. 그 뒤 루트 안의 목록 size를 다시 읽는다
- (다-1)은 getter로 꺼낸 첫 자식의, (다-2)는 원래 목록에 든 첫 자식의
changeQuantity(99)를 부른다. 그 뒤 루트 안 첫 자식의 quantity를 다시 읽는다 - 위 표는 줄인 모양이다. 실제 코드는 (다)에서 돌려받은 새 객체의 quantity를 변수에 담아 결과 줄에 같이 찍는다. 전체 코드는 이 블로그의 원본 파일을 올려 둔 GitHub 저장소의 실험 폴더에 있다
(다-2)는 옛 실험의 (다)가 뚫린 경로다. 같은 경로를 불변 자식으로 다시 밟는다.
(라) 자식 값을 바꾸는 다른 경로는 재지 않았다. 리플렉션 없이 컴파일되는 범위에서 따져보니 표준 모양에는 남은 경로가 없었다.
- 자식이 컬렉션 필드를 가지는 경우 — 그 필드도 생성자에서
copyOf로 복사하라는 같은 규칙에 걸린다. 규칙 파일에서 이 문장은 루트 항목이 아니라 「Domain 구성」 항목 아래 있어서, 루트만이 아니라 자식을 포함한 Domain 전체에 걸린다. 그러면 그 목록을 꺼내 더하기(가), 넘긴 원본에 더하기(나)를 루트 대신 자식을 상대로 똑같이 해 보는 것이 되어, 이번 실험을 한 층 아래서 반복할 뿐이다 - 값이 바뀌는 하위 클래스를 목록에 넣는 경우 — 부모의
quantity필드는private final이라 하위 클래스도 못 바꾼다. 대신 상속한 하위 클래스가 final이 아닌 자기 필드를 하나 따로 두고getQuantity()를 다시 정의해 그 필드를 돌려주게 하면, 겉은OrderItemDomain이지만 밖에서 그 필드를 바꿀 때마다 루트 안에서 읽는 수량이 바뀐다. 생성자가 public인 클래스로 이렇게 짜 보니 실제로 7이 99로 바뀌었다. 그런데 하위 클래스의 생성자는 부모 생성자를 불러야 하고,OrderItemDomain처럼 부모 생성자가 private이면 밖에서는 부를 수 없어 컴파일되지 않는다. 생성자를 private으로 두는 규칙이 이 경로를 닫는다 - 필드를 다시 대입하는 경우 — final 필드라 재대입이 컴파일되지 않는다
하나는 열려 있다. final 필드가 Date나 배열 같은 가변 타입(만든 뒤에도 안의 값을 바꿀 수 있는 타입)이면 값이 바뀔 수 있다. final이 고정하는 것은 필드가 가리키는 객체(참조)이지 그 객체 안의 내용이 아니라서, 시각 하나를 담는 자바 클래스 Date를 예로 들면, getter로 Date를 꺼내 담긴 시각을 고치는 setTime()을 부르면 같은 객체의 내용이 바뀐다. 표준 문장이 그걸 기계적으로 막지는 않는다. 이 실험 밖이다.
결과 — 실험군은 네 경로 모두 막혔고 대조군은 구조만 샜다
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) 안 바뀜
한 줄씩 따라가면 이렇다. 첫 줄 PROBE_ENV는 실험 프로그램이 맨 먼저 찍는 환경 줄이다(이름은 「실험 환경」을 줄인 표시일 뿐이다). java=21.0.9는 돌린 자바 버전이다. copyOfClass=List12는 List.copyOf()가 실제로 만들어 돌려준 객체의 클래스 이름이다 — JDK가 밖에 공개하지 않고 안에서만 쓰는 불변 리스트 구현체 가운데 하나로, 이름의 숫자는 JDK 버전이나 담은 개수에 따라 달라질 수 있는 내부 구현일 뿐이다. 중요한 것은 바로 뒤 rawClass=ArrayList와 종류가 다르다는 사실, 즉 copyOf가 평범한 ArrayList가 아니라 별도의 불변 구현체를 만든다는 사실이다.
아래 8줄은 실험 프로그램이 찍은 원본 줄을 읽기 쉽게 옮긴 것이다. 각 줄은 copyOf(실험군)와 대조군 중 어느 쪽인지, (가)·(나)·(다-1)·(다-2) 중 어느 경로를 밟았는지, 예외가 났는지, size나 quantity가 호출 전후로 어떻게 바뀌었는지(2→2처럼 왼쪽이 호출 전, 오른쪽이 호출 후), 마지막으로 루트 안쪽이 바뀌었는지(안 바뀜/바뀜) 순서로 읽는다. 실험군 (가)의 UnsupportedOperationException은 바꿀 수 없는 목록에 add를 부르면 나는 예외다 — copyOf가 만든 목록이 더하기를 거부했다는 뜻이다. quantity 7→7 (반환 99)는 변경 메서드를 불렀을 때 원래 객체의 값은 7 그대로였지만, 그 메서드가 99를 담은 새 객체를 돌려줬다는 뜻이다.
판정은 supported다. 대조군이 (가)(나)에서 새므로 실험이 구조 변경을 관측할 수 있고, 같은 경로가 실험군에서는 막혔다.
대조군을 둔 이유가 여기 있다. 실험군만 돌려서 「안 바뀜」이 나오면, copyOf가 막아서인지 실험 코드가 애초에 변화를 못 보게 짜여서인지 가를 수 없다. copyOf만 뺀 대조군에서 size가 2→3으로 바뀌는 것이 보였으니, 실험 코드는 변화를 볼 수 있고 실험군의 「안 바뀜」은 copyOf가 막은 결과로 읽힌다.
예상과 달랐던 점 — 자식 값은 copyOf가 아니라 자식 불변이 막았다
대조군의 (다-1)(다-2)도 안 바뀌었다. 목록을 복사하지 않은 루트에서도 자식 값은 그대로였다. 그러니 자식 값을 지킨 것은 List.copyOf가 아니라 자식이 새 객체를 돌려준다는 것 하나다.
두 실험을 겹쳐 놓으면 역할이 깨끗하게 갈린다.
| 밖에서 하는 일 | 막는 것 |
|---|---|
| 목록에 더하거나 뺀다 | List.copyOf (대조군은 샜다) |
| 넘긴 원본 목록을 바꾼다 | List.copyOf (대조군은 샜다) |
| 자식의 값을 바꾼다 | 자식 불변 (옛 실험은 copyOf가 있어도 샜고, 이번 대조군은 copyOf가 없어도 막혔다) |
옛 실험은 틀린 실험이 아니었다. 「copyOf 하나로 자식 값까지 막히지는 않는다」는 사실은 그대로 맞다. 틀린 곳은 그 결과를 표준의 반증으로 읽은 것이다 — 표준은 copyOf 하나에 기대지 않고 자식 불변까지 요구하고 있었다.
(나)가 예외 없이 막힌 것도 짚어둔다. copyOf가 만든 목록은 원본과 떨어진 사본이라, 원본에 더하는 일 자체는 성공하고 루트 쪽에만 비치지 않는다.
이 결과가 표준에 남기는 것 — 막는 범위를 나눠 적는다
규칙은 바꿀 것이 없다. 바꿀 것은 원칙 3의 설명이다. 여기서 「규칙」은 위에서 말한 규칙 파일의 문장(코드를 짤 때 따르는 것)이고, 「설명」은 표준 글 원칙 3 본문(사람에게 왜 그렇게 하는지 풀어 주는 것)이다. 지금 표준 글은 옛 반증을 받아 「지금 문장은 자식이 불변일 때만 성립한다 … 자식을 불변으로 만들지, 깊은 복사를 할지는 아직 안 정했다」고 적고 있다. 그런데 자식 불변은 이미 다른 규칙이 정해두었다.
제안은 하나다 — 원칙 3의 설명을 「List.copyOf는 구조만 막고, 자식 값은 자식 불변 규칙(모든 필드 final, 변경은 새 객체 반환)이 막는다」로 범위를 좁혀 적는다. 반증 줄과 이슈 #162를 어떻게 정리할지는 이 글에서 정하지 않는다. 「반증 줄」은 표준 글 원칙 3에 옛 실험 결과를 「반증:」으로 시작하는 인용 상자 한 줄로 달아 둔 것이고(글 원본에서는 줄 앞에 마크다운의 인용 표시 >를 붙인다), 이슈 #162는 그 반증을 받아 이 블로그의 GitHub 저장소에 연 이슈(「List.copyOf는 구조만 막는다 — 자식 객체를 무엇으로 막을지 비었다」)다.
정리
- 표준대로 만든 루트는 네 경로 모두 안쪽이 그대로였다 — 꺼낸 목록 add, 원본 add, 꺼낸 자식 변경, 원본이 가진 자식 변경
List.copyOf는 구조를 막는다. 대조군은 (가)(나)에서 size가 2→3으로 샜다- 자식 값은 자식 불변이 막는다. copyOf가 없는 대조군에서도 자식 값은 안 바뀌었다
- 옛 반증은 표준이 금한 가변 자식에서 나왔다. 사실로는 맞고, 표준의 반증으로는 무효다
- 재지 않은 것 — final 필드가 가변 타입(
Date, 배열 등)인 자식, 자식이 컬렉션 필드를 가질 때의 한 층 아래 경로, 리플렉션
자신만의 철학을 만들어가는 중입니다.
댓글남기기