본문으로 건너뛰기

숫자 타입과 형 변환

이 챕터에서 다루는 것

Go의 숫자 타입은 종류가 많고, 그 사이를 오갈 때마다 변환을 손으로 써야 한다. 이 불편함이 무엇을 막으려고 만들어졌는지 확인하고, 정수 나눗셈·오버플로·intint64의 관계처럼 조용히 틀리는 지점들을 실제 출력으로 확인한다.

타입 목록과 크기

examples/02-language-basics/02-sizes/main.go
package main

import (
"fmt"
"math"
"runtime"
"unsafe"
)

func main() {
var (
i int
i8 int8
i32 int32
i64 int64
u uint
u8 uint8
f32 float32
f64 float64
r rune
b byte
)

fmt.Println("GOARCH:", runtime.GOARCH)
fmt.Printf("int %d바이트\n", unsafe.Sizeof(i))
fmt.Printf("int8 %d바이트\n", unsafe.Sizeof(i8))
fmt.Printf("int32 %d바이트\n", unsafe.Sizeof(i32))
fmt.Printf("int64 %d바이트\n", unsafe.Sizeof(i64))
fmt.Printf("uint %d바이트\n", unsafe.Sizeof(u))
fmt.Printf("uint8 %d바이트\n", unsafe.Sizeof(u8))
fmt.Printf("float32 %d바이트\n", unsafe.Sizeof(f32))
fmt.Printf("float64 %d바이트\n", unsafe.Sizeof(f64))
fmt.Printf("rune %d바이트 (%T)\n", unsafe.Sizeof(r), r)
fmt.Printf("byte %d바이트 (%T)\n", unsafe.Sizeof(b), b)

fmt.Println()
fmt.Println("MaxInt ", math.MaxInt)
fmt.Println("MinInt ", math.MinInt)
fmt.Println("MaxInt8 ", math.MaxInt8, "MinInt8", math.MinInt8)
fmt.Println("MaxUint8 ", math.MaxUint8)
fmt.Println("MaxInt64 ", math.MaxInt64)
fmt.Printf("MaxFloat64 %v\n", math.MaxFloat64)
}
go run ./02-sizes
GOARCH: arm64
int 8바이트
int8 1바이트
int32 4바이트
int64 8바이트
uint 8바이트
uint8 1바이트
float32 4바이트
float64 8바이트
rune 4바이트 (int32)
byte 1바이트 (uint8)

MaxInt 9223372036854775807
MinInt -9223372036854775808
MaxInt8 127 MinInt8 -128
MaxUint8 255
MaxInt64 9223372036854775807
MaxFloat64 1.7976931348623157e+308

:::warning int의 크기는 플랫폼에 따라 다르다 이 출력은 arm64(64비트)에서 나온 것이라 int가 8바이트다. 32비트 플랫폼(GOARCH=386, arm, wasm)에서는 4바이트이고 math.MaxInt도 21억 남짓으로 줄어든다. int의 크기에 의존하는 코드를 쓰면 안 된다. 정확한 폭이 필요하면 int32/int64를 명시한다. :::

정리하면 다음과 같다.

부류타입
부호 있는 정수int8, int16, int32, int64, int
부호 없는 정수uint8, uint16, uint32, uint64, uint, uintptr
실수float32, float64
복소수complex64, complex128
별칭byte = uint8, rune = int32

byterune완전한 별칭이다. 새 타입이 아니라 다른 이름일 뿐이라, byteuint8은 서로 대입할 수 있다. 이름이 따로 있는 이유는 의도를 드러내기 위해서다. []byte는 "바이트 열"이고 []rune은 "유니코드 코드 포인트 열"이다. 자세한 건 2-3에서 다룬다.

uintptrcomplex64/complex128은 실무에서 거의 만나지 않는다. 전자는 unsafe 패키지와 짝을 이루고, 후자는 신호 처리 같은 특수 영역용이다.

무엇을 기본으로 쓸 것인가

고민이 없으면 intfloat64를 쓴다. 이건 취향이 아니라 관례다.

  • len()int를 반환하고, 인덱스도 int다. 슬라이스와 섞이는 순간 int가 아니면 변환이 계속 붙는다.
  • 표준 라이브러리 대부분이 intfloat64를 받는다. math 패키지는 전부 float64다.
  • 크기를 줄여서 얻는 이득은 대량의 배열/구조체를 다룰 때만 유의미하다.

