본문으로 건너뛰기

변수, 상수, 기본 타입

이 챕터에서 다루는 것

Go에서 값에 이름을 붙이는 방법은 var:= 두 가지뿐이고, 그 둘의 경계는 문법이 아니라 관례로 정해진다. 여기서는 그 경계를 잡고, "선언하면 반드시 쓸 수 있는 값이 들어 있다"는 제로값 규칙과, 컴파일 시점에만 존재하는 상수 세계를 다룬다.

선언은 왜 두 가지인가

다른 언어를 하다 오면 Go의 변수 선언이 두 벌인 게 낭비처럼 보인다.

var name string = "go"
name := "go"

두 번째가 짧으니 항상 그걸 쓰면 될 것 같지만, :=함수 안에서만 동작한다. 패키지 수준에서는 쓸 수 없다.

package main

import "fmt"

count := 3

func main() {
fmt.Println(count)
}
# example.com/scratch
./main.go:5:1: syntax error: non-declaration statement outside function body

이유는 문법 설계에 있다. :=문(statement) 이고, 패키지 수준에는 선언만 올 수 있다. 그래서 전역 상태는 항상 var로 시작한다.

함수 안에서는 둘 다 되지만, 실제로는 이렇게 갈린다.

examples/02-language-basics/01-declarations/main.go
package main

import (
"fmt"
"strconv"
)

// 패키지 수준에서는 := 를 쓸 수 없다. var만 가능하다.
var appName = "language-basics"

func main() {
// 1. 타입 추론에 맡기는 짧은 선언 — 함수 안에서 가장 흔한 형태
port := 8080
host := "localhost"

// 2. 타입을 명시해야 할 때
var ratio float64 = 1
var id int64 = 42

// 3. 제로값으로 시작하고 나중에 채울 때
var parsed int

n, err := strconv.Atoi("17")
if err != nil {
fmt.Println("변환 실패:", err)
return
}
parsed = n

fmt.Println(appName, host, port, ratio, id, parsed)

// := 는 왼쪽 변수 중 하나라도 새 변수면 쓸 수 있다. err는 재사용되고 raw만 새로 선언된다.
raw, err := strconv.Atoi("99")
if err != nil {
fmt.Println("변환 실패:", err)
return
}
fmt.Println(raw)
}
go run ./01-declarations
language-basics localhost 8080 1 42 17
99

판단 기준은 단순하다.

상황쓰는 것
함수 안에서 초기값이 있다:=
패키지 수준var
제로값으로 시작한다var x T
추론된 타입이 원하는 타입과 다르다var x T = ...

세 번째 줄이 중요하다. id := 42int가 되지 int64가 되지 않는다. int64가 필요하면 var id int64 = 42라고 써야 한다. 이 차이는 2-2에서 다시 다룬다.

:::note var x T = v는 대부분 군더더기다 var ratio float64 = 1처럼 타입이 오른쪽에서 뻔히 보이는데 왼쪽에도 적으면 gofmt는 그냥 두지만 린터는 지적한다. 추론 결과가 마음에 들 때는 ratio := 1.0, 마음에 들지 않을 때만 타입을 적는다고 생각하면 된다. :::

여러 개를 묶어 선언할 때는 괄호로 그룹을 만든다. import와 같은 문법이다.

var (
s string
i int
f float64
)

제로값: "초기화되지 않은 변수"라는 상태가 없다

C의 지역 변수는 쓰레기값을 가지고, Java의 지역 변수는 초기화 전에 읽으면 컴파일 에러이며, JavaScript는 undefined라는 별도의 값을 만들었다. Go는 다른 답을 골랐다. 모든 타입에는 제로값이 있고, 선언만 하면 그 값이 들어 있다.

examples/02-language-basics/01-zero-values/main.go
package main

import "fmt"

// Config는 선언만 하고 초기화하지 않아도 모든 필드가 제로값을 가진다.
type Config struct {
Name string
Retries int
Debug bool
Timeout float64
}

