티스토리 뷰

안녕하세요, Shopping Service API팀 강희정입니다.

 

이번 글에서는 Kotlin에서 리스트를 추출하는 메서드에 대해 다뤄보고자 합니다. 비교적 가벼운 내용이긴 하겠지만, 비슷하면서 다른 기능을 하는 메서드들을 정리해야 할 필요가 있다고 생각되어 글을 작성하게 되었습니다.

 

Kotlin을 사용하는 사람들은 대부분 프로그래밍을 처음 접해보는 사람들이 아니라 다른 언어, 특히 Java를 주 언어로 사용하던 사람들일 텐데요, Java 개발자라면 리스트의 부분 리스트를 구해야 할 때 자연스레 subList 메서드를 사용해야겠다! 라는 생각이 드리라 예상됩니다. 실제로 subList는 Kotlin에서 지원하는 메서드기에, Kotlin에 익숙하지 않은 Java 경력의 개발자들은 한 치의 의심도 없이 subList 메서드를 사용해서 부분 리스트를 구하게 될 것입니다.

 

하지만 Kotlin에서는 subList 외에도 slicetake라는 부분 리스트를 구하는 메서드를 제공하고 있습니다. subList만을 사용해서 부분 리스트를 구해도 큰 문제는 없을 것이지만 (후술하겠지만 사실 문제를 일으킬 수도 있습니다), Kotlin에서 제공하는 메서드들의 차이를 잘 파악하여 언어적 특징을 최대한 활용하여 적용해야 프로그래머의 의도대로 동작하는 프로그램을 구현할 수 있습니다.

 

Kotlin에서는 리스트의 부분 리스트를 구하는 메서드로 여러 메서드를 제공하고 있습니다. 이번 글에서 소개할 메서드 모두 부분 리스트를 추출하는 기능을 하는 메서드이며, 원본 리스트를 변경하지 않고 추출한 새로운 리스트를 반환하는 특징을 가지고 있습니다. 또한 이 메서드들은 immutable한 리스트와 mutable한 리스트 모두에서 사용할 수 있습니다.

subList

subList는 리스트의 인덱스를 기반으로 리스트의 일부분을 추출하여 새로운 리스트를 생성하는 메서드입니다. Java의 subList와 유사하게 시작 인덱스부터 끝 인덱스까지 요소를 추출하며, 이때 시작 인덱스는 포함이 되고 끝 인덱스는 포함되지 않습니다.

val list = listOf(1, 2, 3, 4, 5)
val sub = list.subList(1, 4) // [2, 3, 4]

slice

slice는 리스트의 특정 범위를 추출하여 새로운 리스트를 만드는 메서드입니다. subList와는 다르게 인자로 IntRange를 받으며, IntRange에 해당하는 범위의 리스트를 추출하여 새로운 리스트로 생성합니다. 또한 subList가 인자로 받는 범위에서는 끝 인덱스가 포함되지 않지만, slice에서는 IntRange를 받기에 range의 끝에 해당하는 범위가 포함된 리스트가 반환됩니다.

val list = listOf(1, 2, 3, 4, 5)
val sliced = list.slice(1..4) // [2, 3, 4, 5]

subList vs. slice

위 설명만 봤을 때는 subList와 slice가 인자로 받는 파라미터의 차이만 있어 굳이 두 메서드를 구분해서 쓸 필요가 없어 보입니다. 하지만 두 메서드간에는 매우 큰 차이가 있습니다. 아래 코드를 예시로 들어보겠습니다.

val mutableList = mutableListOf(1, 2, 3, 4, 5)

val sub = mutableList.subList(1, 4)  // [2, 3, 4]
val sliced = mutableList.slice(1..3) // [2, 3, 4]

mutableList[2] = 7

println(sub)          // [2, 7, 4]
println(sliced)        // [2, 3, 4]

최초에 subList와 slice를 통해 부분 리스트를 생성할 때까지만 해도 sub와 sliced는 동일한 값을 가지고 있었습니다. 하지만 중간에 원본 리스트의 값을 변경하는 순간, subList를 통해 만든 리스트인 sub의 값이 변경되었습니다.

 

subList 메서드에 의해 추출된 부분 리스트는 원본 리스트의 View에 해당하며, 원본 리스트의 변경 사항이 추출된 부분 리스트에 영향을 끼칩니다.

