10장. 변수의 범위
함수를 자유롭게 쓸 수 있게 됐다. 그러자 새로운 질문이 따라온다.
함수 안에서 만든 변수는 함수 밖에서도 보이나?
if블록 안에서 만든 변수는 그 블록을 벗어나면 어떻게 되나? 같은 이름의 변수를 안과 밖에 모두 두면 어느 쪽이 우선인가?
이 답을 묶어서 변수의 범위(scope) 라고 부른다.
이 장의 목표:
- 변수의 범위라는 개념을 한 줄로 설명할 수 있다
- 패키지 / 함수 / 블록 세 단계의 범위를 구분한다
- 변수 가리기(shadowing) 가 무엇인지 안다
:=가 일으키는 흔한 함정을 피한다- 전역 변수를 함부로 쓰지 않는 이유를 안다
10.1 변수의 범위(scope)란
변수의 범위란 한 마디로 그 이름이 가리키는 변수가 보이는 영역 이다.
func main() {
x := 10
fmt.Println(x) // OK
}
fmt.Println(x) // 컴파일 에러: x 가 없음
x 는 main 함수 안에서만 존재한다.
함수가 끝나면 사라지고, 함수 바깥에서는 보이지 않는다.
Go 에서 범위는 중괄호 { } 단위로 결정된다.
중괄호로 둘러싸인 영역이 하나의 블록이 되고,
그 안에서 선언된 변수는 그 블록의 범위를 가진다.
이 단순한 규칙 위에 세 가지 단계가 쌓여 있다.
10.2 세 가지 범위
패키지 수준 (전역 변수)
함수 밖에 선언한 변수는 같은 패키지의 어떤 함수에서도 접근할 수 있다. 보통 “전역 변수” 라고 부른다.
package main
import "fmt"
var greeting = "Hello"
func sayHi() {
fmt.Println(greeting)
}
func sayBye() {
fmt.Println(greeting, "and bye")
}
func main() {
sayHi()
sayBye()
}
greeting 은 sayHi, sayBye, main 어디서나 보인다.
같은 디렉터리의 다른 .go 파일에서도 같은 패키지라면 접근할 수 있다.
다른 패키지에서도 보이게 하려면 이름의 첫 글자를 대문자로 쓴다 (
Greeting). 패키지 간 export 규칙은 20장에서 자세히 다룬다.
패키지 수준 변수는 := 로 선언할 수 없다.
반드시 var 또는 const 로 적는다.
// OK
var count = 0
const limit = 100
// 컴파일 에러
count := 0
함수 수준 (지역 변수)
함수 안에서 선언한 변수는 그 함수 안에서만 살아 있다.
func work() {
msg := "내부 메시지"
fmt.Println(msg)
}
func main() {
work()
fmt.Println(msg) // 컴파일 에러: msg 가 없음
}
함수가 호출될 때마다 새로운 지역 변수들이 만들어지고, 함수가 끝나면 사라진다.
블록 수준 ({ } 안)
함수 안이라도 더 좁은 범위가 있다.
if, for, switch 의 본문도 각자 하나의 블록이고,
그 안에서 선언한 변수는 그 블록 안에서만 보인다.
func main() {
if x := 10; x > 0 {
fmt.Println(x) // OK
}
fmt.Println(x) // 컴파일 에러
}
if 의 조건 앞에 선언한 x 는
if ~ else 블록 전체에서만 살아 있다.
바깥에서는 같은 이름의 변수가 따로 있어야 한다.
for 도 마찬가지다.
for i := 0; i < 3; i++ {
fmt.Println(i)
}
fmt.Println(i) // 컴파일 에러
루프 변수 i 는 for 블록 안에서만 보인다.
심지어 그냥 중괄호를 열기만 해도 새 블록이 된다.
func main() {
{
msg := "안쪽 블록"
fmt.Println(msg)
}
fmt.Println(msg) // 컴파일 에러
}
이 패턴은 자주 쓰진 않지만, “변수 범위를 일부러 좁히고 싶을 때” 쓸 수 있다.
세 단계 한눈에
| 범위 | 선언 위치 | 보이는 영역 |
|---|---|---|
| 패키지 수준 | 함수 밖 | 같은 패키지 전체 |
| 함수 수준 | 함수 본문 시작부 | 그 함수 안 |
| 블록 수준 | { } 안 | 그 블록 안 |
10.3 함수 매개변수의 범위
함수 매개변수도 결국 변수다. 범위는 함수 본문 전체 다.
func greet(name string) {
fmt.Println("Hello,", name)
}
name 은 greet 안에서만 보인다.
greet 가 끝나면 사라진다.
매개변수는 복사된다
Go 의 매개변수는 기본적으로 값 복사 다. 함수 안에서 매개변수를 바꿔도 호출한 쪽의 원본은 영향을 받지 않는다.
func bump(n int) {
n = n + 1
fmt.Println("안:", n)
}
func main() {
x := 10
bump(x)
fmt.Println("밖:", x)
}
실행 결과:
안: 11
밖: 10
bump 안의 n 은 x 의 복사본이다.
복사본을 고친 것이지 x 자체를 고친 것은 아니다.
함수 안에서 호출자의 변수를 진짜로 바꾸고 싶다면 포인터 를 넘겨야 한다. 포인터는 14장에서 다룬다. 지금은 “매개변수는 복사된다” 만 기억해 두자.
10.4 변수 가리기 (shadowing)
같은 이름의 변수를 바깥 블록과 안쪽 블록 에 동시에 두면 어떻게 될까?
func main() {
x := 10
fmt.Println("바깥:", x)
{
x := 99
fmt.Println("안쪽:", x)
}
fmt.Println("다시 바깥:", x)
}
실행 결과:
바깥: 10
안쪽: 99
다시 바깥: 10
안쪽 블록의 x 는 새로 만들어진 별개의 변수다.
바깥의 x 는 그대로 10 으로 살아 있고,
안쪽 블록이 끝나는 순간 안쪽 x 는 사라진다.
이렇게 안쪽 변수가 같은 이름의 바깥 변수를 가려 버리는 것을 변수 가리기(shadowing) 라고 부른다.
if / for 안에서 의도치 않게
문제는 의도하지 않은 shadowing 이다.
func main() {
err := setup()
if err != nil {
return
}
if v, err := compute(); err == nil { // err 가 새로 만들어짐
fmt.Println(v)
}
fmt.Println(err) // 어떤 err? 바깥의 err, 즉 setup 의 결과
}
if v, err := compute(); ... 의 err 는
조건의 짧은 명령문이라서 새로운 변수 다.
바깥의 err 와는 다른 변수다.
코드를 빨리 읽으면 마지막 err 가
compute() 의 결과처럼 보이지만,
실제로는 setup() 의 결과다.
이런 혼동이 실제로 자주 일어난다.
10.5 := 의 함정
shadowing 의 단골 원인이 := 다.
원리를 짧게 정리해 둔다.
var x int는 항상 새 변수를 만든다x = 10은 이미 있는x에 값을 넣는다x := 10은 그 블록 안에 같은 이름의 변수가 없으면 새로 만들고, 있으면 그대로 쓴다… 가 아니다. 이미 그 블록 안에 같은 이름이 있으면 에러 다.
핵심은 “그 블록 안에” 라는 부분이다.
바깥 블록에 같은 이름이 있어도,
새로운 안쪽 블록에서 := 를 쓰면 새 변수가 만들어진다.
func main() {
x := 10
{
x := 20 // 안쪽 블록의 새 변수
fmt.Println(x) // 20
}
fmt.Println(x) // 10
}
다중 반환에서의 미묘함
가장 헷갈리는 케이스가 다중 반환의 := 다.
func main() {
n, err := strconv.Atoi("10")
if err != nil { /* ... */ }
n, err := strconv.Atoi("20") // ?
}
위 코드는 컴파일 에러다.
n, err 둘 다 이미 같은 블록에 있기 때문이다.
이 경우엔 = 를 써야 한다.
n, err = strconv.Atoi("20")
그런데 다중 반환의 := 는 특이한 규칙이 하나 있다.
왼쪽 이름들 중에 하나라도 새 변수 면, 같은 블록 안이라도
:=가 허용된다. 이미 있는 이름은 단순 대입처럼 동작한다.
예:
n, err := strconv.Atoi("10")
m, err := strconv.Atoi("20") // OK: m 은 새 변수, err 는 기존 변수에 대입
문제는 이 규칙이 블록이 다를 때 와 어우러지면 조용한 shadowing 을 만든다는 점이다.
err := setup()
if cond {
n, err := compute() // 여기서 err 는 새 변수 (블록이 다름)
_ = n
if err != nil {
return
}
}
// 여기 err 는 setup() 의 결과
// compute() 의 err 는 if 블록을 벗어나며 버려졌다
위 코드는 컴파일도 잘 되고 실행도 잘 된다.
하지만 compute() 의 에러는 처리된 적이 없다.
조용히 묻혔다.
어떻게 피하나
- 안쪽 블록에서도 같은 변수를 쓰려면
=를 의식적으로 쓴다. - 변수 이름을 일부러 다르게 짓는다 (
err,errComp). - 도구의 도움을 받는다.
마지막 항목이 중요하다.
Go 표준 도구인 go vet 에는
shadowing 을 잡아 주는 분석기가 있었다.
요즘은 별도 도구로 분리되어 있지만,
린터 묶음(golangci-lint) 에 포함된 shadow 분석기로 흔히 검사한다.
golangci-lint run --enable=shadow
이런 도구를 처음부터 켜 두는 습관을 추천한다. 사람이 눈으로 잡기 가장 어려운 종류의 버그다.
10.6 전역 변수를 자제해야 하는 이유
패키지 수준 변수는 편리해 보인다. 어디서든 접근할 수 있고, 인자로 매번 넘기지 않아도 된다.
하지만 코드가 조금만 커지면 빠르게 문제가 된다.
추적이 어렵다
전역 변수의 값은 패키지 어디에서나 바뀔 수 있다. 버그가 났을 때
“이 값이 왜 이렇게 됐지?”
를 알려면 패키지의 모든 함수를 들춰 봐야 한다. 함수 인자로 들어오는 값은 호출 지점만 보면 되지만, 전역 변수는 그렇지 않다.
테스트가 어렵다
테스트는 보통 “이 함수에 이 입력을 주면 이 결과가 나온다” 형태로 짠다. 함수가 전역 변수를 참조하고 있으면 입력만으로 결과가 결정되지 않는다.
- 테스트 실행 순서에 따라 결과가 달라진다
- 다른 테스트가 전역 상태를 오염시키면 같이 깨진다
- 테스트마다 전역 변수를 초기화/복원하는 코드가 늘어난다
테스트는 32장에서 본격적으로 다룬다. 지금은 “전역 변수가 늘면 테스트가 어려워진다” 정도만 기억하자.
동시성에서 위험하다
여러 고루틴이 동시에 전역 변수를 읽고 쓰면 경쟁 조건(race condition)이 생길 수 있다. 값이 중간에 깨지거나, 한쪽의 변경이 다른 쪽에 안 보인다.
이 문제는 매우 복잡하고 디버깅도 어렵다. 9부(22~25장) 에서 동시성을 다룰 때 자세히 본다.
그럼 언제 써도 되나
전부 금지하라는 뜻은 아니다. 다음 같은 경우는 자연스럽다.
const로 선언한 상수 (값이 바뀌지 않으니 안전)- 패키지 전체에 한 번만 만들어지는 객체
- 예: 로거, 설정, DB 커넥션 풀
- 보통 변수가 아니라 함수로 노출한다
- 매우 작은 유틸 패키지의 내부 캐시 등
기준은 단순하다.
자주 바뀌는 값은 전역 변수에 두지 않는다.
자주 바뀐다면 함수 인자로 넘기거나 구조체에 담아 다닌다 (13장).
10.7 정리
이 장에서 살펴본 내용:
- 변수의 범위는 그 변수가 보이는 영역이다
- Go 는 중괄호
{ }단위로 범위가 갈린다 - 세 단계: 패키지 / 함수 / 블록
if,for,switch의 본문은 각자 하나의 블록- 함수 매개변수는 복사돼서 함수 내부에서만 산다
- 같은 이름을 안쪽에서 다시 선언하면 변수 가리기가 일어난다
- 특히
:=다중 반환과 블록의 조합은 조용한 shadowing 의 원인 - 린터 (
golangci-lint의shadow) 로 미리 잡는다 - 전역 변수는 추적, 테스트, 동시성에서 모두 비싸다. 꼭 필요할 때만 쓴다
여기까지가 “기본 흐름과 함수 모양” 이다. 이제 데이터를 좀 더 본격적으로 묶어 다룰 차례다.
다음 장(11장) 부터는 여러 데이터를 묶는 자료구조 다.
배열과 슬라이스, 맵, 구조체로 이어진다.
지금까지 배운 for range, 함수, 범위 개념이
하나하나 다시 등장한다.