func main() {
var (
s string
i int
f float64
b bool
p *int
)

fmt.Printf("string %q\n", s)
fmt.Printf("int %d\n", i)
fmt.Printf("float64 %v\n", f)
fmt.Printf("bool %v\n", b)
fmt.Printf("*int %v\n", p)

var c Config
fmt.Printf("Config %+v\n", c)
}
go run ./01-zero-values
string ""
int 0
float64 0
bool false
*int <nil>
Config {Name: Retries:0 Debug:false Timeout:0}
타입 부류제로값
숫자(int, float64, …)0
boolfalse
string"" (nil이 아니다)
포인터, 함수, 인터페이스, 채널, 맵, 슬라이스nil
배열, 구조체각 요소·필드가 제로값

여기서 나오는 Go 설계 관용구가 "제로값이 바로 쓸 수 있는 상태가 되게 타입을 설계한다" 이다. sync.Mutex는 선언만 하면 잠글 수 있고, bytes.Buffer는 선언만 하면 쓸 수 있다. 1-4에서 본 strings.Builder 문서의 "The zero value is ready to use"가 바로 그 뜻이다.

:::warning 제로값이 곧 유효한 값은 아니다 nil 맵과 nil 슬라이스는 제로값이지만, 맵에 쓰면 패닉이 난다. 이 구분은 Part 3에서 자세히 다룬다. 지금은 "제로값은 정의돼 있다"와 "제로값이 항상 안전하다"가 다른 말이라는 것만 기억하자. :::

미사용 변수와 미사용 import

1-4에서 이미 확인했듯 미사용 지역 변수와 미사용 import는 경고가 아니라 컴파일 에러다. 실제 메시지와 그 판단 근거는 그쪽에 있으니 여기서는 이 챕터에 직접 걸리는 두 가지만 덧붙인다.

  • 패키지 수준 var는 쓰지 않아도 에러가 아니다. 규칙은 지역 변수에만 적용된다. 다른 파일이나 다른 패키지에서 쓸 수 있으니 컴파일러가 판단할 수 없기 때문이다.
  • 선언한 상수도 쓰지 않아도 에러가 아니다. 상수는 컴파일 결과물에 흔적을 남기지 않는다.

그래서 "잠깐 주석 처리했더니 빌드가 깨진다"는 상황은 사실상 지역 변수에서만 생긴다. 디버깅 중이라면 _ = x로 잠시 눌러 두되, 커밋 전에는 지운다.

상수: 컴파일 시점에만 사는 값

const는 "값이 바뀌지 않는 변수"가 아니다. 컴파일 시점에 값이 정해지는 것이다. 그래서 런타임에 계산되는 것은 상수가 될 수 없다.

const started = time.Now()
./main.go:8:17: time.Now() (value of struct type time.Time) is not constant

Java의 final이나 JavaScript의 const에 익숙하다면 이게 가장 큰 차이다. 그쪽은 "재대입 금지"라 런타임 값도 담을 수 있지만, Go의 상수는 애초에 런타임에 존재하지 않는다. 상수가 될 수 있는 것은 숫자, 문자열, 불리언, 룬, 그리고 이들의 컴파일 시점 연산 결과뿐이다.

iota — 연속된 상수를 만드는 카운터

const 블록 안에서 iota줄 번호 카운터처럼 동작한다. 블록이 시작될 때 0이고, 상수 선언 줄마다 1씩 증가한다. 표현식이 생략된 줄은 바로 위 줄의 표현식을 반복한다.

examples/02-language-basics/01-iota/main.go
package main

import "fmt"

// Level은 로그 수준이다. iota는 const 블록 안에서 0부터 한 줄씩 증가한다.
type Level int

const (
Debug Level = iota // 0
Info // 1
Warn // 2
Error // 3
)

// String은 Level을 사람이 읽을 수 있는 이름으로 바꾼다.
func (l Level) String() string {
names := [...]string{"DEBUG", "INFO", "WARN", "ERROR"}
if l < Debug || l > Error {
return "UNKNOWN"
}
return names[l]
}

// 바이트 단위. 1 << (10 * iota) 로 1024의 거듭제곱을 만든다.
const (
_ = iota // 0은 버린다
KB = 1 << (10 * iota)
MB
GB
)

// 비트 플래그. 각 상수가 서로 다른 비트 하나를 차지한다.
type Perm uint8

const (
Read Perm = 1 << iota
Write
Exec
)

