제어 흐름
이 챕터에서 다루는 것
Go의 제어 흐름 문법은 의도적으로 빈약하다. 반복문은 for 하나뿐이고, 삼항 연산자도 없고,
while도 do-while도 없다. 무엇이 없는지와 그 대신 무엇이 있는지를 정리하고,
초기화문과 스코프, switch의 자동 break, 레이블을 다룬다.
if — 괄호는 없고 중괄호는 필수
if count > 0 {
fmt.Println("있음")
}
세 가지 규칙이 있다.
1. 조건에 괄호를 쓰지 않는다. if (count > 0)도 컴파일은 되지만 gofmt가 괄호를 지운다.
2. 중괄호가 필수다. 한 줄이어도 생략할 수 없다. C나 Java에서 중괄호를 빼먹어 생기는 버그(goto fail 사건이 유명하다)를 문법 차원에서 없앴다.
3. 여는 중괄호는 같은 줄에 있어야 한다.
if count > 0
{
fmt.Println("있음")
}
./main.go:7:14: syntax error: unexpected newline, expected { after if clause
취향 문제가 아니라 문법이다. Go는 줄 끝에 세미콜론을 자동으로 삽입하는데, if count > 0
뒤에서 문장이 끝난 것으로 판단해 세미콜론을 넣어 버리기 때문이다. 브레이스 스타일 논쟁이
Go에는 존재하지 않는다.
4. 조건은 반드시 bool이다.
count := 3
if count {
fmt.Println("있음")
}
./main.go:7:5: non-boolean condition in if statement
C나 Python, JavaScript의 "0은 거짓, 빈 문자열은 거짓" 같은 truthy/falsy 규칙이 없다.
if len(s) > 0, if err != nil처럼 비교를 명시적으로 쓴다.
초기화문과 스코프
if 앞에 세미콜론으로 구분된 문장을 하나 붙일 수 있다. 여기서 선언한 변수는
if-else 체인 전체에서만 살아 있다.
package main
import (
"fmt"
"strconv"
)
// classify는 문자열을 숫자로 해석해 분류한다.
func classify(s string) string {
// 초기화문에서 선언한 n과 err는 if-else 체인 전체에서만 살아 있다.
if n, err := strconv.Atoi(s); err != nil {
return "숫자가 아님: " + err.Error()
} else if n < 0 {
return "음수 " + strconv.Itoa(n)
} else if n == 0 {
return "영"
} else {
return "양수 " + strconv.Itoa(n)
}
}
func main() {
for _, s := range []string{"7", "-3", "0", "abc"} {
fmt.Printf("%-5s %s\n", s, classify(s))
}
// 괄호가 없고 중괄호는 필수다. 조건은 반드시 bool이어야 한다.
count := 3
if count > 0 {
fmt.Println("있음")
}
}
go run ./04-if
7 양수 7
-3 음수 -3
0 영
abc 숫자가 아님: strconv.Atoi: parsing "abc": invalid syntax
있음
블록 밖에서 n을 쓰면 컴파일 에러다.
./main.go:12:14: undefined: n
이게 초기화문의 요점이다. 에러 변수를 함수 전체에 흘리지 않고 필요한 범위에 가둔다. 2-1에서 본 섀도잉 문제도 이 습관으로 상당 부분 줄어든다.
:::tip 에러 처리의 관용 형태
if err := doSomething(); err != nil {
return fmt.Errorf("작업 실패: %w", err)
}
반환값이 에러 하나뿐일 때 가장 흔히 보게 될 모양이다. 값도 함께 받아야 하면
if v, err := f(); err != nil 형태가 되는데, 이때 v는 조건이 참일 때만이 아니라
else 블록에서도 쓸 수 있다는 점을 기억하자.
:::
for — 반복문은 이것 하나뿐
Go에는 while, do-while, foreach가 없다. for 하나가 다섯 가지 모양으로 쓰인다.
package main
import "fmt"
func main() {
// 1. 3절 형태 — C의 for와 같다
for i := 0; i < 3; i++ {
fmt.Print(i, " ")
}
fmt.Println()
// 2. 조건만 — 다른 언어의 while
n := 1
for n < 20 {
n *= 3
}
fmt.Println("n =", n)
// 3. 조건 없음 — 무한 루프. break로 빠져나온다
sum := 0
for {
sum++
if sum == 5 {
break
}
}
fmt.Println("sum =", sum)
// 4. range — 정수
for i := range 3 {
fmt.Print(i, " ")
}
fmt.Println()
// 5. range — 슬라이스. 인덱스만 필요하면 두 번째 변수를 생략한다
words := []string{"go", "언어", "재미"}
for i, w := range words {
fmt.Printf("%d:%s ", i, w)
}
fmt.Println()
for i := range words {
fmt.Print(i, " ")
}
fmt.Println()
// 6. range — 맵. 순회 순서는 매 실행마다 무작위다
ages := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range ages {
fmt.Printf("%s=%d ", k, v)
}
fmt.Println()
// 7. range가 주는 값은 복사본이다. 원본을 고치려면 인덱스로 접근한다
nums := []int{1, 2, 3}
for _, v := range nums {
v *= 10
}
fmt.Println("복사본 수정:", nums)
for i := range nums {
nums[i] *= 10
}
fmt.Println("인덱스 수정:", nums)
}
go run ./04-for
0 1 2
n = 27
sum = 5
0 1 2
0:go 1:언어 2:재미
0 1 2
a=1 b=2 c=3
복사본 수정: [1 2 3]
인덱스 수정: [10 20 30]
:::warning 맵 순회 순서는 매 실행마다 다르다
위 출력의 a=1 b=2 c=3은 한 번의 실행 결과일 뿐이다. 같은 바이너리를 여섯 번 돌려 본
결과는 a=1 b=2 c=3, a=1 b=2 c=3, a=1 b=2 c=3, b=2 c=3 a=1, c=3 a=1 b=2,
a=1 b=2 c=3이었다. Go 런타임이 순회 시작 지점을 의도적으로 무작위화한다. 순서에
의존하는 코드를 아예 쓸 수 없게 만들려는 것이다. 정렬된 출력이 필요하면 키를 모아서
정렬해야 한다. Part 3-3에서 다룬다.
:::
몇 가지 짚을 것.
for i := range 3은 0, 1, 2를 준다. 정수에 대한 range는 Go 1.22에서 들어왔고, 이제for i := 0; i < n; i++보다 이쪽이 관용적이다. 1-6에서 본go fix의 모더나이저가 3절 for를 이 형태로 바꿔 준다.- 두 번째 변수는 생략할 수 있다.
for i := range words는 인덱스만, 문자열이라면 바이트 오프셋만 준다. range가 주는 값은 복사본이다.for _, v := range nums에서v를 고쳐도 원본은 그대로다. 슬라이스 요소가 큰 구조체라면 이 복사 비용도 무시할 수 없다. Part 3에서 다시 다룬다.for안에서 인덱스를 쓰지 않으면_로 버린다. 미사용 변수는 컴파일 에러라 피할 수 없다.
do-while이 필요하면 무한 루프와 break로 만든다.
for {
body()
if !cond {
break
}
}
switch — break가 필요 없다
package main
import (
"fmt"
"time"
)
// grade는 점수를 등급으로 바꾼다. 조건식 switch는 if-else 체인을 대체한다.
func grade(score int) string {
switch {
case score >= 90:
return "A"
case score >= 80:
return "B"
case score >= 70:
return "C"
default:
return "F"
}
}
// kind는 요일을 분류한다. 한 case에 값 여러 개를 쉼표로 나열할 수 있다.
func kind(d time.Weekday) string {
switch d {
case time.Saturday, time.Sunday:
return "주말"
default:
return "평일"
}
}
func main() {
for _, s := range []int{95, 83, 71, 40} {
fmt.Printf("%d:%s ", s, grade(s))
}
fmt.Println()
for d := time.Sunday; d <= time.Saturday; d++ {
fmt.Printf("%s=%s ", d, kind(d))
}
fmt.Println()
// 초기화문을 붙일 수 있다. if와 같은 문법이다.
switch n := len("언어"); {
case n > 3:
fmt.Println("바이트 수가 3보다 큼:", n)
default:
fmt.Println("작거나 같음:", n)
}
// case 하나가 끝나면 자동으로 빠져나온다. 이어서 실행하려면 fallthrough를 명시한다.
for _, level := range []int{1, 2, 3} {
fmt.Printf("level %d: ", level)
switch level {
case 1:
fmt.Print("읽기 ")
fallthrough
case 2:
fmt.Print("쓰기 ")
fallthrough
case 3:
fmt.Print("실행")
}
fmt.Println()
}
}
go run ./04-switch
95:A 83:B 71:C 40:F
Sunday=주말 Monday=평일 Tuesday=평일 Wednesday=평일 Thursday=평일 Friday=평일 Saturday=주말
바이트 수가 3보다 큼: 6
level 1: 읽기 쓰기 실행
level 2: 쓰기 실행
level 3: 실행
C, Java, JavaScript에서 온 사람에게 가장 중요한 차이는 case가 끝나면 자동으로 빠져나온다는
것이다. break를 쓰지 않는다. C 계열에서 break를 빼먹어 생기는 버그는 워낙 흔해서 린터의
단골 항목인데, Go는 기본값을 뒤집어서 문제를 없앴다.
의도적으로 이어서 실행하고 싶으면 fallthrough를 명시한다. 위 출력의 level 1이
"읽기 쓰기 실행"을 전부 찍는 게 그 결과다. fallthrough는 case의 마지막 문장이어야 하고,
다음 case의 조건을 검사하지 않고 무조건 실행한다.
조건식 switch
switch 뒤에 값을 쓰지 않으면 switch true와 같아지고, 각 case에 조건식을 쓸 수 있다.
grade 함수가 그 형태다. if-else 체인보다 읽기 좋을 때가 많다.
switch {
case score >= 90:
return "A"
case score >= 80:
return "B"
}
그 밖에
- 한 case에 값 여러 개를 쉼표로 나열할 수 있다.
case time.Saturday, time.Sunday: - 초기화문을 붙일 수 있다.
switch n := len(s); {.if와 같은 문법이다. - 비교 가능한 타입이면 무엇이든 switch할 수 있다. 문자열도 된다.
- 타입 스위치라는 별도 형태가 있는데, 인터페이스가 필요하므로 4-4에서 다룬다.
break와 continue, 그리고 레이블
break는 가장 안쪽 for·switch·select를 끊고, continue는 가장 안쪽 for의 다음
반복으로 간다. 중첩 루프에서 바깥까지 한 번에 끊으려면 레이블이 필요하다.
package main
import "fmt"
func main() {
grid := [][]int{
{1, 2, 3},
{4, -5, 6},
{7, 8, 9},
}
// 레이블 없는 break는 안쪽 루프만 빠져나온다.
fmt.Print("break만: ")
for _, row := range grid {
for _, v := range row {
if v < 0 {
break
}
fmt.Print(v, " ")
}
}
fmt.Println()
// 레이블을 붙이면 바깥 루프까지 한 번에 빠져나온다.
fmt.Print("레이블 break: ")
search:
for _, row := range grid {
for _, v := range row {
if v < 0 {
break search
}
fmt.Print(v, " ")
}
}
fmt.Println()
// continue에도 레이블을 붙일 수 있다. 바깥 루프의 다음 반복으로 간다.
fmt.Print("레이블 continue: ")
rows:
for _, row := range grid {
for _, v := range row {
if v < 0 {
continue rows
}
fmt.Print(v, " ")
}
}
fmt.Println()
// switch 안의 break는 switch를 끊는다. 루프를 끊으려면 레이블이 필요하다.
fmt.Print("switch 안에서: ")
loop:
for i := range 5 {
switch {
case i == 3:
break loop
default:
fmt.Print(i, " ")
}
}
fmt.Println()
}
go run ./04-labels
break만: 1 2 3 4 7 8 9
레이블 break: 1 2 3 4
레이블 continue: 1 2 3 4 7 8 9
switch 안에서: 0 1 2
첫 줄과 셋째 줄이 같다는 게 이 예제의 요점이다. -5를 만난 그 줄의 나머지를 건너뛰는
동작은 둘 다 같지만, break는 "이 안쪽 루프를 끝낸다"이고 continue rows는 "바깥 루프의
다음 행으로 간다"라 의도가 다르게 읽힌다. 행 단위로 건너뛰는 게 목적이면 레이블
continue가 정확한 표현이다.
마지막 블록이 특히 중요하다. switch 안에서 break를 쓰면 루프가 아니라 switch가
끊긴다. 루프를 끊으려면 레이블이 필수다. 이건 select에서도 똑같이 걸리는 함정이라
Part 7에서 다시 만난다.
:::note goto도 있다
Go에는 goto가 있고, 같은 함수 안에서 레이블로 점프한다. 다만 변수 선언을 건너뛰는
점프는 컴파일러가 막는다. 실무에서 거의 쓰이지 않고, 표준 라이브러리에서도 상태 기계나
깊은 중첩에서 빠져나올 때만 드물게 보인다. 먼저 함수 분리를 고려하자.
:::
흔히 하는 실수
1. 삼항 연산자를 찾는다
Go에는 cond ? a : b가 없다. 일부러 뺐다. 중첩된 삼항 연산자가 읽기 어렵다는 이유다.
대안은 if문이거나, 값이 필요하면 작은 함수다.
larger := b
if a > b {
larger = a
}
실무에서는 표준 라이브러리에 이미 있는 것을 먼저 찾는다. max(a, b)와 min(a, b)는
Go 1.21부터 내장 함수다. 별도 import 없이 쓸 수 있고 정렬 가능한 모든 타입에 동작한다.
2. switch에 break를 쓴다
switch x {
case 1:
doSomething()
break // 아무 효과가 없다
}
에러는 아니지만 불필요하다. 다른 언어의 습관이 남은 것이다. 반대로 fallthrough를
기대하고 아무것도 안 쓰는 것이 진짜 버그다. Go는 이어지지 않는다.
3. 조건에 값을 그대로 넣는다
if len(items) { ... } // 컴파일 에러
if err { ... } // 컴파일 에러
if len(items) > 0, if err != nil이라고 써야 한다. 처음엔 장황해 보이지만,
"빈 문자열이 거짓인지 아닌지"를 언어별로 기억할 필요가 없어진다.
4. range의 복사본을 고친다
for _, item := range items {
item.Count++ // 원본은 그대로다
}
items[i].Count++로 인덱스를 통해 접근해야 한다. 요소가 구조체일 때 특히 자주 걸리는데,
컴파일 에러가 나지 않고 조용히 아무 일도 일어나지 않아서 발견이 늦다. Part 3에서 다시
다룬다.
정리
if: 괄호 없음, 중괄호 필수, 여는 중괄호는 같은 줄, 조건은bool만.- 초기화문
if v, err := f(); err != nil이 변수 스코프를 좁힌다. - 반복문은
for하나. 3절 / 조건만 / 무한 /range네 가지 모양. for i := range n이 정수 반복의 현재 관용구다.- 맵 순회 순서는 무작위다. 의도적으로 그렇게 만들었다.
range가 주는 값은 복사본이다.switch는 자동으로 빠져나온다. 이어 가려면fallthrough.- 조건식
switch가 if-else 체인을 대체한다. - 중첩 루프와
switch안에서 루프를 끊으려면 레이블이 필요하다. - 삼항 연산자는 없다.
max/min은 내장 함수다.
연습문제
-
FizzBuzz를 세 가지 방식으로 써 보자 — if-else 체인, 조건식
switch, 그리고switch i%15처럼 값을 쓰는switch. 어느 쪽이 가장 읽기 좋은가? -
2차원 슬라이스에서 특정 값을 찾아
(행, 열)을 반환하는 함수를 레이블break로 쓴 뒤, 레이블 없이return만으로 다시 써 보자. 어느 쪽이 나은가? 이 비교가 "레이블은 언제 필요한가"에 대한 답이다. -
for i := range 3안에서switch를 쓰고,case 1에서break를 써 보자. 무엇이 끊기는지 출력으로 확인하고, 루프를 끊도록 고쳐 보자.