GO · 심층 가이드
Go 기초 문법 완전 정리
변수 선언과 포인터에서 출발해 타입 단언, iota 열거형, 임베딩, 제네릭 타입 제약까지 Go 타입 시스템의 기초 27가지를 순서대로 익힙니다.
Go의 기초 문법은 배울 게 적어 보이지만, 타입을 다루는 방식이 다른 언어와 미묘하게 어긋나 있어 오해가 쌓이기 쉽습니다. 대표적으로 type MyInt int는 별칭이 아니라 새로운 타입이라 int와 자동으로 섞이지 않습니다. 반대로 타입 없는 상수는 문맥에 맞춰 조용히 맞춰집니다. 암묵적 변환이 거의 없는 언어에서 상수만 유연하게 움직이는 이 비대칭을 이해하지 못하면, 컴파일 에러가 나는 이유와 나지 않는 이유가 둘 다 수수께끼로 남습니다.
27편을 통째로 읽기보다 축을 나누십시오. 값과 참조 축은 변수 선언 — var & :=에서 포인터 — & * 기초, 포인터 심화, 구조체 메서드로 이어지며 리시버를 값으로 받을지 포인터로 받을지의 판단으로 수렴합니다. 타입 축은 커스텀 타입 & 타입 별칭과 iota와 열거형으로 도메인 값을 좁히고, 빈 인터페이스 & any 타입 다음에 타입 단언과 타입 스위치로 다시 좁힙니다. 마지막 축인 타입 파라미터·타입 제약·comparable 제약은 any로 뭉갰던 코드를 타입 안전하게 되돌리는 도구로 읽으면 됩니다.
숨은 함정은 초기화 순서 쪽에 있습니다. 패키지 수준 변수는 의존 관계에 따라 초기화되고 그 뒤에 init이 돌지만, 여러 패키지가 얽히면 순서를 직관으로 짐작하기 어렵습니다. 특히 패키지 초기화와 사이드 이펙트 import에서 다루는 _ import는 등록 부수효과만 노리는 기법이라, 그 패키지를 지우면 컴파일은 통과하는데 런타임 동작이 사라집니다. 초기화 시점 로직은 최소로 두고, 가능하면 명시적 생성 함수로 옮기십시오.
01변수 선언 — var & :=
Go의 두 가지 변수 선언 방식과 제로값(zero value) 초기화.
Go code
package main
import "fmt"
// 패키지 레벨 변수 — var만 가능
var (
appName = "MyApp"
maxRetries = 3
debug bool // zero value: false
)
// 상수
const (
Pi = 3.14159
Version = "1.0.0"
// iota — 자동 증가 상수
Sunday = iota
Monday
Tuesday
Wednesday
)
func main() {
// var 선언
var name string = "Go"
var age int // zero value: 0
var ratio float64 // zero value: 0.0
// 단축 선언 := (함수 내부에서만)
lang := "Go"
version := 1.22
active := true
// 다중 선언
x, y, z := 1, 2, 3
// zero value 확인
var s string // ""
var n int // 0
var b bool // false
var p *int // nil
fmt.Printf("name=%s age=%d ratio=%f
", name, age, ratio)
fmt.Printf("lang=%s v=%.2f active=%t
", lang, version, active)
fmt.Println(x, y, z)
fmt.Printf("zeros: %q %d %t %v
", s, n, b, p)
}Go의 모든 타입은 제로값(zero value)으로 초기화됩니다. int→0, string→"", bool→false, pointer/slice/map→nil. 명시적 초기화가 필요 없는 경우가 많습니다.
:=는 함수 내부에서만 사용 가능합니다. 선언했지만 사용하지 않은 변수는 컴파일 에러입니다. 패키지 레벨 변수는 사용 안 해도 됩니다.
02기본 타입 & 타입 변환
Go의 정적 타입 시스템. 암시적 타입 변환이 없어 모든 변환을 명시해야 합니다.
Go code
package main
import (
"fmt"
"strconv"
)
func main() {
// 정수 타입
var i8 int8 = 127
var i32 int32 = 2_147_483_647
var i64 int64 = 9_223_372_036_854_775_807
var u uint = 255
// 부동소수점
var f32 float32 = 3.14
var f64 float64 = 3.141592653589793
// 타입 변환 — 반드시 명시적
a := 42
b := float64(a) // int → float64
c := int(f64) // float64 → int (소수점 버림)
d := uint(a) // int → uint
// 문자열 변환 (strconv 패키지)
n, err := strconv.Atoi("42") // string → int
if err != nil {
fmt.Println("변환 실패:", err)
}
s := strconv.Itoa(100) // int → string
f, _ := strconv.ParseFloat("3.14", 64) // string → float64
bs := strconv.FormatBool(true) // bool → string
// rune = int32 (유니코드 코드포인트)
var r rune = '한'
fmt.Printf("rune: %c (%d)
", r, r)
// byte = uint8
var by byte = 'A'
fmt.Printf("byte: %c (%d)
", by, by)
_ = i8; _ = i32; _ = i64; _ = u
_ = f32; _ = b; _ = c; _ = d
_ = n; _ = s; _ = f; _ = bs
}int의 크기는 플랫폼에 따라 32bit 또는 64bit입니다. 크기가 중요한 경우 int32/int64를 명시하세요.
string(65)는 "65"가 아니라 "A"를 반환합니다(rune 변환). 정수를 문자열로 변환하려면 반드시 strconv.Itoa()를 사용하세요.
03조건문 & 반복문 — if, for, switch
Go에는 while이 없습니다. for가 모든 반복문 역할을 합니다.
Go code
package main
import "fmt"
func main() {
// if — 초기화 구문 포함 가능
if x := 42; x > 40 {
fmt.Println("x는 40보다 큼:", x)
}
// for — 기본 (C의 for)
for i := 0; i < 5; i++ {
fmt.Print(i, " ")
}
fmt.Println()
// for — while처럼
n := 1
for n < 100 {
n *= 2
}
fmt.Println("n:", n)
// for — 무한루프
count := 0
for {
count++
if count >= 3 {
break
}
}
// for range — 슬라이스/맵/채널
fruits := []string{"🍎", "🍌", "🍇"}
for i, v := range fruits {
fmt.Printf("%d: %s
", i, v)
}
// 인덱스만
for i := range fruits { _ = i }
// 값만
for _, v := range fruits { _ = v }
// switch — fallthrough 없음 (기본)
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
default:
fmt.Println("C 이하")
}
// switch — 타입 스위치
var i interface{} = "hello"
switch v := i.(type) {
case int: fmt.Println("int:", v)
case string: fmt.Println("string:", v)
default: fmt.Println("unknown")
}
}switch에서 fallthrough 키워드를 명시하면 다음 case로 이어집니다. 기본적으로 break가 자동 적용됩니다.
for range에서 값 변수는 복사본입니다. 원본을 수정하려면 인덱스로 접근하세요: slice[i] = newVal
04포인터 — & * 기초
Go의 포인터. C처럼 직접 메모리 주소를 다루지만 포인터 연산은 없습니다.
Go code
package main
import "fmt"
func increment(n *int) {
*n++ // 역참조로 원본 수정
}
func newInt(val int) *int {
// 로컬 변수 주소 반환 OK — Go GC가 관리
return &val
}
func main() {
x := 42
p := &x // p는 x의 주소를 가리키는 포인터
fmt.Println("값:", x) // 42
fmt.Println("주소:", p) // 0xc0000b4008 (예시)
fmt.Println("역참조:", *p) // 42
*p = 100 // 포인터를 통해 값 변경
fmt.Println("변경 후:", x) // 100
// 함수로 포인터 전달
increment(&x)
fmt.Println("increment 후:", x) // 101
// new() — 제로값으로 초기화된 포인터 반환
q := new(int) // *int, 값은 0
*q = 50
fmt.Println("new:", *q)
// nil 포인터 확인
var ptr *int
fmt.Println("nil?", ptr == nil) // true
// fmt.Println(*ptr) // 패닉! nil 역참조
r := newInt(99)
fmt.Println("힙 할당:", *r) // 99
}Go는 포인터 연산(p++)을 허용하지 않습니다. 이는 의도적인 설계로, 메모리 안전성을 높입니다.
nil 포인터를 역참조(*ptr)하면 런타임 패닉입니다. 포인터를 사용하기 전에 항상 ptr != nil을 확인하세요.
05문자열 & rune & strings 패키지
Go 문자열은 바이트 슬라이스입니다. 한글 처리 시 rune 변환이 필요합니다.
Go code
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
s := "Hello, 세계"
// 바이트 vs rune
fmt.Println("바이트 길이:", len(s)) // 13
fmt.Println("문자(rune) 수:", utf8.RuneCountInString(s)) // 9
// 바이트 순회 (한글 깨짐)
for i, b := range []byte(s) {
if i < 7 { fmt.Printf("%d:%c ", i, b) }
}
fmt.Println()
// rune 순회 (올바른 한글 처리)
for i, r := range s {
fmt.Printf("%d:%c ", i, r)
}
fmt.Println()
// rune 슬라이싱
runes := []rune(s)
fmt.Println("첫 5자:", string(runes[:5])) // Hello
// strings 패키지
fmt.Println(strings.ToUpper("hello")) // HELLO
fmt.Println(strings.Contains(s, "세계")) // true
fmt.Println(strings.HasPrefix(s, "Hello")) // true
fmt.Println(strings.Replace(s, "Hello", "Hi", 1))
fmt.Println(strings.Split("a,b,c", ",")) // [a b c]
fmt.Println(strings.Join([]string{"x","y"}, "-")) // x-y
fmt.Println(strings.TrimSpace(" hello ")) // hello
fmt.Println(strings.Count(s, "l")) // 2
// strings.Builder — 효율적 문자열 조합
var b strings.Builder
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "item%d ", i)
}
fmt.Println(b.String())
}strings.Builder는 문자열을 반복적으로 연결할 때 매우 효율적입니다. + 연산자로 루프에서 문자열을 합치면 O(n²) 성능이 됩니다.
s[i]로 한글 문자열을 인덱싱하면 바이트를 반환합니다. 한글 문자 하나는 3바이트이므로 깨진 문자가 됩니다. []rune(s)[i]를 사용하세요.
06타입 별칭 & 타입 정의
기존 타입을 기반으로 새 타입을 만들어 타입 안전성을 높입니다.
Go code
package main
import "fmt"
// 타입 정의 — 새 타입 (메서드 추가 가능)
type Celsius float64
type Fahrenheit float64
type UserID int
type ProductID int
// 타입에 메서드 추가
func (c Celsius) ToFahrenheit() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}
func (f Fahrenheit) ToCelsius() Celsius {
return Celsius((f - 32) * 5 / 9)
}
// 컴파일 타임 타입 안전성
func findOrder(uid UserID, pid ProductID) string {
return fmt.Sprintf("user:%d, product:%d", uid, pid)
}
// 타입 별칭 — 완전히 동일한 타입
type MyInt = int // 메서드 추가 불가
// Stringer 구현
func (uid UserID) String() string {
return fmt.Sprintf("UID(%d)", int(uid))
}
func main() {
boiling := Celsius(100)
fmt.Printf("%.1f°C = %.1f°F
", boiling, boiling.ToFahrenheit())
body := Fahrenheit(98.6)
fmt.Printf("%.1f°F = %.2f°C
", body, body.ToCelsius())
// 타입 안전성
order := findOrder(UserID(1), ProductID(42))
fmt.Println(order)
// findOrder(42, 1) // 컴파일 에러!
// findOrder(ProductID(42), UserID(1)) // 컴파일 에러!
uid := UserID(123)
fmt.Println(uid) // UID(123) — Stringer 호출
}원시 타입을 직접 사용하는 것보다 type UserID int처럼 의미 있는 타입을 정의하면 실수로 ID를 바꿔 넣는 버그를 컴파일 타임에 잡을 수 있습니다.
type Celsius float64와 type Fahrenheit float64는 서로 다른 타입입니다. 직접 대입하면 컴파일 에러가 나며, 명시적 변환이 필요합니다.
07slog 구조화 로깅 (Go 1.21+)
log/slog 패키지로 JSON 구조화 로그 출력
Go code
<span class="cm">// slog 구조화 로깅 (Go 1.21+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("slog 구조화 로깅 (Go 1.21+)") }GO 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
08포인터 심화
포인터를 사용하여 값을 직접 수정하고, nil 포인터를 안전하게 처리합니다. 구조체 포인터와 슬라이스 포인터의 차이를 이해합니다.
Go code
package main
import "fmt"
func increment(p *int) {
if p == nil {
return // nil 안전 처리
}
*p++
}
func main() {
x := 10
increment(&x)
fmt.Println(x) // 11
// 포인터로 구조체 필드 수정
type User struct{ Name string }
u := &User{Name: "Go"}
u.Name = "Gopher" // (*u).Name과 동일
fmt.Println(u.Name)
// new로 포인터 생성
p := new(int) // *int, 0으로 초기화
*p = 42
fmt.Println(*p)
}Go에서 구조체 포인터는 자동 역참조됩니다. p.Field는 (*p).Field와 동일하게 동작합니다.
nil 포인터를 역참조하면 런타임 패닉이 발생합니다. 항상 nil 체크를 수행하거나, 값 타입 사용을 고려하세요.
09구조체 메서드
구조체에 메서드를 정의하여 객체지향 스타일로 코드를 작성합니다. 값 리시버와 포인터 리시버의 차이를 이해합니다.
Go code
package main
import "fmt"
type Rect struct {
Width, Height float64
}
// 값 리시버 — 원본 변경 불가
func (r Rect) Area() float64 {
return r.Width * r.Height
}
// 포인터 리시버 — 원본 수정 가능
func (r *Rect) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
func (r Rect) String() string {
return fmt.Sprintf("Rect(%.1f x %.1f)", r.Width, r.Height)
}
func main() {
r := Rect{Width: 3, Height: 4}
fmt.Println(r.Area()) // 12
r.Scale(2)
fmt.Println(r) // Rect(6.0 x 8.0)
}하나의 메서드라도 포인터 리시버를 사용하면, 일관성을 위해 모든 메서드를 포인터 리시버로 통일하는 것이 관례입니다.
값 리시버 메서드에서 필드를 수정해도 원본에 반영되지 않습니다. 수정이 필요하면 반드시 포인터 리시버를 사용하세요.
10타입 단언 (Type Assertion)
인터페이스 값에서 구체적인 타입을 추출합니다. 콤마-ok 패턴으로 안전하게 타입을 확인합니다.
Go code
package main
import "fmt"
func describe(i any) string {
// 콤마-ok 패턴 (안전)
if s, ok := i.(string); ok {
return "문자열: " + s
}
if n, ok := i.(int); ok {
return fmt.Sprintf("정수: %d", n)
}
return "알 수 없는 타입"
}
func main() {
var v any = "Hello"
s := v.(string) // 확실할 때만 사용
fmt.Println(s)
fmt.Println(describe("Go"))
fmt.Println(describe(42))
fmt.Println(describe(3.14))
}콤마-ok 패턴(v, ok := i.(T))을 사용하면 타입이 맞지 않아도 패닉 없이 ok=false를 반환합니다.
콤마-ok 패턴 없이 i.(T)를 사용할 때 타입이 맞지 않으면 런타임 패닉이 발생합니다.
11타입 스위치
switch v.(type)으로 인터페이스 값의 타입에 따라 분기합니다. 여러 타입을 한 번에 처리할 수 있습니다.
Go code
package main
import "fmt"
func classify(v any) string {
switch x := v.(type) {
case nil:
return "nil 값"
case int, int64:
return fmt.Sprintf("정수: %v", x)
case string:
return "문자열 길이: " + fmt.Sprint(len(x))
case bool:
if x {
return "참"
}
return "거짓"
case []int:
return fmt.Sprintf("정수 슬라이스 (%d개)", len(x))
default:
return fmt.Sprintf("기타: %T", x)
}
}
func main() {
values := []any{42, "Go", true, nil, []int{1, 2}}
for _, v := range values {
fmt.Println(classify(v))
}
}타입 스위치에서 case int, int64:처럼 여러 타입을 나열하면, 변수 x는 원래 인터페이스 타입으로 남습니다.
타입 스위치와 일반 switch를 혼동하지 마세요. switch v.(type) 구문은 인터페이스 변수에서만 사용할 수 있습니다.
12iota와 열거형
iota로 연속된 상수를 자동 생성합니다. 비트 시프트와 결합하면 비트 플래그 패턴을 구현할 수 있습니다.
Go code
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
)
// 비트 플래그 패턴
type Permission int
const (
Read Permission = 1 << iota // 1
Write // 2
Execute // 4
)
func (p Permission) String() string {
s := ""
if p&Read != 0 { s += "r" }
if p&Write != 0 { s += "w" }
if p&Execute != 0 { s += "x" }
return s
}
func main() {
fmt.Println(Monday) // 1
perm := Read | Write
fmt.Println(perm) // rw
}iota는 const 블록 내에서만 유효하며, 새 블록이 시작되면 0으로 리셋됩니다.
iota의 첫 번째 값이 0이므로, 유효하지 않은 상태를 0으로 정의하면 초기화되지 않은 변수와 구분할 수 없습니다.
13커스텀 타입 & 타입 별칭
type 키워드로 새로운 타입을 정의하여 타입 안전성을 높입니다. 타입 별칭과 타입 정의의 차이를 이해합니다.
Go code
package main
import "fmt"
// 타입 정의 — 새로운 타입 (기존 메서드 상속 안 됨)
type UserID int64
type Email string
// 타입 별칭 — 완전히 동일한 타입
type Byte = uint8
func sendEmail(to Email, msg string) {
fmt.Printf("To: %s, Msg: %s\n", to, msg)
}
func main() {
var id UserID = 100
var email Email = "user@go.dev"
// id + 1 → 컴파일 에러 (타입 불일치)
id = UserID(int64(id) + 1)
fmt.Println(id)
sendEmail(email, "Hello!")
// sendEmail("raw@go.dev", "Hi") // 컴파일 에러
sendEmail(Email("raw@go.dev"), "Hi") // 명시적 변환 필요
}커스텀 타입은 함수 시그니처에서 매개변수를 명확히 구분해줍니다. func Transfer(from, to UserID, amount Money)처럼 사용하면 인자 순서 실수를 방지합니다.
타입 정의(type X int)와 타입 별칭(type X = int)을 혼동하지 마세요. 타입 정의는 새 타입이고, 별칭은 기존 타입과 동일합니다.
14타입 임베딩
구조체에 다른 타입을 임베딩하여 필드와 메서드를 승격(promote)시킵니다. 상속 대신 합성을 사용하는 Go의 핵심 패턴입니다.
Go code
package main
import "fmt"
type Logger struct{}
func (l Logger) Log(msg string) {
fmt.Println("[LOG]", msg)
}
type Server struct {
Logger // 임베딩 (이름 없는 필드)
Host string
Port int
}
func (s Server) Start() {
s.Log(fmt.Sprintf("서버 시작: %s:%d", s.Host, s.Port))
}
func main() {
srv := Server{Host: "localhost", Port: 8080}
srv.Start()
srv.Log("직접 호출도 가능") // Logger.Log가 승격됨
}임베딩된 타입의 메서드는 외부 구조체에서 직접 호출할 수 있습니다. 인터페이스 충족에도 자동으로 포함됩니다.
임베딩은 상속이 아닙니다. 외부 구조체가 같은 이름의 메서드를 정의하면 임베딩된 메서드를 가립니다(shadowing).
15빈 인터페이스 & any 타입
any(= interface{})는 모든 타입을 담을 수 있는 빈 인터페이스입니다. 제네릭 도입 전 범용 컨테이너로 사용되었습니다.
Go code
package main
import "fmt"
// any는 interface{}의 별칭 (Go 1.18+)
func printAll(values ...any) {
for i, v := range values {
fmt.Printf("[%d] %T = %v\n", i, v, v)
}
}
// 타입 안전한 대안: 제네릭
func first[T any](slice []T) (T, bool) {
if len(slice) == 0 {
var zero T
return zero, false
}
return slice[0], true
}
func main() {
printAll("Go", 42, true, 3.14)
v, ok := first([]string{"a", "b"})
fmt.Println(v, ok) // a true
}Go 1.18부터 any가 interface{}의 공식 별칭입니다. 새 코드에서는 any를 사용하세요.
any 타입을 남용하면 타입 안전성을 잃습니다. 가능하면 제네릭을 사용하여 컴파일 타임에 타입을 검증하세요.
16비교 연산과 comparable
Go에서 비교 가능한 타입과 불가능한 타입을 이해합니다. comparable 제약은 맵 키와 제네릭에서 중요합니다.
Go code
package main
import "fmt"
// comparable 제약: == 연산 가능한 타입만 허용
func contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func main() {
// 비교 가능: 숫자, 문자열, bool, 포인터, 채널, 배열
fmt.Println(contains([]int{1, 2, 3}, 2)) // true
fmt.Println(contains([]string{"a", "b"}, "c")) // false
// 구조체도 모든 필드가 comparable이면 비교 가능
type Point struct{ X, Y int }
fmt.Println(Point{1, 2} == Point{1, 2}) // true
// 슬라이스, 맵, 함수는 비교 불가
// []int{1} == []int{1} // 컴파일 에러
}맵의 키 타입은 반드시 비교 가능해야 합니다. 구조체를 맵 키로 사용하려면 모든 필드가 comparable이어야 합니다.
슬라이스, 맵, 함수 타입은 ==로 비교할 수 없습니다. nil과의 비교만 허용됩니다.
17문자열과 rune 심화
Go 문자열은 UTF-8 바이트 시퀀스입니다. rune은 유니코드 코드포인트를 나타내며, 한글 등 멀티바이트 문자를 안전하게 처리합니다.
Go code
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
s := "Hello, 세계! 🌍"
fmt.Println("바이트 길이:", len(s))
fmt.Println("문자 수:", utf8.RuneCountInString(s))
// range는 rune 단위로 순회
for i, r := range s {
if r == '세' {
fmt.Printf("'세' at byte %d\n", i)
}
}
// 문자열 빌더 (효율적 연결)
var b strings.Builder
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "%d ", i)
}
fmt.Println(b.String()) // 0 1 2 3 4
}range로 문자열을 순회하면 자동으로 rune(유니코드 문자) 단위로 디코딩됩니다. 인덱스는 바이트 위치입니다.
len(s)는 바이트 수를 반환합니다. 한글 등 멀티바이트 문자의 문자 수를 세려면 utf8.RuneCountInString을 사용하세요.
18상수와 타입 없는 상수
Go 상수는 컴파일 타임에 결정됩니다. 타입 없는 상수는 문맥에 따라 자동으로 타입이 결정되어 유연하게 사용됩니다.
Go code
package main
import "fmt"
const (
Pi = 3.14159265358979 // 타입 없는 상수
MaxSize = 1024
Prefix = "go_"
)
// 타입 있는 상수
const TypedMax int64 = 1<<63 - 1
func main() {
// 타입 없는 상수는 문맥에 맞게 변환
var f32 float32 = Pi // float32로 사용
var f64 float64 = Pi // float64로 사용
fmt.Println(f32, f64)
// 큰 정밀도 유지
const big = 1e1000 // 컴파일 타임에만 존재 가능
fmt.Println(big / 1e999) // 10
}타입 없는 상수는 최소 256비트 정밀도를 가집니다. 변수에 대입될 때 비로소 타입이 결정되므로 유연하게 사용할 수 있습니다.
상수에 :=를 사용할 수 없습니다. 상수는 반드시 const 키워드로 선언해야 합니다.
19초기화 순서와 init 함수
init() 함수는 패키지 초기화 시 자동 실행됩니다. 패키지 변수 초기화 → init() → main() 순서로 실행됩니다.
Go code
package main
import "fmt"
var global = initGlobal()
func initGlobal() int {
fmt.Println("1. 패키지 변수 초기화")
return 42
}
func init() {
fmt.Println("2. init() 실행")
// DB 연결, 설정 로드 등
}
func init() {
// 같은 파일에 여러 init 가능
fmt.Println("3. 두 번째 init() 실행")
}
func main() {
fmt.Println("4. main() 실행")
fmt.Println("global =", global)
}init()은 같은 파일과 패키지에 여러 개 정의할 수 있으며, 파일 내에서는 위에서 아래로, 파일 간에는 파일명 알파벳 순서로 실행됩니다.
init()에서 무거운 작업(네트워크 호출 등)을 수행하면 테스트와 디버깅이 어렵습니다. 의존성 주입을 고려하세요.
20패키지 가시성 규칙
Go는 대문자로 시작하면 공개(exported), 소문자면 비공개(unexported)입니다. 패키지 수준의 캡슐화를 제공합니다.
Go code
package main
import "fmt"
// 공개 타입 (다른 패키지에서 접근 가능)
type User struct {
Name string // 공개 필드
Email string // 공개 필드
age int // 비공개 필드
}
// 공개 생성자 — 비공개 필드 설정에 사용
func NewUser(name, email string, age int) *User {
return &User{Name: name, Email: email, age: age}
}
// 비공개 함수
func validate(email string) bool {
return len(email) > 0
}
func main() {
u := NewUser("Gopher", "go@dev", 5)
fmt.Println(u.Name) // OK
// fmt.Println(u.age) // 같은 패키지라 OK, 다른 패키지면 에러
}비공개 필드를 가진 구조체는 NewXxx 생성자 함수를 제공하여 올바른 초기화를 보장하세요.
구조체의 비공개 필드는 다른 패키지에서 JSON 언마샬링 시 무시됩니다. JSON 바인딩이 필요한 필드는 반드시 대문자로 시작해야 합니다.
21internal 패키지
internal 디렉토리의 코드는 부모 디렉토리의 하위 패키지에서만 import할 수 있습니다. Go 컴파일러가 강제하는 접근 제한입니다.
Go code
package main
import "fmt"
// 디렉토리 구조:
// myapp/
// ├── cmd/server/main.go ← internal 사용 가능
// ├── internal/
// │ ├── auth/auth.go ← 외부 import 불가
// │ ├── db/db.go ← 외부 import 불가
// │ └── config/config.go
// └── pkg/api/api.go ← 외부 import 가능
// internal/config/config.go 예시:
// package config
// type Config struct {
// DBHost string
// Port int
// }
func main() {
fmt.Println("internal/ → 모듈 내부에서만 접근")
fmt.Println("pkg/ → 외부에서도 접근 가능")
fmt.Println("컴파일러가 internal 접근 제한을 강제합니다")
}internal 패키지는 API 표면을 줄이고 구현 세부사항을 숨기는 데 효과적입니다. 라이브러리 작성 시 적극 활용하세요.
internal 패키지를 pkg 디렉토리 안에 넣으면 의미가 충돌합니다. internal은 모듈 루트나 cmd 근처에 배치하세요.
22타입 파라미터 (제네릭 기초)
Go 1.18+의 제네릭으로 타입 안전한 범용 함수와 구조체를 작성합니다. [T any] 구문으로 타입 파라미터를 선언합니다.
Go code
package main
import "fmt"
// 제네릭 함수
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// 제네릭 구조체
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
func main() {
nums := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("#%d", n)
})
fmt.Println(nums) // [#1 #2 #3]
s := &Stack[int]{}
s.Push(10)
s.Push(20)
v, _ := s.Pop()
fmt.Println(v) // 20
}타입 추론 덕분에 호출 시 타입 인자를 생략할 수 있는 경우가 많습니다. Map(nums, fn)처럼 간결하게 사용하세요.
제네릭 타입의 제로 값이 필요할 때 nil을 반환하면 값 타입에서 컴파일 에러가 발생합니다. var zero T를 사용하세요.
23타입 제약 (Type Constraints)
인터페이스를 타입 제약으로 사용하여 제네릭 함수에서 허용하는 타입을 제한합니다. ~로 기저 타입을 포함합니다.
Go code
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// 커스텀 제약 정의
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// constraints.Ordered 사용
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
type Score int // ~int 덕분에 사용 가능
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.1, 2.2})) // 3.3
fmt.Println(Sum([]Score{10, 20, 30})) // 60
fmt.Println(Max("apple", "banana")) // banana
}~int의 ~는 기저 타입이 int인 모든 타입(예: type Score int)을 포함합니다.
타입 제약 없이 [T any]를 사용하면 +, > 등의 연산자를 사용할 수 없습니다. 적절한 제약을 지정하세요.
24comparable 제약
comparable은 ==와 != 연산이 가능한 타입을 나타내는 내장 제약입니다. 맵 키 타입으로 사용됩니다.
Go code
package main
import "fmt"
// comparable: ==, != 연산 가능한 타입
func Index[T comparable](slice []T, target T) int {
for i, v := range slice {
if v == target {
return i
}
}
return -1
}
// 맵 키로 사용
type Set[T comparable] map[T]struct{}
func NewSet[T comparable](items ...T) Set[T] {
s := make(Set[T])
for _, item := range items {
s[item] = struct{}{}
}
return s
}
func (s Set[T]) Has(item T) bool {
_, ok := s[item]
return ok
}
func main() {
fmt.Println(Index([]string{"a", "b", "c"}, "b")) // 1
fruits := NewSet("apple", "banana", "cherry")
fmt.Println(fruits.Has("banana")) // true
fmt.Println(fruits.Has("grape")) // false
}comparable은 맵 키 타입이 충족해야 하는 제약과 동일합니다. 제네릭 세트, 캐시 등에 필수적으로 사용됩니다.
슬라이스, 맵, 함수 타입은 comparable을 만족하지 않습니다. 이들을 맵 키나 세트 원소로 사용할 수 없습니다.
25타입 추론
Go 컴파일러는 인자 타입에서 타입 파라미터를 자동 추론합니다. 명시적 타입 인자가 불필요한 경우가 대부분입니다.
Go code
package main
import "fmt"
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
func Zip[A, B any](a []A, b []B) []struct{ First A; Second B } {
n := len(a)
if len(b) < n { n = len(b) }
result := make([]struct{ First A; Second B }, n)
for i := 0; i < n; i++ {
result[i] = struct{ First A; Second B }{a[i], b[i]}
}
return result
}
func main() {
// 타입 추론: [int] 생략 가능
evens := Filter([]int{1, 2, 3, 4, 5}, func(n int) bool {
return n%2 == 0
})
fmt.Println(evens) // [2 4]
pairs := Zip([]string{"a", "b"}, []int{1, 2})
fmt.Println(pairs)
}타입 추론이 실패하는 경우(반환 타입만으로 추론 필요) 명시적으로 타입 인자를 제공하세요: Func[int](...)
제네릭 타입(구조체)을 인스턴스화할 때는 타입 추론이 불가능합니다. Stack[int]{}처럼 항상 타입을 명시해야 합니다.
26패키지 초기화와 사이드 이펙트 import
import _ "pkg"로 패키지의 init()만 실행합니다. 데이터베이스 드라이버 등록에 흔히 사용됩니다.
Go code
package main
import (
"fmt"
// 사이드 이펙트 import 예시:
// _ "github.com/lib/pq" // PostgreSQL 드라이버 등록
// _ "image/png" // PNG 디코더 등록
// _ "net/http/pprof" // pprof 핸들러 등록
)
// 별칭 import
// import (
// chi "github.com/go-chi/chi/v5" // 별칭
// . "math" // 도트 import (비권장)
// )
func main() {
fmt.Println("사이드 이펙트 import: init()만 실행")
fmt.Println(" _ "database/sql/driver" → 드라이버 등록")
fmt.Println(" _ "image/png" → 포맷 등록")
fmt.Println(" _ "net/http/pprof" → 프로파일링")
}사이드 이펙트 import는 init()에서 sql.Register()나 image.RegisterFormat() 등을 호출하여 전역 등록을 수행합니다.
도트 import(. "math")는 네임스페이스 오염을 일으키므로 테스트 파일 외에는 사용하지 마세요.
27제네릭과 인터페이스 결합
제네릭 타입 제약에 메서드 요구사항을 포함하여 타입 안전한 다형성을 구현합니다.
Go code
package main
import (
"fmt"
"strings"
)
// 메서드 제약 인터페이스
type Stringer interface {
String() string
}
type Validator interface {
Validate() error
}
// 두 제약을 합성
type ValidStringer interface {
Stringer
Validator
}
func PrintValid[T ValidStringer](items []T) {
for _, item := range items {
if err := item.Validate(); err != nil {
fmt.Printf("INVALID: %v\n", err)
continue
}
fmt.Println(item.String())
}
}
type Email string
func (e Email) String() string { return string(e) }
func (e Email) Validate() error {
if !strings.Contains(string(e), "@") {
return fmt.Errorf("유효하지 않은 이메일: %s", e)
}
return nil
}
func main() {
emails := []Email{"user@go.dev", "invalid", "admin@go.dev"}
PrintValid(emails)
}제약 인터페이스에 타입 요소(~int | ~string)와 메서드를 동시에 포함할 수 있습니다.
타입 요소를 포함하는 인터페이스는 제약으로만 사용할 수 있고, 일반 변수 타입으로는 사용할 수 없습니다.
정리하며
- type 정의는 새 타입을 만들며 원본 타입과 자동으로 호환되지 않습니다
- 포인터 리시버와 값 리시버 선택은 메서드 세트와 변경 가능성을 함께 보고 정합니다
- any로 받은 값은 타입 단언 대신 타입 스위치로 분기해야 실패 경로가 드러납니다
- 빈 식별자 import는 부수효과에 의존하므로 제거 시 조용히 동작이 사라집니다
더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.