컬렉션 타입

컬렉션 타입 (Collections Types)

이 페이지는 Scala 3 컬렉션과 그에 딸린 메서드들을 보여줘요. Scala에는 정말 다양한 컬렉션 타입이 있지만, 처음부터 전부 알 필요는 없어요. 몇 가지만으로도 많은 걸 해낼 수 있고, 나중에 필요할 때 나머지를 쓰면 돼요. 각 컬렉션 타입도 마찬가지로 수십 개의 메서드를 갖고 있지만, 소수의 핵심 메서드만 이해해도 아주 많은 것을 해낼 수 있어요.

그래서 이 섹션은 시작할 때 필요한 가장 흔한 타입과 메서드들을 소개하고 보여주는 데 집중할게요. 더 유연함이 필요해지면, 이 섹션 끝에 있는 페이지들을 참고하면 됩니다.

출처: Scala 3 Book — Collections Types

컬렉션의 세 가지 주요 범주 (Three main categories of collections)

Scala 컬렉션을 높은 수준에서 보면 고를 수 있는 세 가지 주요 범주가 있어요.

  • 시퀀스(Sequences) 는 요소를 순차적으로 담는 컬렉션이에요. (배열처럼) 인덱스 기반(indexed) 이거나 (연결 리스트처럼) 선형(linear) 일 수 있어요.
  • 맵(Maps) 은 키/값 쌍의 컬렉션을 담아요. Java의 Map, Python의 dictionary, Ruby의 Hash 같은 거죠.
  • 셋(Sets) 은 순서가 없는 고유 요소의 컬렉션이에요.

이들 모두는 기본 타입이며, 동시성, 캐싱, 스트리밍 같은 특정 목적을 위한 하위 타입을 가져요. 이 세 가지 주요 범주 외에도 범위(range), 스택, 큐 같은 유용한 컬렉션 타입이 더 있어요.

컬렉션 계층 구조 (Collections hierarchy)

간단한 개요로, 다음 세 그림이 Scala 컬렉션의 클래스/트레이트 계층 구조를 보여줘요.

첫 번째 그림은 scala.collection 패키지의 컬렉션 타입을 보여줘요. 이들은 모두 최상위 수준의 추상 클래스나 트레이트로, 일반적으로 불변(immutable)가변(mutable) 구현을 모두 갖고 있어요.

두 번째 그림은 scala.collection.immutable 패키지의 모든 컬렉션을, 세 번째 그림은 scala.collection.mutable 패키지의 모든 컬렉션을 보여줘요.

모든 컬렉션 타입의 자세한 모습을 봤으니, 이제 평소에 자주 쓰게 될 몇 가지 흔한 타입을 소개할게요.

흔한 컬렉션 (Common collections)

평소에 자주 사용하게 될 주요 컬렉션은 다음과 같아요.

컬렉션 타입 불변 가변 설명
List 선형(연결 리스트), 불변 시퀀스
Vector 인덱스 기반, 불변 시퀀스
LazyList 지연(lazy) 불변 연결 리스트. 요소는 필요할 때 계산돼요. 크거나 무한한 시퀀스에 좋아요.
ArrayBuffer 가변, 인덱스 기반 시퀀스의 기본 선택
ListBuffer 가변 List가 필요할 때 사용. 보통 List로 변환돼요.
Map 키/값 쌍으로 이뤄진 iterable 컬렉션
Set 중복 요소가 없는 iterable 컬렉션

보이듯이 Map과 Set은 불변과 가변 버전이 모두 있어요. 각 타입의 기본은 다음 섹션에서 다룰게요.

Scala에서 버퍼(buffer) — ArrayBuffer나 ListBuffer 같은 — 는 늘고 줄 수 있는 시퀀스예요.

불변 컬렉션에 대한 참고 (A note about immutable collections)

다음에 이어지는 섹션에서 불변(immutable) 이라는 단어가 쓰일 때마다, 그 타입이 함수형 프로그래밍(Functional Programming, FP) 스타일로 사용되도록 만들어졌다고 안전하게 가정해도 돼요. 이런 타입에서는 컬렉션을 수정하지 않아요. 대신 컬렉션에 함수형 메서드를 적용해서 새 결과를 만들어 내죠.

시퀀스 고르기 (Choosing a sequence)

