Devin.KR

제어 흐름 - guard 와 switch

개발자KR 조회 0

이 장에서 배우는 것

프로그램은 위에서 아래로 한 줄씩 실행되다가, 조건에 따라 갈라지거나 같은 일을 되풀이한다. 이런 실행 순서의 조절을 제어 흐름(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 에서 바로 다음 날로 넘어가고, 참일 때만 본문을 이어 간다.

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 = "범위 밖"
}
switch 는 case 를 위에서부터 검사해 처음 맞는 하나만 실행하고, 어디에도 맞지 않는 값은 default 가 받는다.

범위를 쓰는 표기는 여러 가지다. 아래 표에 정리했다.

범위 연산자의 종류와 쓰임
표기이름포함되는 값 예주로 쓰는 곳
1...3닫힌 범위1, 2, 3for-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 의 형태별 결과
호출나오는 값끝 값방향
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, 중괄호 필수목표 달성일 세기
잘못된 입력을 먼저 거름guardelse 에서 반드시 범위를 벗어남오류 기록 건너뛰기
값을 구간이나 쌍으로 분류switch완전해야 하고 case 는 위에서부터 검사등급, 물·운동 판정
모든 값을 한 번씩for-in인덱스 순회는 0..<count주간 합계
횟수를 미리 모름while조건을 바꾸는 문장이 본문에 있어야 함현재 연속 달성일
일정 간격으로 건너뜀strideto 는 제외, through 는 포함7일 묶음, 알림 시각

연습 문제

  1. 완성 코드의 등급 switch 는 8... 이라는 숫자를 직접 썼다. goal 값을 바꿔도 맞게 동작하도록 case let v where v >= goal: 형태로 고쳐 쓰고, 이때 case 순서가 왜 중요한지 설명하라.
  2. stride 를 써서 1일차, 3일차, 5일차처럼 격일 기록만 더한 합계를 구하라. 오류 기록은 guard 로 건너뛴다. 완성 코드의 cups 를 기준으로 결과는 얼마인가?
  3. while 로 기록을 앞에서부터 더하다가 누적 합이 처음 50 컵 이상이 되는 일차를 구하라. 오류 기록은 더하지 않는다.
  4. 물 목표 달성 여부(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 가 있으면 이 알림이 사라진다.

댓글 0

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

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