본문 바로가기

개발/Swift

5. 컬렉션

컴퓨터과학에서 컬렉션(Collection)이라고 하면 일반적으로 다수의 데이터들의 모음을 얘기한다.
대부분의 프로그래밍 언어는 이러한 모음을 다루는 방법들을 제공하며, 스위프트 역시 Array, Set, Dictionary와 같은 컬렉션 타입을 통해 이를 제공한다.

 

참고로, 보통 컬렉션 타입으로 된 데이터들은 동일한 타입일 것이라 가정하는 경우가 일반적이다.
하지만 언어나 구현 방식에 따라 동일하지 않은 타입으로도 컬렉션을 만들 수 있어 절대적인 규칙이라 받아드리면 안되니 주의할 것.

 

스위프트에서는 해당 컬랙션에 대해 아래와 같이 간략하게 소개하고 있다.

배열(Array)은 컬렉션 값에 순서를 가지고 있음.
집합(Set)은 반복되지 않는 값에 순서가 없는 컬렉션 타입.
딕셔너리는(Dictionary) 키-값 쌍의 순서가 없는 컬렉션 타입.

Swift의 배열, 집합, 딕셔너리는 저장할 수 있는 값의 타입과 키의 타입에 대해 명확합니다. 
이것은 실수로 컬렉션에 잘못된 타입을 추가할 수 없다는 의미입니다. 
또한 컬렉션에서 값을 조회할 때 타입이 명확하다는 것을 의미합니다.

배열 (Array)

컴퓨터 과학에서의 배열은 데이터가 순서를 가지며, 연속으로 구성된 형태를 말한다.
원래 배열(Array)란 원래 단어의 사전적인 의미는 인상적인 집합체, 잘 정렬된 배치라고 한다.
그런 의미에서 보면 메모리상에 데이터들이 순서대로 그리고 연속적으로 나열되어있는 것을 보면 잘 어울리는 이름인거 같다.

 

배열은 원래 자료구조로서의 개념이 먼저 등장했다.
이때의 배열은 동일한 크기를 가진 값들이 연속적으로 배치된 집합이라는 정의를 가졌기 때문에,
자연스럽게 동일한 타입의 요소로 구성된 집합으로 인식되었다.

 

하지만 이후 배열이 언어 차원으로 확장되어 데이터 타입으로 정의되면서,
그 성격은 순서를 가진 값의 집합이라는 쪽에 더 가까워지게 되었다.
이 과정에서 언어나 구현 방식에 따라 반드시 동일한 타입만을 요구하지 않는 경우도 존재하게 되었다.

 

따라서 배열이 항상 동일한 타입으로 구성된다고 보는 것은
절대적인 규칙이라기보다는 일반적인 가정으로 이해하는 편이 더 적절하다.

 

이 주제는 더 깊게 들어가면 현재 다루는 범위에서 벗어나므로, 여기서는 이 정도로만 정리하도록 하겠다.
추가로, 다른 언어에서는 배열과 유사한 개념을 List라는 이름으로 제공하기도 한다.

 

스위프트 컬렉션에서의 배열은 동일한 데이터들이 순서를 가지고 정렬되있는 데이터들로 구성됨을 보장한다.

선언과 초기화

var/let NAME: Array<Element>
var/let NAME: [Element]

var intArray: Array<Int>
let strArray: [String]

 

배열 역시 하나의 타입이기 때문에, 변수나 상수를 선언하는 방식은 다른 타입과 동일하다.
스위프트에서 배열의 정식 타입 표기는 Array<Element>이며,
이를 [Element] 형태의 축약 문법으로도 표현할 수 있다.
스위프트에서는 일반적으로 축약형 문법을 사용하는 것을 권장한다.

 

배열을 생성하거나 초기화하는 방법에는 여러가지가 있다.

// 빈 배열 생성
var arr: Array<Int> = []
var arr = Array<Int>()
var arr = [Int]()

// 생성과 동시에 초기화
var arr: [Int] = [1,2,3,4,5]
var arr = Array(repeating: "A", count: 3) // Array<String> ["A", "A", "A"]
var arr = Array(repeating: 0, count: 3) // Array<Int>[0, 0, 0]

 

