Study/Kotlin

[Kotlin] 모르고 쓰면 큰일나는 컬렉션 정렬하기

a굥a 2023. 12. 6. 12:00

사건의 발단

QA 기간에 내가 구현한 상품목록 API의 인기순 정렬이 제대로 되지 않는다는 이슈카드가 생성되었다. 상품번호 목록을 인자로 넘기면 순위 정보를 반환하는 인터페이스를 사용하고 있었고 그 정보를 기준으로 정렬했기 때문에 내 코드의 이슈가 아닐 줄 알았다. (역시 끝까지 의심해야 하는 것은 내 코드, 내 자신.)

 

val popularProducts = products.sortedBy { it.rank ?: Int.MAX_VALUE }

 

정말 특별할게 없는 코드였는데 이슈를 크로스체크해 주시던 동료분께 연락이 왔다. 

 

 

👨‍💻 : 이거 인기순으로 정렬 다 하시고 나서 그냥 등록일 순으로 엎어치는데요?

👩‍💻 : 예...?

 

 

코드를 다시 들여다보니 프라이빗 함수로 뺀 부분에서 동순위 방어로직을 수행하고 있었는데 여기 인기순 기준이 없었다. 인기순으로 정렬한 리스트를 인자로 받기 때문에 순위가 유지될 줄 알았는데 thenBy로 체이닝 하지 않아 새로운 리스트로 만들고 있었다. 

 

다행히 동료분께서 빠르게 찾아주셔서 해결했지만, 정렬에 대해서 직접 글로 정리해서 제대로 이해하기로 결심했다.

 


 

1. sorted(), reversed() -> 'ed'에 주목해라 

List에 사용할 수 있는 수많은 표준 라이브러리 함수들의 이름은 대부분 sorted(), reversed(), sortedBy(), sortedWith()...처럼 'ed'가 붙는다. 이 함수들은 원본 객체의 내부를 바꾸지 않고 원하는 결과가 들어있는 새 객체를 생성하고 반환한다. 이런 식으로 원본 객체를 바꾸지 않는 접근 방식을 코틀린 라이브러리에서 일관성 있게 볼 수 있다. 이 패턴에 우리는 적응하고 또 이렇게 구현하도록 노력해야 한다.

 

val numbers = listOf(3,4,1,2)
numbers.sorted() 

println(numbers) // [3,4,1,2] numbers 는 바뀌지 않는다

val sortedNumbers = numbers.sorted()
println(sortedNumbers) // [1,2,3,4]

 

반면 sort(), reverse()는 mutableList에 한해서 사용할 수 있는데 바로 원본 객체를 수정하기 때문이다. 부득이 mutableList를 사용할 때 쓰일 수 있겠지만 자주 사용되지 않을 것 같다. 개인적으로 권장하지 않는 안티패턴 같은 느낌이 든다.

 

val mutableNumbers = mutableListOf<String>("a", "b", "c")
mutableNumbers.add("d") // d 를 추가할 수 있다

println(mutableNumbers) // [a,b,c,d]

mutableNumbers.reverse()
println(mutableNumbers) // [d,c,b,a] 별도 변수에 담지 않아도 바뀐 것을 볼 수 있다

mutableNumbers.sort()
println(mutableNumbers) // [a,b,c,d]

 

 

2. 내림차순은 sortedDescending(), sortedByDescending()... 그리고!

내림차순을 하고 싶을 때는 Descending 만 붙여주면 된다. 개인적으로 코틀린이 현대에 가까운 언어이다 보니 참 직관적이고 명쾌하다고 느껴지는 부분이 많다. 예전에 PHP에서 파이썬으로 개발을 하게 됐을 때 받았던 느낌을 코틀린에서도 동일하게 받고 있다.

 

그런데 이것 말고 다른 방법이 있는데, 바로 마이너스를 붙여주면 된다.

 

val numbers = listOf("a", "bb", "ccc")
val sortedNumbers = numbers.sortedBy { -it.length }

println(numbers) // [a, bb, cccc]
println(sortedNumbers) // [ccc, bb, aa]

 

실무에서 정렬은 참 복잡하다. thenBy 체이닝으로 다중순위를 구현하는 경우가 잦은데 개인적으로 마이너스보다는 Descending을 붙여주는 것이 훨씬 직관적인 것 같다. 다만 성능면에서 이점이 있는지는 나중에 딥 다이브 해보면 좋을 것 같다. (궁금하다!)

 

 

3. comparator를 사용해 정렬하는 sortedWith()의 단짝 compareBy()

실무에서는 아마 가장 많이 보게 되는 함수일 것 같다. comparator(비교기)는 두 원소를 비교하는 객체이다. compareBy()는 인자로 주어진 술어 목록에 따라 비교기를 생성한다. 이때 하나만 넘기고 sortedWith()에 전달하면 sortedBy()와 같은 결과를 얻을 수 있다.

 

아래 예제는 읽지 않은 메시지(isRead = false)를 보낸 사람 순서로 정렬된다.

data class Message(
	val sender: String,
    val test: String,
    val isRead: Boolean,
)

fun main() {
    val messages = listOf(
    	Message("John", "How are you today?", true),
    	Message("Hanna", "Meeting today", false),
    	Message("Dan", "Hey!", false),
    )
    
    val sortedMessage = messages.sortedWith(
    	compareBy(Message::isRead, Message::sender)
    )
    
    println(sortedMessage) 
    // [Message(sender=Dan, test=Hey!, isRead=false), Message(sender=Hanna, test=Meeting today, isRead=false), Message(sender=John, test=How are you today?, isRead=true)]
    
}

 

 


결론

코틀린은 기본적으로 원본 객체를 바꾸지 않는 접근 방식으로 동작한다. 

원하는 데이터를 요구사항에 맞게 정렬하는 방법은 메서드만 잘 찾으면 해결되지만 그 결과는 새로운 객체로 저장되어야 한다는 점을 반드시 명심해야 한다.

 

그리고 글이 너무 길어질 것 같아 정리하지 못했지만 관련 부분의 테스트코드에서 정렬로직 검증에 다소 미흡한 부분이 있었다. 테스트 커버리지만 신경 쓰고 솔직히 Mock 데이터를 만드는 것이 귀찮아서 대충 넘겼던 일이 이렇게 커졌다. 테스트 서버는 대부분 DB에 직접 붙어 디버깅이 수월하지만, 테스트서버에서는 재현되지 않는 현상이 만에 하나 상용 환경에서 발생할 경우에는 테스트코드로 MockUp 해서 보면 버그를 비교적 빨리 찾을 수 있다.

 

결론은 열심히 하자!

 


참고자료

1) 아토믹 코틀린 - 길벗 출판사