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

12장. Kotlin Collection

프로그램을 만들다 보면
값 하나가 아니라 “여러 값“을 한꺼번에 다뤄야 할 때가 많습니다.

회원 목록, 장바구니에 담긴 상품들,
설정값들의 모음처럼요.

이렇게 여러 값을 하나로 묶어서 담는 그릇을
컬렉션(collection)이라고 부릅니다.

이 장에서는 코틀린의 대표적인 컬렉션인
List, Set, Map을 하나씩 살펴봅니다.

그리고 코틀린이 특히 강조하는
“읽기 전용“이라는 생각도 여기서 만나게 됩니다.

컬렉션을 만드는 방법과
그 안에서 값을 꺼내 보는 방법까지 익히고 나면,
백엔드 코드의 절반은 눈에 들어오기 시작합니다.


12.1 List, Set, Map

값을 여러 개 담는 그릇에도 종류가 있습니다.
담는 방식과 목적이 조금씩 다릅니다.

코틀린에서 가장 많이 쓰는 컬렉션은 세 가지입니다.
List, Set, Map입니다.

먼저 셋을 한눈에 비교해 봅시다.

종류특징언제 쓰나
List순서가 있고 중복을 허용순서대로 줄 세운 목록
Set순서가 없고 중복을 허용하지 않음겹치지 않는 값들의 모음
Map키(key)와 값(value)의 짝이름표를 붙여 저장

이제 하나씩 자세히 보겠습니다.

List: 순서가 있는 목록

리스트(List)는 값을 “줄 세워” 담는 그릇입니다.

값을 넣은 순서가 그대로 유지되고,
같은 값을 여러 번 넣어도 됩니다.

val fruits = listOf("사과", "바나나", "사과")

println(fruits)
[사과, 바나나, 사과]

“사과“가 두 번 들어가도 문제없습니다.
순서도 넣은 그대로 유지됩니다.

각 값에는 자리 번호가 있습니다.
이 번호를 인덱스(index)라고 부릅니다.

인덱스는 “몇 번째 자리인가“를 뜻하는 번호입니다.
중요한 점은, 0부터 시작한다는 것입니다.

val fruits = listOf("사과", "바나나", "포도")

println(fruits[0])   // 첫 번째 값
println(fruits[1])   // 두 번째 값
사과
바나나

첫 번째 값의 인덱스가 1이 아니라 0이라는 점을
꼭 기억해 두세요.

일상에서는 “첫째, 둘째“라고 세지만,
프로그래밍에서는 “0번째, 1번째“라고 셉니다.

리스트는 순서가 중요한 목록에 잘 맞습니다.
예를 들어 “먼저 가입한 순서대로 정렬된 회원 목록” 같은 것입니다.

Set: 중복 없는 모음

셋(Set)은 “겹치지 않는 값들“을 담는 그릇입니다.

같은 값을 여러 번 넣어도
결국 하나만 남습니다.

val numbers = setOf(1, 2, 2, 3, 3, 3)

println(numbers)
[1, 2, 3]

2와 3을 여러 번 넣었지만
중복이 자동으로 제거되었습니다.

Set은 순서를 보장하지 않습니다.
“어떤 값이 들어 있는가“에만 관심이 있고,
“몇 번째에 있는가“에는 관심이 없습니다.

그래서 Set에는 인덱스로 접근하지 않습니다.
대신 “이 값이 들어 있는지“를 물어봅니다.

val numbers = setOf(1, 2, 3)

println(2 in numbers)   // 2가 들어 있는가?
println(5 in numbers)   // 5가 들어 있는가?
true
false

in은 “안에 있는가“를 묻는 표현입니다.
사람 말과 순서가 비슷해서 읽기도 편합니다.

Set은 중복을 허용하지 않아야 할 때 잘 맞습니다.
예를 들어 “이 글에 좋아요를 누른 사용자 목록“처럼,
한 사람이 여러 번 세어지면 안 되는 경우입니다.

Map: 키와 값의 짝

맵(Map)은 조금 다른 방식으로 값을 담습니다.