배열을 생성하는 방법은 여러 가지가 있지만, 실제로 자주 사용되는 형태만 정리했다.
Array(repeating:count:)를 사용할 때, 타입을 명시하지 않으면 "A"는 Character가 아닌 String으로 추론된다는 점에 주목할 만하다.

삽입, 삭제, 검색, 정렬

var numbers = [1, 2, 3]

// 삽입
numbers.append(4)            // [1, 2, 3, 4]
numbers.insert(0, at: 0)     // [0, 1, 2, 3, 4]

// 삭제
numbers.remove(at: 2)        // index 2 제거
numbers.removeLast()         // 마지막 요소 제거

// 검색
let hasThree = numbers.contains(3) // true

// 정렬
numbers.sort()               // 원본 정렬
let sorted = numbers.sorted()// 정렬된 새 배열 반환

// 순회
numbers.forEach { value in
    print(value)
}

 

이 외에도 더 많은 기능들을 제공한다.

불변/가변 차이

let immutableArray = [1, 2, 3]
// immutableArray.append(4) // ❌ 컴파일 에러
// immutableArray = [4, 5, 6] // ❌ 컴파일 에러

var mutableArray = [1, 2, 3]
mutableArray.append(4)       // ✅

 

let으로 선언된 배열은 위의 예제 코드에서 보다시피 배열을 바꾸거나, 내부 요소를 추가하거나 삭제하는 등 변경하는 행위 자체를 할 수가 없다.
Objective-C의 경험이 있는 사람은 NSArray와 NSMutableArray의 차이를 떠올리면 충분히 이해할 수 있는 동작이다.
다만 let으로 된 배열이어도 참조 타입으로 이루어진 경우, 그 참조 타입내의 변수들은 변경하는 것은 가능하다.

그 외 배열에 대한 고찰

배열의 순서와 서브스크립션, 그리고 인덱스

배열의 특징 중 하나로 요소들이 순서를 가진다는 점을 앞서 언급했다.
그래서 우리는 배열의 특정 값을 말할때 값 자체가 아니라 위치를 기준으로 표현하게 된다.

 

보통 말로는 "배열의 n번째 요소에 접근한다" 라고 하고,
코드로는 arr[n] 라고 표현한다.

 

이처럼 배열 내부의 값에 접근할 때 숫자를 사용할 수 있는 이유는 배열의 구조적인 특성 때문이다.
전통적인 의미의 배열은 메모리 상에 연속적으로 배치된 동일 크기의 요소 집합이다.
이 구조 덕분에 배열의 특정 요소의 위치는 아래와 같은 식으로 계산된다.

배열의 시작 메모리 주소 + (요소 크기 × 인덱스)

 

대부분의 언어에서 배열의 시작 인덱스가 1이 아닌 0인건 이 계산 방식 때문이다.
즉, n번째 요소가 얼마만큼 떨어져 있는지를 정확히 계산할 수 있기 때문에 O(1) 접근 시간이 가능해진다.
다만, 모든 배열이 항상 이 특성을 완전히 만족하는 것은 아니다.
스위프트의 문자열은 unicode 관련 내부 구현떄문에 O(1) 시간이 보장되지 않는다.

스위프트에서의 서브스크립션

스위프트는 다른 언어들과는 달리 배열 요소에 접근할 때 포인터 연산을 직접 사용하지 않는다.
대신 [] 형태의 서브스크립트(subscript) 문법을 제공한다.

let value = arr[3]
let value = arr.subscript(3)

arr[3] == arr.subscript(3)

[] 문법은 컴파일 과정에서 subscript 함수 호출로 바뀌게 된다.
그리고 subscript 함수 내부 구현은 개념적으로 보면 전통적인 배열 접근 방식과 크게 다르지 않다.

 

그럼 왜 스위프트는 포인터 연산을 왜 함수 연산으로 한번 더 감쌌을까 하는 의문이 드는데
이 부분은 여기서 다루면 주제에서 벗어나는 스위프트 설계 철학으로 주객이 전도되어 버리니 추후 다른 부분에서 다루도록 하겠다.

“인덱스로 접근한다”는 표현에 대하여

