[JAVA] Collection의 복사 방법에 대해 알아보자!(방어적 복사, 얕은 복사, 깊은 복사) feat. 내가 List.copyOf와 Arrays.asList를 쓴 이유

2025. 10. 18. 17:15·Java & Kotlin

들어가며

요즘은 더 좋은 코드를 위해 검색하는 시간이 대다수이다... 그중에서 절묘한 차이로 자료구조의 의미가 완전 달라지는 내용이 있다! 그것은 바로 Collection의 복사방법!!
우테코 프리코스 1주차 계산기 문제 특성상 문자열을 입력받아 나눈다음 List<String>과 같은 자바의 Collection 자료구조를 이용할 수 밖에 없다.  깊게 생각하다 보면 하나의 의문에 빠진다. "Domain 계층에서 만든 객체를 어떻게 반환해야 할까?"

이 생각은 곧 Collection 자료구조의 복사방법과 연관된다. 차근차근 예시와 함께 알아보자!

 

본론으로

자세한 복사 방법을 살펴보기 전에 크게 3가지의 방법들을 살펴보고, 해당 방법이 어떤 방법에 해당하는지 알아가며 체크할 예정이다.

 1. 방어적 복사 (Defensive Copy)

내부 객체를 외부에 그대로 노출하지 않기 위해,
반환 시 복사본을 만들어 돌려주는 방식을 방어적 복사라고 한다.

이렇게 해두면 외부에서 복사본을 변경하더라도
원본 내부 객체는 전혀 영향을 받지 않는다.

2. 얕은 복사 (Shallow Copy)

얕은 복사는 새로운 객체를 생성하긴 하지만,
내부에서 원본 객체의 참조(주소값) 만 복사한다.

그래서 원본과 복사본이 같은 데이터를 가리키게 되고,
한쪽을 수정하면 서로 영향을 주게 된다.
쉽게 말해, call-by-reference 개념과 유사하다.

 3. 깊은 복사 (Deep Copy)

깊은 복사는 원본 객체의 값을 전부 복제하여
완전히 독립적인 새로운 객체를 만드는 방법이다.

이 경우 원본과 복사본은 완전히 분리되어 있어서,
한쪽을 변경해도 다른 쪽에는 전혀 영향이 없다. 
즉, call-by-value와 비슷한 개념이다.

 

방어적 복사 vs 깊은 복사

방어적 복사는 외부에서 내부 상태가 바뀌지 않도록 "방어"하는 행위다.
즉, 객체의 불변성과 캡슐화를 지키기 위한 복사이며, 원본과 완전히 독립일 필요는 없다. (상황에 따라 얕은 복사, 깊은 복사 일 수 있다.)

반면 깊은 복사는 원본과 완전히 독립된 객체를 만들기 위한 복제 행위다. 원본이 바뀌어도 복제본이 전혀 영향을 받지 않아야 하는 상황에서 사용된다. 즉, "보호"보다 완전한 분리의 뉘앙스가 강하다.

 


이처럼 복사에는 세 가지 개념이 있다.
좀 더 이해하기 위해 아래의 자세한 예시를 보자!

방어적 복사가 필요한 이유는 일급 콜렉션을 쓰면서 자연스럽게 나오므로 만약 일급 콜렉션에 대한 이해도가 없다면 아래의 글을 보고 오자!! 예시들도 대부분 일급 콜렉션이다!

https://geniusjun4663.tistory.com/43

 

[JAVA] 일급 콜렉션을 이용하여 상태와 로직을 따로 관리하자!

들어가며우아한테크코스 코드컨벤션 문서를 보면 아래와 같은 내용이 있다.콜렉션에 대해 일급 콜렉션 적용...? 뭐 일급비밀(?) 그런건가? 깔깔 처음 봤을 때 감이 하나도 안와서 수많은 기술블

geniusjun4663.tistory.com

 

 

1. getter 그대로 Collection 반환 -> 방어적 복사 X, 얕은 복사

public class Books {

    private final List<Book> books;

    public Books(final List<Book> books) {
        this.books = books;
    }

    public List<Book> getBooks() {
        return books;
    }
}

1번 형식은 getter, setter 배울 때 보통 많이 쓰던 형식이다. 바로 멤버 변수인 books를 그대로 외부에 반환하는 것이다.

그럼 원본 List와 외부에서 복사한 List사이의 참조가 끊어지지 않은 것이다.(얕은 복사)

그럼 어떤 문제가 발생할까?

-> 원본을 수정하면 복사본도 수정되어버리고, 복사본을 수정하면 원본이 수정되어버린다!!!

도메인 그 자체로 무결성을 유지해야하는데, 이렇게 컬렉션을 생으로 반환해주면 큰 문제가 생긴다.


 

2. new ArrayList<>()로 반환 -> 방어적 복사 O, 얕은 복사

public class Books {

    private final List<Book> books;

    public Books(final List<Book> books) {
        this.books = books;
    }

