고수 팁 — 제어 구조 심화
루프 안 defer 함정
defer는 함수 스코프에 묶여 있습니다. 루프 안에서 defer를 쓰면 루프 반복마다 defer가 쌓이고, 함수가 종료될 때 한꺼번에 실행됩니다.
package main
import (
"fmt"
"os"
)
// 나쁜 예 — 파일 핸들이 함수 종료까지 열려 있음
func badProcessFiles(paths []string) error {
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 루프마다 defer 쌓임 — 위험!
// 파일 처리 ...
}
return nil
// 여기서야 모든 f.Close()가 한꺼번에 실행됨
}
// 좋은 예 1 — 익명 함수로 스코프 제한
func goodProcessFiles(paths []string) error {
for _, path := range paths {
if err := func(p string) error {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // 이 익명 함수가 끝날 때 즉시 실행
fmt.Printf("처리 중: %s\n", p)
return nil
}(path); err != nil {
return err
}
}
return nil
}
// 좋은 예 2 — 별도 함수로 분리 (가장 권장)
func processOneFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
fmt.Printf("처리 중: %s\n", path)
return nil
}
func processFiles(paths []string) error {
for _, path := range paths {
if err := processOneFile(path); err != nil {
return err
}
}
return nil
}
func main() {
paths := []string{"/tmp/a.txt", "/tmp/b.txt"}
_ = goodProcessFiles(paths)
_ = processFiles(paths)
}
defer 클로저 변수 캡처 함정
클로저는 변수를 값이 아닌 참조 로 캡처합니다. 루프 인덱스를 클로저에서 사용할 때 이 함정에 빠지기 쉽습니다.
package main
import "fmt"
func main() {
// 함정 — 모든 defer가 같은 i 참조 (루프 종료 후 i = 3)
fmt.Println("잘못된 캡처:")
for i := 0; i < 3; i++ {
defer func() {
fmt.Printf(" i = %d\n", i) // 항상 3 출력
}()
}
// 출력: 3, 3, 3 (예상: 2, 1, 0)
}
package main
import "fmt"
func main() {
// 해결책 1 — 인자로 값 전달 (복사)
fmt.Println("값 전달로 해결:")
for i := 0; i < 3; i++ {
defer func(n int) {
fmt.Printf(" i = %d\n", n)
}(i) // i의 현재 값을 복사해서 전달
}
// 출력: 2, 1, 0 (LIFO 순서, 올바른 값)
}
package main
import "fmt"
func main() {
// 해결책 2 — 루프 변수 재선언 (Go 1.22 이전)
for i := 0; i < 3; i++ {
i := i // 루프 바디에서 새 변수로 가림
defer func() {
fmt.Printf(" i = %d\n", i)
}()
}
}
Go 1.22부터는 for 루프 변수가 반복마다 새로 선언되어 이 문제가 해결되었습니다.
panic vs error — 판단 기준
언제 panic을 쓰고 언제 error를 리턴할지는 Go 개발자가 자주 혼란스러워하는 부분입니다.
error를 리턴해야 할 때:
- 예측 가능한 실패 (파일 없음, 네트워크 오류, 유효성 검사 실패)
- 호출자가 처리할 수 있는 상황
- 비즈니스 로직에서 발생하는 오류
panic을 써야 할 때:
- 프로그래머의 실수로 인한 불변 조건 위반 (nil 포인터 역참조, 인덱스 초과 등)
- 초기화 단계에서의 치명적 오류 (
must패턴) - 절대로 발생하면 안 되는 상황
package main
import (
"errors"
"fmt"
"regexp"
)
// error를 써야 할 상황 — 파일 존재 여부는 예측 가능한 실패
func readConfig(path string) ([]byte, error) {
// os.ReadFile은 에러 발생 시 panic하지 않고 error 반환
// return os.ReadFile(path)
return nil, errors.New("파일 없음: " + path)
}
// panic을 써야 할 상황 — 잘못된 정규식은 프로그래머 실수
var emailRegex = mustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
func mustCompile(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
// 이 패턴은 소스코드에 하드코딩되므로
// 틀렸다면 프로그래머 버그 → panic이 맞음
panic(fmt.Sprintf("잘못된 정규식: %q: %v", pattern, err))
}
return re
}
// panic을 error로 변환하는 래퍼 — 외부 라이브러리 방어
func safeValidate(email string) (valid bool, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("검증 중 패닉: %v", r)
}
}()
valid = emailRegex.MatchString(email)
return
}
func main() {
emails := []string{
"alice@example.com",
"invalid-email",
"bob@test.org",
}
for _, email := range emails {
valid, err := safeValidate(email)
if err != nil {
fmt.Printf(" 에러: %v\n", err)
} else {
fmt.Printf(" %s: 유효=%v\n", email, valid)
}
}
}
switch fallthrough 주의사항
fallthrough는 다음 case의 조건을 확인하지 않고 무조건 실행합니다. 이 특성이 의도치 않은 버그를 만들 수 있습니다.
package main
import "fmt"
func main() {
n := 10
// 함정 — fallthrough는 조건 무시
switch {
case n > 5:
fmt.Println("5보다 큼")
fallthrough
case n > 100:
// n이 100보다 크지 않아도 실행됨!
fmt.Println("100보다 큼 — 이 출력은 잘못됨!")
case n > 200:
fmt.Println("200보다 큼")
}
}
실행 결과:
5보다 큼
100보다 큼 — 이 출력은 잘못됨!
fallthrough가 필요하다고 느낀다면 대부분 다중 케이스나 함수 분리로 더 명확하게 표현할 수 있습니다.
range 복사본 문제
range는 슬라이스의 헤더(포인터, 길이, 용량)를 복사합니다. 요소 값도 반복마다 복사됩니다. 구조체 슬라이스를 수정할 때 이 점을 반드시 이해해야 합니다.
package main
import "fmt"
type Player struct {
Name string
Score int
}
func main() {
players := []Player{
{"Alice", 100},
{"Bob", 200},
{"Carol", 150},
}
// 함정 — v는 복사본, players 원본은 변경되지 않음
for _, v := range players {
v.Score += 10 // 복사본만 변경
}
fmt.Println("변경 안 됨:", players[0].Score) // 100
// 올바른 방법 1 — 인덱스로 직접 접근
for i := range players {
players[i].Score += 10
}
fmt.Println("변경됨:", players[0].Score) // 110
// 올바른 방법 2 — 포인터 슬라이스 사용
pPlayers := []*Player{
{"Alice", 100},
{"Bob", 200},
}
for _, p := range pPlayers {
p.Score += 10 // 포인터이므로 원본 수정 가능
}
fmt.Println("포인터 변경됨:", pPlayers[0].Score) // 110
}
성능: defer 비용 측정
defer는 편리하지만 호출 오버헤드가 존재합니다. 벤치마크로 측정해봅니다.
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.Mutex
var counter int
// defer 사용 버전
func withDefer() {
mu.Lock()
defer mu.Unlock()
counter++
}
// defer 미사용 버전
func withoutDefer() {
mu.Lock()
counter++
mu.Unlock()
}
func benchmark(name string, fn func(), n int) {
start := time.Now()
for i := 0; i < n; i++ {
fn()
}
elapsed := time.Since(start)
fmt.Printf("%s: %d회 → %v (평균 %v)\n",
name, n, elapsed, elapsed/time.Duration(n))
}
func main() {
const N = 1_000_000
benchmark("defer 사용", withDefer, N)
benchmark("defer 미사용", withoutDefer, N)
fmt.Println("\n결론: 핫패스가 아니라면 defer를 쓰는 것이 더 안전하고 권장됨")
}
Go 1.14부터 defer 성능이 대폭 개선(오픈 코드 인라이닝)되어 단순한 경우 거의 오버헤드가 없습니다. 성능이 중요한 루프나 마이크로초 단위 함수가 아니라면 항상 defer를 사용하세요.
성능: range string의 UTF-8 처리
문자열을 range로 순회하면 바이트가 아닌 rune(유니코드 코드포인트) 단위로 처리됩니다. 멀티바이트 문자(한글, 이모지 등)를 다룰 때 중요합니다.
package main
import "fmt"
func main() {
s := "Go언어🚀"
// range — rune 단위 (UTF-8 디코딩 비용 있음)
fmt.Println("range(rune):")
for i, r := range s {
fmt.Printf(" [%d] %c (%d bytes)\n", i, r, len(string(r)))
}
// 바이트 단위 순회 — UTF-8 디코딩 없음, 빠름
fmt.Println("\n[]byte 순회:")
for i, b := range []byte(s) {
fmt.Printf(" [%d] 0x%02X\n", i, b)
}
// 문자 수 세기
runeCount := 0
for range s {
runeCount++
}
fmt.Printf("\n바이트 수: %d, 문자 수: %d\n", len(s), runeCount)
// 또는: len([]rune(s))
}
실행 결과:
range(rune):
[0] G (1 bytes)
[1] o (1 bytes)
[2] 언 (3 bytes)
[5] 어 (3 bytes)
[8] 🚀 (4 bytes)
바이트 수: 12, 문자 수: 5
ASCII만 처리하거나 바이트 수준 처리가 필요할 때는 []byte로 변환 후 순회가 더 빠릅니다.
실무 팁 요약
| 상황 | 권장 패턴 |
|---|---|
| 리소스 정리 | f, err := open(...); defer f.Close() |
| 루프 안 리소스 | 별도 함수로 분리 |
| 클로저 + 루프 변수 | 인자로 값 전달 func(i int){ ... }(i) |
| 복구 불가능 초기화 오류 | mustXxx() 패턴 + panic |
| 예측 가능한 실패 | error 반환 |
| 서버 고루틴 보호 | defer recover() 미들웨어 |
| 구조체 슬라이스 수정 | for i := range s { s[i].Field = ... } |
| 문자 수 계산 | len([]rune(s)) 또는 utf8.RuneCountInString(s) |