얕은 복사, 깊은 복사, 방어적 복사가 뭐야?

로마·2022년 2월 27일
3
post-thumbnail

사건 발생 🔥

Lotto 미션 중 로또 넘버를 생성하는 로직이 필요했습니다. 그래서 저와 알린은 로직을 다음과 같이 구성했습니다.

public class LottoNumberGenerator {

    private static final int LOTTO_MINIMUM = 1;
    private static final int LOTTO_MAXIMUM = 45;
    private static final int FROM_LOTTO_INDEX = 0;
    private static final int TO_LOTTO_INDEX = 6;

    private static final List<LottoNumber> originLottoNumbers;

    static {
        originLottoNumbers = IntStream.rangeClosed(LOTTO_MINIMUM, LOTTO_MAXIMUM)
                .mapToObj(LottoNumber::new)
                .collect(Collectors.toList());
    }

    private LottoNumberGenerator() {
    }

    public static List<LottoNumber> generateLottoNumbers() {
        Collections.shuffle(originLottoNumbers);
        return originLottoNumbers.subList(FROM_LOTTO_INDEX, TO_LOTTO_INDEX);
    }
}

대략적으로 설명을 해보면 LottoNumberGenerator에서는 1부터 45 까지의 LottoNumber 값이 초기화되어있고 generateLottoNumbers() 메소드를 던질 때마다 해당 리스트를 섞은 뒤(shuffle) 순서대로 6 번 째까지 잘라 리턴을 해주는 로직입니다.

이제 코드를 실행시켜 보겠습니다.

public class LottoNumberTest {

    @Test
    void lottoNumberGenTest() {
        // given
        List<LottoNumber> lottoNumbers1 = LottoNumberGenerator.generateLottoNumbers();
        List<LottoNumber> lottoNumbers2 = LottoNumberGenerator.generateLottoNumbers();
        List<LottoNumber> lottoNumbers3 = LottoNumberGenerator.generateLottoNumbers();
        // then
        System.out.println(lottoNumbers1);
        System.out.println(lottoNumbers2);
        System.out.println(lottoNumbers3);
    }
}

분명 세 리스트와 다른 값이 나올 것이라 생각했지만 위와 같이 같은 값의 객체가 나오는 것을 나와서 꽤나 당황했습니다. 😰

왜 이런 일이 발생했을까요? 오늘은 이를 어떻게 해결할 수 있는지 이번 글에서 알아보는 시간을 갖도록 하겠습니다.



subList 뜯어보기

위에서 발생한 문제를 다시 살펴봅시다. 왜 예상했던대로 동작을 하지 않았을까요? 먼저 저희가 사용한 subList 메소드의 구현을 살펴봅시다.









다음을 살펴보면 subList에서는 new SubList<>로 새로운 리스트를 반환합니다.

이 때 서브리스트의 내부 필드에는 ArrayList인 root가 존재하게 되는데요, 이 root로 this를 넣어주기 때문에 새로운 객체지만 동일한 원본을 가리키는 구조가 되게 됩니다.



같은 원본을 참조하기 때문에 저희가 생각한 대로 동작하지 않고 shuffle로 원본이 되는 originLottoNumbers가 변경되면 모두 변경된 원본을 참조하게 되어 같은 LottoNumber를 가지게 되는 것입니다.

그렇다면 어떻게 코드를 작성해야 이런 원본 참조를 막을 수 같은 원본 참조를 막을 수 있을까요? 이를 위해서는 저희는 객체의 복사에 대해서 알아야합니다.


얕은 복사, 깊은 복사, 방어적 복사 📸

객체를 복사한다는 것은 무엇일까요? 다음과 같이 세 가지 개념을 알아보면서 객체의 복사에 대해서 파보도록 합시다.

얕은 복사

  • 얕은 복사는 값 자체를 복사하는 것이 아니라 주소값 즉 객체 참조 자체를 복사한다.
  • 원본 자체가 변경되면 원본을 얕은 복사한 객체 또한 변경된다.
public class Lotto {

    private List<LottoNumber> lottoNumbers;

    public Lotto(List<LottoNumber> lottoNumbers) {
        this.lottoNumbers = lottoNumbers;
    }

    public List<LottoNumber> getLottoNumbers() {
        return lottoNumbers;
    }
}