func main() {
fmt.Println(Debug, Info, Warn, Error)
fmt.Println(int(Debug), int(Info), int(Warn), int(Error))
fmt.Println(KB, MB, GB)

p := Read | Write
fmt.Printf("perm=%03b read=%v exec=%v\n", p, p&Read != 0, p&Exec != 0)
}
go run ./01-iota
DEBUG INFO WARN ERROR
0 1 2 3
1024 1048576 1073741824
perm=011 read=true exec=false

읽을 거리가 세 군데 있다.

  • Debug Level = iota 한 줄이 아래 세 줄의 타입과 표현식을 모두 결정한다. Info, Warn, Error에는 아무것도 안 적었지만 전부 Level 타입이고 값은 1, 2, 3이다.
  • String() string 메서드를 붙이면 fmt가 알아서 그 결과를 출력한다. 첫 줄이 숫자가 아니라 DEBUG INFO WARN ERROR로 나오는 이유다. 메서드는 4-1에서, fmt가 그걸 어떻게 아는지는 4-3에서 다룬다.
  • _ = iota로 0을 버리는 패턴. KB부터 시작하고 싶은데 iota는 0부터라, 첫 줄을 블랭크 식별자로 흘려보낸다.

:::tip String 메서드는 손으로 쓰지 않아도 된다 stringer 도구가 이 메서드를 생성해 준다. go install golang.org/x/tools/cmd/stringer@latest 후 타입 위에 //go:generate stringer -type=Level을 붙이고 go generate ./...를 실행한다. 값이 늘어날 때 이름 배열을 같이 고치는 걸 잊는 실수를 원천 차단한다. :::

타입 없는 상수

Go에는 암묵적 형 변환이 없다. int 변수를 float64 자리에 넣으면 컴파일 에러다. 그런데도 var f float64 = 3이 되는 건 왜일까?

3타입 없는 상수(untyped constant) 이기 때문이다. 타입 없는 상수는 타입이 정해지기 전까지 임의 정밀도의 수로 존재하다가, 쓰이는 자리에서 그 자리에 맞는 타입이 된다.

examples/02-language-basics/01-untyped-const/main.go
package main

import "fmt"

// 타입 없는 상수. 타입이 정해지지 않았기 때문에 쓰이는 자리에 맞춰 변신한다.
const big = 1 << 40

// 타입 있는 상수. int64로 못이 박혀 있다.
const typed int64 = 1 << 40

func main() {
var a float64 = big // float64로 쓰인다
var b int64 = big // int64로도 쓰인다
fmt.Println(a, b)

// 타입 없는 상수끼리의 연산은 임의 정밀도로 계산된 뒤 마지막에 타입이 정해진다.
const third = 1.0 / 3.0
fmt.Printf("%.20f\n", third)

// 상수 나눗셈은 float64 나눗셈보다 정밀하다.
var x = 1.0 / 3.0 * 3.0
fmt.Println(x == 1.0)

f := 0.1
g := 0.2
fmt.Println(f+g == 0.3)
fmt.Println(0.1+0.2 == 0.3) // 전부 상수라 컴파일 시점에 계산된다

fmt.Println(typed)
}
go run ./01-untyped-const
1.099511627776e+12 1099511627776
0.33333333333333331483
true
false
true
1099511627776

마지막 두 줄이 핵심이다.

  • f + g == 0.3false다. fgfloat64 변수라 IEEE 754 부동소수점 덧셈을 하고, 0.1과 0.2는 이진수로 정확히 표현되지 않는다.
  • 0.1 + 0.2 == 0.3true다. 셋 다 타입 없는 상수라 컴파일러가 임의 정밀도로 계산해 버린다. 런타임에는 true라는 값만 남는다.

1.0/3.0*3.0이 정확히 1인 것도 같은 이유다. 상수 세계는 부동소수점 세계와 규칙이 다르다.

:::info 타입 없는 상수의 기본 타입 문맥이 타입을 정해 주지 않으면 종류별 기본 타입이 적용된다. 정수는 int, 실수는 float64, 룬은 rune, 문자열은 string, 불리언은 bool이다. 그래서 x := 3int, y := 3.0float64가 된다. :::

흔히 하는 실수

