Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

16장. Sequence와 지연 연산

앞 장에서 우리는 컬렉션을 다루는 함수들을 배웠습니다.

filter로 걸러 내고, map으로 바꾸고,
sum으로 합치는 방식이었습니다.

이 함수들은 대부분 편하고 잘 동작합니다.
하지만 데이터가 아주 많아지면 이야기가 달라집니다.

이 장에서는 “연산을 미뤄 두었다가
꼭 필요할 때만 실행하는” 방식을 배웁니다.

이것을 지연 연산(lazy evaluation)이라고 부르고,
코틀린에서는 Sequence로 구현합니다.

이름은 낯설지만 개념은 어렵지 않습니다.
천천히 그림과 함께 따라가 봅시다.


16.1 Collection 연산은 어떻게 실행되는가

즉시 실행되는 연산

13장에서 배운 컬렉션 함수를 다시 떠올려 봅시다.
아래 코드는 리스트에서 짝수만 골라 두 배로 만듭니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val result = numbers
    .filter { it % 2 == 0 }
    .map { it * 2 }

println(result)
// 출력: [4, 8, 12]

코드는 잘 동작합니다.
그런데 안에서 어떤 일이 벌어지는지 생각해 본 적 있나요?

코틀린의 컬렉션 함수는 즉시 연산(eager evaluation) 방식입니다.

즉시 연산이란 이름 그대로
“그 자리에서 바로 계산하는” 방식입니다.

즉, filter가 호출되는 순간
리스트 전체를 훑어서 결과를 만듭니다.

중간 결과가 계속 생긴다

여기서 중요한 점이 있습니다.

각 함수는 계산이 끝나면
그 결과를 담을 새 리스트를 하나씩 만듭니다.

앞의 코드가 실제로 어떻게 흘러가는지
단계별로 그려 보겠습니다.

원본:        [1, 2, 3, 4, 5, 6]
              │
              ▼  filter { 짝수만 }
새 리스트1:  [2, 4, 6]        ← 리스트 하나 새로 생성
              │
              ▼  map { 두 배 }
새 리스트2:  [4, 8, 12]       ← 리스트 또 하나 생성

함수를 두 번 이어 썼을 뿐인데
중간에 임시 리스트가 두 개나 만들어졌습니다.

filter가 만든 [2, 4, 6]
map에 넘겨주고 나면 곧 버려집니다.

잠깐 쓰고 버릴 리스트를
매번 새로 만드는 셈입니다.

데이터가 적을 때는 문제없다

데이터가 여섯 개뿐이라면
임시 리스트 두 개쯤은 아무 문제도 아닙니다.

컴퓨터에게 이 정도 일은 눈 깜짝할 사이입니다.

문제는 데이터가 아주 많아질 때입니다.

100만 개짜리 리스트를 filtermap 하면,
중간에 수십만 개짜리 임시 리스트가 통째로 생깁니다.

그리고 함수를 이어 쓸 때마다
그런 임시 리스트가 하나씩 더 늘어납니다.

이렇게 쓰고 버리는 리스트가 쌓이면
메모리를 낭비하게 됩니다.

바로 이 지점에서
Sequence라는 새로운 도구가 등장합니다.


16.2 Sequence란 무엇인가

값을 하나씩 흘려보내는 통로

Sequence(시퀀스)는 우리말로 “연속된 것“이라는 뜻입니다.

컬렉션이 “값을 모아 담아 둔 상자“라면,
Sequence는 “값이 하나씩 지나가는 통로“에 가깝습니다.

비유로 이해해 봅시다.

컬렉션은 물이 가득 찬 양동이입니다.
Sequence는 물이 흐르는 호스입니다.

양동이는 물을 전부 담아 두고 한꺼번에 봅니다.
호스는 물을 한 방울씩 흘려보냅니다.

이 차이가 왜 중요할까요?

Sequence는 값을 미리 다 만들어 두지 않습니다.
필요한 순간에 값을 하나씩 꺼내 옵니다.

미리 계산하지 않는다

앞 절의 즉시 연산은
filter 단계에서 짝수를 전부 골라 두었습니다.

Sequence는 그렇게 하지 않습니다.

“일단 계산하지 말고 미뤄 두자.
진짜 결과가 필요해질 때 그때 계산하자.”

