이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Java › 클래스와 객체
불변과 방어적 복사
final을 붙였는데 값이 바뀌었다
Order 객체가 상품 목록을 final 필드로 가지고 있습니다. final은 "다른 리스트로 바꿔 끼울 수 없다"는 뜻일 뿐, 그 리스트의 내용이 바뀌는 것은 막지 않습니다. 밖에서 받은 리스트나 안에 가진 리스트를 그대로 주고받지 않고 복사본을 만들어 주고받는 것을 방어적 복사라고 합니다. 생성자와 getter에서 복사를 하느냐에 따라 밖의 코드가 주문의 내용을 바꿀 수 있는지를 봅니다.
실행으로 대조 · JDK 21에서 여섯 가지 조합을 실행해 대조
이 실습에 나오는 개념
- 불변 객체
- 생성된 뒤에는 상태가 바뀌지 않는 객체다. 값이 바뀌지 않으므로 여러 스레드가 동시에 사용해도 안전하고, 다른 곳에 넘겨도 변경될 걱정이 없으며, 캐싱하거나 해시 컬렉션의 키로 쓰기에 적합하다. 필드를 private final로 선언하고 상태를 바꾸는 메서드를 두지 않으며, 클래스를 final로 해 하위 클래스가 동작을 바꾸지 못하게 한다. 필드가 가변 객체(List, Date 등)를 가리키면 생성자와 getter에서 복사본을 주고받아(방어적 복사) 밖에서 내부를 바꾸지 못하게 한다.
- 방어적 복사
- 객체가 리스트, 배열, Date처럼 내용을 바꿀 수 있는 객체를 필드로 가질 때, 밖에서 받은 것을 그대로 저장하지 않고 복사해서 저장하고, 안에 가진 것을 그대로 돌려주지 않고 복사해서 돌려주는 것이다. 그대로 주고받으면 밖의 코드와 이 객체가 같은 리스트를 가리키게 되어, 밖에서 그 리스트를 고치는 것만으로 이 객체의 내부 상태가 바뀐다. 복사본에 넣고 빼는 것은 내부 상태에 영향을 주지 않는다(원소 객체 자체가 가변이면 그것은 따로 막아야 한다). 돌려줄 때는 복사하는 대신 Collections.unmodifiableList 같은 수정할 수 없는 뷰를 줄 수도 있는데, 뷰는 원본 리스트가 바뀌면 함께 바뀐다.
- final
- 더 이상 변경할 수 없음을 나타내는 키워드다. 변수에 붙이면 값을 다시 대입할 수 없고, 메서드에 붙이면 하위 클래스에서 재정의할 수 없으며, 클래스에 붙이면 상속할 수 없다. 참조 변수에 final을 붙이면 다른 객체를 가리키도록 바꿀 수 없을 뿐, 그 객체의 내부 상태는 바뀔 수 있다.
- 캡슐화
- 객체의 상태(필드)와 그 상태를 다루는 메서드를 하나로 묶고, 내부 구현을 외부에서 직접 접근하지 못하게 숨기는 것이다. 필드를 private으로 두고 공개 메서드로만 값을 바꾸게 하면, 잘못된 값이 들어오는 것을 메서드에서 막을 수 있고 내부 구조를 바꿔도 외부 코드에 영향이 없다.
- record
- 데이터를 담는 용도의 (얕은) 불변 클래스를 간결하게 선언하는 문법으로 Java 16에서 정식 도입되었다. 필드 목록만 적으면 생성자, 접근자 메서드, equals, hashCode, toString이 자동으로 만들어진다. 필드는 모두 final이지만, 필드가 가변 객체(List 등)를 가리키면 그 내용은 바뀔 수 있다. DTO나 값 객체를 정의할 때 쓴다.
이렇게 바꿔 보세요
- 처음 설정으로 — 아무 방어도 없음. 밖에서 두 가지 경로로 내부가 바뀝니다.
- 생성자에서만 복사 — 원본을 바꿔도 영향이 없지만 getter로는 여전히 바뀝니다.
- 양쪽 모두 복사 — 밖에서 내부를 바꿀 방법이 없습니다.
- getter는 수정 불가 뷰 — 바꾸려 하면 예외가 납니다.
- 뷰만 주고 생성자에서 복사 안 하면 — 원본 리스트를 통해서는 여전히 바뀝니다.
처음 설정으로 실행하면
- src 리스트 ["키보드", "마우스"]로 Order를 만듭니다. 생성자가 받은 리스트를 그대로 필드에 저장했습니다. 변수 src와 order.items는 힙에 있는 같은 리스트 객체 하나를 가리킵니다.
- (1) src.add("모니터"): 밖에서 원본 리스트에 원소를 추가합니다. 같은 객체이므로 order 내부의 상품도 3개가 됐습니다. Order의 메서드를 하나도 호출하지 않았는데 Order의 상태가 바뀌었습니다. items가 final이어도 막지 못합니다.
- (2) order.getItems().add("의자"): getter가 내부 리스트를 그대로 돌려줬습니다. 그 리스트에 추가하면 곧 order 내부에 추가하는 것입니다. 내부 상품이 4개가 됐습니다.
실행 결과
- final은 변수가 가리키는 대상을 바꾸지 못하게 할 뿐, 대상 객체의 내용 변경은 막지 않습니다. 객체를 불변으로 만들려면 필드가 가리키는 가변 객체(리스트, 배열, Date 등)가 밖과 공유되지 않도록 해야 합니다.
- 밖에서 내부 상태가 바뀌었습니다. 받을 때 복사하고(생성자), 내줄 때도 복사하거나 수정 불가로 감싸야(getter) 합니다. 이렇게 복사해 두는 것을 방어적 복사라고 합니다.
- record도 마찬가지입니다. 필드는 final이지만 리스트 같은 가변 객체를 담으면 그 내용은 바뀔 수 있습니다.
코드
class Order {
private final List<String> items; // final: 다른 리스트로 교체 불가
Order(List<String> items) {
this.items = items; // 받은 리스트를 그대로 저장
}
List<String> getItems() {
return items; // 내부 리스트를 그대로 노출
}
}
List<String> src = new ArrayList<>(List.of("키보드", "마우스"));
Order order = new Order(src);
src.add("모니터"); // (1) 원본 리스트를 수정
order.getItems().add("의자"); // (2) getter로 받은 리스트를 수정
이 모형의 전제
여섯 가지 조합의 결과(내부 상품 수와 예외)를 이 PC의 JDK 21에서 같은 코드를 실행한 결과와 대조했습니다. 리스트의 원소(String)가 불변이라 얕은 복사로 충분한 경우입니다. 원소 자체가 가변 객체라면 원소까지 복사해야 완전히 분리됩니다.
앞 실습: 내부 클래스와 익명 클래스 · 다음 실습: 전략 패턴