나는 멍청이 ...
24일에 들은 강의를 이제야 정리하고 있다 ...
블랙잭 미션하느라 바쁘기도 했고.. 주말에 공부를 안하기도했고...
잘 쉬고 바쁠때는 바쁜 것도 좋지만.. 강의 내용이 기억 안난다.

(진짜 큰일남)
아무튼 그래서 이번 포스팅은 기억 더듬기 + 다시 공부하기 하느라 조금 적는데 오래 걸렸다.
(작성하는 시간보다는 하기 싫음을 이겨내는데 정말 많은 시간이 걸렸다.)
역시나 제이슨의 강의였고, 제이슨은 이날도 안드로이드 크루들을 멘붕에 빠뜨리기 위해 만반의 준비를 해왔다. (사실 준비 안하셨을수도 있음..) 아무튼.. 나는 그 멘붕에 아직도 잘 헤어나오지 못한 느낌이랄까나..^^ㅎ
가장 먼저 간단한 LottoNumbers 클래스를 관찰해보자 🔍 (이거 마이크 아니구 돋보기 맞겠죠..?)
class LottoNumbers(numbers: List<Int>) {
// 얕은복사: 원본의 주소를 전달
val numbers: List<Int> = numbers
}
@Test
fun test1() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
assertThat(actual.numbers).hasSize(6)
}
뭐.. size가 6인 것은 당연한 일이다.
이렇게 간단한 클래스의 문제점은 무엇일까?
바로 아래 테스트를 보면 numbers를 접근하여 actual의 numbers 길이를 변경가능하다.
@Test
fun test2() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
numbers.add(7)
assertThat(actual.numbers).hasSize(7)
}
아래와 같이 numbers를 toList()로 변경해주면 직접 접근하여 리스트에 인자를 직접 추가하는 것을 막을 수 있다.
class LottoNumbers(numbers: List<Int>) {
// 깊은복사 : 원본이 아닌 새 리스트를 참조
val numbers: List<Int> = numbers.toList()
}
@Test
fun test2() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
numbers.add(7)
assertThat(actual.numbers).hasSize(6)
}
그럼 다음과 같은 상황에서는 어떨까?
class LottoNumbers(numbers: List<Int>) {
val numbers: MutableList<Int> = numbers.toMutableList()
fun add(number: Int) {
numbers.add(number)
}
}
@Test
fun test4() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
actual.add(7)
assertThat(actual.numbers).hasSize(7)
}
위와 같이 actualdml numbers에 직접 접근하지 않고 actual에 add 하는 식으로만 더하고 싶은 경우에 이렇게 코드를 작성하면 충분할까? 정답은 예상하다시피 NO 이다.
위 처럼 add 함수를 만들어논 것이 무색하게 아래 테스트에서는 numbers에 접근하여 원소를 추가하는 것이 가능하다.
@Test
fun test3() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
actual.numbers.add(7)
assertThat(actual.numbers).hasSize(7)
}
그렇다면 numbers에 직접 접근하는 것을 막는 것은 불가능할까?
이를 방지하기 위해서 사용하는 것이 바로 방어적 복사이다.
방어적 복사
생성자의 인자로 받은 객체의 복사본을 만들어 내부 필드를 초기화
내부의 객체를 반환할 때, 객체의 복사본을 만들어 반환
효과 : 외부에서 객체를 변경해도 내부의 객체는 변경되지 않는다.
@Test
fun test5() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
// numbers.add(7) -> 방어 ㄱㄴ
// actual.numbers.toMutableList().add(7) -> 방어 ㄱㄴ
// as 는 type을 형변환 해주는 키워드
(actual.numbers as MutableList<Int>).add(7)
assertThat(actual.numbers).hasSize(7)
}
이런식으로 방어적 복사를 사용하면, 위에서 문제가 되었던, actual.numbers.add 같은 것을 수행할 수 없다. (1)번 방법도 안되고 (2)번 방법도 안되면 우리는 완벽하게 방어를 한 것일까? 정말 놀랍게도 이 질문에 대한 대답도 NO 이다.
(3) as를 통해 type을 형변환을 해주면 actual.numbers에 또 접근이 가능해진다.
자바에서는 mutableList와 List를 모두 List로 처리하기 때문에 발생하는 문제이다.
위에서와 같이 같은 주소를 참조하는 얕은 복사의 문제점이 드러나는 것이다.
정말 마지막으로 이를 해결할 수 있는 방법이 무엇이 있을까?
바로 getter 호출 시 toList()를 붙여 copy()를 해주는 것이다. (깊은 복사)
외부에서는 다운 캐스팅을 하여 사용하게 된다.
이것으로 정말 진정한 의미의 방어적 복사가 완성이 된다.
class LottoNumbers(numbers: List<Int>) {
private val _numbers: MutableList<Int> = numbers.toMutableList()
val numbers: List<Int>
get() = _numbers.toList()
}
@Test
fun test6() {
val numbers = mutableListOf(1, 2, 3, 4, 5, 6)
val actual = LottoNumbers(numbers)
(actual.numbers as MutableList<Int>).add(7)
assertThat(actual.numbers).hasSize(6)
}
결론 방어적 복사를 잘 사용하자 !
리팩토링에 대해서도 간단하게 설명해보겠다.
제이슨이 리팩토링하는 방법을 예제로 스무스하게 설명하고 넘어가버려 내 머리 속에서도 스무스하게 흘러가버렸다;; 그래서 급하게 주워담은 TIP들만 끄적끄적 📝
다음은 LottoMachine.kt 내부의 코드이다.
fun match (
userLotto: List<LottoNumber>,
winningLotto: List<LottoNumber>,
bonusNumber: LottoNumber
): Int {
val matchCount = userLotto.match(winningLotto)
val matchBonus = userLotto.contains(bonusNumber)
return Rank.of(matchCount, matchBonus)
}
private fun match (
userLotto: List<LottoNumber>,
winningLotto: List<LottoNumber>
): Int {
return userLotto.numbers.count { winningLotto.numbers.contains(it) }
}
private fun List<LottoNumber>.match (
winningLotto: List<LottoNumber>
): Int {
return count winningLotto.numbers.contains(it)
}
matchCount를 계산하는데 내부 함수인 match()를 통해 하고 있다.
userLotto와 winningLotto 두개를 비교하는 구조를 아래의 match()와 같이 수정할 수 있다.
이때 꼭 의문을 가져야 한다. 이런식으로 할 수 있다면 Lotto 안으로 옮겨도 되지 않을까?
==> 그렇다면 옮겨보자!
아래와 같이 코드를 변경하면 안에서 행하도록 할 수 있다.
위에서 반환값을 보면 winningLotto의 numbers를 직접 꺼내서 사용하는 것을 아래에서는 내부에서 처리할 수 있게 변경된 것도 확인 가능하다!
data class Lotto(val numbers: List<LottoNumber>) {
fun contains(number: LottoNumber): Boolean {
return numbers.contains(number)
}
fun match(winningLotto: Lotto): Int {
return numbers.count { winningLotto.contains(it) }
}
}
fun match2(
userLotto: Lotto,
winningLotto: Lotto,
bonusNumber:LottoNumber
): Int {
// match와 contains를 Lotto 내부에서 시행하도록 변경
val matchCount = userLotto.match(winningLotto)
val matchBonus = userLotto.contains(bonusNumber)
return Rank.of(matchCount, matchBonus)
}
이런 식으로 꺼내 쓰던 걸 안에서 처리하는 방법으로 고칠 수 있음! 꺼내 쓰지 말고 안에서 행하도록 하자
그 다음에 제이슨이 가짜 생성자에 대해 짤막하게 설명한다.
fake construct는 Test할때 코드를 알아보기 힘들정도로 복잡해지는 경우, 이를 이용해 간단하게 코드를 작성할 수 있다. 나도 2단계 로또 미션을 진행하면서 사용했었는데, 확실히 가독성이 좋아진다.
class LottoTest {
private fun Lotto(numbers: List<Int>): Lotto {
return Lotto(numbers.map { LottoNumber(it) })
}
@Test
fun `로또는 번호 여섯 개를 가져야 한다`() {
assertThrows<IllegalArgumentException> {
Lotto(listOf(1, 2, 3, 4, 5))
}
}
}
위와 같이 가짜 생성자를 만들경우, 일일이 LottoNumber 객체로 감싸줘야했던 것을 자동으로 감싸준다. 함수를 마치 생성자처럼 사용하는 것이다. 만약 코드를 짜다가 생성자인줄 알았는데 글자가 기울어져있다면 그건 코틀린에서 제공하는 함수일 것이다. (우와 ~)
하지만 제이슨 왈 : 그냥 생성자 추가하세요. 🤗
생성자 추가해서 쓰는 것도 꽤나 편리하고, 코드에도 사용할 수 있어서 좋았다.
class Lotto(numbers: List<LottoNumber>) {
constructor(vararg numbers: Int): this(numbers.map(::LottoNumber))
}
아 그냥 테스트 코드에 map을 사용하는 방법도 알려주셨다.
Test
fun `1등`() {
val actual = match2(
userLotto = Lotto(listOf(1, 2, 3, 4, 5, 6).map(::LottoNumber)),
winningLotto = Lotto(listOf(1, 2, 3, 4, 5, 6).map(::LottoNumber)),
bonusNumber = LottoNumber(7)
)
}
테스트 코드를 간결하게 만드는데 이렇게.. 많은 방법이 있지만 리뷰어님들끼리의 의견도 갈리는 것을 보면 사용성에 대해서는 조금 더 고민해봐야겠다. (취향 차이일지도?)
오늘은 나머지 공부 없습니다 !!
다음 포스팅이나 빨리 빨리 밀리지 말고 씁시다. (반성)
👻 다운 캐스팅