Devin.KR

컬렉션 - Array·Dictionary·Set

개발자KR 조회 0

이 장에서 배우는 것

습관 기록 앱은 "오늘 물을 몇 번 마셨는가", "운동을 며칠 연속으로 했는가", "이번 주에 한 습관은 무엇인가"처럼 값 하나로는 답할 수 없는 질문을 계속 받는다. 값을 여러 개 담아 두는 그릇이 필요하며, Swift 표준 라이브러리는 이 용도로 세 가지를 준다. 순서대로 늘어놓는 배열(array), 이름표로 값을 찾는 딕셔너리(dictionary), 중복 없이 모아 두는 집합(set)이다. 이 장에서는 세 컬렉션(collection, 여러 값을 담는 타입)을 만들고 고치는 방법과, 잘못 쓰면 프로그램이 멈추는 지점, 그리고 컬렉션을 다른 변수에 넣으면 복사된다는 규칙을 다룬다.

  • 배열을 만들고 값을 추가·삽입·삭제한다.
  • 범위 밖 첨자(subscript, 대괄호 안의 위치 번호)가 왜 프로그램을 멈추는지 이해하고 안전하게 접근한다.
  • 딕셔너리를 조회하면 결과가 옵셔널(optional, 값이 없을 수도 있음을 나타내는 타입)인 이유를 설명하고 기본값으로 처리한다.
  • Set 의 합집합·교집합·차집합을 습관 비교에 사용한다.
  • 값 의미론(value semantics)을 이해하고, 복사한 컬렉션이 원본과 독립임을 확인한다.

문제 상황

습관 기록 앱의 기록 담당 코드를 만든다고 하자. 물 마시기 앱이라면 사용자는 하루에 여러 번 물 마신 양을 입력한다. 이 값들은 입력된 순서가 의미를 가지므로 변수 하나씩 만들 수 없다. water1, water2, water3처럼 이름을 늘려 가면 몇 번 마실지 미리 알 수 없어 곧 막힌다.

습관마다 연속 달성일을 저장하는 일도 비슷하다. "물 마시기는 5일, 운동은 3일"이라는 정보는 순서가 아니라 이름으로 찾는다. 배열에 넣고 몇 번째가 운동인지 외우는 방식은 습관을 하나 추가하거나 지울 때마다 번호가 어긋난다.

더 까다로운 문제도 있다. 월요일에 한 습관과 화요일에 한 습관을 비교해 "이틀 모두 한 것"과 "하루만 한 것"을 가려내려면, 중복 없이 모으고 집합끼리 비교하는 도구가 필요하다. 마지막으로 통계를 계산하려고 기록을 다른 변수에 복사해 정렬하거나 지웠을 때 원본이 함께 바뀌면 곤란하다. 복사가 어떻게 동작하는지 알아야 안심하고 쓸 수 있다. 이 장은 이 네 가지 상황을 차례로 해결한다.

배열과 범위 밖 첨자

배열 만들기

배열은 같은 타입의 값을 순서대로 담는다. 타입은 대괄호로 쓴다. 정수를 담는 배열은 [Int]다. 값을 대괄호 안에 쉼표로 나열해 만든다.

var waterLog: [Int] = [250, 300, 200]
let emptyLog: [Int] = []

앞 장에서 let을 기본으로 쓰고 값이 바뀔 때만 var를 쓰기로 했다. 컬렉션도 같다. var로 선언한 배열만 항목을 추가하거나 지울 수 있고, let 배열은 만든 뒤 내용을 바꿀 수 없다. 빈 배열은 타입을 알 수 없으므로 [Int]처럼 타입을 직접 적어 준다.

추가와 삭제

배열의 각 칸은 0부터 시작하는 번호를 가진다. 이 번호를 첨자라고 하며 waterLog[0]은 첫 번째 값이다. 사람이 세는 "첫 번째"와 한 칸 어긋나므로 처음에는 자주 헷갈린다. 번호는 몇 번째인지가 아니라 맨 앞에서 얼마나 떨어져 있는지를 뜻한다고 기억하면 편하다.

