Devin.KR

Go · 기본

작고 명확한 Go 프로그램

변수·상수·기본 타입 - 제로값이 있는 언어

var·:=, 제로값, int·float64·string·bool·rune, 명시적 형 변환, const·iota

개발자KR · 원고 갱신

이 장에서 배우는 것

프로그램은 값을 담아 두고 계산한 뒤 결과를 보여 주는 일을 반복한다. 값을 담는 그릇이 변수이고, 그릇에 담을 수 있는 값의 종류가 타입(type)이다. Go는 변수를 선언하기만 해도 그 안에 항상 정해진 값이 들어 있게 만든 언어다. 이 장에서는 동네 자전거 대여소의 자전거 수, 점유율, 요금, 옵션을 다루면서 변수와 기본 타입, 상수를 익힌다.

  • var 와 := 로 변수를 선언하고, 언제 무엇을 쓰는지 구분한다.
  • 선언만 한 변수가 갖는 제로값(zero value)을 타입별로 말할 수 있다.
  • int, float64, string, bool, rune 의 쓰임을 알고, 서로 다른 타입 사이에서는 명시적으로 형 변환(type conversion)을 한다.
  • const 와 iota 로 이름 붙은 상수와 순번, 비트 플래그를 만든다.

문제 상황

대여소 화면에 다음 정보를 띄우려고 한다. 거치대 20개 중 자전거가 14대 있으니 점유율은 몇 퍼센트인지, 47분을 빌린 사람의 요금은 얼마인지, 자전거에 헬멧과 바구니가 달려 있는지를 보여 주어야 한다. 대여소 이름은 한글이다.

처음 짠 코드는 흔히 다음 네 곳에서 막힌다.

  • 14대를 20칸으로 나누었는데 점유율이 0으로 나온다.
  • 정수 대수와 소수 요금을 곱하려 했더니 컴파일이 되지 않는다.
  • 대여소 이름 길이를 len 으로 재었더니 8이 아니라 22가 나온다.
  • 옵션이 늘어날 때마다 true/false 변수가 하나씩 늘어난다.

모두 변수와 타입의 규칙을 정확히 알면 사라지는 문제다. 이 장의 규칙을 하나씩 확인하고, 마지막에 이 문제들을 한 파일에서 모두 풀어 본다.

변수 선언과 제로값

var 와 :=

Go에서 변수를 만드는 방법은 두 가지다. 하나는 var 로 타입을 밝히는 선언이고, 다른 하나는 함수 안에서만 쓸 수 있는 짧은 선언 := 이다.

var docks int = 20   // 타입과 값을 모두 적는다
var spare = 3        // 값에서 타입을 알아낸다(int)
bikes := 14          // var 와 타입 표기를 생략한 짧은 형태
bikes = 15           // 이미 있는 변수에 새 값을 넣을 때는 = 만 쓴다

:= 는 "새 변수를 만들고 값을 넣는다"는 뜻이고, = 는 "이미 있는 변수에 값을 넣는다"는 뜻이다. 오른쪽 값의 타입이 그대로 변수의 타입이 된다. 정수 리터럴(코드에 직접 적은 값) 14 는 int, 3.5 는 float64, "B-017" 은 string, 'B' 는 rune 이 된다.

Go 컴파일러는 쓰지 않는 지역 변수를 오류로 처리한다(declared and not used). 다른 언어에서 경고로 넘어갈 일을 Go는 컴파일 단계에서 막는다. 함수 밖, 즉 패키지 수준에서는 := 를 쓸 수 없고 var 만 쓴다.

제로값

C 계열 언어에서는 초기화하지 않은 변수에 이전에 메모리에 있던 값이 남아 있을 수 있다. Go는 그런 일이 없다. 값을 주지 않고 선언한 변수는 타입마다 정해진 제로값으로 시작한다.

값을 주지 않고 선언한 변수는 타입에 따라 0, 0.0, 빈 문자열, false 로 시작한다.
기본 타입의 제로값
타입제로값출력 서식의미
int0%d숫자 0
float640%.1f소수 0.0
string""%q글자가 없는 문자열
boolfalse%t거짓

포인터·슬라이스·맵 같은 타입의 제로값은 nil 이라는 "가리키는 것이 없음" 값이다. 이 타입들은 각각을 설명할 때 다룬다.

제로값이 유용한 이유는 "선언만 해도 쓸 수 있는 상태"가 되기 때문이다. 예를 들어 오늘 반납 건수를 세는 변수는 var returned int 한 줄이면 0에서 시작하는 카운터가 된다. 값을 넣는 줄을 따로 쓰지 않아도 되고, 잊어서 생기는 오류도 없다.

기본 타입과 명시적 형 변환

기본 타입