1. := 재사용으로 인한 섀도잉

:=는 왼쪽 변수 중 하나라도 새 변수면 통과한다. 이 편리함이 함정을 만든다.

examples/02-language-basics/01-shadowing/main.go
package main

import (
"errors"
"fmt"
)

// lookup은 이름이 비어 있으면 에러를 반환한다.
func lookup(name string) (string, error) {
if name == "" {
return "", errors.New("빈 이름")
}
return "ok:" + name, nil
}

func main() {
var err error

if true {
// := 가 안쪽 블록에 새로운 err를 만든다. 바깥 err는 그대로 nil이다.
result, err := lookup("")
fmt.Println("안쪽:", result, err)
}
fmt.Println("바깥:", err)

// 고치는 방법: result만 미리 선언하고 = 로 대입한다.
var result string
if true {
result, err = lookup("")
fmt.Println("안쪽:", result, err)
}
fmt.Println("바깥:", err)
}
go run ./01-shadowing
안쪽: 빈 이름
바깥: <nil>
안쪽: 빈 이름
바깥: 빈 이름

첫 번째 바깥:<nil>이다. 안쪽 블록에서 :=새로운 err를 만들었고, 블록이 끝나면서 그 값이 사라졌다. 에러를 잡아서 바깥에 알려 주려던 코드가 조용히 실패한다.

같은 블록 안에서 :=를 두 번 쓰면 컴파일러가 막아 준다. 문제는 블록이 다를 때다.

./main.go:13:9: no new variables on left side of :=

go vet은 기본으로 이걸 잡지 않는다. shadow 분석기가 따로 있고, golangci-lint에서 govetshadow 체크를 켜면 된다.

2. 추론된 타입이 기대와 다르다

i := 3 // int이지 int64가 아니다
f := 3 // 역시 int다. float64를 원했다면 3.0이라고 써야 한다
b := 'A' // rune(= int32)이지 byte가 아니다

인터페이스가 int64를 요구하는데 int를 넘겨서 나는 에러는 초보자가 가장 자주 만나는 컴파일 에러다. 2-2에서 정면으로 다룬다.

3. 상수 오버플로는 대입할 때 걸린다

var small int8 = 200
./main.go:11:19: cannot use 200 (untyped int constant) as int8 value in variable declaration (overflows)

200 자체는 문제가 없다. int8에 넣으려는 순간 타입이 정해지면서 범위를 벗어난다. 런타임이 아니라 컴파일 시점에 잡히는 게 상수의 장점이다.

정리

  • :=는 함수 안에서만, var는 어디서나. 제로값으로 시작할 때와 타입을 못 박을 때는 var.
  • 모든 타입에 제로값이 있다. "초기화되지 않은 변수"라는 상태가 언어에 없다.
  • 미사용 지역 변수·import는 컴파일 에러. 패키지 수준 var와 상수는 예외다.
  • const는 컴파일 시점 값이다. time.Now() 같은 런타임 값은 담을 수 없다.
  • iota는 const 블록 안의 줄 카운터다. 표현식 생략은 위 줄의 반복이다.
  • 타입 없는 상수는 임의 정밀도로 계산된 뒤 쓰이는 자리에서 타입이 정해진다.
  • :=로 인한 섀도잉이 이 챕터에서 가장 값비싼 함정이다.

연습문제

  1. iota로 요일 상수 Sunday부터 Saturday까지를 만들고 String() 메서드를 붙여 보자. 그다음 Weekend 같은 값을 중간에 끼워 넣으면 어떻게 되는지 확인하자. 힌트: 표현식을 생략한 줄이 위 줄을 반복한다는 규칙이 어떻게 적용되는지 보라.

  2. const maxRetries = 3const maxRetries int = 3을 각각 선언하고, var d time.Duration = maxRetries * time.Second를 시도해 보자. 한쪽만 컴파일된다. 왜 그런지 에러 메시지를 근거로 설명해 보자.

  3. 01-shadowing의 첫 번째 블록에서 섀도잉이 일어나는지를 컴파일러 없이 판별하는 규칙을 한 문장으로 정리해 보자. 그다음 golangci-lint에 govetshadow 체크를 켜고 실제로 잡히는지 확인하자.