배열을 고치는 주요 동작
동작코드하는 일돌려주는 값
끝에 추가append(500)맨 뒤에 값을 붙인다없음
중간에 삽입insert(150, at: 0)해당 위치에 넣고 뒤를 밀어낸다없음
위치로 삭제remove(at: 1)해당 위치를 지우고 뒤를 당긴다지운 값
개수 확인count담긴 값의 수를 알려 준다정수

remove(at:)은 지운 값을 돌려준다. 지운 값이 필요 없으면 받지 않아도 되고, 필요하면 변수에 담는다. 삭제하면 뒤에 있던 값들의 번호가 하나씩 앞당겨진다는 점도 기억해 둔다. 번호를 미리 저장해 두었다가 삭제 뒤에 쓰면 엉뚱한 값을 가리킬 수 있다.

범위 밖 첨자

값이 4개인 배열의 유효한 첨자는 0, 1, 2, 3이다. 그 밖의 번호, 예를 들어 7로 접근하면 Swift 는 실행 중에 프로그램을 멈춘다. 오류 메시지를 보여 주고 종료하는 이 동작을 트랩(trap)이라고 부른다. 빈 값을 조용히 돌려주는 방식도 가능하겠지만, 없는 칸을 읽는 것은 대개 계산이 이미 틀어졌다는 신호이고, 틀린 값을 가지고 계속 진행하면 통계가 조용히 어긋나기 때문에 Swift 는 곧바로 멈추는 쪽을 택했다.

값이 4개인 배열은 첨자 0부터 3까지만 유효하고 7 같은 범위 밖 첨자는 프로그램을 멈춘다.

그래서 번호가 어디서 오는지에 따라 두 방식을 구분한다. 코드 안에서 내가 계산한 번호는 count와 비교해 확인한다. 사용자 입력처럼 믿을 수 없는 번호는 반드시 먼저 확인한다. "첫 값"이나 "마지막 값"이 필요할 때는 번호를 쓰지 않고 first와 last를 쓴다. 이 둘은 배열이 비어 있으면 값 대신 "없음"을 나타내는 옵셔널을 돌려주므로 멈추지 않는다. 옵셔널은 다음 절에서 딕셔너리와 함께 만나고, 여기서는 ?? 뒤에 기본값을 적어 "없으면 이 값을 쓴다"고 처리하는 방법만 쓴다.

딕셔너리와 Set

딕셔너리: 이름표로 찾는 표

딕셔너리는 키(key)와 값(value)의 쌍을 담는다. 타입은 [String: Int]처럼 쓰며 콜론 앞이 키, 뒤가 값이다. 습관 이름을 키로, 연속 달성일을 값으로 두면 번호를 외우지 않고 이름으로 찾을 수 있다.

var streaks: [String: Int] = ["물 마시기": 5, "운동": 2, "독서": 0]
streaks["운동"] = 3      // 이미 있는 키: 값을 바꾼다
streaks["명상"] = 1      // 없는 키: 새 쌍이 생긴다

같은 문법이 키가 있으면 수정, 없으면 추가로 동작한다. 지울 때는 removeValue(forKey:)를 쓴다. 딕셔너리는 담긴 쌍의 순서를 약속하지 않는다. 같은 코드를 두 번 실행하면 순회 순서가 달라질 수 있으므로, 화면이든 로그든 결과를 확인해야 한다면 키를 정렬해서 쓴다. 이 장의 예제는 streaks.keys.sorted()로 정렬한 뒤 출력한다.

조회 결과가 옵셔널인 이유

streaks["운동"]의 타입은 Int가 아니라 Int?다. 물음표는 "값이 있을 수도 없을 수도 있다"는 표시다. 이유는 키가 없을 수 있기 때문이다. 만약 조회가 그냥 Int를 돌려준다면 "수영"처럼 없는 키에는 무엇을 돌려줘야 할까. 0을 돌려주면 이 예제의 "독서: 0일"과 구분되지 않는다. 독서는 기록은 있지만 오늘 연속이 끊긴 상태이고, 수영은 아예 기록이 없는 상태다. 이 둘을 같은 0으로 두면 "시작하지 않은 습관"과 "실패한 습관"을 앱이 구별하지 못한다. 옵셔널은 이 구별을 타입으로 강제한다. 값을 쓰기 전에 있는지 없는지를 반드시 처리하게 만든다.