시퀀스 — 요소를 순차적으로 담은 컬렉션 — 를 고를 때 두 가지 주요 결정을 내려야 해요.

  • 시퀀스가 (배열처럼) 인덱스 기반이어서 어떤 요소든 빠르게 접근할 수 있어야 할까, 아니면 선형 연결 리스트로 구현되어야 할까?
  • 가변 컬렉션을 원할까, 불변 컬렉션을 원할까?

가변/불변과 인덱스/선형 조합에 권장되는, 범용적인 “기본 선택” 시퀀스 컬렉션은 다음과 같아요.

타입/범주 불변 가변
인덱스 기반 Vector ArrayBuffer
선형 (연결 리스트) List ListBuffer

예를 들어 불변, 인덱스 기반 컬렉션이 필요하다면 일반적으로 Vector를 써야 해요. 반대로 가변, 인덱스 기반 컬렉션이 필요하면 ArrayBuffer를 쓰면 돼요.

List와 Vector는 함수형 스타일로 코드를 쓸 때 자주 사용돼요. ArrayBuffer는 명령형(imperative) 스타일로 코드를 쓸 때 흔히 쓰이죠. ListBuffer는 스타일을 섞을 때 — 예를 들어 List를 만들 때 — 사용돼요.

다음 여러 섹션에서는 List, Vector, ArrayBuffer 타입을 간단히 보여줄게요.

List

List 타입은 선형, 불변 시퀀스예요. 즉 수정할 수 없는 연결 리스트라는 뜻이에요. List 요소를 추가하거나 제거하고 싶을 때마다 기존 List에서 새 List를 만들면 됩니다.

List 만들기 (Creating Lists)

초기 List는 이렇게 만들어요.

val ints = List(1, 2, 3)
val names = List("Joel", "Chris", "Ed")

// another way to construct a List
val namesAgain = "Joel" :: "Chris" :: "Ed" :: Nil

원한다면 List의 타입을 선언할 수도 있는데, 보통은 필요 없어요.

val ints: List[Int] = List(1, 2, 3)
val names: List[String] = List("Joel", "Chris", "Ed")

한 가지 예외는 컬렉션에 타입이 섞여 있을 때예요. 그 경우 타입을 명시적으로 지정하고 싶을 수 있어요.

// Scala 2
val things: List[Any] = List(1, "two", 3.0)
// Scala 3
val things: List[String | Int | Double] = List(1, "two", 3.0) // with union types
val thingsAny: List[Any] = List(1, "two", 3.0)                // with any

List에 요소 추가하기 (Adding elements to a List)

List는 불변이므로 새 요소를 추가할 수 없어요. 대신 기존 List에 요소를 앞(prepend)이나 뒤(append)에 붙여 새 리스트를 만들어요. 예를 들어 이 List가 있다고 할게요.

val a = List(1, 2, 3)

List에서 한 요소는 ::로, 다른 List는 :::앞에 붙일 수 있어요.

val b = 0 :: a              // List(0, 1, 2, 3)
val c = List(-1, 0) ::: a   // List(-1, 0, 1, 2, 3)

List에 요소를 뒤에 붙이는 것도 가능해요. 하지만 List는 단일 연결 리스트이므로, 보통은 요소를 앞에만 붙이는 게 좋아요. 뒤에 붙이는 것은 비교적 느린 연산이고, 특히 큰 시퀀스에서 두드러지죠.

팁: 불변 시퀀스의 앞과 뒤에 모두 요소를 붙이고 싶다면 Vector를 사용하세요.

List는 연결 리스트이므로 큰 리스트의 요소를 인덱스 값으로 접근하려 하면 안 돼요. 예를 들어 백만 개 요소를 가진 List에서 myList(999_999)를 접근하면 그 요청이 모든 요소를 거쳐야 하므로 비교적 오래 걸려요. 큰 컬렉션에서 인덱스로 요소를 접근하고 싶다면 Vector나 ArrayBuffer를 쓰세요.

메서드 이름 기억하는 법 (How to remember the method names)

요즘 IDE가 크게 도와주지만, 메서드 이름을 기억하는 한 가지 방법은 : 문자가 그 시퀀스가 위치하는 쪽을 나타낸다고 생각하는 거예요. 그래서 +:를 쓸 때는 리스트가 오른쪽에 있어야 한다는 걸 알 수 있어요.