리스트가 번호(인덱스)로 값을 찾았다면,
맵은 “이름표“로 값을 찾습니다.

이 이름표를 키(key),
이름표에 붙은 실제 값을 값(value)이라고 합니다.

Map은 “키 → 값“의 짝을 모아 둔 그릇입니다.
사전(dictionary)을 떠올리면 쉽습니다.
단어(키)를 찾으면 뜻(값)이 나오니까요.

val scores = mapOf(
    "홍길동" to 90,
    "이순신" to 85
)

println(scores["홍길동"])
90

여기서 to는 키와 값을 이어 붙이는 표현입니다.
"홍길동" to 90은 “홍길동이라는 키에 90이라는 값“이라는 뜻입니다.

값을 꺼낼 때는 대괄호 안에 키를 넣습니다.
scores["홍길동"]처럼요.

Map은 이름표를 붙여 저장할 때 잘 맞습니다.
예를 들어 “회원 아이디로 회원 정보를 찾는” 경우입니다.

세 가지를 언제 고를까

정리하면 이렇게 고르면 됩니다.

  • 순서가 중요하고 중복도 괜찮다면: List
  • 중복 없이 모아 두고 싶다면: Set
  • 이름표로 값을 찾고 싶다면: Map

처음에는 대부분 List만 써도 충분합니다.
그러다 “중복을 없애고 싶다”, “이름으로 찾고 싶다“는
필요가 생길 때 Set과 Map을 떠올리면 됩니다.


12.2 읽기 전용과 Mutable Collection

여기서 코틀린의 중요한 생각이 하나 나옵니다.
2장에서 만난 “불변성“과 이어지는 이야기입니다.

코틀린의 컬렉션은 두 부류로 나뉩니다.
“읽기 전용“과 “바꿀 수 있는” 컬렉션입니다.

읽기 전용 컬렉션

앞 절에서 만든 listOf, setOf, mapOf
모두 읽기 전용(read-only) 컬렉션입니다.

읽기 전용이란,
“값을 꺼내 볼 수는 있지만 넣거나 뺄 수는 없다“는 뜻입니다.

val fruits = listOf("사과", "바나나")

println(fruits[0])       // 꺼내 보기: 가능
fruits.add("포도")        // 오류: 값을 추가할 수 없음

add처럼 내용을 바꾸는 기능이
아예 제공되지 않습니다.

그래서 위 코드는 실행조차 되지 않습니다.
컴파일 단계에서 오류로 걸러집니다.

Mutable Collection

값을 넣거나 빼야 할 때도 분명히 있습니다.
그럴 때는 뮤터블(mutable) 컬렉션을 씁니다.

뮤터블은 “바꿀 수 있는“이라는 뜻입니다.
이름 앞에 Mutable이 붙습니다.

  • List → MutableList
  • Set → MutableSet
  • Map → MutableMap

MutableList는 값을 추가하거나 삭제할 수 있습니다.

val fruits = mutableListOf("사과", "바나나")

fruits.add("포도")      // 값 추가
fruits.remove("사과")   // 값 삭제

println(fruits)
[바나나, 포도]

MutableMap도 마찬가지로
키와 값을 넣거나 뺄 수 있습니다.

val scores = mutableMapOf("홍길동" to 90)

scores["이순신"] = 85    // 새 짝 추가
scores.remove("홍길동")  // 짝 삭제

println(scores)
{이순신=85}

정리하면 이렇습니다.

읽기 전용바꿀 수 있음
listOfmutableListOf
setOfmutableSetOf
mapOfmutableMapOf

읽기 전용과 불변성의 차이

여기서 한 가지 헷갈리기 쉬운 점을 짚겠습니다.

“읽기 전용(read-only)“과
“불변(immutable)“은 비슷해 보이지만 다릅니다.

읽기 전용은
“이 통로로는 내용을 바꿀 수 없다“는 뜻입니다.

값 자체가 절대 안 바뀐다는 보장은 아닙니다.

말이 조금 어렵지요.
코드로 보면 이해가 쉽습니다.

val mutable = mutableListOf("사과")   // 바꿀 수 있는 리스트
val readOnly: List<String> = mutable  // 읽기 전용으로 바라보기