처리 방법은 두 가지를 쓴다. 없을 때 쓸 기본값이 분명하면 ??를 쓴다. 있을 때와 없을 때 하는 일이 다르면 if let으로 값을 꺼낸다. if let 이름 = 옵셔널값은 값이 있으면 그 값을 이름에 담아 블록을 실행하고, 없으면 else 쪽으로 간다. 옵셔널의 전체 규칙은 이후 장에서 따로 다루며, 이 장에서는 이 두 형태만 쓴다.

Set: 중복이 없는 모음

Set 은 같은 값을 두 번 담지 않는 컬렉션이다. 순서도 없다. 배열과 달리 첨자로 접근하지 않고 "이 값이 들어 있는가"를 묻는 데 쓴다. 타입을 적지 않고 ["물 마시기", "운동"]이라고만 쓰면 배열로 해석되므로, Set 을 만들 때는 Set<String>처럼 타입을 적어 준다.

Set 의 장점은 두 집합을 비교하는 연산이 이미 준비되어 있다는 것이다. 월요일에 한 습관 monday와 화요일에 한 습관 tuesday를 아래처럼 비교한다.

월요일 {물 마시기, 운동, 독서}, 화요일 {물 마시기, 독서, 명상}에 적용한 Set 연산
연산코드결과의미
교집합monday.intersection(tuesday)독서, 물 마시기둘 다에 있는 값
합집합monday.union(tuesday)독서, 명상, 물 마시기, 운동어느 한쪽에라도 있는 값
차집합monday.subtracting(tuesday)운동앞쪽에만 있는 값
대칭차monday.symmetricDifference(tuesday)명상, 운동한쪽에만 있는 값

값을 하나 넣을 때는 insert를 쓴다. 이 메서드는 실제로 새로 들어갔는지를 inserted로 알려 주므로, 이미 있는 값을 다시 넣으면 false가 되고 개수는 늘지 않는다. 중복을 직접 검사하는 코드를 쓰지 않아도 되는 것이다. 단, Set 을 그대로 출력하면 순서가 실행마다 달라질 수 있으므로 딕셔너리처럼 sorted()로 정렬한 뒤 출력한다.

값 의미론

배열, 딕셔너리, Set 은 모두 값 타입이다. 변수에 담긴 컬렉션을 다른 변수에 대입하거나 상수에 담으면 같은 대상을 가리키게 되는 것이 아니라 독립된 복사본이 생긴다. 이 성질을 값 의미론이라고 한다. 아래 두 줄에서 copied를 고쳐도 waterLog는 변하지 않는다.

var copied = waterLog
copied.append(999)
컬렉션을 다른 변수에 대입하면 독립된 복사본이 생기므로 복사본에 추가해도 원본은 그대로다.

이 성질 덕분에 통계 계산 코드가 안전해진다. 기록 전체를 복사해서 정렬하거나 일부를 지우며 계산해도 사용자의 원본 기록은 건드리지 않는다. "누가 이 배열을 몰래 바꾸지 않았을까"를 걱정하지 않아도 된다는 뜻이다. 반대로, 복사본을 고친 결과를 원본에 반영하고 싶다면 고친 값을 원본에 다시 대입해야 한다. 자동으로 반영되지 않는다.

복사할 때마다 모든 항목을 실제로 베끼는 것은 아니다. Swift 는 내용을 바꾸기 전까지는 같은 저장 공간을 나누어 쓰고, 한쪽을 고치는 순간 복사한다. 이 최적화는 눈에 보이지 않으므로 의미만 기억하면 된다. 대입하면 독립된 값이라는 의미다. 값 타입과 참조 타입의 차이는 구조체와 클래스를 다루는 장에서 다시 만난다.

완성 코드

아래는 위 내용을 한 파일에 모은 프로그램이다. main.swift로 저장한다. 반복문 for ... in은 다음 장에서 제대로 다루며, 여기서는 "컬렉션의 값을 하나씩 꺼내 블록을 실행한다"는 의미만 알면 된다.

// main.swift
// 습관 기록 앱: 배열, 딕셔너리, Set, 복사

// 1. 배열: 물 마신 양(ml)
var waterLog: [Int] = [250, 300, 200]
waterLog.append(500)
waterLog.insert(150, at: 0)
print("물 기록: \(waterLog)")

let removed = waterLog.remove(at: 1)
print("삭제한 기록: \(removed)ml")
print("남은 기록: \(waterLog)")

