기본 콘텐츠로 건너뛰기

자바 객체 복사에서 얕은 복사와 깊은 복사는 무엇이 다를까

자바 객체 복사에서 얕은 복사와 깊은 복사는 무엇이 다를까

빠른 답

  • 얕은 복사는 바깥 객체는 새로 만들지만, 내부의 다른 객체 참조는 원본과 공유할 수 있다.
  • 깊은 복사는 복사 대상이 가진 가변 객체까지 새로 만들어, 한쪽 변경이 다른 쪽에 퍼지지 않게 한다.
  • String 같은 불변 객체는 참조를 공유해도 상태 변경 문제가 거의 없지만, List, Author 같은 가변 객체는 결과가 달라질 수 있다.
  • 복사 방식은 객체가 수정되는지, 어디까지 소유권을 분리해야 하는지, 복사 비용을 감당할 수 있는지에 따라 달라진다.

한눈에 비교

복사 범위
얕은 복사는 보통 바깥 객체만 새로 만들고, 깊은 복사는 내부의 가변 객체까지 새로 만든다.
참조 공유
얕은 복사는 내부 참조를 원본과 공유할 수 있고, 깊은 복사는 필요한 참조 공유를 끊는다.
변경 영향
얕은 복사에서는 공유된 내부 객체를 바꾸면 원본과 복사본 모두에서 바뀐 상태가 보일 수 있다.
비용
깊은 복사는 더 많은 객체를 만들기 때문에 메모리 사용량과 실행 비용이 커질 수 있다.
사용 기준
읽기 전용 데이터나 불변 객체 중심이면 얕은 복사도 충분할 수 있고, 복사본이 독립적으로 수정되어야 하면 깊은 복사를 고려한다.
흔한 오해
new Book(...) 을 호출했다고 해서 Book 안의 모든 객체까지 자동으로 복제되는 것은 아니다.

왜 복사했는데 원본도 같이 바뀔까

자바에서 객체 변수에는 객체 자체가 들어 있지 않고, 객체를 가리키는 참조가 들어 있다. Book book2 = book1;처럼 대입하면 Book 객체가 새로 만들어지는 것이 아니라 두 변수가 같은 객체를 바라본다.

얕은 복사는 단순 대입과는 다르다. 얕은 복사에서는 바깥 객체를 새로 만들 수 있다. 예를 들어 Book 인스턴스는 새로 생성된다. 하지만 Book 안에 들어 있는 Author 같은 필드를 그대로 넘기면, 새 Book도 기존 Author 객체를 함께 바라보게 된다.

이 차이를 보려면 값, 상태, 참조를 나눠서 생각하는 편이 좋다.

  • 값: int, long 같은 기본형 값이나 불변 객체가 표현하는 내용
  • 상태: 객체 내부에서 시간이 지나며 바뀔 수 있는 필드
  • 참조: 어떤 객체를 바라보고 있는지에 대한 연결

복사 후 원본이 함께 바뀌는 현상은 대개 값이 복사되지 않아서라기보다, 변경 가능한 객체의 참조가 공유되어서 생긴다. Author 객체 하나를 원본과 복사본이 함께 바라보는 상태에서 setName()을 호출하면, 두 Book에서 같은 변경 결과가 관찰된다.

예제로 확인하기

아래 코드는 BookAuthor를 사용해 얕은 복사와 깊은 복사를 비교한다. BooknameString이므로 불변 객체이고, AuthorsetName()으로 상태가 바뀔 수 있는 가변 객체다.

public class CopyExample {
    static class Book {
        private final String name;
        private final Author author;

        Book(String name, Author author) {
            this.name = name;
            this.author = author;
        }

        Book shallowCopy() {
            return new Book(this.name, this.author);
        }

        Book deepCopy() {
            return new Book(this.name, new Author(this.author.getName()));
        }

        void changeAuthorName(String name) {
            this.author.setName(name);
        }

        int authorIdentity() {
            return System.identityHashCode(author);
        }

        @Override
        public String toString() {
            return "Book{name='" + name + "', author=" + author + "}";
        }
    }

    static class Author {
        private String name;

        Author(String name) {
            this.name = name;
        }

        String getName() {
            return name;
        }

        void setName(String name) {
            this.name = name;
        }

        @Override
        public String toString() {
            return "Author{name='" + name + "'}";
        }
    }