mutable.add("바나나")   // 원본을 바꾸면

println(readOnly)       // 읽기 전용 쪽에도 반영됨
[사과, 바나나]

readOnly는 값을 추가할 수 없습니다.
그래서 “읽기 전용“입니다.

하지만 같은 리스트를 가리키는
mutable 쪽에서 값을 바꾸면,
readOnly로 봐도 그 변화가 보입니다.

비유하자면 이렇습니다.

읽기 전용은 “유리창 너머로 보기“입니다.
내가 유리창을 통해 물건을 바꿀 수는 없지만,
안에 있는 사람이 물건을 옮기면
유리창 너머로 그 변화가 보입니다.

정리하면 이렇게 기억해 두면 됩니다.

  • 읽기 전용: 이 변수로는 못 바꾼다 (완전한 불변은 아님)
  • 뮤터블: 이 변수로 바꿀 수 있다

그래도 실무에서는
“바꿀 필요가 없으면 읽기 전용을 쓴다“가 좋은 습관입니다.

2장에서 “일단 val“이라고 배운 것과
같은 맥락입니다.

되도록 읽기 전용으로 시작하고,
꼭 바꿔야 할 때만 Mutable을 쓴다.

Java의 컬렉션과 비교하기

자바를 조금 아는 분이라면
이 구분이 낯설 수 있습니다.

자바의 List, Set, Map
기본적으로 값을 넣고 뺄 수 있습니다.

// 자바
List<String> fruits = new ArrayList<>();
fruits.add("사과");   // 언제든 추가 가능

읽기 전용으로 만들려면
Collections.unmodifiableList 같은
별도의 처리를 해 줘야 했습니다.

코틀린은 이 구분을 타입 자체로 나눴습니다.
List는 읽기 전용, MutableList는 바꿀 수 있는 것으로요.

덕분에 “실수로 값을 바꾸는” 일을
문법 차원에서 미리 막아 줍니다.


12.3 Collection 생성하기

이제 컬렉션을 직접 만들어 보겠습니다.
만드는 함수들은 이름이 규칙적이라 외우기 쉽습니다.

읽기 전용 컬렉션 만들기

읽기 전용 컬렉션은 ~Of 함수로 만듭니다.

val fruits = listOf("사과", "바나나", "포도")
val numbers = setOf(1, 2, 3)
val scores = mapOf("홍길동" to 90, "이순신" to 85)
  • listOf(...) : 읽기 전용 List
  • setOf(...) : 읽기 전용 Set
  • mapOf(...) : 읽기 전용 Map

Map을 만들 때만 to가 등장합니다.
키와 값을 짝지어야 하기 때문입니다.

val scores = mapOf(
    "홍길동" to 90,
    "이순신" to 85,
    "강감찬" to 95
)

읽기 편하도록
한 줄에 하나씩 적는 경우가 많습니다.

빈 컬렉션 만들기

값 없이 빈 컬렉션을 만들 수도 있습니다.
empty~Of 함수를 쓰거나, 타입을 명시합니다.

val emptyList = emptyList<String>()
val emptySet = emptySet<Int>()
val emptyMap = emptyMap<String, Int>()

여기서 꺾쇠괄호 안의 타입(<String>)은
“이 컬렉션이 어떤 값을 담을지” 알려 주는 표시입니다.

값이 비어 있으면 코틀린이 타입을 추론할 수 없어서,
이렇게 직접 적어 줘야 합니다.

꺾쇠괄호 안의 타입을 제네릭(generic)이라고 부릅니다.
“이 그릇에는 String만 담는다“고 약속하는 표시입니다.
제네릭은 나중에 더 자세히 다룹니다.

바꿀 수 있는 컬렉션 만들기

바꿀 수 있는 컬렉션은 mutable~Of 함수로 만듭니다.

val fruits = mutableListOf("사과", "바나나")
val numbers = mutableSetOf(1, 2, 3)
val scores = mutableMapOf("홍길동" to 90)

값을 넣지 않고
빈 상태로 시작할 수도 있습니다.