int64를 쓰는 경우는 명확하다. 32비트에서도 21억을 넘길 수 있는 값(타임스탬프 밀리초, 바이트 카운트, DB의 BIGINT)이거나, 외부 포맷이 폭을 정해 놓은 경우다.

uint는 "음수가 될 수 없다"는 표현으로 쓰지 않는다. 이건 다른 언어에서 온 사람이 가장 자주 하는 실수다. 뺄셈 한 번이면 순환해서 거대한 양수가 되기 때문에, 오히려 버그를 숨긴다. 비트 연산, 해시, 바이너리 포맷처럼 비트 패턴 자체가 의미인 경우에만 쓴다.

왜 암묵적 변환이 없는가

var i int = 7
var f float64 = 1.5
var i64 int64 = 3

var x float64 = i
sum := i + f
total := i + i64
./main.go:10:18: cannot use i (variable of type int) as float64 value in variable declaration
./main.go:11:9: invalid operation: i + f (mismatched types int and float64)
./main.go:12:11: invalid operation: i + i64 (mismatched types int and int64)

C나 Java, JavaScript를 하다 오면 세 줄 다 통과할 거라 기대한다. Go는 전부 막는다. 심지어 intint64도 다른 타입이라, 64비트 플랫폼에서 크기가 같아도 섞이지 않는다.

이 결정의 근거는 "숫자 승격 규칙을 아무도 정확히 기억하지 못한다"는 관찰이다. C의 정수 승격 규칙은 표준 문서 몇 페이지 분량이고, 부호 있는 타입과 부호 없는 타입을 비교하면 무슨 일이 벌어지는지는 숙련자도 매번 헷갈린다. Go는 규칙을 외우게 하는 대신 규칙 자체를 없앴다. 변환이 필요하면 코드에 보이게 쓴다.

examples/02-language-basics/02-conversion/main.go
package main

import (
"fmt"
"math"
)

// Celsius와 Fahrenheit는 둘 다 근본 타입이 float64지만 서로 다른 타입이다.
type Celsius float64
type Fahrenheit float64

// ToF는 섭씨를 화씨로 바꾼다.
func (c Celsius) ToF() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}

func main() {
var i int = 7
var f float64 = float64(i) // 변환은 항상 명시적으로
var u uint = uint(i)
fmt.Println(i, f, u)

// 실수 -> 정수 변환은 반올림이 아니라 0 방향 절삭이다.
pos, neg := 2.9, -2.9
fmt.Println(int(pos), int(neg))
fmt.Println(int(math.Round(pos)), int(math.Round(neg)))

// 이름 있는 타입 사이의 변환도 명시적으로 써야 한다.
body := Celsius(36.5)
fmt.Printf("%.1f°C = %.2f°F\n", float64(body), float64(body.ToF()))

// 컴파일은 되지만 의미가 없는 변환: 섭씨 값을 화씨 타입에 그냥 밀어넣기
wrong := Fahrenheit(body)
fmt.Printf("잘못된 변환: %.1f\n", float64(wrong))
}
go run ./02-conversion
7 7 7
2 -2
3 -3
36.5°C = 97.70°F
잘못된 변환: 36.5

세 가지를 짚는다.

  • 실수 → 정수 변환은 반올림이 아니라 0 방향 절삭이다. int(2.9)는 2, int(-2.9)는 -2. 반올림하려면 math.Round를 거친다. 다른 언어의 floor 동작을 기대하면 음수에서 틀린다.
  • 이름 있는 타입은 근본 타입이 같아도 다른 타입이다. CelsiusFahrenheit는 둘 다 float64지만 서로 대입할 수 없다. 이게 도메인 단위를 타입으로 표현하는 근거다.
  • 그러나 변환은 여전히 뚫린다. Fahrenheit(body)는 컴파일되고, 36.5라는 숫자를 화씨로 둔갑시킨다. 타입은 실수를 어렵게 만들 뿐 불가능하게 만들지는 않는다.

:::note 상수는 예외처럼 보인다 var f float64 = 3은 되는데 var f float64 = i는 안 된다. 앞서 2-1에서 본 것처럼 3은 타입 없는 상수라 문맥에 맞춰 float64가 되기 때문이다. 변환 규칙은 변수에만 엄격하다. :::