slice 메서드에 의해 추출된 부분 리스트는 원본 리스트와 완전히 독립된 리스트로, 원본 리스트의 변경 사항이 추출된 부분 리스트에 영향을 끼치지 않습니다.

 

따라서 원본 리스트와 연결된 부분 리스트를 생성해야 할 경우 subList로, 원본 리스트와 연결되지 않은 독립적인 부분 리스트를 생성해야 할 경우 slice를 사용해서 부분 리스트를 생성해야 합니다.

 

앞서 잠깐 언급한 내용이지만, 무턱대고 subList를 사용했다가 원본 리스트의 변경 사항이 부분 리스트까지 전이되어 예기치 못한 일이 발생할 수 있기에, 부분 리스트를 생성할 때는 이 점을 고려하여 메서드를 선택해야 합니다.

subList와 slice의 동작 파헤치기

(본 소주제는 글의 이해를 돕기 위해 추가된 내용으로 필수적인 내용이 아닙니다. 핵심만 읽으실 분들은 건너뛰시고 바로 take, drop 단락으로 넘어가셔도 무방합니다.)

 

두 메서드는 어떤 차이가 있기에 같은 리스트를 받고서도 다르게 동작하는 것일까요?

 

차이점을 확인하기 위해 메서드의 정의(subList, slice)를 봐도, 두 메서드가 어떻게 동작하는지 한눈에 파악하기가 어렵습니다. 확인할 수 있는 차이점이라고는 subList는 List의 멤버 함수라는 것과 slice는 collections의 확장 함수란 것 외에는 별다른 차이점이 보이지를 않죠. 차이점을 조금 더 쉽게 확인하기 위해 IDE의 Debugger를 활용해 보도록 합시다.

val mutableList = mutableListOf(1, 2, 3, 4, 5)

val sub = mutableList.subList(1, 4)  // [2, 3, 4]
val sliced = mutableList.slice(1..3) // [2, 3, 4]

// mutableList[2] = 7

println(sub)          // [2, 7, 4]
println(sliced)        // [2, 3, 4]

앞서 예시로 들었던 코드에서 mutableList[2] = 7을 제외하고 디버깅을 해 보도록 하겠습니다.

 

 

mutableList와 sub, sliced 각각 다른 해시코드를 가지고 있어 다른 객체임을 확인할 수 있지만, mutableList와 sliced는 ArrayList 클래스 타입으로 내려오고, sub는 ArrayList 클래스의 내부 클래스인 SubList 클래스로 내려오는 것을 확인할 수 있습니다.

 

이때 각각의 리스트를 구성하는 요소들의 해시코드가 동일한 것으로 보아, 세 리스트는 내부적으로 모두 동일한 객체를 가리키고 있는 것을 확인할 수 있습니다. mutableList, sub, sliced 모두 Integer@830, Integer@831, Integer@832 객체를 가리키고 있죠.

 

위 코드에서 mutableList[2] = 7에 걸려 있던 주석을 제외하고 다시 실행해 보면 어떤 일이 발생할까요?

mutablieList와 sub가 가리키던 요소는 7을 가리키는 새로운 객체(Integer@831)로 대체되고, sliced가 가리키는 요소는 기존의 객체(Integer@837)가 유지되고 있습니다. subList로 추출한 리스트는 원본 리스트의 참조를 따라가고, slice로 추출한 리스트의 경우 기존의 참조를 유지하고 있는 것을 확인할 수 있습니다.

take, drop

take는 리스트의 앞부분부터 지정한 개수만큼의 요소를 추출하여 새로운 리스트를 생성하는 메서드입니다.

drop은 이와 반대로 리스트의 앞부분부터 지정한 개수만큼의 요소를 뺀 새로운 리스트를 생성하는 메서드입니다.

val list = listOf(1, 2, 3, 4, 5)
val taken = list.take(3)       // [1, 2, 3]
val dropped = list.drop(3)    // [4, 5]

take와 drop은 slice와 유사하게 원본 리스트와 독립적으로 생성되며, 원본 리스트가 변경되어도 추출된 부분 리스트에 영향을 주지 않습니다.

val mutableList = mutableListOf(1, 2, 3, 4, 5)
val taken = mutableList.take(3)     // [1, 2, 3]
val dropped = mutableList.drop(3)    // [4, 5]

mutableList[1] = 7
mutableList[3] = 7