배열을 정리하다보니 갑자기 이런 의문이 들었다
우리는 보통 배열의 요소에 접근할때 "인덱스로 접근한다" 식의 표현을 사용한다.
생각해보니 인덱스로 접근한다는 말은 틀린표현이었다.

 

인덱스란 단어 뜻은 색인, 즉 어떤 값이 어디에 있는지를 알려주는 길잡이 정보이지, 인덱스가 값을 의미하는 것은 아니기 때문이다.

 

따라서 보다 정확한 표현은 다음에 가깝다.

 

"배열의 값을 찾는 인덱스로 정수를 사용한다"

 

엄청 중요한건 아니지만 배열을 정리하다 보니 이 부분이 헷갈려져서 개념과 용어정리을 명확히 하기 위해 정리해 보았다.

 

정말로 배열은 동일한 크기를 갖는가?

var anyArr: [Any] = [1, "2", 3.5, true]

 

위의 코드를 보면 Any 타입으로 된 배열에는 서로 다른 타입으로 된 값이 모여있어서 직관적으로 보면 서로 다른 크기를 가질 것으로 보인다.
하지만 Any도 타입이기 때문에 해당 배열은 Any타입의 데이터들이 모인 배열로 취급되어 동일한 크기를 가진다는 배열의 정의를 위반하지 않는다.

그럼 아무리 Any 타입이어도 실제로는 타입별로 다른 크기를 가지기에 그 값들은 어디에 있는가? 라고 질문을 하면 
이 곳의 주제를 한참 벗어나는 질문이라 다루지는 않겠지만, 
Any 타입의 구현을 보면 컨테이너를 가져서 저장되는 해당 값의 크기에 따라 직접 내부에 저장하거나 힙 영역에 저장하여 참조 형태로 관리하는 형태로 되어있다고 한다.
그래서 Any 타입으로 된 값은 불러오는 과정에서 오버헤드가 발생할 가능성이 있어서 [Any]를 피하라는 말이 나오는 것이다.

 

집합 (Set)

집합(Set)은 우리가 수학 시간에 배웠던 그 집합 개념을 코딩 영역으로 옮긴 컬렉션 타입이다.
그래서 수학에서 배운 집합의 성질들을 그대로 코드로 활용할 수 있다는 점이 가장 큰 특징이다.

 

스위프트에서 집합(Set)은 다음과 같은 성질을 가진다.
• 순서가 없다
• 중복을 허용하지 않는다

 

이 두 가지 성질이 집합을 배열(Array)과 구분 짓는 핵심이다.

선언 및 초기화

집합을 선언하고 초기화하는 방법은 여러 가지가 있지만, 자주 사용되는 방식들만 정리해보자.

// 타입 명시
var numberSet: Set<Int> = [1, 2, 3, 4]

// 빈 집합 생성
var emptySet = Set<String>()

// 배열 리터럴로 초기화
let fruits: Set<String> = ["apple", "banana", "orange"]

 

배열과 문법이 비슷해 보이지만, 타입은 반드시 Set<Element> 형태로 명시되어야 한다.
또한 배열 리터럴을 사용하더라도 내부적으로는 집합의 성질이 적용된다.

중복 제거

수학에서의 집합이 중복을 허용하지 않듯이, 코딩에서의 집합 역시 중복을 허용하지 않는다.
그래서 중복된 데이터가 있을 때 집합을 이용해 중복을 제거하는 패턴은 매우 자주 사용된다.

let numbers = [1, 2, 2, 3, 3, 3, 4]
let uniqueNumbers = Set(numbers)

print(uniqueNumbers) // [1, 2, 3, 4]

 

이처럼 배열을 집합으로 변환하는 것만으로도 간단하게 중복을 제거할 수 있다.
필요하다면 다시 배열로 변환해서 사용할 수도 있다.

합집합, 교집합, 차집합

집합의 수학적 성질들은 스위프트의 Set 타입이 제공하는 메서드를 통해 그대로 사용할 수 있다.

let a: Set = [1, 2, 3, 4]
let b: Set = [3, 4, 5, 6]

// 합집합
let union = a.union(b)        // [1, 2, 3, 4, 5, 6]