0 +: a

마찬가지로 :+를 쓸 때는 리스트가 왼쪽에 있어야 해요.

a :+ 4

이것보다 더 기술적인 생각 방식이 있지만, 메서드 이름을 기억하는 데는 이렇게 생각하는 게 도움이 될 수 있어요.

또한 이 기호 메서드 이름들이 일관적이라는 것도 좋은 점이에요. 같은 메서드 이름이 Seq나 Vector 같은 다른 불변 시퀀스에서도 쓰여요. 원한다면 기호가 아닌 메서드 이름으로도 요소를 앞/뒤에 붙일 수 있습니다.

List 반복하기 (How to loop over lists)

이런 이름 리스트가 있을 때

val names = List("Joel", "Chris", "Ed")

각 문자열을 이렇게 출력할 수 있어요.

// Scala 2
for (name <- names) println(name)
// Scala 3
for name <- names do println(name)

REPL에서 이렇게 보여요.

// Scala 2
scala> for (name <- names) println(name)
Joel
Chris
Ed
// Scala 3
scala> for name <- names do println(name)
Joel
Chris
Ed

컬렉션에 for 루프를 쓰는 것의 좋은 점은 Scala가 일관적이라, 같은 접근 방식이 Array, ArrayBuffer, List, Seq, Vector, Map, Set 등 모든 시퀀스에 적용된다는 거예요.

역사 한 조각 (A little bit of history)

역사를 조금 좋아하는 분들을 위해, Scala의 List는 원래 1958년에 명세된 Lisp 프로그래밍 언어의 List와 비슷해요. 실제로 이렇게 List를 만드는 것 외에도

val ints = List(1, 2, 3)

완전히 같은 리스트를 이렇게 만들 수도 있어요.

val list = 1 :: 2 :: 3 :: Nil

REPL은 이것이 어떻게 동작하는지 보여줘요.

scala> val list = 1 :: 2 :: 3 :: Nil
list: List[Int] = List(1, 2, 3)

이것이 동작하는 이유는 List가 Nil 요소로 끝나는 단일 연결 리스트이고, ::가 Lisp의 “cons” 연산자처럼 동작하는 List 메서드이기 때문이에요.

여담: LazyList

Scala 컬렉션에는 지연(lazy) 불변 연결 리스트인 LazyList도 있어요. 요소를 필요할 때만 계산하기 때문에 “lazy” — 즉 비엄격(non-strict) — 하다고 불러요.

REPL에서 LazyList가 얼마나 l한지 볼 수 있어요.

val x = LazyList.range(1, Int.MaxValue)
x.take(1)      // LazyList(<not computed>)
x.take(5)      // LazyList(<not computed>)
x.map(_ + 1)   // LazyList(<not computed>)

이 예시들 모두에서 아무 일도 일어나지 않아요. 실제로 foreach 메서드를 호출하는 것처럼 강제하기 전까지는 아무 일도 일어나지 않죠.

scala> x.take(1).foreach(println)
1

엄격(strict) 컬렉션과 비엄격(lazy) 컬렉션의 용도, 장점, 단점에 대해 더 알고 싶다면 Scala 2.13 컬렉션의 구조 페이지의 “strict”와 “non-strict” 논의를 참고하세요.

Vector

Vector는 인덱스 기반 불변 시퀀스예요. 설명에서 “인덱스 기반”이라는 부분은 사실상 상수 시간의 임의 접근과 갱신을 제공한다는 뜻이에요. 그래서 listOfPeople(123_456_789)처럼 인덱스 값으로 Vector 요소를 빠르게 접근할 수 있죠.

일반적으로 (a) Vector는 인덱스 기반이고 List는 그렇지 않으며, (b) List는 :: 메서드를 가진다는 차이 외에는 두 타입이 똑같이 동작해요. 그래서 다음 예시를 빠르게 훑어볼게요.

Vector를 만드는 몇 가지 방법이에요.

val nums = Vector(1, 2, 3, 4, 5)

val strings = Vector("one", "two")

case class Person(name: String)
val people = Vector(
  Person("Bert"),
  Person("Ernie"),
  Person("Grover")
)