이 장에서 쓰는 기본 타입
타입크기담는 값리터럴 예
int32 또는 64비트부호 있는 정수14
float6464비트소수(부동소수점)3.99
string가변바이트 열, 수정할 수 없음"B-017"
bool1바이트참 또는 거짓true
rune32비트글자 하나의 번호'B'

int 의 크기는 실행하는 컴퓨터에 따라 다르다. 요즘 macOS와 Linux 컴퓨터는 대부분 64비트다. 크기를 정확히 고정하고 싶으면 int8, int32, int64, uint8 처럼 이름에 크기가 붙은 타입을 쓴다. 특별한 이유가 없으면 정수는 int, 소수는 float64 를 쓰면 된다. byte 는 uint8 의 다른 이름이고 rune 은 int32 의 다른 이름이다.

명시적 형 변환

Go는 서로 다른 타입의 값을 자동으로 바꾸지 않는다. int 와 float64 를 곱하려면 개발자가 어느 쪽으로 맞출지 직접 적어야 한다. 문법은 타입(값) 이다.

hours := float64(rentalMinutes) / 60   // int 를 float64 로
whole := int(avgKm)                    // float64 를 int 로: 소수 부분은 버려진다

소수를 정수로 바꾸면 반올림이 아니라 소수점 아래를 잘라 낸다. 3.99는 3이 된다. 번거로워 보이지만 어디서 정밀도를 잃는지 코드에 그대로 드러나는 장점이 있다.

정수끼리 나누면 결과도 정수다. 14 / 20 은 0이고 47 / 10 은 4이며, 나머지는 % 로 구한다(47 % 10 은 7). 요금을 "10분 단위로 올림"하려면 이 성질을 이용해 (분 + 9) / 10 으로 계산한다.

문자열과 rune

문자열은 바이트(byte, 8비트 수) 열이다. Go 소스와 문자열은 UTF-8 이라는 방식으로 글자를 바이트로 바꾸어 저장하는데, 영문 한 글자는 1바이트이지만 한글 한 글자는 3바이트를 쓴다. 그래서 len("한강") 은 2가 아니라 6이다.

문자열 한강B 는 바이트 7개이지만 글자(rune)는 3개다.

글자 수가 필요하면 문자열을 []rune(s) 로 바꾸어 그 길이를 재거나 utf8.RuneCountInString(s) 를 쓴다. rune(룬)은 유니코드에서 글자 하나에 붙은 번호를 담는 타입이다. 'B' 처럼 작은따옴표로 적으면 그 글자의 번호(66)를 가진 값이 된다. 큰따옴표 "B" 는 문자열이므로 둘은 다른 타입이다. 문자열의 s[0] 은 글자가 아니라 첫 바이트를 돌려준다. 문자열의 내용은 바꿀 수 없어서 s[0] = 'b' 는 컴파일 오류다.

상수와 iota

바뀌지 않는 값은 const 로 선언한다. 기본 요금이나 단위 시간이 그런 예다. 상수는 컴파일할 때 값이 정해지고, 나중에 대입하려 하면 컴파일 오류가 난다.

타입을 적지 않은 상수는 "타입 없는 상수"이고, 쓰이는 자리에 맞춰 타입이 정해진다. unitFee 가 200 인 상수라면 int 변수와도, float64 변수와도 섞어 쓸 수 있다. 변수는 이렇게 되지 않는다는 점이 상수의 편한 부분이다.

iota

iota 는 const 묶음 안에서 0부터 1씩 커지는 값을 만들어 준다. 자전거 종류처럼 번호를 붙이는 데 쓴다.

const (
	Standard = iota // 0
	Electric        // 1: 식을 생략하면 앞 줄의 식이 반복된다
	Cargo           // 2
)

식에 iota 를 넣으면 규칙에 따른 값이 나온다. 1 << iota 는 1을 왼쪽으로 iota 칸 미는 계산이라 1, 2, 4, 8로 늘어난다. 이진수로 쓰면 001, 010, 100 이어서 자리마다 켜고 끌 수 있는 스위치가 된다. 이것을 비트 플래그라고 한다. 옵션 여러 개를 bool 변수 여러 개 대신 정수 하나에 담을 수 있다. 합치는 것은 |, 확인하는 것은 & 로 한다.

완성 코드

앞 장에서 만든 모듈 폴더에 main.go 로 저장한다.

package main

import (
	"fmt"
	"unicode/utf8"
)

const stationName = "한강공원 대여소"

const (
	Standard = iota
	Electric
	Cargo
)

const (
	FlagHelmet = 1 << iota
	FlagLock
	FlagBasket
)

const (
	baseFee         = 1000
	unitFee         = 200
	unitMinutes     = 10
	discountPercent = 15
)