var total = 0
for amount in waterLog {
    total += amount
}
print("오늘 총량: \(total)ml, 기록 \(waterLog.count)회")

// 2. 범위 밖 첨자는 먼저 확인한다
let wanted = 7
if wanted < waterLog.count {
    print("첨자 \(wanted)의 기록: \(waterLog[wanted])ml")
} else {
    print("첨자 \(wanted): 범위 밖이다 (유효한 첨자는 0부터 \(waterLog.count - 1)까지)")
}

let emptyLog: [Int] = []
print("빈 배열의 첫 값: \(emptyLog.first ?? 0)")
print("물 기록의 마지막 값: \(waterLog.last ?? 0)")

// 3. 딕셔너리: 습관별 연속 달성일
var streaks: [String: Int] = ["물 마시기": 5, "운동": 2, "독서": 0]
streaks["운동"] = 3
streaks["명상"] = 1
let dropped = streaks.removeValue(forKey: "독서")
print("지운 값: \(dropped ?? -1)")

let waterStreak = streaks["물 마시기"] ?? 0
print("물 마시기 연속 달성일: \(waterStreak)일")

if let swimStreak = streaks["수영"] {
    print("수영 연속 달성일: \(swimStreak)일")
} else {
    print("수영은 아직 기록이 없다")
}

for name in streaks.keys.sorted() {
    print("- \(name): \(streaks[name] ?? 0)일")
}

// 4. Set: 요일별 습관 비교
let monday: Set<String> = ["물 마시기", "운동", "독서"]
let tuesday: Set<String> = ["물 마시기", "독서", "명상"]
print("이틀 모두: \(monday.intersection(tuesday).sorted().joined(separator: ", "))")
print("하루라도: \(monday.union(tuesday).sorted().joined(separator: ", "))")
print("월요일에만: \(monday.subtracting(tuesday).sorted().joined(separator: ", "))")
print("한쪽에만: \(monday.symmetricDifference(tuesday).sorted().joined(separator: ", "))")

var doneToday: Set<String> = ["운동"]
let firstTry = doneToday.insert("독서").inserted
let secondTry = doneToday.insert("독서").inserted
print("독서 첫 추가: \(firstTry), 다시 추가: \(secondTry), 개수: \(doneToday.count)")

// 5. 값 의미론: 복사는 독립이다
var copied = waterLog
copied.append(999)
print("원본: \(waterLog)")
print("복사본: \(copied)")

var snapshot = streaks
snapshot["운동"] = 10
print("원본 운동: \(streaks["운동"] ?? 0)일, 복사본 운동: \(snapshot["운동"] ?? 0)일")

줄별 해설

  • 배열 만들기와 고치기: waterLog는 var이므로 append로 500을 맨 뒤에, insert(150, at: 0)으로 150을 맨 앞에 넣는다. 그 결과 [150, 250, 300, 200, 500]이 된다.
  • 삭제: remove(at: 1)은 1번 칸의 250을 지우고 돌려준다. 돌려받은 값을 removed에 담아 출력한다. 뒤의 값들이 앞으로 당겨져 [150, 300, 200, 500]이 남는다.
  • 합계: total은 0으로 시작해, for 블록이 값을 하나씩 amount에 꺼낼 때마다 +=로 더한다. 150 + 300 + 200 + 500 이므로 1150이다.
  • 첨자 확인: wanted < waterLog.count는 4보다 작은지 묻는다. 7은 크므로 else 쪽이 실행되어 접근 자체를 하지 않는다. waterLog.count - 1은 마지막으로 유효한 첨자인 3이다.
  • first와 last: 빈 배열의 first는 값이 없으므로 ?? 0의 기본값 0이 쓰인다. 값이 있는 waterLog.last는 500이다.
  • 딕셔너리 수정: 이미 있는 "운동"은 값이 3으로 바뀌고, 없던 "명상"은 새로 생긴다. removeValue(forKey: "독서")는 지운 값인 0을 옵셔널로 돌려주며 ?? -1은 지울 것이 없었을 때를 대비한 기본값이다. 이번에는 값이 있으므로 0이 출력된다.
  • 조회: streaks["물 마시기"] ?? 0은 5다. "수영"은 키가 없어서 if let 조건이 거짓이 되고 else 가 실행된다.
  • 정렬된 순회: streaks.keys.sorted()는 키를 정렬한 배열이다. 한글은 가나다 순서로 정렬되어 명상, 물 마시기, 운동 순이 된다.
  • Set 연산: 연산 결과도 Set 이므로 sorted()로 배열로 바꾸고 joined(separator: ", ")로 쉼표로 이어 붙여 출력한다.
  • insert: 독서를 처음 넣으면 inserted가 true이고, 다시 넣으면 이미 있으므로 false다. 최종 개수는 운동과 독서 둘이다.
  • 복사: copied에 append(999)를 해도 waterLog는 그대로다. 딕셔너리도 마찬가지여서 snapshot의 운동을 10으로 바꿔도 streaks의 운동은 3이다.

