Swift · 기본
Swift로 만드는 첫 앱 로직
제네릭과 동시성 첫걸음 - async await
제네릭 함수·타입과 제약, async 함수와 await, Task 와 결과 모으기, 순서가 정해진 출력 만들기, Sendable 경고 만나는 법
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 오류를 던지고 잡는 방법을 익혔다. 이 장에서는 두 가지 도구를 더한다. 하나는 같은 로직을 여러 타입에 재사용하게 해 주는 제네릭(generic)이다. 다른 하나는 오래 걸리는 일을 기다리는 동안 다른 일을 진행할 수 있게 해 주는 동시성(concurrency)이다. 두 도구 모두 습관 기록 앱의 통계 로직에 바로 쓰인다.
- 제네릭 함수와 제네릭 타입을 만들고, 타입 매개변수에 제약을 거는 이유를 설명한다.
async함수를 정의하고await로 결과를 기다린다.async let,Task, 작업 그룹으로 여러 작업을 동시에 시작하고 결과를 모은다.- 끝나는 순서와 상관없이 정해진 순서로 출력을 만든다.
- Sendable 오류와 경고가 왜 나오는지 읽고 고친다.
문제 상황
습관 기록 앱에 통계 기능을 붙이고 있다. 물 마시기는 밀리리터, 운동은 분, 독서는 쪽 수를 기록한다. 이제 습관마다 합계와 하루 최고 기록을 구해야 한다.
처음에는 정수 배열의 합을 구하는 함수를 만든다. 얼마 뒤 평균 운동 시간을 소수로 계산해야 해서 Double 배열용 함수를 복사해 만든다. 최고 기록은 문자열 비교에도 쓰고 싶어서 또 복사한다. 본문이 똑같은 함수가 타입 이름만 바꾼 채 여러 개 생긴다. 한 곳에서 버그를 고치면 나머지 복사본도 모두 찾아 고쳐야 한다.
두 번째 문제는 기다림이다. 습관별 기록을 저장소에서 불러오는 데 시간이 걸린다고 하자. 세 습관을 하나씩 차례로 불러오면 걸리는 시간이 세 번 더해진다. 세 습관은 서로 상관이 없으니 동시에 불러오고 싶다. 그런데 동시에 불러오면 먼저 끝난 습관이 먼저 도착해서, 화면에 보일 순서가 매번 달라질 수 있다. 이 장은 이 두 문제를 차례로 푼다.
제네릭: 타입을 나중에 정하는 코드
제네릭 함수
제네릭 함수는 타입 자리에 이름표를 두고, 호출하는 시점에 실제 타입을 채운다. 이름표를 타입 매개변수(type parameter)라 하고, 관례로 T 처럼 대문자 한 글자나 의미 있는 이름을 쓴다. 꺾쇠 안에 이름을 적어 선언한다.
func largest<T: Comparable>(in values: [T]) -> T? {
guard var best = values.first else {
return nil
}
for value in values.dropFirst() {
if value > best {
best = value
}
}
return best
}
이 함수는 [Int] 로 부르면 Int? 를, [String] 으로 부르면 String? 을 돌려준다. 빈 배열일 수 있으므로 반환 타입은 옵셔널이다. 컴파일러가 인자의 타입을 보고 T 를 알아서 정하므로 호출할 때 타입을 따로 적지 않는다.
제약: T 가 할 수 있는 일을 약속하기
<T: Comparable> 에서 콜론 뒤는 제약(constraint)이다. T 는 크기를 비교할 수 있는 타입이어야 한다는 뜻이다. 제약이 없으면 T 가 어떤 타입인지 알 수 없어서 > 연산자를 쓸 수 없다. 제약은 함수 본문이 쓸 수 있는 기능의 목록이기도 하고, 호출하는 쪽이 지켜야 할 조건이기도 하다. 비교할 수 없는 타입을 넘기면 컴파일러가 호출하는 줄에서 오류를 낸다.
| 제약 | 쓸 수 있게 되는 것 | 예시 타입 | 이 장의 쓰임 |
|---|---|---|---|
Comparable | <, >, 정렬 | Int, Double, String | 최고 기록, 순위 |
Equatable | ==, != | Int, String, Bool | 이 장에서는 쓰지 않음 |
AdditiveArithmetic | +, -, zero | Int, Double | 합계 |
Sendable | 작업 사이로 안전하게 전달 | 값만 가진 구조체 | 작업 결과 전달 |
합계 함수는 시작값이 필요하다. 0 이라고 적으면 Double 에는 맞지 않을 수 있으므로, AdditiveArithmetic 이 약속하는 zero 를 쓴다. 정수에서는 0, 소수에서는 0.0 이 된다.
func total<T: AdditiveArithmetic>(of values: [T]) -> T {
var result = T.zero
for value in values {
result = result + value
}
return result
}
제네릭 타입
함수뿐 아니라 구조체와 클래스, 열거형도 제네릭으로 만들 수 있다. 배열 Array<Element> 와 딕셔너리 Dictionary<Key, Value> 가 그 예다. 점수가 높은 순서로 값을 모으는 순위표를 만들어 보자.
struct Ranking<Item: Comparable> {
private(set) var items: [Item] = []
mutating func add(_ item: Item) {
items.append(item)
items.sort(by: >)
}
func top(_ count: Int) -> [Item] {
Array(items.prefix(count))
}
}
사용할 때는 Ranking<Int>() 처럼 타입을 꺾쇠 안에 적는다. 같은 구조체로 문자열 순위표도 만들 수 있다.
async 와 await: 기다리는 동안 다른 일 하기
async 함수와 await
중간에 멈추고 기다릴 수 있는 함수에는 반환 화살표 앞에 async 를 붙인다. 이런 함수를 부를 때는 호출 앞에 await 를 적는다. 여기서 기다린다는 표시다. 기다리는 동안 프로그램은 스레드를 붙잡고 있지 않고, 다른 작업이 그 자리를 쓸 수 있다.
func loadSummary(log: HabitLog) async -> HabitSummary {
// 저장소에서 읽어 오는 데 걸리는 시간을 흉내 낸다
try? await Task.sleep(for: .milliseconds(20))
return HabitSummary(name: log.name, total: 0, best: 0)
}
let summary = await loadSummary(log: someLog)
await 는 async 함수 안이나 main.swift 의 최상위 코드에서만 쓸 수 있다. 일반 함수 안에서 쓰면 컴파일 오류가 난다. try? 는 앞 장에서 본 것처럼 오류를 옵셔널로 바꾼다. Task.sleep 은 취소되면 오류를 던지는데, 여기서는 취소를 다루지 않으므로 무시한다.
결과를 모으는 세 가지 방법
await 를 줄줄이 적으면 한 번에 하나씩 순서대로 실행된다. 독립적인 일을 동시에 시작하려면 아래 도구를 쓴다.
| 도구 | 작업 개수 | 결과 받는 법 | 알맞은 상황 |
|---|---|---|---|
async let | 코드에 적은 개수만큼 | 변수를 await | 서너 개를 한꺼번에 시작 |
withTaskGroup | 실행 중에 정해짐 | for await 로 도착하는 대로 | 배열의 모든 원소를 처리 |
Task { } | 하나 | await task.value | 동기 코드에서 비동기 일을 시작 |
async let 은 변수를 선언하는 순간 작업이 시작되고, 값이 필요한 지점에서 await 한다.
async let a = loadSummary(log: first)
async let b = loadSummary(log: second)
let both = await [a, b]
두 작업이 같이 진행되므로 전체 시간은 둘의 합이 아니라 더 오래 걸리는 쪽에 가깝다. 이런 작업은 함수 범위 안에서 반드시 끝난다. 이를 구조화된 동시성(structured concurrency)이라 한다.
작업 그룹은 개수가 실행 중에 정해질 때 쓴다. 그룹에 addTask 로 작업을 넣고, for await 로 끝난 것부터 하나씩 받는다.
Task { } 는 새 작업을 하나 시작하고 그 작업을 가리키는 값을 돌려준다. 결과가 필요하면 await task.value 로 기다린다. 기다리지 않으면 프로그램이 먼저 끝나 작업이 중간에 사라질 수 있으므로, 이 장의 예제는 항상 결과를 기다린다.
순서가 정해진 출력 만들기
작업 그룹은 끝난 순서대로 결과를 내준다. 기록이 적은 습관이 더 빨리 끝난다면 화면에 보일 순서가 호출할 때마다 달라질 수 있다. 사용자에게는 늘 같은 순서로 보여야 한다. 해결은 단순하다. 작업을 넣을 때 번호(index)를 붙여 결과와 함께 돌려받고, 모두 모인 뒤에 번호로 정렬한다.
func loadAll(_ logs: [HabitLog]) async -> [HabitSummary] {
await withTaskGroup(of: (Int, HabitSummary).self, returning: [HabitSummary].self) { group in
for (index, log) in logs.enumerated() {
group.addTask {
let summary = await loadSummary(log: log)
return (index, summary)
}
}
var results: [(Int, HabitSummary)] = []
for await item in group {
results.append(item)
}
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
이렇게 하면 작업이 끝나는 순서는 달라져도 반환되는 배열의 순서는 입력 순서와 같다. 출력이 결정적이어야 하는 이 책의 예제에서도 이 방식을 쓴다.
Sendable: 작업 사이로 넘겨도 안전한 값
동시에 실행되는 작업들이 같은 변수를 동시에 바꾸면 결과를 예측할 수 없다. 그래서 Swift 는 작업 경계를 넘나드는 값이 안전한지 컴파일 시점에 검사한다. 안전하다고 보증된 타입에는 Sendable 이라는 표시가 붙는다. Int, String, 그리고 이런 타입만 가진 구조체와 열거형은 값이 복사되어 넘어가므로 안전하다. 바뀔 수 있는 저장 속성을 가진 클래스는 여러 작업이 하나의 객체를 공유하게 되므로 그대로는 안전하지 않다.
Swift 6 언어 모드에서 swiftc 는 이런 위반을 오류로 알린다. Swift 5 모드에서는 같은 내용이 경고로 나온다. 메시지는 대개 “Sendable 이 아닌 타입을 Sendable 클로저에서 캡처했다”는 취지다. 읽는 요령은 세 가지다. 어떤 변수인지, 어떤 타입인지, 어느 클로저인지를 차례로 확인한다. 그리고 공유를 없애는 쪽으로 고친다. 이 장의 예제에서는 작업이 값을 돌려주고, 합치는 일은 부모 쪽에서 한 번에 하는 방식을 쓴다. 이 구조에서는 공유하는 변수가 없으므로 경고가 나오지 않는다.
완성 코드
아래 코드는 main.swift 한 파일이다. 세 습관의 기록을 동시에 불러와 합계와 최고 기록을 구하고, 입력 순서대로 출력한다.
// 제네릭 함수와 제약
func total<T: AdditiveArithmetic>(of values: [T]) -> T {
var result = T.zero
for value in values {
result = result + value
}
return result
}
func largest<T: Comparable>(in values: [T]) -> T? {
guard var best = values.first else {
return nil
}
for value in values.dropFirst() {
if value > best {
best = value
}
}
return best
}
// 제네릭 타입
struct Ranking<Item: Comparable> {
private(set) var items: [Item] = []
mutating func add(_ item: Item) {
items.append(item)
items.sort(by: >)
}
func top(_ count: Int) -> [Item] {
Array(items.prefix(count))
}
}
// 작업 사이로 넘겨도 안전한 값 타입
struct HabitLog: Sendable {
let name: String
let amounts: [Int]
}
struct HabitSummary: Sendable {
let name: String
let total: Int
let best: Int
}
// 기록이 적을수록 오래 걸리게 해서 끝나는 순서가 입력 순서와 다르게 만든다
func loadSummary(log: HabitLog) async -> HabitSummary {
let delay = max(5, 60 - log.amounts.count * 10)
try? await Task.sleep(for: .milliseconds(delay))
let amountSum = total(of: log.amounts)
let amountBest = largest(in: log.amounts) ?? 0
return HabitSummary(name: log.name, total: amountSum, best: amountBest)
}
func loadAll(_ logs: [HabitLog]) async -> [HabitSummary] {
await withTaskGroup(of: (Int, HabitSummary).self, returning: [HabitSummary].self) { group in
for (index, log) in logs.enumerated() {
group.addTask {
let summary = await loadSummary(log: log)
return (index, summary)
}
}
var results: [(Int, HabitSummary)] = []
for await item in group {
results.append(item)
}
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
func loadTwo(_ first: HabitLog, _ second: HabitLog) async -> [HabitSummary] {
async let a = loadSummary(log: first)
async let b = loadSummary(log: second)
return await [a, b]
}
func line(_ summary: HabitSummary) -> String {
"\(summary.name): 합계 \(summary.total), 최고 \(summary.best)"
}
let logs = [
HabitLog(name: "물 마시기", amounts: [500, 750, 600, 900]),
HabitLog(name: "운동", amounts: [30, 45, 0, 40, 25]),
HabitLog(name: "독서", amounts: [20, 35, 15])
]
print("== 제네릭 ==")
print(total(of: [1, 2, 3]))
print(total(of: [0.5, 0.25]))
print(largest(in: ["apple", "pear"]) ?? "없음")
let empty: [Int] = []
print(largest(in: empty) ?? -1)
print("== 동시에 불러오기 ==")
let summaries = await loadAll(logs)
for summary in summaries {
print(line(summary))
}
print("== Task 하나 ==")
let firstTask = Task {
await loadSummary(log: logs[0])
}
let firstSummary = await firstTask.value
print("\(firstSummary.name) \(firstSummary.total)")
print("== async let ==")
let pair = await loadTwo(logs[1], logs[2])
print(pair.map { $0.name }.joined(separator: ", "))
print("== 합계 순위 ==")
var ranking = Ranking<Int>()
for summary in summaries {
ranking.add(summary.total)
}
print(ranking.top(2))
줄별 해설
total(of:):AdditiveArithmetic제약 덕분에T.zero와+를 쓴다. 함수 안에서 지역 상수 이름을total로 하면 함수 이름과 겹치므로, 호출하는 쪽에서는amountSum처럼 다른 이름을 쓴다.largest(in:):guard var로 첫 원소를 꺼내 시작값으로 삼는다. 빈 배열이면nil을 돌려준다. 나머지는dropFirst()로 첫 원소를 건너뛰고 비교한다.Ranking:private(set)은 바깥에서 읽기만 허용한다.sort(by: >)는 큰 값이 앞에 오도록 정렬한다.prefix(count)는 앞의 일부를 잘라 내며, 개수가 모자라도 오류 없이 있는 만큼만 돌려준다.HabitLog,HabitSummary: 문자열과 정수만 가진 구조체이므로Sendable을 명시해도 문제가 없다. 작업이 이 값을 복사해서 가져가므로 공유 상태가 없다.loadSummary: 기록 개수에 따라 대기 시간을 정한다. 물 마시기는 20ms, 운동은 10ms, 독서는 30ms 이므로 끝나는 순서는 운동, 물 마시기, 독서다. 입력 순서와 다르다는 점을 일부러 만든 것이다.loadAll:of:는 자식 작업이 돌려줄 타입이고returning:은 그룹 전체가 돌려줄 타입이다.enumerated()로 번호를 얻어 결과와 함께 돌려준다. 마지막 줄에서 번호로 정렬한 뒤 번호를 떼어 낸다.loadTwo:async let두 줄이 두 작업을 동시에 시작한다.await [a, b]에서 두 결과를 한 번에 기다린다.- 최상위 코드:
await를 바로 쓸 수 있다.Task { ... }의 클로저는 한 식뿐이므로 값을 바로 돌려주고,firstTask.value로 그 값을 받는다. ranking.add: 합계 세 개 2750, 140, 70 이 큰 순서로 정렬되고,top(2)가 앞의 두 개를 돌려준다.
실행 결과
$ swiftc main.swift -o app
$ ./app
== 제네릭 ==
6
0.75
pear
-1
== 동시에 불러오기 ==
물 마시기: 합계 2750, 최고 900
운동: 합계 140, 최고 45
독서: 합계 70, 최고 35
== Task 하나 ==
물 마시기 2750
== async let ==
운동, 독서
== 합계 순위 ==
[2750, 140]
작업이 끝나는 순서는 실행할 때마다 달라질 수 있지만, 출력은 번호로 정렬하므로 매번 위와 같다.
실무에서 자주 틀리는 것
제약 없이 비교하기
틀린 코드는 T 가 무엇인지 모르는 상태에서 > 를 쓴다.
func largest<T>(in values: [T]) -> T? {
guard var best = values.first else { return nil }
for value in values.dropFirst() {
if value > best { best = value } // 오류: T 에는 > 가 없다
}
return best
}
고친 코드는 Comparable 제약을 건다.
func largest<T: Comparable>(in values: [T]) -> T? {
guard var best = values.first else { return nil }
for value in values.dropFirst() {
if value > best { best = value }
}
return best
}
await 를 빼먹거나 일반 함수에서 쓰기
틀린 코드는 async 함수를 일반 함수 안에서 부르고, await 도 없다.
func printFirst(log: HabitLog) {
let summary = loadSummary(log: log) // 오류: await 가 필요하고, 일반 함수에서는 부를 수 없다
print(summary.name)
}
고친 코드는 호출하는 함수도 async 로 만들고 await 를 적는다.
func printFirst(log: HabitLog) async {
let summary = await loadSummary(log: log)
print(summary.name)
}
끝나는 순서를 입력 순서로 착각하기
틀린 코드는 그룹에서 받은 순서대로 배열에 쌓는다. 실행할 때마다 순서가 바뀔 수 있다.
var names: [String] = []
for await summary in group {
names.append(summary.name) // 먼저 끝난 습관이 앞에 온다
}
고친 코드는 번호를 함께 받아 정렬한다.
var results: [(Int, HabitSummary)] = []
for await item in group {
results.append(item)
}
let ordered = results.sorted { $0.0 < $1.0 }.map { $0.1 }
작업들이 공유 객체를 바꾸기
틀린 코드는 여러 작업이 클래스 객체의 값을 동시에 바꾼다. Swift 6 언어 모드에서는 오류가, Swift 5 모드에서는 경고가 나오며, 메시지는 Sendable 이 아닌 타입을 캡처했다는 내용이다.
final class Counter {
var value = 0
}
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<3 {
group.addTask {
counter.value += 1 // 여러 작업이 같은 객체를 동시에 바꾼다
}
}
}
고친 코드는 작업이 값을 돌려주고 부모가 합친다.
let count = await withTaskGroup(of: Int.self, returning: Int.self) { group in
for _ in 0..<3 {
group.addTask { 1 }
}
var sum = 0
for await part in group {
sum += part
}
return sum
}
print(count)
한눈에 보기
| 문법 | 역할 | 예 | 주의 |
|---|---|---|---|
<T> | 타입 매개변수 선언 | func f<T>(_ x: T) | 본문은 T 의 기능을 모른다 |
<T: 프로토콜> | 제약 걸기 | <T: Comparable> | 쓰는 기능만큼만 건다 |
struct S<Item> | 제네릭 타입 | Ranking<Int>() | 사용할 때 타입을 정한다 |
async / await | 기다릴 수 있는 함수와 호출 | await loadSummary(log:) | async 문맥에서만 쓴다 |
async let | 개수가 고정된 동시 작업 | async let a = f() | 값은 나중에 await 한다 |
withTaskGroup | 개수가 가변인 동시 작업 | group.addTask { } | 번호로 정렬해 순서를 만든다 |
Task { } | 작업 하나 시작 | await task.value | 기다리지 않으면 중간에 끝날 수 있다 |
Sendable | 작업 사이 전달 안전 표시 | struct X: Sendable | 공유 가변 객체는 피한다 |
제네릭과 동시성의 공식 설명은 The Swift Programming Language 의 해당 장에서 확인할 수 있다.
연습 문제
- 정수와 소수 배열의 평균을 구하는 함수를 하나의 제네릭 함수로 만들 수 있는지 생각해 보자. 어떤 제약이 부족한지, 그리고
total(of:)와 어떤 점이 다른지 설명하라. Ranking에 가장 작은 값을 돌려주는bottom속성을 추가하라. 비어 있을 때를 어떻게 표현할지도 함께 정하라.loadAll에서 번호 정렬 줄을 지우면 어떤 일이 생기는지 설명하라. 이 장의 데이터에서 예상되는 출력 순서도 적어라.- 다음 코드를 Swift 6 언어 모드에서 컴파일할 때 오류가 나는 이유를 설명하고, 값 타입을 사용해 고쳐라.
final class Tally { var minutes = 0 } let tally = Tally() await withTaskGroup(of: Void.self) { group in group.addTask { tally.minutes += 30 } group.addTask { tally.minutes += 45 } }
정답과 해설
- 평균은 합계를 개수로 나눠야 한다.
AdditiveArithmetic에는 나눗셈이 없고, 정수와 소수는 나눗셈의 결과 타입도 다르다(정수 나눗셈은 소수점 아래를 버린다). 소수만 대상으로 하려면BinaryFloatingPoint같은 제약을 쓰고, 개수는T(values.count)로 변환한다. 정수와 소수를 같은 방식으로 다루려면 결과를Double로 고정하고 정수는 변환해서 넘기는 편이 단순하다. 제약은 본문이 쓰는 연산에 맞춰 고른다는 점이 핵심이다. var bottom: Item? { items.last }를 추가한다. 내림차순으로 정렬해 두었으므로 마지막 원소가 가장 작다. 비어 있으면last가nil을 돌려주므로, 값이 없을 수 있다는 사실이 옵셔널 타입에 그대로 드러난다.- 결과가 끝난 순서대로 쌓인다. 이 장의 데이터에서는 운동(10ms), 물 마시기(20ms), 독서(30ms) 순으로 끝나므로 출력도 이 순서가 되기 쉽다. 다만 시스템 부하에 따라 달라질 수 있어서, 같은 코드가 어떤 날은 다른 순서로 출력될 수 있다. 입력 순서를 보장하려면 번호로 정렬해야 한다.
- 두 작업이 같은
Tally객체의minutes를 동시에 바꾸고,Tally는Sendable이 아니라서 오류가 난다. 작업이 값을 돌려주게 바꾼다.
출력은 75 다. 합치는 일을 부모 쪽 한 곳에서만 하므로 공유하는 변수가 없다.let minutes = await withTaskGroup(of: Int.self, returning: Int.self) { group in group.addTask { 30 } group.addTask { 45 } var sum = 0 for await part in group { sum += part } return sum } print(minutes)
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.