func main() {
	// 1. 제로값
	var stationID int
	var lateRate float64
	var note string
	var closed bool
	fmt.Printf("제로값: %d %.1f %q %t\n", stationID, lateRate, note, closed)

	// 2. var 와 :=
	var docks int = 20
	bikes := 14
	occupancy := float64(bikes) / float64(docks) * 100
	fmt.Printf("%s: 거치대 %d개 중 %d대 (%.1f%%)\n", stationName, docks, bikes, occupancy)

	// 3. 요금 계산
	rentalMinutes := 47
	units := (rentalMinutes + unitMinutes - 1) / unitMinutes
	fee := baseFee + units*unitFee
	discounted := fee * (100 - discountPercent) / 100
	fmt.Printf("대여 %d분: %d단위, 요금 %d원, 할인 후 %d원\n", rentalMinutes, units, fee, discounted)
	fmt.Println("몫과 나머지:", rentalMinutes/unitMinutes, rentalMinutes%unitMinutes)

	// 4. 형 변환
	hours := float64(rentalMinutes) / 60
	avgKm := 3.99
	fmt.Printf("%.2f시간, 평균 %.2fkm는 정수로 %d\n", hours, avgKm, int(avgKm))
	var half float64 = unitFee / 2
	fmt.Printf("단위 요금의 절반: %.1f\n", half)

	// 5. 문자열과 rune
	runes := []rune(stationName)
	fmt.Printf("바이트 %d, 글자 %d, 앞 두 글자 %s\n", len(stationName), utf8.RuneCountInString(stationName), string(runes[:2]))
	bikeID := "B-017"
	fmt.Println(stationName + " / " + bikeID)
	prefix := rune(bikeID[0])
	fmt.Printf("%c %d %t\n", prefix, prefix, prefix == 'B')

	// 6. iota
	fmt.Println("종류:", Standard, Electric, Cargo)
	options := FlagHelmet | FlagBasket
	fmt.Printf("옵션 %03b, 잠금장치 포함 %t, 바구니 포함 %t\n", options, options&FlagLock == FlagLock, options&FlagBasket == FlagBasket)
}

줄별 해설

선언부. stationName 은 문자열 상수다. 첫 번째 const 묶음은 자전거 종류 번호를 0, 1, 2로 만든다. 두 번째 묶음은 1 << iota 로 1, 2, 4를 만들어 헬멧, 잠금장치, 바구니 플래그로 쓴다. 세 번째 묶음은 요금 규칙이다. 이 값들은 코드 곳곳에 숫자로 흩어 놓지 않고 이름을 붙여 한곳에 모았다.

1번 블록. 값을 주지 않은 int, float64, string, bool 변수 네 개를 출력한다. %d 는 정수, %.1f 는 소수점 아래 한 자리, %q 는 따옴표를 붙인 문자열, %t 는 참/거짓이다. 빈 문자열이 "" 로 보이는 것은 %q 덕분이다.

2번 블록. var docks int = 20 은 타입을 모두 적은 선언이고 bikes := 14 는 짧은 선언이다. 점유율은 두 값을 float64 로 바꾼 뒤 나누어 0.7을 얻고 100을 곱한다. %% 는 서식 문자열에서 퍼센트 기호 하나를 뜻한다.

3번 블록. (47 + 10 - 1) / 10 은 정수 나눗셈이라 5가 되고, 이것이 10분 단위 올림 결과다. 요금은 1000 + 5 * 200 = 2000 이다. 할인은 소수를 쓰지 않고 2000 * 85 / 100 으로 계산해 1700원이다. 돈을 정수(원 단위)로 다루면 소수 오차를 피할 수 있다. 다음 줄은 같은 47과 10에서 몫 4와 나머지 7을 보여 준다.

4번 블록. float64(rentalMinutes) / 60 은 소수 나눗셈이라 약 0.783이 되고 %.2f 가 0.78로 표시한다. int(avgKm) 은 3.99의 소수 부분을 잘라 3을 만든다. var half float64 = unitFee / 2 에서는 타입 없는 상수 200 / 2 가 100이 되어 float64 변수에 그대로 들어간다. 상수가 아니라 int 변수였다면 이 대입은 컴파일되지 않는다.

5번 블록. len(stationName) 은 바이트 수 22를, utf8.RuneCountInString 은 글자 수 8을 돌려준다. runes[:2] 는 앞 두 글자만 잘라 낸 것이고 string(...) 으로 다시 문자열로 바꾼다. 문자열은 + 로 이을 수 있다. bikeID[0] 은 첫 바이트이고 rune(...) 으로 바꾼 뒤 %c(글자), %d(번호)로 출력한다. 'B' 와 비교한 결과는 true 다.

6번 블록. 세 종류 상수를 출력하면 0, 1, 2다. FlagHelmet | FlagBasket 은 001 과 100 을 합쳐 101 이다. %03b 는 이진수를 세 자리로 채워 보여 준다. options & FlagLock 은 잠금장치 자리만 남기는데 그 자리가 꺼져 있으니 0이고, FlagLock(2)과 같지 않으므로 false 다. 바구니 자리는 켜져 있어서 true 다. 이 프로그램은 쓰지 않는 변수나 가져오고 쓰지 않는 패키지가 없어 경고 없이 컴파일된다.