Vector는 불변이므로 새 요소를 추가할 수 없어요. 대신 기존 Vector에 요소를 뒤나 앞에 붙여 새 시퀀스를 만들어요. 이 예시들은 Vector에 요소를 뒤에 붙이는 방법을 보여줘요.

val a = Vector(1,2,3)         // Vector(1, 2, 3)
val b = a :+ 4                // Vector(1, 2, 3, 4)
val c = a ++ Vector(4, 5)     // Vector(1, 2, 3, 4, 5)

이렇게 요소를 앞에 붙입니다.

val a = Vector(1,2,3)         // Vector(1, 2, 3)
val b = 0 +: a                // Vector(0, 1, 2, 3)
val c = Vector(-1, 0) ++: a   // Vector(-1, 0, 1, 2, 3)

빠른 임의 접근과 갱신 외에도 Vector는 빠른 뒤/앞 추가 시간을 제공하므로, 원하는 대로 이 기능들을 사용할 수 있어요. Vector와 다른 컬렉션의 성능 세부 사항은 컬렉션 성능 특성을 참고하세요.

마지막으로, Vector를 for 루프에서 List, ArrayBuffer, 다른 시퀀스와 똑같이 사용해요.

// Scala 2
scala> val names = Vector("Joel", "Chris", "Ed")
val names: Vector[String] = Vector(Joel, Chris, Ed)

scala> for (name <- names) println(name)
Joel
Chris
Ed
// Scala 3
scala> val names = Vector("Joel", "Chris", "Ed")
val names: Vector[String] = Vector(Joel, Chris, Ed)

scala> for name <- names do println(name)
Joel
Chris
Ed

Array

Scala Array의 요소는 가변이고, 인덱스 기반이며, 고정된 크기를 가져요.

Array 만들기 (Creating an Array)

초기 값으로 Array를 이렇게 만들어요.

val a = Array(1, 2, 3)

요소 접근과 갱신 (Accessing and updating elements)

Array 요소는 ArrayBuffer처럼 접근하고 갱신해요.

val a = Array(1, 2, 3)
a(0)                  // 1
a(0) = 10             // Array(10, 2, 3)

크기를 바꿀 수 있는 시퀀스가 필요하다면 ArrayBuffer를 쓰는 걸 고려해 보세요.

ArrayBuffer

Scala 애플리케이션에서 범용적인 가변 인덱스 시퀀스가 필요할 때 ArrayBuffer를 사용해요. 가변이라 요소를 바꿀 수도 있고 크기를 조정할 수도 있죠. 인덱스 기반이라 요소의 임의 접근이 빠르구요.

ArrayBuffer 만들기 (Creating an ArrayBuffer)

ArrayBuffer를 사용하려면 먼저 import 해요.

import scala.collection.mutable.ArrayBuffer

빈 ArrayBuffer에서 시작해야 한다면 타입만 지정하면 돼요.

var strings = ArrayBuffer[String]()
var ints = ArrayBuffer[Int]()
var people = ArrayBuffer[Person]()

ArrayBuffer가 결국 필요로 하는 대략적인 크기를 알고 있다면 초기 크기로 만들 수 있어요.

// ready to hold 100,000 ints
val buf = new ArrayBuffer[Int](100_000)

초기 요소를 가진 새 ArrayBuffer를 만들려면 List나 Vector처럼 초기 요소만 지정하면 돼요.

val nums = ArrayBuffer(1, 2, 3)
val people = ArrayBuffer(
  Person("Bert"),
  Person("Ernie"),
  Person("Grover")
)

ArrayBuffer에 요소 추가하기 (Adding elements to an ArrayBuffer)

+=++= 메서드로 새 요소를 ArrayBuffer에 뒤에 붙일 수 있어요. 텍스트 이름의 메서드를 선호한다면 append, appendAll, insert, insertAll, prepend, prependAll도 쓸 수 있어요.

+=++=의 예시를 볼게요.

val nums = ArrayBuffer(1, 2, 3)   // ArrayBuffer(1, 2, 3)
nums += 4                         // ArrayBuffer(1, 2, 3, 4)
nums ++= List(5, 6)               // ArrayBuffer(1, 2, 3, 4, 5, 6)

ArrayBuffer에서 요소 제거하기 (Removing elements from an ArrayBuffer)

ArrayBuffer는 가변이라 -=, --=, clear, remove 같은 메서드를 가져요. 다음 예시가 -=--= 메서드를 보여줘요.

