Java에서 불변성(immutability)과 방어적 복사(defensive copy)는 객체의 내부 상태를 안전하게 보호하기 위해 자주 언급되는 개념입니다. 특히 컬렉션(List와 같은 자료구조)을 캡슐화하고 getter 메서드로 반환할 때, 이 두 가지 기법을 어떻게 활용하는지가 중요합니다. 이번 글에서는 Java 컬렉션을 예로 불변 컬렉션과 방어적 복사의 의미, new ArrayList(), List.copyOf(), Collections.unmodifiableList() 등의 차이점을 살펴보고, 왜 Collections.unmodifiableList가 진짜 불변이 아닌지와 그 예시를 알아보겠습니다. 또한 getter에서 언제 방어적 복사를 하고 언제 생략해도 괜찮은지, 그리고 실무에서 개발자들이 일반적으로 따르는 기준이나 모범 사례를 정리해 보겠습니다.
불변 컬렉션과 방어적 복사의 개념
불변 컬렉션은 한 번 만들어진 후 그 구조나 요소를 변경할 수 없는 컬렉션을 말합니다. 불변 컬렉션을 사용하면 외부에서 함부로 데이터를 수정하지 못하므로 내부 상태가 보호됩니다. Java에서 불변 컬렉션을 만들기 위한 방법으로 JDK 10부터 도입된 List.copyOf()나, JDK 9의 List.of() 같은 팩토리 메서드가 있습니다. 이들은 새로운 불변 리스트 인스턴스를 만들어 주며, 생성 이후 요소를 추가하거나 삭제하려 하면 예외가 발생합니다. 반면 Collections.unmodifiableList()는 기존 리스트를 감싸서 읽기 전용 뷰(view)를 제공하지만, 이것만으로 진정한 의미의 불변(immutable)을 보장하지는 않습니다.
이에 대해서는 뒤에서 자세히 다루겠습니다. 방어적 복사는 말 그대로 객체를 방어하기 위해 복사본을 만들어 사용하는 기법입니다. 주로 내부 필드로 가변 객체(mutable object)를 보관하거나 이를 반환해야 할 때 사용합니다. 예를 들어, 어떤 클래스가 List 필드를 가지고 있고 이를 getList()로 제공해야 한다면, 방어적 복사를 통해 새로운 리스트를 만들어 반환함으로써 외부에서 내부 리스트를 직접 변경하지 못하게 할 수 있습니다
방어적 복사를 하면 외부 코드가 반환받은 컬렉션을 변경하더라도 내부의 원본 컬렉션은 안전하기 때문입니다. 즉, 객체 내부의 값을 외부로부터 보호하는 것이 방어적 복사의 핵심 목적입니다
new ArrayList, List.copyOf, Collections.unmodifiableList의 차이
Java에서 컬렉션을 복사하거나 불변화하는 방법에는 여러 가지가 있는데, 특히 세 가지 방식인 new ArrayList(...)를 통한 복사, List.copyOf(...) (혹은 List.of(...))를 통한 불변 리스트 생성, 그리고 Collections.unmodifiableList(...)를 통한 읽기 전용 뷰 생성은 각각 동작이 다릅니다. 이들의 차이를 정리하면 다음과 같습니다:
- new ArrayList<>(기존컬렉션) – 새로운 리스트 객체를 생성하여 내용을 복사합니다. 이 방법으로 얻은 리스트는 독립적인 복사본이며 원본과 참조가 끊어져 있습니다. 따라서 원본 컬렉션이 이후 변경되더라도 새 리스트에는 영향이 없고, 그 복사본은 자유롭게 수정(가변)할 수 있습니다. 예를 들어 List copy = new ArrayList<>(original);처럼 하면 copy는 original과 같은 요소를 갖지만 별개로 추가/삭제가 가능합니다.
- List.copyOf(기존컬렉션) – Java 10에서 도입된 팩토리 메서드로, 전달된 컬렉션의 요소들을 새로운 불변 리스트로 복사하여 반환합니다. new ArrayList와 마찬가지로 원본과 참조가 끊어진 별도 객체를 만들지만, 결과 리스트는 수정이 불가능한 불변 컬렉션입니다. 이 리스트에 add나 set 등을 호출하면 UnsupportedOperationException이 발생합니다. 예를 들어 List copy = List.copyOf(original);을 하면 copy는 original의 내용으로 채워진 수정 불가 리스트입니다. (참고로 List.copyOf는 내부적으로 ImmutableCollections를 사용하여 불변 리스트를 생성하므로 요소 추가가 불가능합니다.) 또한 List.of(e1, e2, ...)로 생성한 리스트도 마찬가지로 불변입니다. 만약 복사하려는 원본 컬렉션이 이미 불변이라면, List.copyOf는 성능을 위해 내부에서 굳이 새 복사를 만들지 않고 원본을 그대로 반환할 수도 있습니다. (이때도 반환되는 참조는 불변 리스트 타입으로 다뤄집니다.)
- Collections.unmodifiableList(기존리스트) – 주어진 리스트를 감싸서 수정 불가한 읽기 전용 뷰를 반환합니다. 여기서는 새로운 리스트를 만들지 않고, 전달된 리스트에 대한 wrapper 객체를 생성합니다. 이 래퍼 객체는 리스트의 add, remove, set 등의 메서드를 오버라이드하여 호출 시 예외를 던짐으로써 수정만 막을 뿐, 데이터 자체는 여전히 원본 리스트에 저장됩니다. 따라서 반환된 리스트는 겉보기엔 불변처럼 보여도 실제로는 원본 리스트에 대한 얕은(shallow) 참조입니다. 읽기 메서드 (get, iterator 등)는 모두 원본에 위임되므로 성능 차이는 없으며, 원본 리스트에 직접 변화가 생기면 그 내용이 이 읽기 전용 리스트에 그대로 반영됩니다. 쉽게 말해, unmodifiableList는 원본을 보호하는 껍데기일 뿐, 진짜 복사본을 만들어주는 것은 아닙니다.
위의 차이로 인해, 필요에 따라 적절한 방법을 선택해야 합니다. 내용을 복사하면서 새로운 가변 리스트가 필요하면 new ArrayList 방식이 유용하고, 진정한 불변 컬렉션(snapshot)을 얻고 싶다면 List.copyOf (또는 불변 컬렉션 팩토리) 방식이 적절합니다. 반대로 내부 컬렉션을 외부에서 수정하지 못하게만 하고, 원본과 데이터를 공유하고자 한다면 Collections.unmodifiableList를 사용할 수 있습니다. 이 경우 객체 생성 비용이 적고 원본과 뷰 간 동기화된 상태를 유지할 수 있다는 장점이 있지만, 다음 섹션에서 설명할 한계에 유의해야 합니다.
Collections.unmodifiableList는 왜 진짜 불변이 아닌가?
Collections.unmodifiableList로 감싼 리스트는 언뜻 보면 불변처럼 느껴질 수 있지만, 진짜 불변 객체(immutable object)와는 구분해야 합니다. 그 이유는 이 컬렉션이 얕은 불변(shallow unmodifiable) 특성만 갖기 때문입니다. 구체적으로 어떤 문제가 있는지 살펴보죠.
- 원본 리스트가 변경되는 경우: unmodifiableList는 원본 리스트에 대한 뷰이므로, 원본 리스트 객체를 누군가 계속 참조하고 있다면 그 원본을 통해 데이터 변경이 가능합니다. 이렇게 원본 리스트의 내용이 바뀌면, unmodifiableList로 받은 리스트에서도 바뀐 내용이 그대로 보입니다. 예를 들어, 아래 코드를 생각해봅시다:
List<String> fruits = new ArrayList<>(Arrays.asList("사과", "바나나"));
List<String> readOnlyFruits = Collections.unmodifiableList(fruits);
System.out.println(readOnlyFruits);
fruits.add("체리");
fruits.remove("바나나");
System.out.println(readOnlyFruits);
처음에 readOnlyFruits는 ["사과", "바나나"]를 보여주지만, 이후 원본 fruits에 "체리"를 추가하고 "바나나"를 제거하면, readOnlyFruits의 내용이 [사과, 체리]로 바뀝니다. 즉, readOnlyFruits자체는 수정 메서드를 허용하지 않지만 내용물이 변해버린 것입니다. 실제로 Collections.unmodifiableList()는 깊은 복사(deep copy)를 만드는 것이 아니며, 단지 원본을 감싼 보호 껍데기(protective shell)일 뿐임을 알 수 있습니다. 진정한 불변 리스트라면 한 번 만들어진 후 외부 요인으로 내용이 변할 일이 없어야겠지만, 이처럼 원본을 계속 들고 있는 누군가가 있다면 그 불변성은 깨지는 셈입니다.
- 내부 요소가 변경되는 경우: 설사 원본 리스트를 더 이상 누구도 수정하지 않더라도, 리스트 안에 들어있는 객체들이 가변 객체라면 그 객체들의 상태는 바뀔 수 있습니다. 이때도 unmodifiableList는 리스트 구조의 수정만 막을 뿐, 내부 객체의 상태 변화까지 막아주지는 않습니다. 예를 들어 리스트에 Mutable한 객체가 들어있고 그 객체의 필드를 바꾸면, 리스트가 감싸고 있는 객체의 내용이 변하기 때문에 결과적으로 리스트의 내용도 변한 것처럼 보일 수 있습니다. 이러한 경우는 불변 컬렉션의 깊은 불변성(deep immutability)까지 요구되는 상황인데, JDK의 unmodifiableList는 얕은 수준에서만 불변을 보장합니다.
이러한 이유로 Collections.unmodifiableList로 반환된 컬렉션은 흔히 "read-only 뷰"로 부르며, 진짜 불변 객체로 취급하면 안 된다고 합니다. Oracle의 공식 문서에서도 "unmodifiableList로 만든 컬렉션은 여전히 원본이 수정되면 변화가 반영되므로 실제로 불변 컬렉션이 아니다"라고 명시하고 있습니다. 따라서, 외부에 완전히 불변한 스냅샷을 제공하고 싶다면 반드시 원본과 분리된 복사본을 만들어야 합니다 (List.copyOf나 Guava의 ImmutableList 등 활용).
getter에서 방어적 복사를 하는 이유
객체 지향 설계 원칙에서 객체의 내부 상태를 캡슐화(encapsulation)하는 것은 매우 중요합니다. getter 메서드로 컬렉션 같은 가변 객체를 반환할 때 방어적 복사를 하는 주된 이유는 외부에서 그 컬렉션을 변경함으로써 내부 상태까지 변경되는 일을 방지하기 위함입니다. 앞서 살펴본 unmodifiableList를 사용하지 않고 만약 getter가 내부 리스트를 그대로 반환하면, 호출자는 그 리스트에 자유롭게 add나 remove를 호출하여 내부 데이터를 바꿀 수 있게 됩니다. 심지어 private로 감춰둔 리스트라도 참조를 노출하는 순간 캡슐화가 깨지는 것이죠.
예를 들어, class Team 내부에 List players가 있고 getPlayers()가 단순히 return players;라면, 외부에서는 team.getPlayers().clear()와 같이 팀 구성원을 임의로 제거하는 일을 할 수도 있습니다. 이러한 상황을 막으려면 두 가지 접근이 일반적으로 거론됩니다:
- 방어적 복사: getPlayers()에서 return new ArrayList<>(players);처럼 새 리스트를 만들어 반환합니다. 이렇게 하면 호출자는 복사된 리스트를 받기 때문에, 그 리스트를 변경해도 원본 players 리스트에는 아무 영향을 주지 않습니다. 방어적 복사의 효과로 내부 상태는 안전하게 지켜지며, 호출자는 필요한 경우 복사본을 변경하여 쓸 수 있습니다 (물론 복사본을 변경해봐야 원본과 동기화되지 않으므로 주의해야 합니다).
- 수정 불가 뷰 반환: getPlayers()에서 return Collections.unmodifiableList(players);처럼 읽기 전용 리스트 뷰를 반환합니다. 이렇게 하면 호출자는 리스트 자체를 변경할 수는 없고 (add 등을 호출하면 바로 예외), 내부 원본도 직접 노출되지 않으므로 안전합니다. 다만 앞서 언급했듯이, 이 뷰는 원본과 연결되어 있어 Team 객체 내부에서 players를 변경하면 외부 뷰도 변화를 볼 수 있다는 점은 기억해야 합니다. 따라서 Team이 players를 계속 변경할 여지가 없다면 이 방법도 하나의 선택입니다.
Effective Java에서도 가변 객체를 반환할 때는 방어적 복사를 통해 내부 정보를 보호하라고 조언합니다. 특히 공개 API를 설계할 때는 “절대 신뢰할 수 없는 외부 코드가 내 객체를 망가뜨리지 못하도록 해야 한다”는 관점에서, 내가 소유한 객체라면 절대로 그대로 노출하지 말고 복사본을 주거나 불변 객체로 만들어 주는 습관이 좋다고 많은 개발자들이 언급합니다. 실제 한 개발자는 "내부 상태에 의존하는 객체라면 절대 남을 믿지 말고 복사본을 주라. 다른 방법으로 컬렉션을 그냥 공개하는 일은 피하라"고 강하게 얘기하기도 했습니다.
방어적 복사를 하지 않아도 되는 경우는?
그렇다면 언제는 굳이 방어적 복사를 안 해도 될까요? 이것은 대부분 상황과 사용 범위에 따른 trade-off의 문제입니다. 방어적 복사는 안전하지만, 매번 복사본을 만들면 성능 오버헤드가 생길 수 있고, 때로는 불필요한 복사가 될 수도 있습니다. 실무에서 다음과 같은 기준으로 판단하는 경우가 많습니다:
- 내부 컬렉션이 불변이거나 한 번 설정 후 변경되지 않는 경우: 생성자 등에서 컬렉션을 받아 내부에 저장한 뒤 더 이상 수정하지 않는 클래스라면, 굳이 매번 getter에서 복사할 필요가 없습니다. 이때는 한 번만 방어적 복사를 해서 내부에 불변 컬렉션으로 저장해두고 (예: this.players = List.copyOf(playersParam);), getter에서는 그 불변 컬렉션을 그대로 반환해도 괜찮습니다. 어차피 내부도 변하지 않고 외부도 수정 못하므로 안전합니다. 또는 생성 시 Collections.unmodifiableList(new ArrayList<>(원본))으로 감싸 두는 방법도 있습니다. 이런 패턴에서는 객체 불변성이 유지되므로 추가 복사 비용 없이도 encapsulation을 지킬 수 있습니다.
- 외부에서 변경할 일이 거의 없고, 성능이 매우 중요한 경우: 예를 들어 어플리케이션 내부에서만 사용되는 클래스이고, 이 클래스의 컬렉션을 건드릴 다른 개발자가 사실상 없는 상황이라면 (즉, 코드베이스 내에서 사용이 통제되는 경우), 방어적 복사를 생략하고 내부 컬렉션을 직접 반환할 수도 있습니다. 그러나 이런 경우라도 문서화나 주석으로 "반환된 리스트를 변경하지 말 것"을 명시하지 않으면 실수로 수정할 위험이 있습니다. 하지만 현실적으로 개발자들은 문서를 종종 무시하기 때문에, 문서화에만 의존한 채 가변 객체를 노출하는 것은 안전하지 않다는 의견이 지배적입니다. 따라서 이 접근은 성능이 정말 중요하고, 팀 내 합의가 있는 특별한 경우에 한정하는 것이 좋습니다.
- 컬렉션이 아주 작고 복사 비용이 무시할만한 경우: 리스트 크기가 작다면 방어적 복사를 해도 성능 영향이 크지 않으므로, 특별히 생략해야 할 이유가 없습니다. 방어적 복사는 예방 비용이 비교적 낮은 보험과 같다는 말이 있듯이, 심각한 성능 문제가 없다면 기본적으로 방어적 복사를 해주는 편이 버그 예방에 도움이 됩니다. 오히려 방어적 복사를 하지 않아서 발생하는 버그는 찾기 어려운 논리 오류로 이어질 수 있어 개발 시간을 크게 낭비할 수 있으니, "성급한 최적화는 금물"이라는 관점에서 먼저 안전한 코드를 작성하는 것이 권장됩니다.
- 클래스 자체가 컬렉션을 실시간으로 변경하며 외부와 공유해야 하는 경우: 이런 상황에서는 방어적 복사를 고집하면 매 호출마다 새 복사본을 만들어야 하므로 비효율적입니다. 대신 공유 가능한 읽기 전용 뷰를 한 번 만들어 놓고 재사용하는 편이 실용적입니다. 예컨대, 내부 players 리스트를 private final List playersView = Collections.unmodifiableList(players);로 한 번 감싸 두고, getPlayers()에서는 이 playersView를 반환하면, 매번 객체를 생성하지 않으면서도 외부 수정은 차단할 수 있습니다. 단, 앞서 언급한 것처럼 내부에서 players를 수정하면 이 뷰도 변하기 때문에, 외부에 항상 최신 상태를 보여주고 싶을 때에만 유효한 방법입니다. (이 패턴은 Observable 컬렉션처럼 외부에 내부 변화가 전파되는 것이 허용되거나 의도된 경우에 사용됩니다.)
요약하면, 대부분의 경우 기본적으로 방어적 복사나 불변 처리를 적용하고, 성능 병목이 우려되거나 데이터 크기가 큰 특별한 경우에 한해 그 비용과 위험을 저울질하여 결정하는 것이 일반적입니다. 실제로 한 개발자는 "물론 모든 곳에서 복사를 두 번씩 하는 건 바보 같고, 불필요한 복사는 성능을 해칠 수 있다"며 지나친 복사 남용을 경계했지만, 동시에 "적절한 상황에서 외부에 객체를 공개할 땐 그에 맞는 조치를 취하는 것이 옳다"고 말했습니다. 결국 정답은 '맥락에 따라 다르다'이지만, 불변성 확보와 캡슐화를 우선적으로 고려하는 것이 좋은 기본 전략입니다.
결론
- 새 복사본 vs 불변 뷰: 컬렉션을 반환해야 할 때 원본과 완전히 분리된 복사본이 필요하면 new ArrayList로 복사하거나 List.copyOf로 불변 복사본을 만들어 주고, 원본과 동기화된 읽기 전용 상태를 공유하고 싶다면 Collections.unmodifiableList를 사용하세요. 두 방법 모두 외부에서의 직접 수정은 막아주지만, 전자는 스냅샷 제공에 가깝고 후자는 공유 뷰 제공에 가깝습니다.
- unmodifiableList의 함정: Collections.unmodifiableList로 반환한 리스트는 절대 원본이 변하지 않는 경우에만 불변처럼 동작합니다. 원본을 계속 가지고 있으면서 이를 변경할 가능성이 있다면, 차라리 방어적 복사를 사용하여 매번 새로운 컬렉션을 반환하는 편이 예측 가능성이 높습니다. 또는 애초에 원본을 불변으로 만들어 놓고 활용하는 것도 방법입니다 (예: List.copyOf를 활용해 내부에 보관).
- 방어적 복사의 비용 관리: 방어적 복사가 빈번하게 일어나 큰 성능 문제가 된다면, 설계 자체를 재검토해야 할 신호일 수 있습니다. 예컨대 매 프레임마다 수천 개의 객체를 복사해 반환해야 한다면, 객체의 라이프사이클이나 접근 방식을 바꿔야 할 수도 있습니다. 필요하다면 캐싱을 통해 한 번 만든 컬렉션을 재사용하거나, 불변 컬렉션을 미리 만들어 공유하는 방식을 고려해 볼 수 있습니다.
- 개발자들의 공통적인 권고: 다수의 Java 개발자들은 "가능한 한 내부 컬렉션은 외부에 직접 노출하지 말라"는 데 의견이 모입니다 팀원이나 API 사용자에게 내부 컬렉션을 넘겨줄 때는, 그들이 실수로든 고의로든 데이터를 건드릴 수 없도록 방어적 복사나 불변 래핑을 적용하는 것이 습관화되어 있습니다. 만약 방어적 복사를 생략해야 한다면, 그 결정에 대한 이유(예: 성능)와 안전장치(예: 문서화, 제한된 사용 범위)를 함께 고려해야 합니다.
마지막으로 기억할 점은, 불변성과 방어적 복사는 코드의 견고함을 높이는 수단이지만 과도하게 사용하면 불필요한 비용을 초래할 수 있다는 것입니다. 중요한 것은 우리의 클래스가 어떤 불변성 계약을 갖느냐입니다. 외부에 "이 컬렉션은 내가 관리하며 당신은 읽기만 가능하다"는 계약을 명확히 하고 싶다면 불변 컬렉션 또는 방어적 복사를 통해 이를 보장하고, 그렇지 않고 공유 변경이 필요하다면 그에 맞는 설계를 해야 합니다. 결국 상황에 맞는 균형 잡힌 적용이 핵심이며, 기본 원칙은 "내부 상태는 내가 통제한다"는 것입니다. 이를 지키기 위한 도구로서 new ArrayList(...), List.copyOf(...), Collections.unmodifiableList()를 적재적소에 활용하면, 컬렉션 설계에 있어서 버그와 사이드 이펙트를 크게 줄일 수 있을 것입니다.
참고자료
- Effective Java, Joshua Bloch
- Oracle Java SE API Documentation
- Nicolai Parlog, “The Cost of Defensive Copies”
- Stack Overflow Discussions on Immutability and Defensive Copying
- Baeldung - Immutable Collections in Java
- DZone - Collections.unmodifiableList vs List.copyOf