    public static void main(String[] args) {
        Book original = new Book("이펙티브 자바", new Author("조슈아 블로크"));

        Book shallowCopied = original.shallowCopy();
        shallowCopied.changeAuthorName("Joshua Bloch");

        System.out.println("After shallow copy");
        System.out.println("original: " + original);
        System.out.println("copy:     " + shallowCopied);
        System.out.println("original author id: " + original.authorIdentity());
        System.out.println("copy author id:     " + shallowCopied.authorIdentity());

        Book anotherOriginal = new Book("리팩터링", new Author("마틴 파울러"));

        Book deepCopied = anotherOriginal.deepCopy();
        deepCopied.changeAuthorName("Martin Fowler");

        System.out.println();
        System.out.println("After deep copy");
        System.out.println("original: " + anotherOriginal);
        System.out.println("copy:     " + deepCopied);
        System.out.println("original author id: " + anotherOriginal.authorIdentity());
        System.out.println("copy author id:     " + deepCopied.authorIdentity());
    }
}

shallowCopy()new Book(this.name, this.author)를 호출한다. 여기서 Book은 새로 만들어지지만 author는 기존 참조를 그대로 넘긴다. 그래서 원본 Book과 복사본 Book은 서로 다른 객체지만, 내부의 Author는 같은 객체일 수 있다.

deepCopy()new Author(this.author.getName())Author까지 새로 만든다. 처음에는 같은 이름을 가진 것처럼 보이지만, 원본과 복사본은 서로 다른 Author 인스턴스를 바라본다. 이후 한쪽의 저자 이름을 바꿔도 다른 쪽에는 영향을 주지 않는다.

출력 결과로 참조 공유 확인하기

복사 문제를 디버깅할 때는 “복사본을 바꿨는데 원본 출력도 바뀌는지”를 먼저 확인하면 된다. 여기에 System.identityHashCode()를 함께 찍으면 두 객체가 같은 내부 객체를 공유하는지도 추적할 수 있다.

javac CopyExample.java
java CopyExample

After shallow copy
original: Book{name='이펙티브 자바', author=Author{name='Joshua Bloch'}}
copy:     Book{name='이펙티브 자바', author=Author{name='Joshua Bloch'}}
original author id: 1078694789
copy author id:     1078694789

After deep copy
original: Book{name='리팩터링', author=Author{name='마틴 파울러'}}
copy:     Book{name='리팩터링', author=Author{name='Martin Fowler'}}
original author id: 1831932724
copy author id:     1747585824

첫 번째 결과에서는 원본의 저자 이름도 Joshua Bloch로 바뀌었다. originalshallowCopied가 같은 Author 객체를 공유하기 때문이다. Book은 두 개지만 Author는 하나인 상태다.

두 번째 결과에서는 원본의 저자 이름이 마틴 파울러로 남아 있다. deepCopied가 가진 Author는 복사 과정에서 새로 만들어졌기 때문이다.

identityHashCode 값은 실행할 때마다 달라질 수 있다. 숫자 자체보다 두 값이 같은지 다른지가 중요하다. 같은 값이면 같은 객체를 공유하고 있을 가능성이 높고, 다른 값이면 서로 다른 객체를 참조한다고 볼 수 있다.

불변 객체와 가변 객체를 구분해야 하는 이유

String은 불변 객체다. 여러 객체가 같은 String 참조를 공유하더라도 문자열 내부 상태를 직접 바꿀 수 없다. 그래서 Bookname처럼 문자열 필드를 그대로 넘기는 것은 대부분의 경우 공유 변경 문제를 만들지 않는다.

반면 Author, ArrayList, HashMap, 배열처럼 내부 상태를 바꿀 수 있는 객체는 다르게 봐야 한다. 참조를 공유한 상태에서 한쪽이 setName(), add(), put() 같은 변경 메서드를 호출하면 다른 쪽에서도 바뀐 결과가 보일 수 있다.

여기서 final에 대한 오해도 자주 생긴다. final Author authorauthor 필드가 다른 Author 객체를 가리키도록 재할당되는 것을 막는다. 하지만 그 Author 객체의 내부 상태 변경까지 막지는 않는다.

final Author author = new Author("조슈아 블로크");

author.setName("Joshua Bloch");       // 가능
// author = new Author("다른 저자");  // 재할당은 불가능

즉, final은 참조 변수의 재할당을 제한한다. 참조 대상 객체가 불변인지, 내부 상태가 바뀔 수 있는지는 별도로 확인해야 한다. 객체를 안전하게 공유하려면 필드가 final인지뿐 아니라, 참조 대상의 변경 가능성도 함께 봐야 한다.

컬렉션과 중첩 객체에서 생기는 함정