val a = ArrayBuffer.range('a', 'h')   // ArrayBuffer(a, b, c, d, e, f, g)
a -= 'a'                              // ArrayBuffer(b, c, d, e, f, g)
a --= Seq('b', 'c')                   // ArrayBuffer(d, e, f, g)
a --= Set('d', 'e')                   // ArrayBuffer(f, g)

ArrayBuffer 요소 갱신하기 (Updating ArrayBuffer elements)

ArrayBuffer 요소는 원하는 요소를 다시 할당하거나 update 메서드를 사용해 갱신해요.

val a = ArrayBuffer.range(1,5)        // ArrayBuffer(1, 2, 3, 4)
a(2) = 50                             // ArrayBuffer(1, 2, 50, 4)
a.update(0, 10)                       // ArrayBuffer(10, 2, 50, 4)

Maps

Map은 키/값 쌍으로 이뤄진 iterable 컬렉션이에요. Scala에는 가변과 불변 Map 타입이 모두 있고, 이 섹션에서는 불변 Map을 사용하는 법을 보여줄게요.

불변 Map 만들기 (Creating an immutable Map)

불변 Map은 이렇게 만들어요.

val states = Map(
  "AK" -> "Alaska",
  "AL" -> "Alabama",
  "AZ" -> "Arizona"
)

Map이 생기면 for 루프에서 이렇게 요소를 순회할 수 있어요.

// Scala 2
for ((k, v) <- states)  println(s"key: $k, value: $v")
// Scala 3
for (k, v) <- states do println(s"key: $k, value: $v")

REPL에서 이렇게 보여요.

// Scala 2
scala> for ((k, v) <- states)  println(s"key: $k, value: $v")
key: AK, value: Alaska
key: AL, value: Alabama
key: AZ, value: Arizona
// Scala 3
scala> for (k, v) <- states do println(s"key: $k, value: $v")
key: AK, value: Alaska
key: AL, value: Alabama
key: AZ, value: Arizona

Map 요소 접근하기 (Accessing Map elements)

괄호 안에 원하는 키 값을 지정해 Map 요소에 접근해요.

val ak = states("AK")   // ak: String = Alaska
val al = states("AL")   // al: String = Alabama

실무에서는 keys, keySet, keysIterator, for 루프, 그리고 map 같은 고차 함수를 써서 Map의 키와 값을 다루게 될 거예요.

Map에 요소 추가하기 (Adding elements to a Map)

불변 Map에는 +++를 사용해 요소를 추가하고, 결과를 새 변수에 할당하는 것을 잊지 마세요.

val a = Map(1 -> "one")    // a: Map(1 -> one)
val b = a + (2 -> "two")   // b: Map(1 -> one, 2 -> two)
val c = b ++ Seq(
  3 -> "three",
  4 -> "four"
)
// c: Map(1 -> one, 2 -> two, 3 -> three, 4 -> four)

Map에서 요소 제거하기 (Removing elements from a Map)

불변 Map에서 ---에 제거할 키 값을 사용해 요소를 제거하고, 결과를 새 변수에 할당하는 것을 잊지 마세요.

val a = Map(
  1 -> "one",
  2 -> "two",
  3 -> "three",
  4 -> "four"
)

val b = a - 4       // b: Map(1 -> one, 2 -> two, 3 -> three)
val c = a - 4 - 3   // c: Map(1 -> one, 2 -> two)

Map 요소 갱신하기 (Updating Map elements)

불변 Map의 요소를 갱신하려면 updated 메서드(또는 + 연산자)를 사용하면서 결과를 새 변수에 할당해요.

val a = Map(
  1 -> "one",
  2 -> "two",
  3 -> "three"
)

val b = a.updated(3, "THREE!")   // b: Map(1 -> one, 2 -> two, 3 -> THREE!)
val c = a + (2 -> "TWO...")      // c: Map(1 -> one, 2 -> TWO..., 3 -> three)

Map 순회하기 (Traversing a Map)

앞서 봤듯이, for 루프로 Map 요소를 수동으로 순회하는 흔한 방법이 있어요.

// Scala 2
val states = Map(
  "AK" -> "Alaska",
  "AL" -> "Alabama",
  "AZ" -> "Arizona"
)

