Swift · 기본
Swift로 만드는 첫 앱 로직
제어 흐름 - guard 와 switch
if·else, guard 로 일찍 빠져나가기, switch 의 완전성과 범위·튜플 패턴, for-in 과 범위 연산자, while, stride
개발자KR · 원고 갱신
이 장에서 배우는 것
프로그램은 위에서 아래로 한 줄씩 실행되다가, 조건에 따라 갈라지거나 같은 일을 되풀이한다. 이런 실행 순서의 조절을 제어 흐름(control flow)이라고 한다. 이 장에서는 습관 기록 앱의 물 마시기 기록을 화면 없이 다룬다. 이틀 치도 아니고 2주 치 기록을 읽으면서 등급을 매기고, 연속 달성일과 주간 합계를 계산한다.
앞 장에서 만든 배열은 값을 모아 두기만 했다. 이제 그 값을 하나씩 꺼내 판단하고 되풀이하는 도구가 필요하다.
if·else로 조건에 따라 실행할 코드를 고른다.guard로 잘못된 값을 먼저 걸러 내고 본문을 평평하게 유지한다.switch에서 범위 패턴과 튜플 패턴을 쓰고, 컴파일러가 요구하는 완전성(exhaustiveness)을 이해한다.for-in, 범위 연산자,while,stride중에서 상황에 맞는 반복 방식을 고른다.
문제 상황
습관 기록 앱을 만든다고 하자. 사용자는 하루에 마신 물의 컵 수를 기록하고, 목표는 하루 8컵이다. 2주 치 기록이 배열로 들어 있다. 이 기록을 두고 앱은 다음 질문에 답해야 한다.
- 날마다 "목표 달성", "보통", "부족" 같은 등급은 무엇인가?
- 오늘까지 며칠 연속으로 목표를 채웠는가?
- 주마다 몇 컵을 마셨는가?
실제 기록에는 잘못된 값도 섞인다. 입력 실수나 동기화 오류로 -1 같은 값이 들어올 수 있다. 이런 값을 평균과 합계에 그대로 더하면 통계가 틀어진다. 조건문을 겹겹이 쌓으면 코드가 오른쪽으로 계속 밀려 읽기 어려워지고, 배열 범위를 한 칸만 잘못 잡아도 프로그램이 멈춘다. 이 장의 도구들은 이런 문제를 막는 데 쓴다.
조건 분기: if 와 guard
if·else
if 는 조건이 참(true)일 때만 중괄호 안을 실행한다. 조건은 반드시 Bool 값이어야 한다. 숫자 1 을 참으로 취급하지 않는다. 조건을 괄호로 감쌀 필요는 없지만 중괄호는 한 줄짜리 본문에도 꼭 써야 한다.
let value = 7
let goal = 8
if value >= goal {
print("목표 달성")
} else if value >= 4 {
print("보통")
} else {
print("부족")
}
위에서부터 차례로 조건을 검사하고, 처음 참인 갈래 하나만 실행한 뒤 나머지는 건너뛴다. 갈래가 세 개를 넘어 값 하나를 여러 구간으로 나누는 경우에는 뒤에서 볼 switch 가 더 읽기 쉽다.
guard 로 일찍 빠져나가기
잘못된 값은 맨 앞에서 걸러 내고 싶다. if 로 정상 값을 감싸면 본문이 한 단계 들여쓰기된다. 검사가 늘어날수록 들여쓰기가 깊어진다. guard 는 반대로 쓴다. "이 조건이 참이어야 아래로 내려간다"고 먼저 선언하고, 거짓이면 else 안에서 현재 범위를 떠난다.
for day in 1...cups.count {
let value = cups[day - 1]
guard value >= 0 else {
print("\(day)일차: 기록 오류, 건너뜀")
continue
}
// 여기서부터는 value 가 0 이상이라고 믿고 쓴다
}
guard 의 else 블록은 반드시 현재 범위를 벗어나야 한다. 벗어나는 방법은 반복문 안에서는 continue 와 break, 함수 안에서는 return 이다. 이 책의 뒷부분에서 함수를 배우면 guard 가 함수 맨 앞의 입력 검사에 가장 자주 쓰인다는 것을 알게 된다. 벗어나는 문장이 없으면 컴파일 오류다. 오류가 나므로 실수로 빼먹은 채 실행될 일은 없다.
switch: 완전하게 나누기
switch 는 값 하나를 여러 패턴(pattern)과 차례로 맞춰 보고, 처음 맞는 case 하나만 실행한다. 다른 언어와 달리 case 끝에 break 를 쓰지 않아도 아래 case 로 흘러 내려가지 않는다. 한 case 에 여러 값을 쓰려면 쉼표로 잇는다. 예를 들면 case 1, 2, 3: 이다.
완전성
Swift 는 switch 가 가능한 모든 값을 다루도록 강제한다. 이 성질을 완전성이라 한다. Bool 은 true 와 false 두 경우만 있으므로 둘을 모두 쓰면 충분하다. 하지만 Int 는 값의 종류가 너무 많아서 컴파일러가 "빠진 값이 없다"고 확인하지 못한다. 그래서 나머지 전부를 받는 default 를 붙인다.
범위 패턴
case 자리에는 값뿐 아니라 범위도 쓸 수 있다. 구간을 나누는 일에서 if·else if 사슬보다 한눈에 들어온다.
switch value {
case 0:
grade = "기록 없음"
case 1...3:
grade = "부족"
case 4...7:
grade = "보통"
case 8...:
grade = "목표 달성"
default:
grade = "범위 밖"
}
범위를 쓰는 표기는 여러 가지다. 아래 표에 정리했다.
| 표기 | 이름 | 포함되는 값 예 | 주로 쓰는 곳 |
|---|---|---|---|
1...3 | 닫힌 범위 | 1, 2, 3 | for-in, case |
0..<3 | 반닫힌 범위 | 0, 1, 2 | 인덱스 순회 |
8... | 하한만 있는 범위 | 8 이상 전부 | case 패턴 |
..<0 | 상한만 있는 범위 | 0 미만 전부 | case 패턴 |
한쪽만 닫힌 범위는 "몇 이상", "몇 미만"을 말할 때 편하다. 끝이 없는 범위는 값을 하나씩 꺼내는 순회에 쓰지 않는다.
튜플 패턴
값 두 개를 묶어 한꺼번에 비교할 수도 있다. 튜플(tuple)은 값 여러 개를 괄호로 묶은 것이다. 각 자리에 값, 범위, 아니면 "무엇이든 좋다"는 뜻의 _ 를 쓴다. 물 컵 수와 운동한 분(分)을 함께 보는 예는 이렇다.
switch (todayCups, todayMinutes) {
case (8..., 20...):
message = "물과 운동 모두 목표 달성"
case (8..., _):
message = "물은 달성, 운동은 아직"
case (_, 20...):
message = "운동은 달성, 물은 부족"
default:
message = "둘 다 아직"
}
순서가 중요하다. 첫 번째 case 가 가장 구체적이고, 아래로 갈수록 느슨해진다. (8..., _) 를 맨 위에 두면 운동 여부와 상관없이 물만 채우면 걸려서 첫 번째 case 에 닿지 못한다.
반복: for-in, while, stride
for-in 과 범위
for-in 은 배열이나 범위의 값을 하나씩 꺼내 변수에 담아 본문을 되풀이한다. 값이 필요 없고 횟수만 필요하면 변수 자리에 _ 를 쓴다.
for value in cups {
// 배열의 값을 하나씩
}
for i in 0..<cups.count {
// 인덱스 0 부터 count - 1 까지
}
for _ in 1...3 {
// 세 번 되풀이, 값은 쓰지 않는다
}
배열의 칸을 인덱스로 순회할 때는 0..<cups.count 가 정석이다. 반닫힌 범위는 끝 값을 포함하지 않으므로 마지막 인덱스가 count - 1 인 배열과 딱 맞는다. 반복 중간에 그만두려면 break, 이번 차례만 건너뛰려면 continue 를 쓴다.
while
while 은 몇 번 돌지 미리 모르고 조건이 참인 동안 되풀이할 때 쓴다. 조건을 먼저 검사하므로 처음부터 거짓이면 본문은 한 번도 실행되지 않는다. 연속 달성일을 셀 때가 대표적이다. 가장 최근 날부터 거꾸로 가다가 목표를 못 채운 날을 만나면 멈춘다. 몇 번 돌지는 기록에 따라 달라진다. 조건을 바꿔 줄 문장, 즉 인덱스를 줄이는 문장을 본문에 직접 써야 한다.
&& 는 왼쪽이 거짓이면 오른쪽을 검사하지 않고 곧바로 거짓으로 끝낸다. 그래서 "인덱스가 범위 안인가"를 왼쪽에, 그 인덱스로 값을 꺼내 비교하는 식을 오른쪽에 쓴다.
stride
stride 는 일정한 간격으로 값을 건너뛰며 순회한다. 2주 기록을 7일씩 묶거나, 알림 시각을 네 시간 간격으로 만들 때 쓴다. 끝 값을 포함할지에 따라 두 가지 형태가 있다.
| 호출 | 나오는 값 | 끝 값 | 방향 |
|---|---|---|---|
stride(from: 0, to: 10, by: 5) | 0, 5 | 포함하지 않음 | 증가 |
stride(from: 0, through: 10, by: 5) | 0, 5, 10 | 포함함 | 증가 |
stride(from: 10, through: 0, by: -5) | 10, 5, 0 | 포함함 | 감소 |
감소시키려면 by 에 음수를 준다. 시작 값이 끝 값보다 작은데 음수 간격을 주면 값이 하나도 나오지 않는다. 오류는 아니고 본문이 실행되지 않을 뿐이다. 정확한 사양은 Swift 표준 라이브러리 문서에서 확인할 수 있다.
완성 코드
아래 파일 하나가 이 장의 내용을 모두 쓴다. main.swift 로 저장한다.
// 2주 치 물 마시기 기록(컵 수). -1 은 기록 오류를 뜻한다.
let cups = [8, 9, 8, 8, 6, 0, -1, 3, 9, 7, 7, 8, 8, 9]
let goal = 8
print("== 일별 기록 ==")
var validDays = 0
var totalCups = 0
var achievedDays = 0
for day in 1...cups.count {
let value = cups[day - 1]
guard value >= 0 else {
print("\(day)일차: 기록 오류, 건너뜀")
continue
}
validDays += 1
totalCups += value
if value >= goal {
achievedDays += 1
}
let grade: String
switch value {
case 0:
grade = "기록 없음"
case 1...3:
grade = "부족"
case 4...7:
grade = "보통"
case 8...:
grade = "목표 달성"
default:
grade = "범위 밖"
}
print("\(day)일차: \(value)컵 \(grade)")
}
print("== 요약 ==")
print("유효 기록일: \(validDays)일, 목표 달성일: \(achievedDays)일")
let tenths = totalCups * 10 / validDays
print("하루 평균: \(tenths / 10).\(tenths % 10)컵")
print("== 연속 달성 ==")
var currentStreak = 0
var index = cups.count - 1
while index >= 0 && cups[index] >= goal {
currentStreak += 1
index -= 1
}
var bestStreak = 0
var run = 0
for value in cups {
if value >= goal {
run += 1
if run > bestStreak {
bestStreak = run
}
} else {
run = 0
}
}
print("현재 연속 달성일: \(currentStreak)일")
print("최장 연속 달성일: \(bestStreak)일")
print("== 주간 합계 ==")
for start in stride(from: 0, to: cups.count, by: 7) {
let end = min(start + 7, cups.count)
var weekTotal = 0
for i in start..<end {
if cups[i] > 0 {
weekTotal += cups[i]
}
}
print("\(start / 7 + 1)주차(\(start + 1)~\(end)일): \(weekTotal)컵")
}
print("== 점검 ==")
var firstEmptyDay = 0
for day in 1...cups.count {
if cups[day - 1] == 0 {
firstEmptyDay = day
break
}
}
print("처음 0컵인 날: \(firstEmptyDay)일차")
let todayCups = cups[cups.count - 1]
let todayMinutes = 0
let message: String
switch (todayCups, todayMinutes) {
case (8..., 20...):
message = "물과 운동 모두 목표 달성"
case (8..., _):
message = "물은 달성, 운동은 아직"
case (_, 20...):
message = "운동은 달성, 물은 부족"
default:
message = "둘 다 아직"
}
print("오늘(\(cups.count)일차): \(message)")
print("== 알림 시각 ==")
for hour in stride(from: 9, through: 21, by: 4) {
print("\(hour)시 물 마시기 알림")
}
줄별 해설
데이터 준비
cups 는 14일 치 기록이고 goal 은 목표 컵 수다. 둘 다 바뀌지 않으므로 let 으로 선언했다. 일곱 번째 값 -1 은 일부러 넣은 오류 기록이다.
일별 기록 루프
for day in 1...cups.count 는 1일차부터 14일차까지 번호를 만든다. 사람이 세는 번호는 1부터 시작하지만 배열 인덱스는 0부터이므로 cups[day - 1] 로 한 칸을 맞춘다. 바로 아래 guard 는 value 가 0 이상이어야 통과시킨다. 음수는 else 에서 메시지를 찍고 continue 로 다음 날로 넘어간다. 그래서 7일차는 합계와 일수에 들어가지 않는다.
통과한 값은 validDays 와 totalCups 에 더한다. if value >= goal 은 목표 달성일을 센다. let grade: String 은 값을 나중에 넣겠다는 선언이다. switch 의 모든 갈래가 grade 를 정확히 한 번씩 정하므로 컴파일러가 이를 허용한다. default 는 음수 같은 범위 밖의 값을 위한 갈래다. 이미 guard 가 걸렀으므로 실제로는 실행되지 않는다.
요약
평균은 소수점 없이 계산했다. totalCups * 10 / validDays 로 10배 한 정수 몫을 구하고, / 10 을 정수 부분, % 10 을 소수 첫째 자리로 쓴다. 소수 둘째 자리부터는 버린다. 90컵을 13일로 나누면 6.92...이므로 6.9가 나온다.
연속 달성
현재 연속 달성일은 while 로 센다. index 는 마지막 인덱스에서 시작해 한 칸씩 줄어든다. 목표를 못 채운 날을 만나거나 배열 앞을 벗어나면 멈춘다. 조건에서 index >= 0 이 왼쪽에 있으므로 cups[-1] 같은 잘못된 접근은 일어나지 않는다. 최장 연속 달성일은 for-in 으로 앞에서부터 훑는다. 목표를 채우면 run 을 올리고 최댓값을 갱신하며, 못 채우면 run 을 0 으로 되돌린다. 오류 기록 -1 도 목표 미달이므로 연속을 끊는다.
주간 합계
stride(from: 0, to: cups.count, by: 7) 는 0, 7 을 만든다. 각 주의 시작 인덱스다. 끝 인덱스는 start + 7 이되 배열 길이를 넘지 않게 min 으로 잘랐다. 14일이라 이번에는 넘지 않지만, 기록이 10일뿐이어도 같은 코드가 안전하다. 안쪽 for i in start..<end 는 반닫힌 범위로 그 주의 칸만 돈다. 0 이상인 값만 더하므로 오류 기록은 합계에서 빠진다.
점검
firstEmptyDay 는 0 컵인 첫 날을 찾는다. 찾으면 번호를 저장하고 break 로 반복을 끝낸다. 하나도 못 찾으면 0 이 남는다. 0 이 "없음"이라는 뜻의 약속인 셈인데, 이런 약속에 기대는 코드가 왜 불안한지는 다음 장에서 다룬다. 마지막 switch 는 오늘의 컵 수와 운동 시간을 튜플로 묶어 네 가지 중 하나로 가른다. todayMinutes 가 0 이므로 두 번째 case 가 선택된다.
알림 시각
stride(from: 9, through: 21, by: 4) 는 9, 13, 17, 21 을 만든다. through 를 썼기 때문에 21 도 포함된다.
실행 결과
$ swiftc main.swift -o app
$ ./app
== 일별 기록 ==
1일차: 8컵 목표 달성
2일차: 9컵 목표 달성
3일차: 8컵 목표 달성
4일차: 8컵 목표 달성
5일차: 6컵 보통
6일차: 0컵 기록 없음
7일차: 기록 오류, 건너뜀
8일차: 3컵 부족
9일차: 9컵 목표 달성
10일차: 7컵 보통
11일차: 7컵 보통
12일차: 8컵 목표 달성
13일차: 8컵 목표 달성
14일차: 9컵 목표 달성
== 요약 ==
유효 기록일: 13일, 목표 달성일: 8일
하루 평균: 6.9컵
== 연속 달성 ==
현재 연속 달성일: 3일
최장 연속 달성일: 4일
== 주간 합계 ==
1주차(1~7일): 39컵
2주차(8~14일): 51컵
== 점검 ==
처음 0컵인 날: 6일차
오늘(14일차): 물은 달성, 운동은 아직
== 알림 시각 ==
9시 물 마시기 알림
13시 물 마시기 알림
17시 물 마시기 알림
21시 물 마시기 알림
실무에서 자주 틀리는 것
Int 를 switch 하면서 default 를 빼먹는다
범위로 모든 구간을 나눴다고 생각해도 컴파일러는 Int 의 모든 값을 덮었는지 확인하지 못한다. 그래서 컴파일 오류가 난다.
// 틀린 코드: 완전하지 않다는 컴파일 오류
switch value {
case 0:
print("기록 없음")
case 1...7:
print("목표 미만")
case 8...:
print("목표 달성")
}
// 고친 코드: 나머지를 받는 default 를 둔다
switch value {
case 0:
print("기록 없음")
case 1...7:
print("목표 미만")
case 8...:
print("목표 달성")
default:
print("범위 밖")
}
오류가 번거롭게 느껴져도 값이 빠진 채 지나가는 것보다 낫다. default 에서는 반드시 의미 있는 처리를 하자. 빈 갈래로 두면 잘못된 값을 조용히 삼킨다.
0...count - 1 로 인덱스를 돈다
기록이 하나도 없으면 count 가 0 이고 0...-1 이 된다. 닫힌 범위는 시작이 끝보다 클 수 없어서 프로그램이 실행 중에 멈춘다.
// 틀린 코드: 빈 배열이면 실행 중 멈춘다
let empty: [Int] = []
for i in 0...empty.count - 1 {
print(empty[i])
}
// 고친 코드: 반닫힌 범위는 빈 배열에서 0..<0 이 되어 본문을 건너뛴다
let empty: [Int] = []
for i in 0..<empty.count {
print(empty[i])
}
guard 의 else 에서 빠져나가지 않는다
guard 는 조건이 거짓일 때 흐름이 아래로 이어지면 안 된다. 메시지만 찍고 끝내면 컴파일 오류가 난다.
// 틀린 코드: else 가 범위를 벗어나지 않는다
for value in cups {
guard value >= 0 else {
print("기록 오류")
}
print(value)
}
// 고친 코드: continue 로 다음 값으로 넘어간다
for value in cups {
guard value >= 0 else {
print("기록 오류")
continue
}
print(value)
}
while 조건의 순서를 거꾸로 쓴다
인덱스를 검사하기 전에 그 인덱스로 값을 꺼내면 범위를 벗어난 접근이 일어난다. 연속 달성일이 배열 전체에 걸쳐 있을 때 index 가 -1 까지 내려가고, 그때 cups[-1] 을 읽으면 프로그램이 멈춘다.
// 틀린 코드: index 가 -1 이 되어도 먼저 cups[index] 를 읽는다
while cups[index] >= goal && index >= 0 {
currentStreak += 1
index -= 1
}
// 고친 코드: 범위 검사를 왼쪽에 둔다
while index >= 0 && cups[index] >= goal {
currentStreak += 1
index -= 1
}
또 하나, 본문에서 index -= 1 을 빼먹으면 조건이 영영 바뀌지 않아 프로그램이 끝나지 않는다. while 을 쓸 때는 조건을 바꾸는 문장이 본문에 있는지 먼저 확인한다.
한눈에 보기
| 상황 | 쓸 구문 | 핵심 규칙 | 이 장의 예 |
|---|---|---|---|
| 조건 하나로 두세 갈래 | if·else | 조건은 Bool, 중괄호 필수 | 목표 달성일 세기 |
| 잘못된 입력을 먼저 거름 | guard | else 에서 반드시 범위를 벗어남 | 오류 기록 건너뛰기 |
| 값을 구간이나 쌍으로 분류 | switch | 완전해야 하고 case 는 위에서부터 검사 | 등급, 물·운동 판정 |
| 모든 값을 한 번씩 | for-in | 인덱스 순회는 0..<count | 주간 합계 |
| 횟수를 미리 모름 | while | 조건을 바꾸는 문장이 본문에 있어야 함 | 현재 연속 달성일 |
| 일정 간격으로 건너뜀 | stride | to 는 제외, through 는 포함 | 7일 묶음, 알림 시각 |
연습 문제
- 완성 코드의 등급
switch는8...이라는 숫자를 직접 썼다.goal값을 바꿔도 맞게 동작하도록case let v where v >= goal:형태로 고쳐 쓰고, 이때case순서가 왜 중요한지 설명하라. stride를 써서 1일차, 3일차, 5일차처럼 격일 기록만 더한 합계를 구하라. 오류 기록은guard로 건너뛴다. 완성 코드의cups를 기준으로 결과는 얼마인가?while로 기록을 앞에서부터 더하다가 누적 합이 처음 50 컵 이상이 되는 일차를 구하라. 오류 기록은 더하지 않는다.- 물 목표 달성 여부(
Bool)와 운동 여부(Bool)를 튜플로 묶어 네 가지 경우를default없이switch로 처리하라. 왜default가 필요 없는지도 설명하라.
정답과 해설
1번
let grade: String
switch value {
case 0:
grade = "기록 없음"
case let v where v >= goal:
grade = "목표 달성"
case 4...:
grade = "보통"
case 1...3:
grade = "부족"
default:
grade = "범위 밖"
}
where 는 패턴에 조건을 덧붙인다. 이 방식에서는 goal 이 4 이하로 내려가지 않는 한 순서가 맞아야 한다. "목표 달성"을 "보통"보다 먼저 두지 않으면, 8컵이 4... 에 먼저 걸려 "보통"이 된다. 위에서부터 처음 맞는 하나만 실행하므로 구체적인 조건을 위에, 넓은 조건을 아래에 둔다. 음수는 case 4... 와 case 1...3 에 닿지 못하고 default 로 간다. goal 이 4 이하로 바뀌는 경우까지 다루려면 "보통" 구간 자체를 goal 에 맞춰 다시 정의해야 한다.
2번
var sum = 0
for day in stride(from: 1, through: cups.count, by: 2) {
let value = cups[day - 1]
guard value >= 0 else {
continue
}
sum += value
}
print(sum)
결과는 46이다. 1, 3, 5, 7, 9, 11, 13일차의 값은 8, 8, 6, -1, 9, 7, 8 이다. 7일차의 -1 은 guard 가 건너뛰므로 8+8+6+9+7+8 이 되어 46이다. 간격이 2이므로 14일차는 방문하지 않는다. through: cups.count 로 써도 14는 홀수 순서에서 나오지 않으므로 문제가 없다.
3번
var sum = 0
var day = 0
while day < cups.count && sum < 50 {
if cups[day] > 0 {
sum += cups[day]
}
day += 1
}
print("\(day)일차")
출력은 9일차 다. 8일차까지의 누적 합은 42이고, 9일차에 9컵을 더하면 51이 되어 조건이 거짓이 된다. 루프는 day 를 올린 뒤에 조건을 검사하므로 마지막으로 처리한 인덱스는 8이고 day 는 9가 된다. 이때 day 는 "처리한 날의 수"로 읽으면 된다. 50컵에 끝내 못 닿는 데이터에서는 day < cups.count 가 반복을 끝낸다. 그 경우 day 는 14가 되므로 14일차라는 출력이 "도달했다"는 뜻은 아니다. 도달 여부는 sum >= 50 으로 따로 확인해야 한다.
4번
let waterDone = true
let workoutDone = false
switch (waterDone, workoutDone) {
case (true, true):
print("둘 다 달성")
case (true, false):
print("물만 달성")
case (false, true):
print("운동만 달성")
case (false, false):
print("둘 다 아직")
}
Bool 은 값이 두 가지뿐이므로 튜플로 묶어도 가능한 조합은 네 가지다. 네 조합을 모두 썼으니 컴파일러가 빠진 값이 없다고 확인한다. 이 경우 default 를 쓰지 않는 편이 낫다. 나중에 조합이 늘어나면 컴파일러가 새로 생긴 경우를 처리하라고 알려 주지만, default 가 있으면 이 알림이 사라진다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.