// 교집합
let intersection = a.intersection(b) // [3, 4]

// 차집합
let subtracting = a.subtracting(b)   // [1, 2]

 

이 메서드들 외에 집합의 성질을 제공하는 메서드들을 더 정의되어 있으니 궁금한 사람은 각자 찾아보길 바란다.

빠른 검색과 해시 테이블

스위프트의 Set은 평균적으로 O(1)의 탐색 시간을 보장한다.
이 빠른 검색 성능의 이유는 집합의 내부 구현 방식에 있다.

 

스위프트 문서에서는 집합에 저장되는 값이 반드시 Hashable 프로토콜을 준수해야 한다고 명시한다.
이는 스위프트의 집합이 해시 테이블(Hash Table) 기반으로 구현되어 있기 때문이다.
(해시 테이블 자료구조에 대한 얘기는 여기와는 동떨어진 주제이므로 언급하지는 않겠다)

 

집합에 값을 저장할 때:
• 값 자체는 데이터로 저장되고
• 해당 값의 해시 값이 위치를 결정하는 데 사용된다

 

이 구조 덕분에 특정 값이 존재하는지 여부를 바로 확인할 수 있고,
그 결과 평균적으로 O(1)의 탐색 시간이 가능해진다.

 

또한 동일한 해시 값을 가진 데이터는 이미 존재한다고 판단되므로,
중복된 값이 자연스럽게 허용되지 않게 된다.

 

추가로 알아두면 좋은 특징들

• 집합은 순서가 없기 때문에, 집합을 출력할 때마다 요소의 순서는 다른걸 볼 수 있다
• Set과 Array 간의 변환은 매우 흔하게 사용되는 패턴이다
• Set은 값 타입(Value Type)이므로, 복사 시 독립적인 값으로 동작한다
• 두 집합의 비교(==)는 순서와 무관하며, 요소의 구성만으로 판단된다

 

딕셔너리 (Dictionary)

딕셔너리는 배열과 함께 가장 많이 사용되는 컬렉션 타입 중 하나다.
키(Key)와 값(Value)을 쌍(pair)으로 저장하는 자료구조이며, 다른 언어에서는 Map(맵)이라는 이름으로 제공되기도 한다.
그 다음 중요한 특징은 집합처럼 순서가 없다는 점이다.
딕셔너리는 저장된 위치나 순서가 아닌, 오직 키를 통해서만 값에 접근하는 컬렉션이다.

 

스위프트에서는 이러한 딕셔너리를 언어 차원에서 기본 컬렉션으로 지원한다.

 

딕셔너리라는 단어를 해석하면 "사전"이 되는데, 업계에서는 딕셔너리(Dictionary)라는 표현을 주로 사용하므로 여기서도 해당 용어를 그대로 사용하겠다.

선언과 초기화

딕셔너리의 타입은 Dictionary<Key, Value> 형태로 표현되며,
대부분의 경우 [Key: Value]라는 축약 문법을 사용한다.

var scores: Dictionary<String, Int>
var scores: [String: Int]

var scores = [String: Int]()
var scores: [String: Int] = [:]

let fixedScores = ["Alice": 80, "Bob": 90]

 

키(Key)는 반드시 Hashable 프로토콜을 준수해야 하며, 값(Value)은 어떤 타입이든 올 수 있다.
키가 Hashable 프로토콜을 준수해야하는 이유는 딕셔너리 또한 기반은 집합처럼 해시 테이블이기 때문이다.

값 접근, 추가, 수정, 삭제

딕셔너리는 키를 통해 값에 접근한다.
딕셔너리의 값 접근에는 배열과 동일하게 [] 서브스크립션(subscript) 문법이 사용된다.
배열에서는 정수 인덱스를 통해 값에 접근했다면,
딕셔너리에서는 키를 인덱스처럼 사용해 값에 접근하는 구조라고 이해하면 된다.

 

이때 가장 중요한 점은, 존재하지 않는 키로 접근하는 것이 문법적으로 가능하다는 것이다.
그래서 딕셔너리의 값 접근 결과는 항상 Optional 이다.

var scores = ["Alice": 80, "Bob": 90]

