
p>1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를 다뤘습니다. 3편에서는 함수 설계를 정리했습니다.
4편에서는 null safety를 살펴봤습니다. 5편에서는 data class와 sealed class로 모델링을 다뤘습니다.
6편에서는 extension function과 scope function의 사용 기준을 정리했습니다.
이번 7편에서는 실무에서 가장 자주 마주치는 영역을 다룹니다. 바로 컬렉션과 람다입니다.
코틀린은 컬렉션 API가 아주 강력합니다. map, filter, flatMap, associateBy, groupBy 같은 연산을 자연스럽게 조합할 수 있습니다.
람다 문법도 짧고 유연합니다.
그래서 코드는 쉽게 짧아집니다. 문제는 그다음입니다. 짧아진 코드가 항상 읽기 좋은 코드는 아닙니다. 체이닝이 길어지면 데이터 흐름이 흐려집니다. 람다가 중첩되면 it가 누구를 가리키는지 헷갈립니다. 사이드 이펙트까지 섞이면 더 빨리 복잡해집니다.
클린코드 관점에서 중요한 것은 함수형 스타일 자체가 아닙니다. 컬렉션 처리 흐름이 한 번에 읽히는지가 더 중요합니다.
이번 글에서는 컬렉션 코드를 읽기 좋게 만드는 기준을 정리하겠습니다.
먼저 map과 forEach를 어떻게 구분하면 좋은지 살펴보겠습니다. 이어서 filter 대신 any나 count를 써야 할 때를 정리하겠습니다.
그리고 체이닝이 길어질 때는 어디서 끊어야 하는지, Sequence는 언제 도움이 되고 언제 과한지까지 실무 기준으로 설명하겠습니다.
시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편, 5편, 6편도 함께 보시면 좋습니다.
- 왜 컬렉션과 람다가 쉽게 복잡해질까요
- 문법보다 먼저 결과 형태를 정해야 합니다
map과forEach는 목적이 다릅니다filter보다 더 분명한 함수가 있을 때가 많습니다- 람다 파라미터 이름은 가독성에 직접 영향을 줍니다
- 체이닝이 길어지면 중간 이름을 도입해야 합니다
- 의도를 더 잘 드러내는 전용 함수를 고르세요
forEach보다 일반for문이 더 읽기 좋을 때Sequence는 습관이 아니라 기준으로 써야 합니다- 컬렉션 체인 안에 사이드 이펙트를 숨기지 마세요
- before & after 리팩터링 예제
- 체크리스트
- 마무리
왜 컬렉션과 람다가 쉽게 복잡해질까요?
처음에는 대부분 잘 읽힙니다. 문제는 조금씩 욕심이 붙을 때 시작됩니다.
필터링 한 번, 변환 한 번, 정렬 한 번까지는 괜찮습니다. 그런데 여기에 null 처리, 그룹화, 로깅, 외부 리스트 수정까지 섞이면 코드의 성격이 흐려집니다.
아래 코드를 보겠습니다.
val result = users
.filter { it.isActive }
.map { user ->
user.orders
.filter { it.status == OrderStatus.PAID }
.map { it.totalPrice }
}
.flatten()
.filter { it > 0 }
.sortedDescending()
.take(10)
문법 자체는 문제 없습니다. 하지만 읽는 사람은 몇 가지를 바로 떠올리게 됩니다.
- 최종적으로 무엇을 만들려는 코드일까요
- 중간 단계마다 데이터 타입이 어떻게 바뀌고 있을까요
- 정말 한 줄 흐름으로 읽히는 것이 맞을까요
이런 코드는 “짧다”는 장점은 있습니다. 대신 “빠르게 이해된다”는 장점은 약해집니다.
컬렉션 연산은 보통 데이터 흐름을 표현합니다. 그래서 더더욱 단계 이름이 중요합니다. 어떤 값을 걸러내는지, 어떤 형태로 바꾸는지, 왜 그 시점에 정렬하는지가 코드에 드러나야 합니다.
문법보다 먼저 결과 형태를 정해야 합니다
컬렉션 코드를 읽기 어렵게 만드는 대표 원인 중 하나는, 개발자가 연산을 먼저 고르고 목적은 나중에 설명하는 방식입니다.
하지만 실무 코드는 반대로 가는 편이 좋습니다. 먼저 최종 결과가 무엇인지를 정해야 합니다. 그다음에 그 결과를 가장 자연스럽게 만드는 연산을 고르면 됩니다.
data class Order(
val id: Long,
val customerId: Long,
val status: OrderStatus,
)
enum class OrderStatus {
READY,
PAID,
CANCELLED,
}
예를 들어 우리가 정말 필요로 하는 값이 “결제 완료 주문의 고객 ID 목록”인지, “중복 없는 고객 ID 집합”인지부터 분명해야 합니다.
val result = orders
.filter { it.status == OrderStatus.PAID }
.map { it.customerId }
이 코드는 맞을 수도 있습니다. 하지만 result라는 이름만으로는 최종 의도가 잘 보이지 않습니다.
val paidCustomerIds = orders
.filter { it.status == OrderStatus.PAID }
.map { it.customerId }
이제 무엇을 만드는지 한 번에 보입니다.
만약 중복이 없어야 한다면 마지막 단계는 toSet()가 되어야 합니다.
val paidCustomerIds = orders
.filter { it.status == OrderStatus.PAID }
.map { it.customerId }
.toSet()
클린코드는 보통 화려한 문법에서 나오지 않습니다. 정확한 결과 이름에서 먼저 나옵니다.
map과 forEach는 목적이 다릅니다
이 구분은 단순해 보이지만, 실무에서는 자주 흐려집니다.
map은 각 원소를 다른 값으로 변환해서 새 컬렉션을 만드는 연산입니다. 반면 forEach는 원소를 하나씩 순회하면서 어떤 동작을 수행하는 연산입니다.
즉, map은 결과 컬렉션이 중요할 때 쓰고, forEach는 사이드 이펙트가 목적일 때 씁니다.
아래 코드는 흔하지만 좋지 않습니다.
val emailList = mutableListOf<String>()
users.map { user ->
emailList.add(user.email)
}
map을 썼지만 실제로는 변환 결과를 사용하지 않습니다. 외부 리스트를 수정하는 것이 진짜 목적입니다. 이런 코드는 읽는 사람을 헷갈리게 만듭니다.
새 컬렉션이 필요하다면 그냥 map의 결과를 받으면 됩니다.
val emailList = users.map { user -> user.email }
반대로 진짜 목적이 전송이나 저장 같은 동작이라면 forEach가 더 잘 맞습니다.
users.forEach { user ->
emailSender.sendWelcomeMail(user.email)
}
정리하면 이렇습니다.
- 결과 컬렉션이 필요하면
map - 동작 수행이 목적이면
forEach map안에서 외부 상태를 바꾸지 않기
이 기준 하나만 지켜도 컬렉션 코드는 훨씬 또렷해집니다.
filter보다 더 분명한 함수가 있을 때가 많습니다
컬렉션 코드를 길게 만드는 원인 중 하나는, 모든 상황을 filter로 시작하려는 습관입니다.
하지만 질문이 다르면 함수도 달라져야 합니다.
존재 여부를 알고 싶다면 any가 더 낫습니다
val hasAdmin = users
.filter { user -> user.role == UserRole.ADMIN }
.isNotEmpty()
틀린 코드는 아닙니다. 하지만 “관리자가 하나라도 있는가”라는 질문에는 any가 더 직접적입니다.
val hasAdmin = users.any { user -> user.role == UserRole.ADMIN }
하나도 없음을 확인할 때는 none이 더 읽기 쉽습니다
val noExpiredCoupon = coupons
.filter { coupon -> coupon.isExpired }
.isEmpty()
val noExpiredCoupon = coupons.none { coupon -> coupon.isExpired }
개수가 필요하면 count를 고려하세요
val activeUserCount = users
.filter { user -> user.isActive }
.size
val activeUserCount = users.count { user -> user.isActive }
filter는 분명 유용합니다. 하지만 존재 여부, 부재 여부, 개수처럼 더 직접적인 질문이 있다면 그 질문에 맞는 함수를 쓰는 편이 좋습니다.
코드를 읽는 사람은 구현보다 의도를 먼저 봅니다. 그래서 정답에 가까운 연산 이름을 고르는 일이 중요합니다.
람다 파라미터 이름은 가독성에 직접 영향을 줍니다
코틀린의 it는 편리합니다. 짧고, 빠르고, 반복도 줄여줍니다.
하지만 모든 람다에서 it를 고집하면 금방 읽기 어려워집니다. 특히 람다가 중첩될 때 그렇습니다.
짧고 단순한 람다에서는 it가 괜찮습니다.
val names = users.map { it.name }
하지만 중첩 람다에서는 이름을 드러내는 편이 훨씬 낫습니다.
val result = users.groupBy { it.teamId }
.mapValues { it.value.filter { it.isActive } }
이 코드는 두 번째 줄의 it가 누구인지 잠깐 멈추게 만듭니다. Map.Entry인지, 사용자 목록인지, 사용자 한 명인지 바로 보이지 않습니다.
같은 코드를 조금만 풀어도 읽기가 쉬워집니다.
val result = users
.groupBy { user -> user.teamId }
.mapValues { (_, teamUsers) ->
teamUsers.filter { user -> user.isActive }
}
파라미터 이름을 길게 지으라는 뜻은 아닙니다. 다만 중첩되는 순간부터는 누가 누구인지 보이게 해야 한다는 뜻입니다.
제 기준은 단순합니다.
- 한 줄짜리 단순 람다에서는
it사용 가능 - 람다가 중첩되면 이름을 명시
- entry, user, order, item처럼 역할이 보이는 이름 사용
체이닝이 길어지면 중간 이름을 도입해야 합니다
컬렉션 체이닝은 잘 쓰면 자연스럽습니다. 하지만 단계가 많아질수록 추적 비용이 커집니다.
특히 각 단계가 서로 다른 추상화 수준을 다룰 때는 한 체인으로 유지하지 않는 편이 좋습니다.
아래 예제를 보겠습니다.
val topNicknames = users
.filter { user -> user.isActive }
.map { user -> user.nickname?.trim() }
.filterNotNull()
.filter { nickname -> nickname.length >= 2 }
.distinct()
.sorted()
.take(10)
아주 나쁜 코드는 아닙니다. 그래도 한 번에 읽기에는 조금 깁니다. “활성 사용자만 고른다”, “닉네임을 꺼낸다”, “유효한 닉네임만 남긴다”는 단계가 이름 없이 지나갑니다.
이럴 때는 중간 값을 도입하는 편이 좋습니다.
val activeUsers = users.filter { user -> user.isActive }
val validNicknames = activeUsers
.mapNotNull { user -> user.nickname?.trim() }
.filter { nickname -> nickname.length >= 2 }
val topNicknames = validNicknames
.distinct()
.sorted()
.take(10)
줄 수는 조금 늘었습니다. 대신 데이터 흐름은 더 또렷해졌습니다.
클린코드에서는 줄 수보다 의미 단위가 더 중요합니다. 체인이 길어질수록 “한 번에 읽히는가”보다 “단계별로 이해되는가”를 우선해도 좋습니다.
의도를 더 잘 드러내는 전용 함수를 고르세요
코틀린 표준 라이브러리에는 이미 의도를 잘 드러내는 함수가 많이 있습니다. 이런 함수를 고르면 코드가 더 짧아질 뿐 아니라 의미도 선명해집니다.
map + filterNotNull보다 mapNotNull
val nicknames = users
.map { user -> user.nickname?.trim() }
.filterNotNull()
val nicknames = users.mapNotNull { user ->
user.nickname?.trim()
}
한 단계 줄었고, “변환하면서 null은 제거한다”는 의도도 바로 보입니다.
map + flatten보다 flatMap
data class Article(
val title: String,
val tags: List<String>,
)
val allTags = articles
.map { article -> article.tags }
.flatten()
val allTags = articles.flatMap { article -> article.tags }
중첩 컬렉션을 평탄화하는 의도가 더 직접적으로 드러납니다.
associateBy는 편하지만 중복 키를 의식해야 합니다
목록을 빠르게 맵으로 바꿀 때는 associateBy가 아주 편합니다.
val userById = users.associateBy { user -> user.id }
다만 키가 중복될 수 있는 상황이라면 조심해야 합니다. 어떤 값이 남는지 의도를 분명히 알고 써야 합니다. 중복 키가 의미 있는 도메인이라면 groupBy가 더 나을 수 있습니다.
val usersByTeamId = users.groupBy { user -> user.teamId }
핵심은 간단합니다. 할 수 있는 연산이 아니라 정확히 말해주는 연산을 고르는 것입니다.
forEach보다 일반 for문이 더 읽기 좋을 때
많은 분이 코틀린답게 보인다는 이유로 모든 순회를 forEach로 바꾸곤 합니다. 하지만 항상 그런 것은 아닙니다.
특히 중간에 continue 같은 흐름 제어가 필요할 때는 일반 for문이 더 읽기 좋습니다.
아래 코드는 흔하지만 조금 부담스럽습니다.
users.forEach { user ->
if (!user.isActive) return@forEach
process(user)
}
코틀린을 잘 아는 사람에게는 익숙합니다. 그래도 처음 읽는 사람 입장에서는 return@forEach가 잠깐 멈추게 만듭니다.
같은 의도를 일반 for문으로 쓰면 더 자연스러울 때가 많습니다.
for (user in users) {
if (!user.isActive) continue
process(user)
}
흐름 제어가 있는 순회, 여러 줄의 비즈니스 로직이 들어가는 순회, 중간 탈출이 필요한 순회라면 forEach보다 for문이 더 명확할 수 있습니다.
forEach는 좋은 도구입니다. 다만 모든 루프의 대체품은 아닙니다.
Sequence는 습관이 아니라 기준으로 써야 합니다
Sequence는 자주 오해받는 도구입니다.
많은 분이 “체인이 길면 무조건 Sequence가 더 좋다”라고 생각합니다. 하지만 항상 그렇지는 않습니다.
Iterable 기반 컬렉션 연산은 각 단계가 즉시 실행됩니다. 반면 Sequence는 가능한 범위에서 지연 평가를 사용합니다. 그래서 중간 컬렉션 생성을 줄일 수 있습니다.
아래처럼 긴 처리 흐름에서 일부 결과만 필요할 때는 Sequence가 잘 맞을 수 있습니다.
val topEmails = users
.asSequence()
.filter { user -> user.isActive }
.mapNotNull { user -> user.email }
.map { email -> email.lowercase() }
.distinct()
.take(10)
.toList()
다만 지연 평가는 마지막에 결과를 소비하는 연산이 있어야 실제로 실행됩니다. 그래서 toList(), first(), count() 같은 마지막 단계가 필요합니다.
하지만 작은 컬렉션에 단순한 연산 두세 개만 수행한다면, Sequence가 항상 이득이라고 보기는 어렵습니다. 오히려 코드만 무거워질 수 있습니다.
val names = users.map { user -> user.name }
이 코드는 그냥 이대로 두는 편이 낫습니다. 굳이 asSequence()를 붙일 이유가 없습니다.
Sequence를 쓸 때는 두 가지만 기억하면 됩니다.
- 중간 컬렉션 생성을 줄일 가치가 있는가
- 지연 평가가 코드 이해를 방해하지 않는가
성능 최적화는 중요합니다. 하지만 클린코드에서는 기본값을 단순하게 두고, 필요한 경우에만 복잡도를 올리는 습관도 중요합니다.
컬렉션 체인 안에 사이드 이펙트를 숨기지 마세요
컬렉션 연산이 읽기 어려워지는 가장 빠른 길은, 변환과 동작을 한 체인 안에 섞는 것입니다.
아래 코드는 겉으로만 보면 자연스러워 보입니다.
val emails = users
.filter { user ->
logger.info("checking user=${user.id}")
user.isActive
}
.map { user ->
audit.logExport(user.id)
user.email
}
이 코드는 두 가지 문제를 만듭니다.
filter와map가 순수 변환인지, 부수 효과가 섞인 동작인지 한눈에 보이지 않습니다- 테스트가 어려워지고, 재사용 시 예상치 못한 부수 효과가 생길 수 있습니다
조금 길어져도 역할을 나누는 편이 좋습니다.
val activeUsers = users.filter { user -> user.isActive }
activeUsers.forEach { user ->
audit.logExport(user.id)
}
val emails = activeUsers.map { user -> user.email }
이제 각 단계의 목적이 분명합니다.
- 활성 사용자만 고른다
- 내보내기 로그를 남긴다
- 이메일 목록을 만든다
컬렉션 체인은 가능하면 순수한 변환 흐름으로 유지하는 편이 좋습니다.
before & after 리팩터링 예제
지금까지 정리한 기준을 한 번에 적용해보겠습니다.
data class OrderItem(
val productName: String,
val quantity: Int,
)
data class PaymentOrder(
val status: OrderStatus,
val cancelled: Boolean,
val items: List<OrderItem>,
)
먼저 before 코드입니다.
val result = orders
.filter { it.status == OrderStatus.PAID }
.filter { !it.cancelled }
.map { order ->
order.items
.filter { it.quantity > 0 }
.map { it.productName.trim() }
}
.flatten()
.filter { it.isNotBlank() }
.distinct()
.sorted()
한 번에 읽을 수는 있습니다. 그래도 다음 문제가 보입니다.
- 최종 결과 이름이 모호합니다
- 중간 단계의 의미가 드러나지 않습니다
- 중첩 람다의
it가 계속 바뀝니다
이제 같은 로직을 정리해보겠습니다.
val paidOrders = orders.filter { order ->
order.status == OrderStatus.PAID && !order.cancelled
}
val validItems = paidOrders.flatMap { order ->
order.items
}.filter { item ->
item.quantity > 0
}
val productNames = validItems
.map { item -> item.productName.trim() }
.filter { name -> name.isNotBlank() }
val sortedProductNames = productNames
.distinct()
.sorted()
길이는 조금 늘었습니다. 대신 흐름은 더 편하게 따라갈 수 있습니다.
여기서 한 단계 더 나가면 도메인 함수로 추출할 수도 있습니다.
fun PaymentOrder.isPaidAndValid(): Boolean =
status == OrderStatus.PAID && !cancelled
fun OrderItem.hasStock(): Boolean =
quantity > 0
fun OrderItem.normalizedName(): String =
productName.trim()
val sortedProductNames = orders
.filter { order -> order.isPaidAndValid() }
.flatMap { order -> order.items }
.filter { item -> item.hasStock() }
.map { item -> item.normalizedName() }
.filter { name -> name.isNotBlank() }
.distinct()
.sorted()
이제 컬렉션 체인이 “무슨 일을 하는지”를 이름이 대신 설명합니다. 이런 상태가 되면 컬렉션 코드는 짧지 않아도 충분히 읽기 좋습니다.
체크리스트
- 이 코드는 최종 결과가 무엇인지 이름으로 드러나나요
map을 변환이 아니라 사이드 이펙트 용도로 쓰고 있지 않나요filter대신any,none,count가 더 직접적인 질문은 아닌가요- 중첩 람다에서
it가 누구인지 헷갈리지는 않나요 - 체인이 길다면 중간 이름을 도입하는 편이 낫지 않나요
mapNotNull,flatMap,groupBy,associateBy처럼 더 잘 맞는 함수가 있지 않나요- 흐름 제어가 있는 순회라면
forEach보다for문이 낫지 않나요 Sequence를 습관처럼 붙이지 않았나요- 컬렉션 체인 안에 로그, 저장, 전송 같은 부수 효과를 숨기고 있지 않나요
마무리
코틀린의 컬렉션 API와 람다는 아주 강력합니다. 그래서 더 조심해서 써야 합니다.
표현력이 높다는 것은, 잘 쓰면 아주 좋고 잘못 쓰면 아주 빠르게 복잡해진다는 뜻이기도 합니다.
이번 글의 핵심은 단순합니다.
- 연산 이름은 질문에 맞게 고르기
- 체인이 길어지면 중간 이름 도입하기
- 중첩 람다에서는 파라미터 이름 드러내기
- 순수 변환과 사이드 이펙트 분리하기
Sequence는 기준이 있을 때만 쓰기
컬렉션 코드는 짧다고 끝이 아닙니다. 읽는 사람이 데이터 흐름을 놓치지 않게 만드는 것이 더 중요합니다. 그 기준을 지키면 함수형 스타일도 훨씬 강력한 도구가 됩니다.
다음 편에서는 코틀린 예외 처리를 다뤄보겠습니다. require, check, Result를 언제 어떻게 써야 읽기 좋은 실패 처리 코드가 되는지 실무 관점에서 정리하겠습니다.