실행 결과

$ go run .
제로값: 0 0.0 "" false
한강공원 대여소: 거치대 20개 중 14대 (70.0%)
대여 47분: 5단위, 요금 2000원, 할인 후 1700원
몫과 나머지: 4 7
0.78시간, 평균 3.99km는 정수로 3
단위 요금의 절반: 100.0
바이트 22, 글자 8, 앞 두 글자 한강
한강공원 대여소 / B-017
B 66 true
종류: 0 1 2
옵션 101, 잠금장치 포함 false, 바구니 포함 true

실무에서 자주 틀리는 것

1. 함수 밖에서 := 를 쓴다

package main

bikes := 14 // 컴파일 오류: non-declaration statement outside function body

func main() {}

패키지 수준에서는 선언이 var, const, func, type 으로 시작해야 한다. 고친 코드는 다음과 같다.

package main

var bikes = 14

func main() {}

2. 정수와 실수를 그대로 섞는다

bikes := 14
price := 1500.5
total := bikes * price // 컴파일 오류: mismatched types int and float64

어느 쪽을 바꿀지 코드에 적어야 한다. 요금 계산이라면 소수로 맞춘다.

total := float64(bikes) * price

3. 정수 나눗셈으로 비율을 구한다

ratio := bikes / docks // 14 / 20 은 0
percent := float64(bikes/docks) * 100 // 나눈 뒤에 바꾸므로 여전히 0

형 변환은 나눗셈 전에 해야 한다.

percent := float64(bikes) / float64(docks) * 100 // 70

4. 한글 문자열의 길이를 len 으로 잰다

name := "한강공원 대여소"
if len(name) > 10 { // 바이트 22 로 판단한다
	// ...
}

화면에 보이는 글자 수가 기준이라면 룬을 센다. (조건문 if 는 다음 장에서 자세히 다룬다.)

n := utf8.RuneCountInString(name) // 8

한눈에 보기

이 장의 핵심 문법 정리
주제문법핵심주의
변수 선언var x int, x := 1:= 는 함수 안에서 새 변수를 만든다패키지 수준에서는 var 만 가능하고, 쓰지 않는 지역 변수는 오류다
제로값0, 0.0, "", false선언만 해도 값이 정해져 있다포인터·슬라이스·맵은 nil
형 변환float64(n), int(f)타입이 다르면 직접 바꾼다소수를 정수로 바꾸면 잘린다
문자열과 runelen(s), []rune(s)len 은 바이트 수한글은 글자당 3바이트
상수const, iota번호와 비트 플래그를 이름으로 만든다상수에는 다시 대입할 수 없다
자주 쓰는 Printf 서식
서식대상예출력
%d정수1414
%.2f소수0.78330.78
%q문자열""""
%crune66B

연습 문제

  1. 다음 코드의 출력을 예상하라.
    var a int
    var b string
    c := 7 / 2
    d := 7 / 2.0
    fmt.Println(a, b == "", c, d)
  2. 다음 코드는 컴파일되지 않는다. 이유를 말하고 고쳐라.
    bikes := 14
    price := 1500.5
    total := bikes * price
  3. 자전거 상태 Available, Rented, Broken 을 iota 로 선언하되 값이 0이 아닌 1부터 시작하게 하라. 이때 Rented 의 값은 얼마인가?
  4. s := "자전거 A" 의 len(s) 와 글자 수를 각각 구하라.

정답과 해설

  1. 출력은 0 true 3 3.5 다. a 는 제로값 0이고 b 는 빈 문자열이라 b == "" 가 참이다. 7 / 2 는 정수 나눗셈이라 3이다. 2.0 은 소수 리터럴이므로 7 / 2.0 은 소수 계산이 되어 d 는 float64 3.5다.
  2. bikes 는 int, price 는 float64 라서 곱할 수 없다(mismatched types). total := float64(bikes) * price 로 고친다. 반대로 int(price) 로 바꾸면 소수 부분이 잘리므로 요금 계산에는 맞지 않다.
  3. const (
    	Available = iota + 1
    	Rented
    	Broken
    )
    Rented 는 2다. 첫 줄의 식 iota + 1 이 뒤 줄에도 반복되어 1, 2, 3이 된다. 제로값 0을 "정해지지 않은 상태"로 남겨 두고 싶을 때 이렇게 시작 값을 옮긴다.
  4. len(s) 는 11이다. "자전거"는 한글 3글자라 9바이트, 공백 1바이트, A 1바이트를 더한다. 글자 수는 utf8.RuneCountInString(s) 로 구하며 5다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.