// 값 접근
let aliceScore = scores["Alice"]   // Optional(80)
let tomScore = scores["Tom"]       // nil

// 값 수정
scores["Alice"] = 85

// 값 추가
scores["Charlie"] = 70

// 값 삭제
scores["Bob"] = nil

 

스위프트의 딕셔너리는 집합처럼 내부적으로 해시 기반 구조를 사용하므로,
특정 키에 대한 값 접근·수정·삭제는 일반적으로 O(1) 의 시간 복잡도를 가진다.

 

하지만 이러한 빠른 접근이 가능한 대신,
해당 키가 실제로 존재하는지는 런타임에만 알 수 있기 때문에 그 결과를 Optional로 표현하도록 타입 시스템이 강제한다.

 

배열이나 집합과 달리,
딕셔너리는 “없는 값에 접근할 수 있다”는 가능성을 타입으로 드러내는 컬렉션이다.

 

따라서 딕셔너리를 사용할 때는 다음과 같은 패턴이 자연스럽다.

if let score = scores["Alice"] {
    print(score)
}

let score = scores["Tom"] ?? 0

 

이 Optional 특성을 이해하지 못하면 딕셔너리는 편리한 컬렉션이 아니라,
옵셔널 처리로 가득 찬 불편한 존재가 되기 쉽다.

배열과 딕셔너리의 서브스크립션 문법 차이

배열과 딕셔너리는 모두 [] 서브스크립션 문법을 사용하지만,
이 문법이 가지는 의미는 두 컬렉션에서 다르다.

 

배열의 경우,
서브스크립션에 전달하는 정수 인덱스는 항상 유효한 위치를 가리킨다는 전제가 있다.
그래서 범위를 벗어나면 런타임 에러가 발생하고,
정상적인 접근의 반환 타입은 Optional이 아니다.

 

반면 딕셔너리의 서브스크립션에서 키는 존재할 수도 있고, 존재하지 않을 수도 있다.

이 차이 때문에 딕셔너리의 서브스크립션은
“값을 바로 가져온다”기보다는
해당 키에 대응되는 값이 있는지 질의한다는 성격이 더 강하다.

 

그 결과가 바로 Optional<Value> 이다.

 

즉,
• 배열의 []는 위치를 통한 직접 접근 이고
• 딕셔너리의 []는 키를 통한 조회(query) 에 가깝다

updateValue(_:forKey:)

딕셔너리에는 서브스크립션 외에 값을 추가하거나 수정할 수 있는 또 하나의 메서드가 있다.

let oldValue = scores.updateValue(95, forKey: "Alice")
print(oldValue) // Optional(85)

 

updateValue(_:forKey:)는 값을 갱신하면서
기존에 저장되어 있던 값을 반환한다는 점이 특징이다.

 

이 반환값 역시 Optional인데,
이는 해당 키가 기존에 존재했는지 여부를 함께 표현하기 위함이다.

let oldValue = scores.updateValue(60, forKey: "David")
print(oldValue) // nil

 

이 메서드는 단순히 값을 덮어쓰는 용도라기보다는,
• 기존 값이 있었는지 확인해야 할 때
• 상태 변경 전·후를 비교해야 할 때

 

딕셔너리를 상태 저장소처럼 사용할 경우에 의미가 생긴다.

 

서브스크립션이 “현재 상태를 조회하거나 설정하는 문법”이라면,
updateValue는 “이전 상태를 의식하며 상태를 변경하는 메서드”라고 볼 수 있다.

 

이 점에서 updateValue는
딕셔너리가 단순한 key-value 컨테이너를 넘어
상태의 변화를 다루는 컬렉션임을 드러내는 API라고 할 수 있다.

 

참고로 이에 대응되는 개념으로,
키에 해당하는 값을 제거하면서 이전 값을 반환하는 removeValue(forKey:) 메서드도 제공된다.

'개발 > Swift' 카테고리의 다른 글

7. 구조체와 클래스  (0) 2026.02.26
6. 문자열과 텍스트 처리  (0) 2026.02.13
4. 클로저  (0) 2026.01.23
3. 함수  (0) 2026.01.23
2. 제어문  (0) 2026.01.17