실행 결과

$ swiftc main.swift -o app
$ ./app
물 기록: [150, 250, 300, 200, 500]
삭제한 기록: 250ml
남은 기록: [150, 300, 200, 500]
오늘 총량: 1150ml, 기록 4회
첨자 7: 범위 밖이다 (유효한 첨자는 0부터 3까지)
빈 배열의 첫 값: 0
물 기록의 마지막 값: 500
지운 값: 0
물 마시기 연속 달성일: 5일
수영은 아직 기록이 없다
- 명상: 1일
- 물 마시기: 5일
- 운동: 3일
이틀 모두: 독서, 물 마시기
하루라도: 독서, 명상, 물 마시기, 운동
월요일에만: 운동
한쪽에만: 명상, 운동
독서 첫 추가: true, 다시 추가: false, 개수: 2
원본: [150, 300, 200, 500]
복사본: [150, 300, 200, 500, 999]
원본 운동: 3일, 복사본 운동: 10일

실무에서 자주 틀리는 것

1. 범위를 확인하지 않고 첨자로 접근한다

사용자가 고른 번호를 그대로 첨자에 넣으면 입력에 따라 프로그램이 멈춘다.

// 틀린 코드: 번호가 3보다 크면 실행 중에 멈춘다
let log = [150, 300, 200, 500]
let choice = 9
print(log[choice])
// 고친 코드: 유효한 번호인지 먼저 확인한다
let log = [150, 300, 200, 500]
let choice = 9
if choice >= 0 && choice < log.count {
    print(log[choice])
} else {
    print("없는 번호다")
}

음수 번호도 범위 밖이므로 0 이상인지도 함께 확인한다. 위치가 필요 없고 처음이나 끝만 필요하다면 first, last와 ??를 쓰는 편이 간단하다.

2. 옵셔널인 조회 결과를 그대로 계산한다

// 틀린 코드: 컴파일 오류. Int? 는 Int 가 아니다
let streaks: [String: Int] = ["운동": 3]
let next = streaks["운동"] + 1
// 고친 코드: 없을 때의 기본값을 정한 뒤 계산한다
let streaks: [String: Int] = ["운동": 3]
let next = (streaks["운동"] ?? 0) + 1

이 오류는 컴파일러가 알려 주므로 실행 전에 발견된다. 이때 느낌표로 강제로 값을 꺼내는 방법도 있지만, 키가 없으면 역시 프로그램이 멈추므로 초보 단계에서는 ??나 if let을 먼저 쓴다.

3. let 으로 만든 컬렉션을 고치려 한다

// 틀린 코드: 컴파일 오류. let 배열은 바꿀 수 없다
let log = [250, 300]
log.append(500)
// 고친 코드: 내용이 바뀌는 값은 var 로 선언한다
var log = [250, 300]
log.append(500)

앞 장에서 let을 기본으로 쓰라고 했지만, 그것은 값이 바뀌지 않을 때의 이야기다. 기록이 쌓이는 컬렉션은 var가 맞다. 반대로 한 번 만들고 바꾸지 않는 컬렉션은 let으로 두면 실수로 고치는 일을 컴파일러가 막아 준다.

4. 복사본을 고치면 원본도 바뀐다고 기대한다

// 틀린 코드: 정렬 결과가 원본에 반영되리라 기대한다
var scores = [30, 10, 20]
var sortedScores = scores
sortedScores.sort()
print(scores)   // [30, 10, 20] 그대로다
// 고친 코드: 원본을 바꾸려면 결과를 다시 대입한다
var scores = [30, 10, 20]
var sortedScores = scores
sortedScores.sort()
scores = sortedScores
print(scores)   // [10, 20, 30]