val fruits = mutableListOf<String>()

fruits.add("사과")
fruits.add("바나나")

println(fruits)
[사과, 바나나]

처음에는 비어 있다가
필요할 때 하나씩 채워 넣는 방식입니다.

val인데 값이 추가된다?

여기서 한 가지 궁금증이 생길 수 있습니다.

val fruits = mutableListOf("사과")
fruits.add("바나나")   // val인데 추가가 되네?

val은 값을 바꿀 수 없다고 배웠는데,
어떻게 리스트에 값이 추가될까요?

핵심은 “무엇이 고정되는가“입니다.

val이 고정하는 것은
“이 변수가 어떤 리스트를 가리키는가“입니다.

리스트 “안의 내용“까지
고정하는 것은 아닙니다.

val fruits = mutableListOf("사과")

fruits.add("바나나")           // 가능: 리스트 내용 변경
fruits = mutableListOf("포도") // 오류: 다른 리스트로 교체 불가

비유하자면 이렇습니다.

val은 “이 바구니를 계속 쓴다“는 약속입니다.
바구니 자체를 다른 바구니로 바꿀 수는 없지만,
바구니 안에 과일을 더 넣는 것은 됩니다.

내용까지 못 바꾸게 하고 싶다면
mutableListOf 대신 listOf를 쓰면 됩니다.

생성 함수 정리

지금까지 나온 생성 함수를
표로 정리해 봅시다.

만들 것읽기 전용바꿀 수 있음
ListlistOfmutableListOf
SetsetOfmutableSetOf
MapmapOfmutableMapOf
빈 것emptyListmutableListOf<타입>()

이름 규칙만 기억하면
필요한 함수를 자연스럽게 떠올릴 수 있습니다.


12.4 Collection 조회하기

컬렉션을 만들었다면
이제 그 안의 값을 꺼내 봐야 합니다.

값을 꺼내는 것을 조회(lookup)라고 부릅니다.

특히 List에서 값을 찾는
대표적인 함수들을 살펴보겠습니다.

first: 첫 번째 값 꺼내기

first는 리스트의 첫 번째 값을 돌려줍니다.

val fruits = listOf("사과", "바나나", "포도")

println(fruits.first())
사과

fruits[0]과 같은 결과이지만,
first()가 더 읽기 쉽습니다.

반대로 마지막 값은 last()로 꺼냅니다.

val fruits = listOf("사과", "바나나", "포도")

println(fruits.last())
포도

그런데 여기에는 함정이 하나 있습니다.
리스트가 비어 있으면 어떻게 될까요?

val empty = listOf<String>()

println(empty.first())   // 오류 발생!

값이 하나도 없는데
“첫 번째 값“을 달라고 했으니,
first()는 오류를 냅니다.

이런 상황을 대비한 함수가 따로 있습니다.

firstOrNull: 없으면 null

firstOrNull은 첫 번째 값을 돌려주되,
값이 없으면 오류 대신 null을 돌려줍니다.

이름 그대로 “first, 없으면(Or) null“입니다.

val empty = listOf<String>()

println(empty.firstOrNull())   // 오류 없이 null
null

값이 있을 때는
평범하게 첫 번째 값을 돌려줍니다.

val fruits = listOf("사과", "바나나")

println(fruits.firstOrNull())
사과

여기서 2장에서 만난 ?를 떠올려 봅시다.
null이 나올 수 있다는 것은,
결과 타입이 String?이라는 뜻입니다.

이름 끝에 OrNull이 붙은 함수는
“값이 없으면 오류 대신 null을 준다“는 신호입니다.

리스트가 비어 있을 가능성이 있다면
first()보다 firstOrNull()이 더 안전합니다.

마찬가지로 lastOrNull()도 있습니다.
마지막 값을 안전하게 꺼낼 때 씁니다.

find: 조건에 맞는 값 찾기

지금까지는 “첫 번째, 마지막“처럼
자리로 값을 꺼냈습니다.

find는 조건에 맞는 값을 찾아 줍니다.
“이런 조건을 만족하는 첫 번째 값“을 돌려줍니다.