정수 나눗셈과 0으로 나누기

examples/02-language-basics/02-division/main.go
package main

import (
"fmt"
"math"
)

func main() {
// 정수끼리의 / 는 정수 나눗셈이다. 소수점 아래는 버려진다.
fmt.Println(7/2, 7%2)
fmt.Println(-7/2, -7%2) // 0 방향으로 잘린다. 나머지 부호는 피제수를 따른다

// 실수 결과를 원하면 피연산자를 먼저 실수로 바꿔야 한다.
a, b := 7, 2
fmt.Println(float64(a) / float64(b))
fmt.Println(float64(a / b)) // 이미 늦었다. 3을 3.0으로 바꿀 뿐이다

// 0으로 나누기: 정수는 런타임 패닉, 실수는 Inf/NaN
var zero float64
fmt.Println(1/zero, -1/zero, zero/zero)
fmt.Println(math.IsInf(1/zero, 1), math.IsNaN(zero/zero))

// NaN은 자기 자신과도 같지 않다.
nan := math.NaN()
fmt.Println(nan == nan)
}
go run ./02-division
3 1
-3 -1
3.5
3
+Inf -Inf NaN
true true
false
  • 7/2는 3이다. 피연산자가 둘 다 정수면 /는 정수 나눗셈이다. Python 3의 /처럼 실수를 돌려주지 않는다.
  • float64(a / b)는 틀린 고침이다. 괄호 위치가 전부다. 나눗셈이 먼저 일어나 3이 되고 그다음 3.0이 된다. 반드시 나누기 전에 변환한다.
  • -7 / 2는 -3이고 -7 % 2-1이다. Go는 0 방향으로 자른다(truncated division). Python의 -7 // 2는 -4, -7 % 2는 1이다. 음수 인덱스를 모듈러로 감쌀 때 이 차이가 정확히 버그가 된다.
  • 정수를 0으로 나누면 패닉이다.
a, b := 7, 0
fmt.Println(a / b)
panic: runtime error: integer divide by zero

상수 7/0이면 컴파일 에러이지만, 변수라면 런타임까지 간다. 나누는 값이 외부에서 온다면 0 검사를 직접 해야 한다.

  • 실수는 0으로 나눠도 패닉이 아니다. IEEE 754를 따라 +Inf, -Inf, NaN이 나온다. 그리고 **NaN != NaN**이다. x == x가 false면 NaN이라는 뜻인데, 실무에서는 math.IsNaN(x)를 쓴다.

오버플로는 조용하다

examples/02-language-basics/02-overflow/main.go
package main

import (
"fmt"
"math"
)

func main() {
// 부호 있는 정수 오버플로는 패닉이 아니라 순환(wraparound)이다.
var i8 int8 = math.MaxInt8
fmt.Println(i8, i8+1)

var u8 uint8 = 0
fmt.Println(u8, u8-1)

// int도 예외가 아니다. 64비트 플랫폼 기준.
big := math.MaxInt64
fmt.Println(big, big+1)

// 변환으로 인한 손실도 조용히 일어난다.
n := 300
fmt.Println(n, int8(n), uint8(n))

// 음수를 부호 없는 타입으로 바꾸면 비트 패턴이 그대로 재해석된다.
neg := -1
fmt.Println(neg, uint8(neg), uint32(neg), uint64(neg))

// 안전하게 확인하려면 직접 검사한다.
fmt.Println(addInt8(120, 10))
fmt.Println(addInt8(120, 7))
}

// addInt8은 오버플로가 나면 ok=false를 반환한다.
func addInt8(a, b int8) (sum int8, ok bool) {
s := int16(a) + int16(b)
if s > math.MaxInt8 || s < math.MinInt8 {
return 0, false
}
return int8(s), true
}
go run ./02-overflow
127 -128
0 255
9223372036854775807 -9223372036854775808
300 44 44
-1 255 4294967295 18446744073709551615
0 false
127 true

Go는 정수 오버플로를 정의된 동작으로 규정한다. 2의 보수 순환이다. C의 부호 있는 정수 오버플로가 미정의 동작인 것과 다르고, Python의 무한 정밀도 정수와도 다르고, Java와는 같다.