이렇게 계산을 뒤로 미루는 성질을
지연 연산(lazy evaluation)이라고 합니다.

lazy는 영어로 “게으른“이라는 뜻입니다.
“시키기 전까지는 일하지 않는다“는 느낌입니다.

이 게으름이 오히려 장점이 됩니다.
쓸데없는 중간 리스트를 만들지 않기 때문입니다.

자바의 Stream과 닮았다

자바를 조금 아는 분이라면
이 개념이 익숙할 수 있습니다.

자바 8부터 등장한 Stream(스트림)이
바로 이 지연 연산 방식입니다.

구분자바코틀린
즉시 연산 컬렉션List, Set 등List, Set 등
지연 연산StreamSequence
통로로 바꾸기.stream().asSequence()

이름만 다를 뿐 생각은 같습니다.

코틀린의 Sequence는
자바의 Stream과 같은 역할을 합니다.

다만 코틀린은 컬렉션 함수(13장)가
워낙 편하게 잘 되어 있어서,
Sequence는 필요할 때만 골라 씁니다.

이 점은 뒤에서 자세히 다루겠습니다.


16.3 asSequence

컬렉션을 Sequence로 바꾸기

이미 가지고 있는 리스트를
Sequence로 바꾸는 방법은 간단합니다.

asSequence()를 붙이면 됩니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val result = numbers
    .asSequence()          // 통로로 바꾸기
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .toList()              // 다시 리스트로 받기

16.1의 코드와 거의 똑같습니다.
asSequence()toList()만 추가되었습니다.

  • asSequence() : 리스트를 Sequence(통로)로 바꾼다
  • toList() : Sequence를 다시 리스트로 모은다

여기서 toList()가 왜 필요한지
궁금할 수 있습니다.

Sequence는 “흐르는 통로“라고 했습니다.
통로 자체는 최종 결과가 아닙니다.

그래서 마지막에
“흘러나온 값들을 리스트로 모아 줘“라고
말해 주어야 합니다.

그 역할을 하는 것이 toList()입니다.

처음부터 Sequence로 만들기

리스트를 거치지 않고
바로 Sequence를 만들 수도 있습니다.

sequenceOf를 쓰면 됩니다.

val seq = sequenceOf(1, 2, 3, 4, 5)

listOf와 사용법이 똑같습니다.
값을 나열해 주기만 하면 됩니다.

값을 규칙에 따라 계속 만들어 내는
generateSequence라는 함수도 있습니다.

// 1부터 시작해 1씩 커지는 무한한 흐름
val infinite = generateSequence(1) { it + 1 }

val firstFive = infinite
    .take(5)      // 앞에서 다섯 개만
    .toList()

println(firstFive)
// 출력: [1, 2, 3, 4, 5]

여기서 놀라운 점이 있습니다.

generateSequence는 끝이 없는
“무한한 값의 흐름“을 만듭니다.

그런데도 프로그램이 멈추지 않고
잘 동작합니다.

즉시 연산이었다면
무한한 리스트를 만들다가 프로그램이 멈췄을 것입니다.

하지만 Sequence는 게으르기 때문에
take(5)가 요청한 다섯 개만 만들고 멈춥니다.

이것이 지연 연산의 힘입니다.
그 원리를 이제 자세히 살펴봅시다.


16.4 Intermediate Operation과 Terminal Operation

두 종류의 연산

Sequence의 연산은 두 종류로 나뉩니다.
이 구분을 알아야 지연 연산을 이해할 수 있습니다.

  • 중간 연산(intermediate operation)
  • 최종 연산(terminal operation)

이름이 어렵게 들리지만 뜻은 단순합니다.

중간 연산은 “통로를 잇는” 연산이고,
최종 연산은 “결과를 뽑아내는” 연산입니다.

중간 연산은 실행되지 않는다

중간 연산은 값을 실제로 계산하지 않습니다.
그저 “이런 작업을 하겠다“는 계획만 세워 둡니다.

대표적인 중간 연산은 이렇습니다.

  • filter : 걸러 내기
  • map : 바꾸기
  • take : 앞에서 몇 개만 가져오기

이 함수들은 아무리 이어 써도
실제 계산은 일어나지 않습니다.

다음 코드를 봅시다.

val seq = listOf(1, 2, 3)
    .asSequence()
    .filter {
        println("filter 실행: $it")
        it > 1
    }