Lotto가 다음과 같이 정의되어 있을 때 생성자를 통해 넘어온 List의 참조 자체를 내부 값에 할당합니다.
그렇게 되면 인자로 받은 객체의 참조 자체를 함께 lottoNumbers에서 가리키게 됩니다.

   
    void shallowCopyTest() {
        List<LottoNumber> originNumbers = new ArrayList<>(
                List.of(new LottoNumber(1), new LottoNumber(2), new LottoNumber(3)));
        // originNumbers : 1, 2, 3

        Lotto lotto = new Lotto(originNumbers);
        // Lotto's List<LottoNubmer> : 1, 2, 3

        originNumbers.add(new LottoNumber(45));
        // originNumbers : 1, 2, 3, 45  
        // Lotto's List<LottoNubmer> : 1, 2, 3, 45 (( 원본 값이 바뀜에 따라 함께 바뀐다. ))
    }

따라서, 위와 같이 원본이 변경되었을 때 Lotto 내부의 값 또한 변경되는 것을 확인할 수 있죠. 그림으로 표현하면 아래와 같습니다.

깊은 복사

  • 깊은 복사는 복사 대상 뿐만 아니라 내부 요소들 모두 전부 복사하여 완전히 새로운 객체를 생성한다.
  • 객체가 가리키는 주소 값이 원본과 모두 달라야한다. ( 내부요소들의 참조값 또한 주소값이 원본과 달라야한다.)

깊은 복사는 얕은 복사와 달리 참조값을 전부 새로 생성한 인스턴스를 참조한다는 점이다. 따라서 원본 객체의 값의 변경에도 영향을 받지 않고 원본 객체 내부 요소들이 변화해도 마찬가지로 영향을 받지 않는다.

public class Lotto {

    private List<LottoNumber> lottoNumbers;

    public Lotto(List<LottoNumber> lottoNumbers) {
        this.lottoNumbers = lottoNumbers;
    }

    public static Lotto deepCopy(List<LottoNumber> lottoNumbers) {
        List<LottoNumber> newLottoNumber = new ArrayList<>();
        for (LottoNumber lottoNumber : lottoNumbers) {
            newLottoNumber.add(new LottoNumber(lottoNumber.getNumber()));
        }

        return new Lotto(newLottoNumber);
    }

위 코드는 정적 팩토리 메소드를 활용해 deepCopy를 구현한 코드입니다. List<LottoNumber> 만을 새로 생성하여 할당하는 것이 아닌 내부의 참조값 또한 새로 생성해서 할당해주는 방식입니다.

  • 물론 멀티 쓰레드 환경에서 위와 같이 가변인자 lottoNumbers를 사용하는 것은 TOCTOU 공격의 우려가 있으므로 복사본을 만들어 foreach 문의 인자로 사용해주는 것이 좋습니다. ( 이펙티브 자바 item 50. 적시에 방어적 복사본을 만들라 )

그럼 이제 deepCopy의 결과가 어떻게 나오는지 확인해봅시다.

@Test
void deepCopy() {
    List<LottoNumber> originNumbers = new ArrayList<>(
            List.of(new LottoNumber(1), new LottoNumber(2), new LottoNumber(3)));

    Lotto lotto = Lotto.deepCopy(originNumbers);

    originNumbers.add(new LottoNumber(1));
    // originNumbers : 1, 2, 3, 4
    // lotto's List<LottoNumber> : 1, 2, 3
}

위 코드를 디버그 모드로 실행시켜 보면 객체들이 어떤 주소값에 위치하고 있는지 확인 할 수 있습니다.

위 코드를 살펴보면 ArrayList 뿐만 아니라 내부의 LottoNumber 모두 각각 다른 주소를 참조하고 있음을 알 수 있고 원본 객체가 아무리 변화하더라도, 원본 객체의 내부 상태가 아무리 변화하더라도 영향을 받지 않는 상태의 Lotto를 생성할 수 있게 됩니다.

그림으로 나타내면 다음과 같은 그림으로 표현할 수 있습니다.

방어적 복사

  • 생성자에서 사용 시 입력받은 인자를 내부 필드로 초기화한다. 하지만 인자 내부 요소들은 그대로 원본 내부요소를 가리킨다.
  • getter에서 사용 시 인스턴스 내부의 필드를 복사하여 다른 참조로 만들어 반환한다.

아마 코드를 짤 때 미션을 진행하면서 가장 많이 사용하게 될 것이 바로 이 방어적 복사일 듯 하다. 방어적 복사는 인자를 새 인스턴스로 초기화 하는 복사는 진행하지만 깊은 복사와 달리 원본에서 사용한 객체 내부 요소들은 그대로 사용한다.

public class Lotto {

    private List<LottoNumber> lottoNumbers;

    public Lotto(List<LottoNumber> lottoNumbers) {
        this.lottoNumbers = lottoNumbers;
    }