val numbers = listOf(1, 3, 5, 8, 10)

val firstEven = numbers.find { it % 2 == 0 }

println(firstEven)
8

여기서 중괄호 안의 코드가
“찾을 조건“입니다.

it은 리스트의 각 값을 가리키는 이름입니다.
값들을 하나씩 대입해 보면서 조건을 확인합니다.

it은 “지금 검사 중인 값“을 뜻하는 이름입니다.
it % 2 == 0은 “이 값이 짝수인가“를 묻는 조건입니다.

즉 위 코드는
“짝수인 첫 번째 값을 찾아라“라는 뜻입니다.
1, 3, 5는 홀수라 지나치고, 8에서 멈춥니다.

이렇게 중괄호로 조건을 넘기는 방식은
람다(lambda)라고 부릅니다.
람다는 뒤에서 더 자세히 다룹니다.

find도 조건에 맞는 값이 하나도 없으면
null을 돌려줍니다.

val numbers = listOf(1, 3, 5)

val firstEven = numbers.find { it % 2 == 0 }

println(firstEven)   // 짝수가 없으므로 null
null

그래서 find의 결과도
“없을 수 있는 값“으로 다뤄야 합니다.

getOrNull: 안전하게 인덱스로 꺼내기

앞에서 대괄호로 값을 꺼냈습니다.
fruits[0]처럼요.

그런데 없는 자리를 꺼내려 하면
오류가 납니다.

val fruits = listOf("사과", "바나나")

println(fruits[5])   // 오류: 5번 자리는 없음

리스트에는 0번과 1번 자리밖에 없는데
5번 자리를 달라고 했기 때문입니다.

이럴 때 getOrNull을 쓰면
오류 대신 null을 돌려줍니다.

val fruits = listOf("사과", "바나나")

println(fruits.getOrNull(0))   // 있는 자리
println(fruits.getOrNull(5))   // 없는 자리
사과
null

이름 그대로
“get, 없으면(Or) null“입니다.

인덱스가 범위를 벗어날 수 있는 상황이라면
[ ]보다 getOrNull()이 더 안전합니다.

안전한 조회를 기본으로

지금까지 나온 조회 함수를
정리해 봅시다.

함수하는 일값이 없으면
first()첫 번째 값오류 발생
firstOrNull()첫 번째 값null
find { 조건 }조건에 맞는 첫 값null
getOrNull(인덱스)그 자리의 값null

규칙이 하나 보입니다.

이름 끝에 OrNull이 붙거나
find처럼 null을 돌려주는 함수는
“값이 없을 때 오류 대신 null“을 준다.

백엔드에서는 “값이 없는 경우“가 정말 흔합니다.
검색 결과가 비어 있거나,
찾는 회원이 존재하지 않는 경우처럼요.

그래서 오류로 프로그램이 멈추기보다,
null을 받아 차분히 처리하는 편이
훨씬 안전할 때가 많습니다.

null을 안전하게 다루는 방법은
3부에서 집중적으로 배웁니다.

한 가지 덧붙이면,
컬렉션의 값을 걸러 내고 변형하는
더 강력한 가공 함수들이 있습니다.

filter, map, sortedBy 같은 것들입니다.
이 함수들은 13장에서 본격적으로 다룹니다.


12장을 마치며

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

  • 여러 값을 담는 그릇인 컬렉션에는
    List, Set, Map이 있다는 점
  • List는 순서와 중복을 허용하고,
    Set은 중복을 없애며,
    Map은 키와 값의 짝으로 저장한다는 점
  • 읽기 전용 컬렉션과
    바꿀 수 있는(Mutable) 컬렉션의 차이
  • listOf, setOf, mapOf로 만들고,
    앞에 mutable을 붙이면 바꿀 수 있다는 규칙
  • first, firstOrNull, find, getOrNull
    값을 안전하게 꺼내는 방법

특히 두 가지를 기억해 두면 좋습니다.
“되도록 읽기 전용”, 그리고 “값이 없을 땐 OrNull“입니다.

다음 장에서는
컬렉션을 자유자재로 가공하는
함수형 스타일을 다뤄 보겠습니다.