복사본은 독립이므로 한쪽의 변경은 다른 쪽에 전해지지 않는다. 처음부터 원본을 바꾸려는 것이라면 복사하지 않고 scores.sort()를 바로 호출하면 된다.

한눈에 보기

세 컬렉션의 성질 비교
컬렉션순서중복조회 결과
Array [Int]넣은 순서를 유지한다같은 값을 여러 번 담을 수 있다첨자로 접근하며 범위 밖이면 멈춘다
Dictionary [String: Int]순서를 믿지 않는다키는 하나뿐이다키로 조회하며 결과가 옵셔널이다
Set Set<String>순서를 믿지 않는다같은 값은 한 번만 담긴다contains로 포함 여부를 묻는다
  • 배열은 append, insert(_:at:), remove(at:)로 고친다. 고치려면 var여야 한다.
  • 첨자는 0부터 count - 1까지만 유효하다. 믿을 수 없는 번호는 먼저 확인하고, 처음과 끝은 first, last를 쓴다.
  • 딕셔너리 조회는 키가 없을 수 있어서 옵셔널이다. ??로 기본값을 주거나 if let으로 나눠 처리한다.
  • 딕셔너리와 Set 을 출력할 때는 정렬해서 결과를 일정하게 만든다.
  • 컬렉션은 값 타입이므로 대입하면 독립된 복사본이 생긴다.

연습 문제

  1. 독서 기록 var pages = [12, 30, 25]가 있다. 18을 맨 뒤에 추가하고 맨 앞 값을 삭제한 뒤, 남은 페이지의 합을 출력하는 코드를 써라.
  2. var counts: [String: Int] = ["물": 3]에서 counts["운동"]의 타입과 값은 무엇인가. 그리고 "운동" 횟수를 1 늘리는 코드를 써라. 처음에는 기록이 없는 상태다.
  3. 지난주 습관 ["물", "명상"]과 이번 주 습관 ["물", "운동", "독서"]를 Set 으로 만들고, 이번 주에 새로 시작한 습관을 정렬해서 쉼표로 이어 출력하는 코드를 써라.
  4. 다음 코드의 출력을 예측하고 이유를 설명하라.
    var a = [1, 2, 3]
    var b = a
    b[0] = 99
    a.append(4)
    print(a, b)

정답과 해설

1번.

var pages = [12, 30, 25]
pages.append(18)
pages.remove(at: 0)

var sum = 0
for page in pages {
    sum += page
}
print(sum)

추가 후 [12, 30, 25, 18], 맨 앞 값 12를 지우면 [30, 25, 18]이다. 합은 73이 출력된다. remove(at:)이 돌려주는 값은 쓰지 않아도 된다.

2번. counts["운동"]의 타입은 Int?이고 키가 없으므로 값은 없음(nil)이다. 횟수를 1 늘리는 코드는 다음과 같다.

counts["운동"] = (counts["운동"] ?? 0) + 1

없으면 0으로 보고 1을 더하므로 "운동"은 1이 된다. 이미 있었다면 그 값에 1이 더해진다. 같은 문법이 키가 없을 때는 추가, 있을 때는 수정으로 동작한다는 점을 활용한 것이다.

3번.

let lastWeek: Set<String> = ["물", "명상"]
let thisWeek: Set<String> = ["물", "운동", "독서"]
let started = thisWeek.subtracting(lastWeek)
print(started.sorted().joined(separator: ", "))

이번 주에만 있는 값은 차집합 thisWeek.subtracting(lastWeek)이다. 결과는 "독서, 운동"이다. 순서를 고정하려고 sorted()를 거쳤고, 가나다순으로 독서가 먼저다.

4번. 출력은 [1, 2, 3, 4] [99, 2, 3]이다. var b = a에서 b는 a의 독립된 복사본이 된다. 이후 b[0] = 99는 b만, a.append(4)는 a만 바꾼다. 두 배열은 서로의 변경에 영향을 받지 않는다. 공식 설명은 Swift 언어 안내서의 컬렉션 타입 항목에서 확인할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.