    public static Lotto defensiveCopy(List<LottoNumber> lottoNumbers) {
        return new Lotto(new ArrayList<>(lottoNumbers));
    }
}

위 코드는 방어적 복사를 구현한 코드이며 이를 이용한 예제를 살펴봅시다.

@Test
void defensiveCopy() {
    List<LottoNumber> originNumbers = new ArrayList<>(
            List.of(new LottoNumber(1), new LottoNumber(2), new LottoNumber(3)));

    Lotto lotto = Lotto.defensiveCopy(originNumbers);

    originNumbers.add(new LottoNumber(1));
    // originNumbers : 1, 2, 3, 4
    // lotto's List<LottoNumber> : 1, 2, 3
}

위 코드만 보았을 때는 깊은 복사와 차이가 없어 보입니다. 하지만 내부 주소값을 살펴보면 이야기가 다릅니다.

원본과 비교해보면 리스트의 참조 주소는 다르지만 내부의 LottoNumber들은 원본과 동일한 참조를 하고 있는 것을 확인할 수 있습니다. 그러므로 깊은 복사보다 덜 깊게 복사를 한 것이라고 볼 수 있습니다.

위 상황도 그림으로 나타내보면 다음과 같습니다.

방어적 복사는 내부 요소들은 여전히 원본 객체의 값을 참조합니다. 따라서 원본 객체의 LottoNumber가 외부에 setter가 열려있는 등 가변 객체라면 값이 변경될 우려가 있죠. 이를 막기 위해서는 내부의 객체를 불변한 객체를 만들어주는 것이 필요합니다.

  • 불변 객체를 만드는 법에 대해서는 이 글에서는 넘어가겠습니다.

LottoGenerator 문제 해결하기

이제 객체를 복사하는 세 가지 방법에 대해서 알게 됐습니다. 그럼 이제 앞서 겪었던 문제를 해결해 보겠습니다.

public class LottoNumberGenerator {

    private static final int LOTTO_MINIMUM = 1;
    private static final int LOTTO_MAXIMUM = 45;
    private static final int FROM_LOTTO_INDEX = 0;
    private static final int TO_LOTTO_INDEX = 6;

    private static final List<LottoNumber> originLottoNumbers;

    static {
        originLottoNumbers = IntStream.rangeClosed(LOTTO_MINIMUM, LOTTO_MAXIMUM)
                .mapToObj(LottoNumber::new)
                .collect(Collectors.toList());
    }

    private LottoNumberGenerator() {
    }

    public static List<LottoNumber> generateLottoNumbers() {
        Collections.shuffle(originLottoNumbers);
        return originLottoNumbers.subList(FROM_LOTTO_INDEX, TO_LOTTO_INDEX).stream()
                .sorted()
                .collect(Collectors.toList());
    }
}

저와 알린은 이를 해결하기 위해서 stream을 활용하여 새로운 List를 만들어 반환해주는 방식으로 방어적 복사를 진행했습니다. 이로써 원본 객체와의 주소 공유는 깨지게 되고 원본 객체가 shuffle에 의해 변경되어도 내보낸 List에서는 값의 변화가 일어나지 않게 됩니다.

@Test
void lottoNumberGenTest() {
    // given
    List<LottoNumber> lottoNumbers1 = LottoNumberGenerator.generateLottoNumbers();
    List<LottoNumber> lottoNumbers2 = LottoNumberGenerator.generateLottoNumbers();
    List<LottoNumber> lottoNumbers3 = LottoNumberGenerator.generateLottoNumbers();
    // then
    System.out.println(lottoNumbers1);
    System.out.println(lottoNumbers2);
    System.out.println(lottoNumbers3);
}


이제 모두 각기 다른 로또 값을 얻어낼 수 있게 되었습니다.

결론

원본과의 주소 공유를 끊기 위해서는 방어적 복사 혹은 깊은 복사를 해야한다.

참고

https://velog.io/@miot2j/%EC%96%95%EC%9D%80%EB%B3%B5%EC%82%AC-%EA%B9%8A%EC%9D%80%EB%B3%B5%EC%82%AC-%EB%B0%A9%EC%96%B4%EC%A0%81-%EB%B3%B5%EC%82%AC%EB%9E%80

https://zzang9ha.tistory.com/372

https://www.baeldung.com/java-deep-copy

https://tecoble.techcourse.co.kr/post/2021-04-26-defensive-copy-vs-unmodifiable/

profile
우공이산

4개의 댓글

comment-user-thumbnail
2022년 2월 27일

좋네요 ^^*~

1개의 답글
comment-user-thumbnail
2022년 3월 3일

이미지는 얕은 복사 하셨나봐요

1개의 답글