println("아직 아무것도 출력되지 않음")

이 코드를 실행하면
filter 실행은 한 번도 출력되지 않습니다.

아직 아무것도 출력되지 않음

filter를 분명히 적었는데
안쪽 코드가 실행되지 않은 것입니다.

중간 연산은 계획만 세울 뿐,
아직 일하지 않기 때문입니다.

최종 연산이 방아쇠를 당긴다

그럼 언제 실제로 계산이 일어날까요?

바로 최종 연산이 호출될 때입니다.

최종 연산은 “결과를 내놔“라고
요구하는 연산입니다.

대표적인 최종 연산은 이렇습니다.

  • toList : 리스트로 모으기
  • sum : 전부 더하기
  • count : 개수 세기
  • first : 첫 번째 값 가져오기
  • forEach : 하나씩 꺼내 무언가 하기

앞의 코드에 최종 연산을 붙여 봅시다.

val result = listOf(1, 2, 3)
    .asSequence()
    .filter {
        println("filter 실행: $it")
        it > 1
    }
    .toList()          // 최종 연산 추가

println("결과: $result")

이제 실행하면 이렇게 출력됩니다.

filter 실행: 1
filter 실행: 2
filter 실행: 3
결과: [2, 3]

toList()를 붙이는 순간
비로소 filter가 실제로 동작했습니다.

정리하면 이렇습니다.

중간 연산은 계획을 세우고,
최종 연산이 그 계획을 실행시킨다.

이 “실행시킨다“는 표현을
방아쇠를 당긴다고도 말합니다.


16.5 Lazy Evaluation

값 하나가 통로를 끝까지 지나간다

이제 이 장의 핵심에 도착했습니다.

즉시 연산과 지연 연산의 진짜 차이는
“값이 흐르는 순서“에 있습니다.

같은 코드를 두 방식으로 비교해 봅시다.

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

numbers
    .filter { it % 2 == 0 }
    .map { it * 10 }

먼저 즉시 연산(컬렉션)의 흐름입니다.
단계별로 전체를 처리합니다.

[즉시 연산: 단계별로 전부 처리]

1단계 filter:  1→X  2→O  3→X  4→O   → [2, 4]
2단계 map:     2→20  4→40           → [20, 40]

(filter를 네 번 다 한 뒤, map을 시작한다)

이번엔 같은 코드를 Sequence로 바꾼 흐름입니다.
값 하나가 통로 끝까지 갔다가 다음 값이 출발합니다.

[지연 연산: 값 하나씩 끝까지]

값 1 → filter(홀수라 탈락)              → 버림
값 2 → filter(통과) → map(20)          → 결과에 담음
값 3 → filter(홀수라 탈락)              → 버림
값 4 → filter(통과) → map(40)          → 결과에 담음

(값 하나가 filter와 map을 모두 거친 뒤,
 다음 값이 출발한다)

차이가 보이시나요?

즉시 연산은 “가로줄“로 처리합니다.
filter를 전부 끝내고 map을 전부 합니다.

지연 연산은 “세로줄“로 처리합니다.
값 하나가 모든 단계를 통과한 뒤
다음 값이 출발합니다.

중간 리스트가 생기지 않는다

이 세로줄 방식의 장점은 분명합니다.

값이 하나씩 흘러가므로
중간에 임시 리스트를 만들 필요가 없습니다.

16.1에서 즉시 연산은
[2, 4]라는 임시 리스트를 만들었습니다.

하지만 지연 연산에서는
그런 임시 리스트가 아예 생기지 않습니다.

값 하나가 통로를 지나가고 사라질 뿐,
중간에 값을 모아 두지 않는다.

데이터가 많을수록
이 차이는 크게 벌어집니다.

필요한 만큼만 계산한다

지연 연산의 또 다른 장점은
“필요한 만큼만” 계산한다는 점입니다.

앞에서 필요한 개수를 정해 두는 경우를 봅시다.

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8)

val result = numbers
    .asSequence()
    .map {
        println("map 실행: $it")
        it * 2
    }
    .first { it > 4 }      // 조건에 맞는 첫 값 하나만

println("결과: $result")

실행 결과는 이렇습니다.

map 실행: 1
map 실행: 2
map 실행: 3
결과: 6

원본에는 값이 여덟 개나 있는데
map은 딱 세 번만 실행됐습니다.

