- Published on
Kotlin Sequence로 N+1 부르는 중간연산 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Kotlin의 Sequence는 지연(lazy) 평가 덕분에 불필요한 컬렉션 생성을 줄이고, 파이프라인을 깔끔하게 유지해줍니다. 문제는 “지연”이라는 특성이 원소 단위로 부수효과(특히 DB/HTTP 호출)를 실행하게 만들 수 있다는 점입니다. 즉, 컬렉션이라면 한 번에 끝났을 작업이 Sequence에서는 소비(terminal operation) 시점에 원소마다 호출되어 N+1 형태로 폭발할 수 있습니다.
이 글에서는 “Sequence 자체가 나쁘다”가 아니라, 중간 연산(intermediate operation)을 어떤 방식으로 쓰면 N+1을 부르는지를 6가지로 정리합니다. 예시는 DB를 단순화한 repo 호출로 설명하지만, HTTP/Redis/외부 API에도 그대로 적용됩니다.
참고: 지연 평가와 스트림 파이프라인 최적화 관점은 Elixir 함수형 파이프라인 최적화 - Stream vs Enum에서도 유사한 사고방식을 얻을 수 있습니다.
전제: N+1이 Sequence에서 더 자주 보이는 이유
Sequence는 중간 연산을 즉시 실행하지 않습니다. 아래처럼 작성해도 map은 아직 실행되지 않습니다.
val seq = ids.asSequence().map { id -> repo.findUser(id) }
실행은 toList(), first(), count() 같은 종단 연산이 시작될 때 일어나고, 그때도 원소를 하나씩 당겨가며(pull) 처리합니다. 따라서 중간 연산 람다 안에서 repo.find...() 같은 호출이 있으면, 호출 횟수는 대개 “원소 수”와 1:1로 증가합니다.
핵심은 이겁니다.
Sequence는 “원소 단위 실행”에 최적화- DB/HTTP는 “배치/조인/IN 쿼리”에 최적화
이 간극을 메우지 못하면 N+1이 됩니다.
예제에서 사용할 더미 리포지토리
data class User(val id: Long, val teamId: Long)
data class Team(val id: Long)
a class UserRepo {
fun findByIds(ids: List<Long>): List<User> = TODO("SELECT ... WHERE id IN (...) ")
fun findById(id: Long): User = TODO("SELECT ... WHERE id = ?")
}
class TeamRepo {
fun findById(id: Long): Team = TODO("SELECT ... WHERE id = ?")
fun findByIds(ids: List<Long>): List<Team> = TODO("SELECT ... WHERE id IN (...) ")
}
1) map: 엔티티/연관 조회를 람다 안에서 호출
가장 흔한 패턴입니다.
val users: List<User> = ids
.asSequence()
.map { id -> userRepo.findById(id) } // id마다 1번
.toList()
- 호출 수:
1 + N이 아니라 사실상 N회 - JPA라면
findById가 영속성 컨텍스트/캐시로 일부 줄어들 수도 있지만, 일반적으로는 N회 쿼리
안전한 대안
- “원소별 조회”를 “배치 조회”로 바꾸기
val users = userRepo.findByIds(ids)
또는 Sequence를 유지하더라도 IO는 바깥에서 한 번에 끝내고, 이후에만 시퀀스를 쓰는 식이 좋습니다.
val usersSeq = userRepo.findByIds(ids).asSequence()
.map { it /* CPU 작업 */ }
2) flatMap: 1:N 연관을 원소마다 펼치며 하위 조회
flatMap은 특히 N+1을 “당연하게” 만들어버립니다.
data class Post(val id: Long)
a class PostRepo {
fun findByUserId(userId: Long): List<Post> = TODO()
fun findByUserIds(userIds: List<Long>): List<Post> = TODO()
}
val posts = users.asSequence()
.flatMap { user -> postRepo.findByUserId(user.id).asSequence() } // 사용자마다 쿼리
.toList()
- 호출 수: 사용자 수만큼
findByUserId호출 - 데이터가 커질수록 쿼리 수가 선형 증가
안전한 대안
IN쿼리로 한 번에 가져오고, 메모리에서 그룹핑
val userIds = users.map { it.id }
val posts = postRepo.findByUserIds(userIds)
val postsByUserId = posts.groupBy { post -> /* post.userId */ TODO() }
Sequence는 이 단계(그룹핑 이후의 CPU 파이프라인)에서 쓰는 게 안전합니다.
3) filter: 조건 판별을 위해 원소마다 외부 조회
filter는 “조건만 확인”하는 것처럼 보여서 더 위험합니다. 조건 검사에 IO를 넣는 순간 N+1이 됩니다.
val activeUsers = ids.asSequence()
.map { userRepo.findById(it) }
.filter { user ->
// 예: 팀 상태를 확인하려고 팀을 매번 조회
val team = teamRepo.findById(user.teamId)
/* team.isActive */ true
}
.toList()
- 사용자 조회 N회 + 팀 조회 N회로, 결과적으로 2N에 가까운 호출
안전한 대안
- 조인/페치 조인/배치로 필요한 정보를 한 번에 가져오기
- 또는 팀을 먼저 배치로 가져와서
Map으로 캐싱
val users = userRepo.findByIds(ids)
val teamIds = users.map { it.teamId }.distinct()
val teamsById = teamRepo.findByIds(teamIds).associateBy { it.id }
val activeUsers = users.asSequence()
.filter { user ->
val team = teamsById[user.teamId]
team != null /* && team.isActive */
}
.toList()
4) distinctBy: 키 계산이 비싼데, 그 안에서 조회
distinctBy는 내부적으로 “키를 계산해서 Set에 넣고 중복 제거”를 합니다. 키 계산 함수가 순수 함수면 문제 없지만, 여기서 외부 조회를 하면 원소마다 호출됩니다.
val uniqueByTeamName = users.asSequence()
.distinctBy { user ->
val team = teamRepo.findById(user.teamId) // 사용자마다 호출
/* team.name */ "team"
}
.toList()
distinctBy는 중복 제거가 목적이라 “어차피 일부만 남겠지”라고 착각하기 쉽지만,- 키 계산은 모든 원소에 대해 수행됩니다.
안전한 대안
- 키 계산에 필요한 데이터를 배치로 준비
val teamsById = teamRepo.findByIds(users.map { it.teamId }.distinct())
.associateBy { it.id }
val unique = users.asSequence()
.distinctBy { user -> teamsById[user.teamId]?.id }
.toList()
5) groupBy / associateBy 계열을 Sequence로 쓰며 값 변환에서 조회
Kotlin에는 Sequence.groupBy가 있고, Iterable.groupBy도 있습니다. 어느 쪽이든 “그룹 키/값 변환” 람다에서 외부 조회를 하면 원소마다 호출됩니다.
val usersByTeam = users.asSequence()
.groupBy(
keySelector = { it.teamId },
valueTransform = { user ->
// valueTransform에서 추가 정보가 필요하다고 팀을 조회
val team = teamRepo.findById(user.teamId)
user to team
}
)
- 팀 조회가 사용자 수만큼 실행
- 심지어 같은
teamId가 반복되어도 매번 호출
안전한 대안
- 먼저
teamId를 수집해 배치 조회 후, 매핑
val teamIds = users.map { it.teamId }.distinct()
val teamsById = teamRepo.findByIds(teamIds).associateBy { it.id }
val usersByTeam = users.groupBy(
keySelector = { it.teamId },
valueTransform = { user -> user to teamsById[user.teamId] }
)
여기서 중요한 포인트는 Sequence 여부가 아니라, 람다 안에서 IO를 하지 않는 구조입니다.
6) onEach: “로그/메트릭”처럼 보여도 IO를 넣으면 N+1
onEach는 디버깅/사이드이펙트에 자주 쓰입니다. 그런데 여기서도 DB/HTTP를 호출하면 그대로 N+1입니다.
val result = ids.asSequence()
.map { userRepo.findById(it) }
.onEach { user ->
// 예: 접근 로그를 DB로 남김
/* auditRepo.insert(user.id) */
}
.toList()
이 패턴은 특히 운영에서 위험합니다.
toList()가 호출되는 순간 원소마다 insert/update가 실행- 재시도 로직이 붙으면 폭발적으로 늘어날 수 있음
안전한 대안
- 감사 로그는 배치로 적재하거나 비동기 큐로 넘기기
- 최소한 메모리에서 모아서 한 번에 처리
val users = userRepo.findByIds(ids)
val auditIds = mutableListOf<Long>()
val processed = users.asSequence()
.onEach { auditIds += it.id }
.toList()
// auditRepo.insertBatch(auditIds)
N+1을 피하는 Sequence 사용 가이드 (체크리스트)
1) 중간 연산 람다에 IO가 있으면 일단 의심
다음 중 하나가 람다 안에 있으면 N+1 후보입니다.
repo.find...()- HTTP 클라이언트 호출
- Redis/Memcached 조회
- 파일/네트워크 I/O
2) “배치로 한 번” + “메모리에서 조립”으로 구조 변경
- 먼저 필요한 ID들을
distinct()로 뽑고 findByIds같은 배치 API로 가져온 뒤associateBy로Map을 만들어 조립
3) Sequence는 CPU 파이프라인에서 빛난다
- 문자열 가공, 정렬 전 전처리, 필터링, 변환 등
- IO 경계 밖에서 사용하면 성능/가독성 모두 챙길 수 있습니다.
4) 관측 가능성(Observability)로 조기 탐지
N+1은 “코드 리뷰에서 놓치기 쉬운” 문제라, 지표로 잡아내는 게 중요합니다.
- API 요청당 DB 쿼리 수
- 외부 API 호출 수
p95/p99지연시간
운영에서 원인 추적이 필요할 때는 재시도/타임아웃 같은 연쇄 효과도 함께 봐야 합니다. 분산 환경에서 호출이 꼬이면 보상 트랜잭션까지 전파될 수 있으니, 실패 원인 관점은 MSA 사가 패턴 보상 트랜잭션 실패 원인 7가지도 함께 참고할 만합니다.
실전 리팩터링 예시: Sequence는 유지하되 N+1 제거
문제 코드
val teamNames = userIds.asSequence()
.map { userRepo.findById(it) }
.map { user -> teamRepo.findById(user.teamId) }
.map { team -> /* team.name */ "name" }
.toList()
- 사용자 N회 + 팀 N회
개선 코드
val users = userRepo.findByIds(userIds)
val teamsById = teamRepo.findByIds(users.map { it.teamId }.distinct())
.associateBy { it.id }
val teamNames = users.asSequence()
.mapNotNull { user -> teamsById[user.teamId] }
.map { team -> /* team.name */ "name" }
.toList()
- 사용자 1회 + 팀 1회
- 이후 변환은
Sequence로 지연 처리
마무리
정리하면, Sequence에서 N+1을 “부르는” 중간 연산 6가지는 다음과 같습니다.
mapflatMapfilterdistinctBygroupBy(및associateBy계열의 변환 람다)onEach
이 연산들이 문제인 게 아니라, 람다 내부에 원소별 IO를 넣는 순간 지연 평가가 N+1을 더 쉽게 드러나게 만든다는 게 핵심입니다. “배치로 가져오고 메모리에서 조립한 뒤, CPU 파이프라인에 Sequence를 쓰는 방식”으로 구조를 바꾸면 성능과 가독성을 함께 얻을 수 있습니다.