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에서는 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>
만을 새로 생성하여 할당하는 것이 아닌 내부의 참조값 또한 새로 생성해서 할당해주는 방식입니다.
그럼 이제 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를 생성할 수 있게 됩니다.
그림으로 나타내면 다음과 같은 그림으로 표현할 수 있습니다.
아마 코드를 짤 때 미션을 진행하면서 가장 많이 사용하게 될 것이 바로 이 방어적 복사일 듯 하다. 방어적 복사는 인자를 새 인스턴스로 초기화 하는 복사는 진행하지만 깊은 복사와 달리 원본에서 사용한 객체 내부 요소들은 그대로 사용한다.
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가 열려있는 등 가변 객체라면 값이 변경될 우려가 있죠. 이를 막기 위해서는 내부의 객체를 불변한 객체를 만들어주는 것이 필요합니다.
이제 객체를 복사하는 세 가지 방법에 대해서 알게 됐습니다. 그럼 이제 앞서 겪었던 문제를 해결해 보겠습니다.
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://zzang9ha.tistory.com/372
https://www.baeldung.com/java-deep-copy
https://tecoble.techcourse.co.kr/post/2021-04-26-defensive-copy-vs-unmodifiable/
좋네요 ^^*~