println(taken)            // [1, 2, 3]
println(dropped)        // [4, 5]

subList(slice) vs. take(drop)

take, drop은 다른 메서드에 비해 더 간결하게 부분 리스트를 생성할 수 있는 것처럼 보입니다. 보기에도 더 직관적인 것 같고요. 물론 이 장점만으로도 take와 drop을 선택할 이유는 충분하겠지만, subList, slice와 비교했을 때 이 메서드들이 가지는 큰 장점은 따로 있습니다.

val list = listOf(1, 2, 3)        // 길이가 3이고, maxIndex가 2인 list
val sub = list.subList(0, 4)    // IndexOutOfBoundsException
val sliced = list.slice(0..3)    // IndexOutOfBoundsException
val taken = list.take(4)      // [1, 2, 3]
val dropped = list.drop(4)    // []

subList와 slice는 리스트의 범위를 넘어가는 인덱스를 인자로 받을 경우 IndexOutOfBoundsException을 발생시킵니다. 반면 take와 drop은 리스트의 범위를 넘어가는 인덱스를 인자로 받더라도 별도의 Exception을 발생시키지 않습니다.

 

리스트의 범위를 넘어가는 인덱스를 인자로 받을 경우 take는 원본 리스트를 그대로 가져오고, drop은 리스트의 모든 요소를 제외하여 부분 리스트를 생성합니다. 이 메서드들을 사용하면 IndexOutOfBoundsException에서 조금은 자유로워질 수 있게 되는 것이죠.

그래서 언제 어떤것을 써야 할까?

앞서 Kotlin에서 부분 리스트를 구하는 메서드들과 각 메서드들 간의 차이를 설명해 드렸습니다. 각 메서드별로 사용하면 좋을 상황을 정리해 보면 아래와 같습니다.

 

물론 이 내용은 메서드들간의 특징을 바탕으로 필자의 개인적인 주관이 들어간 부분이니 100% 정답이라고는 할 수 없습니다. 더 나은 방향이 있다면 댓글 부탁드립니다 🙂

  • subList
    • 원본 리스트와 부분 리스트 간의 관계를 유지하고자 할 때
    • 0번 인덱스부터 자르는 것이 아닌 중간부터 자를 때 (끝까지 자르는 것이 아닌 도중에 자를 때)
      • ex: subList(1, 4)
  • slice
    • 원본 리스트와 관계없는 독립적인 리스트를 생성해야 할 때
    • 0번 인덱스부터 자르는 것이 아닌 중간부터 자를 때 (끝까지 자르는 것이 아닌 도중에 자를 때)
      • ex: slice(1..4)
  • take, drop
    • slice를 대체할 수 있으면 사용하는 것이 좋음
      • slice(0, 3) 대신 take(3) 사용 → IndexOutOfBoundsException으로부터 안전
      • subList(0, 4) 대신 take(3)을 사용할 경우, 원본 리스트 간의 동기화 문제가 발생할 수 있으므로 이 점 참고하여 사용
    • IndexOutOfBoundsException을 발생시켜야 하는 상황에서는 다른 메서드 사용

부분 리스트를 구하는 다른 메서드들

이외에도 Kotlin에서는 부분 리스트를 구하는 여러 다른 메서드들을 제공하고 있습니다.

 

특정 조건을 만족하는 부분 리스트를 구하는 filter, take와 유사하게 리스트의 뒷부분부터 지정한 개수만큼의 요소를 취하는 takeLast, 주어진 조건을 만족하는 요소들을 리스트의 앞부분부터 제외하는 dropWhile, 일정한 간격으로 리스트를 추출하는 chunked 등 앞서 소개드린 메서드 이외에도 많은 메서드들을 Kotlin에서 제공하고 있습니다.

맺음말

이번 글에서는 Kotlin에서 부분 리스트를 추출하는 여러 메서드들에 대해 알아보았습니다. 언뜻 보면 비슷한 기능을 하는 메서드들이기에 혼동될 수 있지만, 이러한 메서드들을 잘 파악하고 활용하면 더 효율적이고 안정적으로 코드를 작성하고 설계하는 데 도움이 되리라 생각됩니다.

Reference

https://stackoverflow.com/questions/57240799/difference-between-list-sublist-and-slice-in-kotlin

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/slice.html

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-list/sub-list.html

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/take.html

https://kotlinlang.org/docs/collection-parts.html

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/drop.html

댓글