컬렉션에서는 얕은 복사와 깊은 복사 차이가 더 쉽게 드러난다. new ArrayList<>(oldList)는 리스트 컨테이너를 새로 만들지만, 리스트 안의 요소 객체까지 자동으로 복제하지 않는다.

예를 들어 List<Author>를 복사한다고 해보자. 새 리스트를 만들었더라도 안에 들어 있는 Author들이 같은 객체라면, 한 리스트에서 저자 이름을 바꿨을 때 다른 리스트에서도 바뀐 이름이 보인다. 리스트 자체의 복사와 리스트 요소의 복사는 다른 층위의 문제다.

  • 리스트 얕은 복사: 리스트 컨테이너만 새로 만들고 요소 참조는 공유한다.
  • 요소 깊은 복사: 리스트도 새로 만들고 각 요소 객체도 새로 만든다.
  • 중첩 객체 복사: 요소 안에 또 다른 가변 객체가 있으면 그 깊이까지 판단해야 한다.
  • 불변 요소 공유: 요소가 불변이면 참조 공유가 문제가 되지 않는 경우가 많다.

깊은 복사도 단순히 new를 한 번 더 쓰면 끝나는 문제가 아닐 수 있다. Book 안에 Author가 있고, Author 안에 Address가 있으며, Address 안에 변경 가능한 리스트가 있다면 어디까지 복사해야 하는지 정해야 한다. 전부 복사하면 격리는 강해지지만 비용과 코드 복잡도도 함께 늘어난다.

복사 정책을 코드 밖에서도 드러내기

복사 방식은 구현 세부사항처럼 보이지만, 객체 소유권과 데이터 경계에 관한 결정이기도 하다. 외부 API 응답, 비동기 작업 메시지, 캐시 값처럼 객체가 현재 코드 흐름을 벗어나는 경우에는 참조 공유가 예상치 못한 결과를 만들 수 있다.

팀 안에서 이런 기준을 맞추려면 코드 리뷰 규칙이나 설정 문서에 복사 정책을 남겨두는 방식도 도움이 된다. 아래 예시는 실제 런타임이 자동으로 읽는 설정이라기보다, 어떤 상황에서 깊은 복사를 검토할지 표현한 구성 예시다.

copy-policy:
  book:
    default: shallow
    mutable-fields:
      - author
      - tags
    deep-copy-when:
      - external-api-response
      - async-job-payload
      - cache-value

이 예시에서 author, tags는 변경 가능한 필드로 분류되어 있다. 외부 API 응답이나 비동기 작업 페이로드, 캐시 값처럼 다른 흐름에서 재사용될 수 있는 데이터라면 내부 가변 객체를 공유해도 되는지 한 번 더 확인한다는 의미다.

캐시를 예로 들면, 요청 처리 중 수정한 객체가 캐시에 그대로 남아 다음 요청에 영향을 줄 수 있다. 비동기 작업에서는 큐에 넣은 객체가 실제 실행되기 전에 호출자 코드에서 바뀔 수도 있다. 이런 상황에서는 복사 비용보다 참조 공유로 생기는 추적 비용이 더 커질 수 있다.

복사 방식을 고를 때 보는 조건

얕은 복사와 깊은 복사는 어느 한쪽이 항상 더 좋은 선택이라기보다 조건에 따라 다른 비용과 안전성을 가진다. 읽기 전용 객체를 많이 다루는 코드에서 모든 객체를 깊은 복사하면 불필요한 객체 생성이 늘어난다. 반대로 독립적으로 수정되어야 하는 데이터를 얕은 복사로 넘기면 원본 상태가 예상과 다르게 바뀔 수 있다.

판단할 때는 다음 질문이 도움이 된다.

  • 복사본이 원본과 독립적으로 수정되는가?
  • 내부 필드 중 변경 가능한 객체가 있는가?
  • 컬렉션 안의 요소 객체도 변경될 수 있는가?
  • 객체가 외부 API, 캐시, 비동기 작업처럼 현재 호출 흐름 밖으로 나가는가?
  • 깊은 복사 비용이 데이터 크기와 호출 빈도에 비해 감당 가능한가?

읽기 전용 데이터나 불변 객체 중심이라면 참조 공유가 단순하고 효율적일 수 있다. 복사본이 독립적으로 변경되어야 하거나, 외부 코드가 객체를 수정할 수 있는 경계로 넘어간다면 내부 가변 객체까지 분리하는 쪽이 더 안전하다.

원문 참고

https://www.maeil-mail.kr/question/62

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...