first가 조건에 맞는 값을
찾자마자 통로를 멈췄기 때문입니다.

  • 값 1 → 2 (조건 미달)
  • 값 2 → 4 (조건 미달)
  • 값 3 → 6 (조건 통과, 여기서 끝)

나머지 4, 5, 6, 7, 8은
map을 거치지도 않았습니다.

만약 즉시 연산이었다면
여덟 개 전부에 map을 실행한 뒤
그중에서 첫 값을 찾았을 것입니다.

지연 연산은 답을 찾으면 곧바로 멈춘다.
그래서 헛일을 하지 않는다.

이것이 16.3에서 본
“무한한 흐름“이 멈추지 않았던 이유이기도 합니다.


16.6 Collection과 Sequence 선택하기

언제 무엇을 쓸까

지금까지 보면
Sequence가 무조건 좋아 보일 수 있습니다.

하지만 그렇지 않습니다.
상황에 맞게 골라 써야 합니다.

기준을 간단히 정리하면 이렇습니다.

상황추천
데이터 개수가 적다 (수백~수천 개)Collection
데이터 개수가 아주 많다 (수십만 개 이상)Sequence
연산을 여러 단계 이어 붙인다Sequence
연산이 한두 단계로 짧다Collection
조건에 맞는 일부만 필요하다 (first 등)Sequence
무한한 흐름을 다룬다Sequence

기본은 Collection

여기서 오해하지 말아야 할 점이 있습니다.

평소에는 그냥 컬렉션 함수(13장)를 쓰면 됩니다.
Sequence는 “특별한 경우“에 꺼내 드는 도구입니다.

대부분의 백엔드 코드는
데이터베이스에서 가져온
수십, 수백 개짜리 목록을 다룹니다.

이 정도 크기에서는
즉시 연산이든 지연 연산이든
속도 차이를 느끼기 어렵습니다.

오히려 컬렉션 함수가
더 읽기 쉽고 익숙합니다.

그래서 실무의 기본 원칙은 이렇습니다.

일단 컬렉션 함수로 쓴다.
데이터가 아주 많거나 단계가 길 때만
Sequence를 고려한다.

2장에서 “일단 val로 시작하라“고 했던 것처럼,
여기서는 “일단 컬렉션으로 시작하라“고
기억해 두면 됩니다.


16.7 Sequence가 항상 빠른 것은 아니다

통로를 여는 비용

Sequence가 지연 연산이라
언제나 빠를 것 같지만 사실은 다릅니다.

Sequence를 만들고
값을 하나씩 흘려보내는 데에도
나름의 비용이 듭니다.

값 하나가 지나갈 때마다
“다음 값 줘, 다음 값 줘” 하고
통로에 계속 요청을 보내야 하기 때문입니다.

데이터가 적으면
이 요청 비용이 오히려 더 클 수 있습니다.

작은 데이터에서는 손해일 수 있다

예를 들어 값이 열 개뿐인 리스트를
filter 한 번만 한다고 해 봅시다.

val small = listOf(1, 2, 3, 4, 5)

// 이 경우엔 그냥 컬렉션이 낫다
val a = small.filter { it > 2 }

// 굳이 Sequence로 감쌀 필요가 없다
val b = small.asSequence().filter { it > 2 }.toList()

두 코드는 결과가 같습니다.
하지만 아래쪽은 통로를 만들었다가
다시 리스트로 모으는 수고가 더 듭니다.

작은 데이터에서는
이 수고가 이득보다 클 수 있습니다.

정리하면 이렇습니다.

Sequence는 “많은 데이터“와
“긴 연산 단계“에서 빛난다.
작고 짧으면 오히려 손해다.

직접 재 보는 습관

무엇이 더 빠른지는
상황마다 다를 수 있습니다.

그래서 성능이 정말 중요한 자리라면
“느낌“으로 판단하지 말고
직접 시간을 재 보는 것이 가장 정확합니다.

코틀린에는 실행 시간을 재 주는
measureTimeMillis라는 함수가 있습니다.

import kotlin.system.measureTimeMillis

val time = measureTimeMillis {
    // 여기에 시간을 재고 싶은 코드
    (1..1_000_000).filter { it % 2 == 0 }.count()
}

println("걸린 시간: ${time}ms")