for ((k, v) <- states) println(s"key: $k, value: $v")
// Scala 3
val states = Map(
  "AK" -> "Alaska",
  "AL" -> "Alabama",
  "AZ" -> "Arizona"
)

for (k, v) <- states do println(s"key: $k, value: $v")

그렇긴 하지만, Map의 키와 값을 다루는 방법은 아주 많아요. 흔한 Map 메서드로는 foreach, map, keys, values가 있어요. Scala에는 CollisionProofHashMap, HashMap, LinkedHashMap, ListMap, SortedMap, TreeMap, WeakHashMap 등 더 특수한 Map 타입도 많습니다.

셋 다루기 (Working with Sets)

Scala의 Set은 중복 요소가 없는 iterable 컬렉션이에요. Scala에는 가변과 불변 Set 타입이 모두 있고, 이 섹션에서는 불변 Set을 보여줄게요.

Set 만들기 (Creating a Set)

새 빈 Set은 이렇게 만들어요.

val nums = Set[Int]()
val letters = Set[Char]()

초기 데이터가 있는 Set은 이렇게 만들어요.

val nums = Set(1, 2, 3, 3, 3)           // Set(1, 2, 3)
val letters = Set('a', 'b', 'c', 'c')   // Set('a', 'b', 'c')

Set에 요소 추가하기 (Adding elements to a Set)

불변 Set에는 +++를 사용해 요소를 추가하고, 결과를 새 변수에 할당하는 것을 잊지 마세요.

val a = Set(1, 2)                // Set(1, 2)
val b = a + 3                    // Set(1, 2, 3)
val c = b ++ Seq(4, 1, 5, 5)     // HashSet(5, 1, 2, 3, 4)

중복 요소를 추가하려고 하면 조용히 버려진다는 점을 주목하세요. 또 요소의 반복 순서가 임의적이라는 점도 주목하세요.

Set에서 요소 삭제하기 (Deleting elements from a Set)

불변 Set에서 ---를 사용해 요소를 제거하고, 역시 결과를 새 변수에 할당해요.

val a = Set(1, 2, 3, 4, 5)   // HashSet(5, 1, 2, 3, 4)
val b = a - 5                // HashSet(1, 2, 3, 4)
val c = b -- Seq(3, 4)       // HashSet(1, 2)

범위 (Range)

Scala의 Range는 데이터 구조를 채우고 for 루프를 반복하는 데 자주 사용돼요. 다음 REPL 예시들이 범위를 만드는 방법을 보여줘요.

1 to 5         // Range(1, 2, 3, 4, 5)
1 until 5      // Range(1, 2, 3, 4)
1 to 10 by 2   // Range(1, 3, 5, 7, 9)
'a' to 'c'     // NumericRange(a, b, c)

범위로 컬렉션을 채울 수 있어요.

val x = (1 to 5).toList     // List(1, 2, 3, 4, 5)
val x = (1 to 5).toBuffer   // ArrayBuffer(1, 2, 3, 4, 5)

for 루프에서도 사용해요.

// Scala 2
scala> for (i <- 1 to 3) println(i)
1
2
3
// Scala 3
scala> for i <- 1 to 3 do println(i)
1
2
3

컬렉션에도 range 메서드가 있어요.

Vector.range(1, 5)       // Vector(1, 2, 3, 4)
List.range(1, 10, 2)     // List(1, 3, 5, 7, 9)
Set.range(1, 10)         // HashSet(5, 1, 6, 9, 2, 7, 3, 8, 4)

테스트를 실행할 때 범위는 테스트 컬렉션을 생성하는 데도 유용해요.

val evens = (0 to 10 by 2).toList     // List(0, 2, 4, 6, 8, 10)
val odds = (1 to 10 by 2).toList      // List(1, 3, 5, 7, 9)
val doubles = (1 to 5).map(_ * 2.0)   // Vector(2.0, 4.0, 6.0, 8.0, 10.0)

// create a Map
val map = (1 to 3).map(e => (e,s"$e")).toMap
    // map: Map[Int, String] = Map(1 -> "1", 2 -> "2", 3 -> "3")

더 자세한 내용 (More details)

특수 컬렉션에 대한 더 자세한 정보가 필요할 때는 다음 자료를 참고하세요.

더 알아보기 (Learn more)