패닉이 아니라는 게 핵심이다. 127 + 1-128이 되고 프로그램은 아무 일 없다는 듯 계속 돈다. 그래서 경계에 닿을 가능성이 있는 계산은 직접 검사해야 한다.

int8(300)처럼 상수를 넘치게 변환하면 컴파일러가 잡는다.

./main.go:13:16: constant 300 overflows int8

하지만 n := 300; int8(n)처럼 변수를 거치면 잡히지 않는다. 위 출력의 44가 그 결과다 (300 = 256 + 44).

:::tip 오버플로 검사를 손으로 쓰기 싫다면 math/bits 패키지에 bits.Add64, bits.Mul64 같은 캐리 반환 함수가 있다. 금액·카운터처럼 정확성이 중요한 계산이라면 int64처럼 넉넉한 폭을 쓰고, 그래도 부족하면 math/bigbig.Int로 간다. :::

흔히 하는 실수

1. intint64를 같은 것으로 여긴다

64비트 플랫폼에서는 크기가 같아서 개념적으로도 같다고 착각하기 쉽지만, 타입 시스템에서는 완전히 다른 타입이다. time.Durationint64이고, len()int를 반환한다. 둘을 곱하려면 변환이 필요하다.

n := 3
d := time.Duration(n) * time.Second // n * time.Second 는 컴파일 에러

반대로 3 * time.Second는 잘 된다. 3이 타입 없는 상수라서다. 이 비대칭이 처음에는 일관성 없어 보이지만, "상수는 유연하고 변수는 엄격하다"는 한 문장으로 설명된다.

2. 평균을 정수 나눗셈으로 구한다

avg := total / count // total, count 둘 다 int면 소수점이 날아간다
avg := float64(total) / float64(count) // 이렇게

total이 7이고 count가 2면 첫 줄은 3이다. 통계 값이 미묘하게 낮게 나오는 버그의 단골 원인이다.

3. uint로 "음수 금지"를 표현한다

var remaining uint = 3
remaining -= 5 // 18446744073709551614

패닉도, 에러도 없다. 그리고 for i := uint(len(s)) - 1; i >= 0; i--영원히 끝나지 않는다. uint는 절대 음수가 되지 않으니 i >= 0이 항상 참이기 때문이다. 음수를 막고 싶으면 int를 쓰고 검사를 넣는다.

4. float32를 아낀다고 쓴다

float32는 유효 자릿수가 약 7자리다. 누적 합계나 금액 계산에는 부족하다. 메모리가 정말 문제가 되는 대량 데이터가 아니면 float64를 쓴다. 애초에 math 패키지가 전부 float64float32를 쓰면 변환이 끝없이 붙는다.

그리고 금액은 부동소수점으로 다루지 않는다. 최소 단위(원, 센트)를 정수로 세거나 십진 소수 라이브러리를 쓴다.

정리

  • 기본은 intfloat64. int의 크기는 플랫폼에 따라 다르다(여기서는 8바이트).
  • byte = uint8, rune = int32. 별칭이지 새 타입이 아니다.
  • 암묵적 변환이 없다. intint64도 섞이지 않는다. 상수만 예외적으로 유연하다.
  • 정수 /는 절삭 나눗셈이고, 음수에서 0 방향으로 자른다. 나누기 전에 변환한다.
  • 정수를 0으로 나누면 패닉, 실수는 Inf/NaN. NaN != NaN.
  • 오버플로는 조용한 순환이다. 상수만 컴파일러가 잡는다.
  • uint는 "음수 금지"의 표현 수단이 아니다.

연습문제

  1. for i := uint(2); i >= 0; i--를 실제로 돌려 보고 무슨 일이 일어나는지 확인하자. (몇 초 안에 Ctrl+C로 끊자.) 그다음 이 루프를 int로 고쳐 보자.

  2. int의 곱이 오버플로하는지 미리 판정하는 mulOK(a, b int) (int, bool)을 써 보자. 힌트: int64로 넓혀서 계산하는 방법은 int가 이미 64비트라 통하지 않는다. a != 0 && result/a != b 같은 사후 검증이나 math/bits.Mul64를 살펴보라.

  3. math.Round, math.Floor, math.Ceil, math.Trunc2.5, -2.5, 2.4, -2.4를 각각 넣어 표를 만들어 보자. int(x)가 이 넷 중 무엇과 같은가?