이렇게 두 방식을 각각 재서 비교하면
어느 쪽이 빠른지 눈으로 확인할 수 있습니다.

성능 판단은 추측이 아니라
측정으로 하는 것이 안전합니다.


16.8 대량 데이터 처리와 Sequence의 한계

메모리에 다 올릴 때의 문제

Sequence는 중간 리스트를 만들지 않아
메모리를 아낀다고 배웠습니다.

하지만 여기에는 조건이 하나 있습니다.
원본 데이터가 이미 메모리에 다 올라와 있다면,
그 원본만큼의 메모리는 어차피 써야 합니다.

예를 들어 봅시다.

// 이미 100만 개가 메모리에 올라와 있음
val huge = (1..1_000_000).toList()

val result = huge
    .asSequence()
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .toList()

이 경우 Sequence는
중간 리스트는 아껴 줍니다.

그러나 huge 자체가
이미 100만 개짜리 리스트입니다.

즉, 애초에 100만 개를
전부 메모리에 담아 둔 상태입니다.

Sequence는 이 원본의 크기까지
줄여 주지는 못합니다.

진짜 대량 데이터는 흘려보내야 한다

그렇다면 데이터가
메모리에 다 담기지 못할 만큼
거대하다면 어떻게 할까요?

예를 들어
수 기가바이트짜리 로그 파일을
한 줄씩 처리해야 한다고 해 봅시다.

이런 경우엔 파일 전체를
리스트로 읽어 오면 안 됩니다.
메모리가 터지기 때문입니다.

대신 한 줄씩 읽어서
흘려보내며 처리해야 합니다.

이럴 때 Sequence가 제 역할을 합니다.

import java.io.File

File("big-log.txt")
    .useLines { lines ->        // 한 줄씩 흐르는 Sequence
        lines
            .filter { it.contains("ERROR") }
            .forEach { println(it) }
    }

useLines는 파일을
한 번에 다 읽지 않습니다.

한 줄씩 읽어서
Sequence로 흘려보냅니다.

그래서 파일이 아무리 커도
메모리에는 한 줄씩만 올라옵니다.

파일 전체를 양동이에 퍼 담는 대신,
호스로 한 줄씩 흘려보내는 것입니다.

이것이 대량 데이터 처리에서
Sequence가 꼭 필요한 이유입니다.

한계도 기억하기

마지막으로 한계를 짚어 둡시다.

Sequence는 만능이 아닙니다.

  • 값을 한 방향으로만 흘려보낸다
    (뒤로 돌아가 다시 볼 수 없다)
  • 한 번 흘려보낸 Sequence는
    상황에 따라 다시 쓰기 어렵다
  • 정렬(sorted)처럼
    전체를 다 봐야 하는 연산은
    지연 연산의 이점이 사라진다

특히 sorted가 중요합니다.
정렬은 값을 전부 모아 봐야
순서를 정할 수 있습니다.

그래서 이런 연산이 끼면
“게으르게 하나씩” 흘려보내는
Sequence의 장점이 그 지점에서 멈춥니다.

이런 한계까지 알고 나면
Sequence를 언제 써야 할지
더 분명하게 판단할 수 있습니다.


16장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • 컬렉션 함수는 즉시 연산이라
    단계마다 중간 리스트를 만든다는 점
  • Sequence는 지연 연산이라
    값을 하나씩 통로로 흘려보낸다는 점
  • 중간 연산은 계획만 세우고,
    최종 연산이 방아쇠를 당긴다는 점
  • 값 하나가 모든 단계를 통과한 뒤
    다음 값이 출발하는 “세로줄” 흐름
  • 필요한 만큼만 계산하고,
    답을 찾으면 곧바로 멈춘다는 점
  • Sequence가 항상 빠른 것은 아니며,
    작은 데이터에서는 오히려 손해라는 점
  • 진짜 대량 데이터는 리스트가 아니라
    Sequence로 흘려보내야 한다는 점

가장 중요한 원칙은 하나입니다.

평소에는 컬렉션 함수를 쓰고,
데이터가 많거나 단계가 길 때만
Sequence를 꺼내 든다.

즉시 연산과 지연 연산,
이 두 방식의 차이를 이해했다면
이 장의 목표는 충분히 이룬 것입니다.

다음 장에서는 또 다른 주제로
코틀린을 더 깊이 파고들어 봅시다.