    public List<Book> getBooks() {
        return new ArrayList<books>();
    }
}

return 시에 ArrayList<books>(); 처럼 새로운 컬렉션을 만들어 반환중이다!

새로운 객체를 만들어서 줬다는 점에서 원본 리스트와 복사한 리스트간의 참조는 끊어졌기에 방어적 복사는 지켜졌다고 할 수 있다.

 

하지만 복사한 리스트가 변경이 가능하다는 단점이 있고(books.add("책1"), books.clear() 등)

얕은 복사이기 때문에 복사한 리스트의 안의 객체가 변경되면 서로 영향을 끼친다.

ex - copiedBooks.get(0).setName()으로 책의 이름을 바꾸면 원본의 책 이름도 바뀐다. -> 일급 콜렉션상 더욱 더 주의!

즉, 새로 만들어서 반환해주는 방법도 불변을 지킬 수는 없다!!!


 

3. Collections.unmodifiableList -> 방어적 복사 X, 얕은 복사

그래서인지 Collection에서 unmodifiableList, 이름 그대로 변경 불가능한 리스트를 지원한다!!!

unmodifiableList는 .add()를 시도하면 UnsupportedOperationException 에러를 발생시킨다.

그럼 해당 리스트를 반환해주면 2번에서의 문제였던 복사한 리스트가 변경 가능하다는 단점은 해소할 수 있다.

 

그럼 unmodifiableList를 반환하게끔 하면 되는거 아닌가?

 

new ArrayList<>() vs Collections.unmodifiableList 의 차이점

- new ArrayList<>()는 원본과 복사본의 참조를 끊어버리기에 둘 중 어느 하나를 수정해도 서로 전혀 영향이 없다는 것이 장점이지만, 복사된 리스트가 수정가능하다는 단점이 있다.

- Collections.unmodifiableList는 수정이 불가능 하게끔 하는 것이 주목적이기에 복사본에 .add()를 하면 오류가 나지만, 주소가 끊기지 않은 얕은 복사라 혹여나 원본(List)을 수정하게 된다면 복사본(unmodifiableList)도 modify가 되는 역설이 발생한다.

 

4. List.copyOf() -> 방어적 복사, 얕은 복사 -> 이번에 적용한 메서드! 왜 썻을까?

자세한 이해를 위해 List.copyOf()의 코드를 들어가보자.

static <E> List<E> copyOf(Collection<? extends E> coll) {
    return ImmutableCollections.listCopy(coll);
}

ImmutableCollections(불변 Collection)을 반환해준다.

그럼 그 안의 ImmutableCollection.listCopy()의 내용은 아래와 같다.

    @SuppressWarnings("unchecked")
    static <E> List<E> listCopy(Collection<? extends E> coll) {
        if (coll instanceof List12 || (coll instanceof ListN<?> c && !c.allowNulls)) {
            return (List<E>)coll;
        } else if (coll.isEmpty()) { // implicit nullcheck of coll
            return List.of();
        } else {
            return (List<E>)List.of(coll.toArray());
        }
    }

위의 코드를 통해 분석해보자면 List.copyOf는 복사본을 만들 때 원본 리스트와의 참조를 끊고 새로운 리스트를 만들어서 반환하기 때문에 Collections.unmodifiableList의 단점이었던 '원본 리스트를 변경하면, 복사한 리스트도 변경된다.'를 해결할 수 있다!!

하지만 List.copyOf()도 얕은 복사이기에 아래와 같은 문제점은 해결할 수가 없다.

미적감각 제로입니다 죄송합니다

그림이 이해될까..? 즉 껍데기만 불변하게끔 새로만들어서 반환해줬을뿐 내부의 주소는 동일하다!!

예를 들면 멤버변수인 List<Book> books에서 Book 클래스 내부에 값을 변경하는 메서드가 있다면, 복사한 ImmutableCollections 자료구조 안의 객체의 값도 변환될 것 (이게 불변이냐!!) 이다.

 

그렇다면.. 안의 객체도 불변이면 되지 않을까?!

 

 

그렇다 아래 코드처럼 ImmutableCollections 자료구조 안의 객체의 변수들도 불변으로 지정해줘야 진정한 불변! 이라고 할 수 있다.

public class Book{
	private final String name;
	private final int bookNumber;
}

여기까지 불변인 Collection을 쓰기 위해서 여러가지 방법들이 있다는 것을 정리했다. 어떠한가?

이렇게 까지 정리한 이유는 이번 우테코 프리코스 1주차 문제에서 일급 콜렉션 도메인을 설계할 때 모두 List.copyOf() 로 내부 리스트를 복사해 저장하도록 했다. 아래는 코드의 일부분이다.

private final List<String> tokens;

private Tokens(List<String> tokens) {
    this.tokens = List.copyOf(tokens); // 방어적 복사 + 불변 리스트
}

