Published on

Kotlin Sequence로 N+1 부르는 중간연산 6가지

Authors

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로 가져온 뒤
  • associateByMap을 만들어 조립

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가지는 다음과 같습니다.

  • map
  • flatMap
  • filter
  • distinctBy
  • groupBy(및 associateBy 계열의 변환 람다)
  • onEach

이 연산들이 문제인 게 아니라, 람다 내부에 원소별 IO를 넣는 순간 지연 평가가 N+1을 더 쉽게 드러나게 만든다는 게 핵심입니다. “배치로 가져오고 메모리에서 조립한 뒤, CPU 파이프라인에 Sequence를 쓰는 방식”으로 구조를 바꾸면 성능과 가독성을 함께 얻을 수 있습니다.