이렇게 하면 외부에서 전달된 리스트의 변경이 내부 상태에 영향을 주지 않고, 동시에 리스트 자체가 불변(ImmutableCollections) 으로 감싸져 수정할 수 없게 된다. 또한 리스트 안의 요소(예: String, Integer 등) 도 불변 객체로 제한했다.

🔒 결과적으로 “불변한 컬렉션(껍데기)” + “불변한 내부 요소(내용물)”을 함께 보장하게 되어,
도메인 객체의 캡슐화와 무결성을 완전히 확보할 수 있었다.

 

객체 생성시에만 List.copyOf()를 썻고, 내부에서는 Arrays.asList() 사용했다. -> 왜 그랬을까?

아래 코드를 보자.

private static List<String> split(String body, String delimiter) {
            return Arrays.asList(body.split(delimiter, -1));
        }

 

  • Arrays.asList는 그 배열을 복사하지 않고 고정 크기 리스트 뷰로 감싼다 → 추가 할당 없이 싸게 리스트 인터페이스로 다룰 수 있음.
  • 문제 특성상 파싱 직후 검증만 하고 구조를 바꾸지 않는다. Arrays.asList는 add/remove가 불가하지만 iteration/검증에는 충분하다.
  • 그래서 파싱/검증 단계는 Arrays.asList로 가볍게, 도메인 확정 시점에 List.copyOf로 영구 불변화한다는 단계적 의도가 분명해진다.

“파싱/검증은 싸게(Arrays.asList), 도메인 넘길 땐 강하게(List.copyOf)”
→ 성능과 안전(방어적 복사 + 불변성)을 각 단계에 맞게 모두 챙김.

하지만 결국 tokens를 숫자로 반환해주려면 내부 멤버변수에 접근해줘야해서 Getter를 열어줘야 하는 상황이 발생하였다.⚠️ 

    public Stream<String> stream() {
        return tokens.stream();
    }

Stream()만 반환해주어 조회만 가능하게 하였다.

 

마지막으로

나는 왜 불변 Collection을 고집했을까?

이렇게까지 불변 Collection을 지키려 한 이유는, 도메인의 무결성과 신뢰성을 보장하기 위해서다. 외부에서 컬렉션을 수정할 수 있게 두면 객체의 내부 상태가 예기치 않게 변질되어, “값 객체로서의 의미”와 “도메인의 규칙”이 깨질 수 있다고 생각한다. 불변으로 만들어두면 객체생성 시점 이후 상태 변화가 완전히 차단되어, 테스트가 단순해지고, 예측 가능한 코드 흐름을 유지할 수 있다.

즉, 불변성은 단순한 코드 스타일이 아니라, 도메인의 신뢰성을 지키는 방패이자, 객체지향 설계의 기본 안전장치이다!

안전하게 객체지향 설계해보자!

 

'Java & Kotlin' 카테고리의 다른 글

[Kotlin] 자바 개발자를 위한 코틀린 정리(차이점을 위주로)  (0) 2025.11.06
[JAVA] 함수형 인터페이스란?(Supplier<T>, Predicate<T>를 적용하게 된 이유를 중점으로)  (0) 2025.11.03
[JAVA] split() 메서드 정복하기 (나는 왜 split(delimiter, -1)을 썻을까?)  (1) 2025.10.17
[JAVA] 정규 표현식, Pattern 클래스로 쉽게 이용해보자! (Pattern.quote()를 중점적으로)  (0) 2025.10.17
[JAVA] 일급 콜렉션을 이용하여 상태와 로직을 따로 관리하자!  (0) 2025.10.17
'Java & Kotlin' 카테고리의 다른 글
  • [Kotlin] 자바 개발자를 위한 코틀린 정리(차이점을 위주로)
  • [JAVA] 함수형 인터페이스란?(Supplier<T>, Predicate<T>를 적용하게 된 이유를 중점으로)
  • [JAVA] split() 메서드 정복하기 (나는 왜 split(delimiter, -1)을 썻을까?)
  • [JAVA] 정규 표현식, Pattern 클래스로 쉽게 이용해보자! (Pattern.quote()를 중점적으로)
노을을
노을을
진인사대천명
  • 노을을
    노을의 개발일기장
    노을을
  • 전체
    오늘
    어제
    • All (61) N
      • Java & Kotlin (16)
      • Spring (3) N
      • Problem Solve (13) N
      • Computer Science (0)
      • Infra (1)
      • DB (2)
      • Various Dev (23)
        • 우아한테크코스 (9)
        • Git&Github (2)
        • Unity (12)
      • Book (1)
      • Writing (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    java
    합격
    github
    게임개발
    유니티
    스프링
    코딩
    코딩테스트
    알고리즘
    개발자
    프리코스
    티스토리챌린지
    우테코
    우아한테크코스
    오픈미션
    백준
    코테
    개발
    자바
    8기
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
노을을
[JAVA] Collection의 복사 방법에 대해 알아보자!(방어적 복사, 얕은 복사, 깊은 복사) feat. 내가 List.copyOf와 Arrays.asList를 쓴 이유
상단으로

티스토리툴바