1Go에서 기본 내장 타입과 참조형 타입의 기본값(zero value)이 어떻게 동작하는지 설명하고, 명시적 초기화 없이 변수를 선언할 때 이 기본값이 왜 중요한지 설명해 주세요.
Go에서 명시적인 초기화 값 없이 선언된 변수는 해당 타입의 기본값(zero value)으로 자동 초기화됩니다. 숫자 타입은 0, `bool`은 `false`, `string`은 `""`이 되며, 배열이나 구조체는 각 요소 또는 필드별로 기본값으로 채워집니다. 포인터, 슬라이스, 맵, 채널, 함수, 인터페이스와 같은 포인터형 또는 참조형 타입은 `nil`을 기본값으로 갖습니다. 이러한 특성은 Go의 변수나 생략된 구조체 필드가 쓰레기 값(garbage value) 대신 항상 결정론적인 상태로 시작하도록 보장하므로 중요합니다. 또한 많은 API가 기본값 자체로도 유용하게 동작하도록 설계되어 있지만, 일부 `nil` 값은 특정 작업을 수행하기 전에 별도의 초기화가 필요합니다.
package main
import "fmt"
type Config struct {
Port int
Debug bool
Name string
Tags []string
Options map[string]string
}
func main() {
var c Config
fmt.Printf("port=%d debug=%v name=%q tags==nil:%v options==nil:%v\n",
c.Port, c.Debug, c.Name, c.Tags == nil, c.Options == nil)
}
2Go는 구조체의 동등성 비교를 어떻게 처리하며, 구조체에 비교 불가능한 필드가 포함되어 있으면 어떻게 되나요?
Go의 구조체 값은 구조체 내의 모든 필드가 비교 가능할 때만 `==` 및 `!=`로 비교할 수 있습니다. 동등성 비교는 각 필드의 고유한 비교 규칙을 사용하여 대응하는 필드끼리 비교합니다. 구조체에 슬라이스, 맵, 함수와 같이 비교 불가능한 필드가 포함되어 있다면 해당 구조체 타입은 비교 불가능하며, 해당 구조체 타입의 두 값을 `==`로 비교하려고 하면 컴파일 타임 에러가 발생합니다. 이러한 구조체의 경우, 특히 테스트 등에서 사용자 정의 비교 로직이나 적절한 깊은 비교(deep-equality) 헬퍼를 사용해야 합니다.
package main
import "fmt"
type UserID struct {
Name string
Age int
}
type UserProfile struct {
Name string
Tags []string
}
func main() {
a := UserID{"Ann", 30}
b := UserID{"Ann", 30}
fmt.Println(a == b)
x := UserProfile{Name: "Ann", Tags: []string{"go"}}
y := UserProfile{Name: "Ann", Tags: []string{"go"}}
_, _ = x, y
// fmt.Println(x == y) // compile error: struct containing []string cannot be compared
}
3Go에서 문자열 불변성(string immutability)과 string, []byte, bytes.Buffer, strings.Builder 간의 관계를 설명해 주세요.
Go의 `string`은 불변(immutable) 바이트 시퀀스이며, 대개 UTF-8 텍스트이지만 반드시 유효한 UTF-8이어야 하는 것은 아닙니다. 문자열은 제자리에서(in-place) 수정할 수 없으므로, 내용을 변경하려면 일반적으로 바이트 단위 수정을 위해 `[]byte`로 변환하거나 코드 포인트 단위 수정을 위해 `[]rune`으로 변환한 뒤 다시 되돌려야 합니다. `string`과 `[]byte` 간의 일반적인 변환은 데이터를 복사하므로 메모리 할당이 발생할 수 있어, 반복문 내에서 빈번한 변환이나 반복적인 문자열 연결(concatenation)을 수행하면 큰 비용이 발생할 수 있습니다. `strings.Builder`는 문자열을 효율적으로 구성하도록 최적화되어 있으며, `bytes.Buffer`는 바이트 기반 데이터 처리 및 I/O에 유용한 가변 바이트 버퍼로 최종 문자열을 생성하는 데에도 사용할 수 있습니다.
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteByte(' ')
sb.WriteString("world")
fmt.Println(sb.String())
var buf bytes.Buffer
buf.Write([]byte{0x48, 0x69})
buf.WriteByte('!')
fmt.Println(buf.String())
s := "cat"
b := []byte(s) // normally copies
b[0] = 'b'
fmt.Println(s, string(b))
}
4Go에서 포인터, 슬라이스, 맵, 채널, 함수, 인터페이스에 대해 nil이 각각 어떻게 다르게 동작하는지 설명해 주세요.
Go에서 nil은 포인터, 슬라이스, 맵, 채널, 함수, 인터페이스의 기본값(zero value)이지만, 이러한 nil 값에 수행할 수 있는 연산은 타입마다 다릅니다. nil 포인터는 nil과 비교할 수 있지만, 역참조(dereferencing)하면 패닉(panic)이 발생합니다. nil 슬라이스는 길이(length)와 용량(capacity)이 모두 0이며, `for range` 문을 순회하거나 `append` 함수로 요소를 추가할 수 있습니다. nil 맵은 값을 읽거나 순회할 수 있지만, 새 키와 값을 할당하려고 하면 패닉이 발생합니다. nil 채널에 데이터를 송신하거나 수신하려고 하면 무한히 블로킹되며, nil 채널을 닫으려고(`close`) 하면 패닉이 발생합니다. nil 함수를 호출하면 패닉이 발생합니다. 인터페이스는 동적 타입과 동적 값을 모두 가지고 있지 않을 때만 nil입니다. nil 포인터와 같이 타입이 있는 nil 값을 담고 있는 인터페이스는 인터페이스 자체가 nil이 아닙니다.
package main
import "fmt"
type MyErr struct{}
func (*MyErr) Error() string { return "my error" }
func returnsTypedNil() error {
var e *MyErr = nil
return e
}
func main() {
var p *int = nil
var s []int = nil
var m map[string]int = nil
var err error = returnsTypedNil()
fmt.Println(p == nil)
fmt.Println(len(s), cap(s), s == nil)
fmt.Println(m["missing"])
fmt.Println(err == nil)
}
5Go에서 비교 가능한 타입(comparable types)이란 무엇이며, 비교 가능성 규칙이 맵 키, 동등성 비교, 제네릭 제약 조건에 어떤 영향을 미치나요?
Go에서 비교 가능한 타입이란 값을 `==` 및 `!=`로 비교할 수 있는 타입을 말합니다. 기본 타입, 포인터, 채널, 인터페이스, 그리고 모든 필드나 요소가 비교 가능한 구조체 및 배열은 비교 가능합니다. 반면 슬라이스, 맵, 함수는 `nil`과의 비교를 제외하고는 비교할 수 없습니다. 맵의 키는 반드시 비교 가능해야 합니다. 동등성 비교는 각 타입의 비교 규칙을 따르며, 인터페이스 비교는 런타임의 동적 구체 값에 따라 달라집니다. 비교 대상 인터페이스가 비교 불가능한 동적 값을 담고 있다면 비교 시 패닉(panic)이 발생합니다. 제네릭에서는 미리 선언된 `comparable` 제약 조건을 통해 타입 매개변수를 `==`/`!=`로 비교하고 맵 키로 사용할 수 있도록 허용합니다.
package main
import "fmt"
type Point struct{ X, Y int } // comparable
type Bag struct{ Items []string } // not comparable because of slice field
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
func main() {
p1, p2 := Point{1, 2}, Point{1, 2}
fmt.Println(p1 == p2)
m := map[Point]string{p1: "value"}
fmt.Println(m[p2])
fmt.Println(Contains([]string{"a", "b"}, "b"))
var a any = []int{1}
var b any = []int{1}
_, _ = a, b
// fmt.Println(a == b) // panic: comparing uncomparable type []int
}
6Go에서는 바이트, 룬(rune), UTF-8 (8-bit Unicode Transformation Format) 인코딩 텍스트를 어떻게 표현하며, len(s)가 사용자가 눈으로 보는 문자 수와 다를 수 있는 이유는 무엇인가요?
Go에서 `byte`는 `uint8`의 별칭(alias)으로 단일 원시 바이트(raw byte)를 나타내며, `rune`은 `int32`의 별칭으로 유니코드 코드 포인트(Unicode code point)를 나타냅니다. `string`은 읽기 전용 바이트 시퀀스로, 대개 UTF-8로 인코딩된 텍스트이지만 임의의 바이트도 포함할 수 있습니다. `len(s)`는 룬이나 눈에 보이는 문자 수가 아닌 바이트 수를 반환합니다. 문자열을 인덱싱하면 바이트가 반환되고, `for range` 루프로 문자열을 순회하면 UTF-8을 디코딩하여 바이트 인덱스와 룬을 함께 제공합니다. `len(s)`가 눈에 보이는 문자 수와 다를 수 있는 이유는 UTF-8에서 코드 포인트당 여러 바이트를 사용할 수 있는 데다, 결합 문자(combining mark)나 이모지 시퀀스처럼 사용자가 인지하는 하나의 문자가 여러 코드 포인트로 구성될 수 있기 때문입니다.
package main
import "fmt"
func main() {
s := "é🙂"
fmt.Println(len(s)) // bytes: é is 2 bytes, 🙂 is 4 bytes
fmt.Println(len([]rune(s))) // Unicode code points
fmt.Printf("first byte: %x\n", s[0])
for i, r := range s {
fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
}
}
7Go에서 배열(array)과 슬라이스(slice)의 차이점을 길이, 용량, 기본 저장소(underlying storage)의 동작 방식을 포함하여 설명해 주세요.
Go에서 배열은 [3]int와 같이 타입의 일부로서 고정된 길이를 가집니다. 배열은 요소를 직접 저장하며, 배열을 대입하거나 전달하면 배열 값 전체가 복사됩니다. 반면 []int와 같은 슬라이스는 기본 배열(underlying array)을 가리키는 작은 디스크립터입니다. 개념적으로 슬라이스는 요소를 가리키는 포인터, 길이(length), 용량(capacity)을 포함합니다. 슬라이스의 길이는 접근 가능한 요소의 개수이며, 용량은 슬라이스 시작점부터 기본 배열의 끝까지 사용할 수 있는 요소의 개수입니다. 슬라이스는 유연하게 동작합니다. 리슬라이싱은 디스크립터를 변경하며, append는 용량이 충분하면 동일한 기본 배열을 재사용하고 그렇지 않으면 새 배열을 할당합니다.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a, b)
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println(s, t)
fmt.Println(len(s), cap(s))
}
8Go의 map 타입은 키 타입, 존재하지 않는 키, nil 맵, 순회 순서와 관련하여 어떻게 동작하나요?
Go의 map 키 타입은 비교 가능(comparable)해야 하므로 슬라이스, 맵, 함수는 키로 직접 사용할 수 없습니다. 존재하지 않는 키를 조회하면 해당 요소 타입의 제로값(zero value)이 반환되므로, 키의 부재와 실제 저장된 제로값을 구분하기 위해 comma-ok 구문(`v, ok := m[k]`)을 사용합니다. nil 맵은 값을 읽거나 range 순회를 할 수는 있지만, 값을 할당하려고 하면 패닉(panic)이 발생하므로 쓰기 전에 반드시 초기화해야 합니다. map의 순회 순서는 정해져 있지 않으며(unspecified), 코드에서 이 순서에 의존해서는 안 됩니다.
package main
import "fmt"
func main() {
counts := map[string]int{"a": 0, "b": 2}
fmt.Println(counts["missing"]) // zero value for int
v, ok := counts["a"]
fmt.Println(v, ok) // present even though value is zero
var m map[string]int
fmt.Println(m["x"]) // read from nil map is OK
// m["x"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1
for k, v := range counts {
fmt.Println(k, v) // order is not guaranteed
}
}
9슬라이스 재슬라이싱(reslicing)과 대입 연산으로 인해 여러 슬라이스가 동일한 내부 배열을 공유하게 되는 원리와, 이로 인해 발생할 수 있는 버그를 설명해 주세요.
슬라이스 값은 내부 배열을 가리키는 헤더 구조체입니다. 슬라이스를 대입하거나 함수에 인자로 전달할 때 요소 전체가 아닌 헤더만 복사됩니다. 재슬라이싱을 수행하면 동일한 내부 배열의 특정 범위를 가리키는 또 다른 헤더가 생성됩니다. 따라서 여러 슬라이스가 동일한 저장 공간을 참조(aliasing)할 수 있습니다. 즉, 한 슬라이스를 통해 요소를 변경하면 다른 슬라이스에도 그대로 반영되며, 여유 용량(capacity)이 남아 있는 상태에서 한 슬라이스에 `append`를 수행하면 다른 슬라이스에서 참조 중인 데이터를 덮어쓸 수 있습니다. 이로 인해 의도치 않은 데이터 변경, 결과 왜곡, 작은 서브 슬라이스로 인한 거대한 내부 배열의 메모리 누수 유지, 동시 접근 시 데이터 경합(data race) 등의 버그가 발생할 수 있습니다. 의도치 않은 공유를 방지하려면 `copy` 함수나 `append([]T(nil), s...)`를 사용해 방어적 복사를 수행하거나, `append` 전에 전체 슬라이스 표현식(full-slice expression)으로 용량을 제한해야 합니다.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[:2] // len 2, cap 4
b := base[2:] // len 2, cap 2
a[0] = 99
fmt.Println(base, a, b)
a = append(a, 77) // reuses base's backing array, overwrites base[2]
fmt.Println(base, a, b)
c := append([]int(nil), base[:2]...) // defensive copy
c[0] = 42
fmt.Println(base, c)
}
10append 시 슬라이스(slice) 확장의 개념적 동작 방식과 반복적인 재할당이 성능에 미치는 영향을 설명해 주세요.
append로 슬라이스에 요소를 추가할 때 용량(capacity)이 충분하다면 기존의 기반 배열(backing array)에 새 값을 씁니다. 용량이 부족하면 Go는 더 큰 기반 배열을 새로 할당하고 기존 요소를 복사한 뒤 새 요소를 쓰고 새 저장소를 가리키는 슬라이스 헤더를 반환합니다. 정확한 확장 정책은 런타임 구현에 따라 다르지만, 개념적으로는 반복적인 append의 분할 상환(amortized) 시간 복잡도가 효율적이 되도록 충분한 크기로 늘어납니다. 하지만 반복적인 재할당은 복사로 인한 CPU 비용 발생, 새로운 메모리 할당, GC(Garbage Collector) 압박 증가를 초래하며 기존 슬라이스 별칭(alias)과의 데이터 공유 관계를 끊어버릴 수 있습니다. 예상 크기를 미리 알고 있다면 재할당 횟수를 줄이기 위해 append로 구성할 때는 make([]T, 0, n)으로, 인덱스로 직접 채울 때는 make([]T, n)으로 사전 할당하는 것이 좋습니다.
package main
func collectNoPrealloc(input []int) []int {
var out []int
for _, v := range input {
out = append(out, v*2)
}
return out
}
func collectPrealloc(input []int) []int {
out := make([]int, 0, len(input))
for _, v := range input {
out = append(out, v*2)
}
return out
}
11Go에서 빈 식별자(blank identifier)는 미사용 값, 임포트, 컴파일 타임 인터페이스 검사에 어떻게 동작하나요?
빈 식별자 `_`는 쓰기 전용 플레이스홀더입니다. 여기에 값을 할당하면 해당 값은 버려지며, 사용할 수 있는 변수가 생성되지 않습니다. 불필요한 반환값이나 루프 변수를 무시할 때, `import _ "pkg"`처럼 패키지의 부수 효과만을 위해 임포트할 때, 그리고 `var _ io.Reader = (*MyReader)(nil)`과 같이 컴파일 타임에 인터페이스 구현 여부를 검사할 때 사용됩니다. 빈 식별자를 이용해 임포트하더라도 해당 패키지의 초기화 코드는 정상적으로 실행됩니다. 인터페이스 검사용 할당문에서는 구체 타입(concrete type)의 메서드 집합이 인터페이스 조건을 충족하지 못하면 컴파일 에러가 발생합니다.
package main
import "fmt"
func lookup() (string, bool) {
return "gopher", true
}
func main() {
name, _ := lookup() // ignore the bool
fmt.Println(name)
for i, _ := range []int{10, 20} {
fmt.Println(i)
}
}
12Go에서 `init` 함수와 임포트된 의존성을 포함한 패키지 초기화 순서는 어떻게 동작하나요?
Go는 의존성 순서에 따라 패키지를 초기화합니다. 특정 패키지가 임포트한 의존성 패키지들은 해당 패키지보다 먼저 초기화됩니다. 패키지 내부에서는 언어 명세에 정의된 의존성 및 선언 순서에 따라 패키지 수준 변수가 임의의 `init` 함수보다 먼저 초기화됩니다. 이후 해당 패키지의 `init` 함수들이 자동으로 실행됩니다. 한 패키지는 여러 개의 `init` 함수를 가질 수 있으며, 이 함수들은 코드에서 직접 호출할 수 없습니다. 각 패키지는 프로그램 실행 중 단 한 번만 초기화됩니다. 실행 파일의 경우 임포트 그래프가 먼저 초기화된 후 `main` 패키지가 초기화되고, 마지막으로 `main.main` 함수가 호출됩니다.
// a/a.go
package a
import "fmt"
var V = func() int {
fmt.Println("a var")
return 1
}()
func init() { fmt.Println("a init") }
// main.go
package main
import (
"fmt"
"example/a"
)
var M = func() int {
fmt.Println("main var", a.V)
return 2
}()
func init() { fmt.Println("main init") }
func main() { fmt.Println("main") }
13공개 식별자(exported identifier)와 `internal/` 디렉터리 규칙을 포함하여 Go 언어의 패키지 가시성 규칙을 설명해 주세요.
Go에서 패키지 가시성은 접근 제어 키워드가 아니라 식별자 이름 규칙에 의해 제어됩니다. 유니코드 대문자로 시작하는 식별자는 외부로 공개(exported)되어 다른 패키지에서 참조할 수 있으며, 그 외의 식별자는 비공개(unexported) 상태로 동일 패키지 내부에서만 사용할 수 있습니다. 이는 함수, 타입, 메서드, 변수, 상수 및 구조체 필드에 모두 적용됩니다. 패키지는 공개 식별자를 통해 공개 API를 정의하고 세부 구현은 비공개로 유지합니다. 이와 별도로, `internal/` 디렉터리 하위에 위치한 패키지는 해당 `internal` 디렉터리의 부모 트리 내에 속한 임포트 경로의 코드에서만 임포트할 수 있으며, 이는 Go 툴체인에 의해 강제됩니다.
// module example.com/app
// internal/store/store.go
package store
type Client struct { // exported type
dsn string // unexported field
}
func New(dsn string) *Client { return &Client{dsn: dsn} } // exported
func parseDSN(s string) string { return s } // unexported
// cmd/api/main.go -- allowed: inside example.com/app tree
package main
import "example.com/app/internal/store"
func main() {
c := store.New("db")
_ = c
// c.dsn is not accessible here: field is unexported.
}
// Code outside example.com/app cannot import example.com/app/internal/store.
14Go에서 실행 순서, 인자 평가 시점, 반환값과의 상호작용을 포함하여 defer는 어떻게 동작하나요?
`defer`는 함수가 정상적인 반환으로 종료되든 패닉 언와인딩(panic unwinding)으로 종료되든, 자신을 둘러싼 감싸는 함수가 종료될 때 특정 함수 호출이 실행되도록 예약합니다. 여러 개의 지연 호출(deferred call)은 후입선출(LIFO, Last-In-First-Out) 순서로 실행됩니다. 지연될 함수 값과 전달되는 인자는 `defer` 문이 실행되는 즉시 평가되지만, 실제 함수 호출 자체는 나중에 수행됩니다. 이름이 지정된 반환값(named return values)이 있는 경우, return 문이 반환값을 먼저 할당한 후 지연 함수들이 실행되므로 지연된 클로저는 호출자가 값을 받기 전에 명명된 결과 변수를 관찰하거나 수정할 수 있습니다. 이러한 특성 덕분에 `defer`는 파일 닫기, 뮤텍스 잠금 해제, 리소스 반환과 같은 정리 작업에 매우 유용합니다.
package main
import "fmt"
func f() (result int) {
x := 1
defer fmt.Println("arg evaluated now:", x)
defer func() {
result++
fmt.Println("deferred closure sees x later:", x)
}()
x = 2
return 10
}
func main() {
fmt.Println("return:", f())
}
15Go의 에러 처리 모델과 에러를 생성, 반환, 검사하는 관용적인 방법을 설명해 주세요.
Go는 에러를 예외가 아닌 일반적인 값으로 취급합니다. 내장 `error` 인터페이스는 `Error() string` 메서드를 가진 모든 타입에 의해 충족됩니다. 함수는 관례적으로 에러를 마지막 반환값으로 반환하며, `nil`은 성공을 나타내고 non-nil 에러는 호출자가 해당 실패를 처리하거나 상위로 전파해야 함을 나타냅니다. 단순한 에러는 주로 `errors.New`로 생성하고, 포맷팅된 에러는 `fmt.Errorf`로 생성하며, 호출 측에서는 일반적으로 `if err != nil { ... }`로 검사합니다.
package main
import (
"errors"
"fmt"
)
func findUser(id int) (string, error) {
if id <= 0 {
return "", errors.New("invalid user id")
}
if id == 42 {
return "", fmt.Errorf("user %d not found", id)
}
return "alice", nil
}
func handler(id int) error {
name, err := findUser(id)
if err != nil {
return fmt.Errorf("find user: %w", err)
}
fmt.Println(name)
return nil
}
16Go 백엔드 서비스에서 패닉 복구(panic recovery)는 어떻게 처리해야 하며, 고루틴(goroutine)에서 패닉이 발생할 때 일어나는 일과 프로세스를 복구해야 하는 경우와 중단(crash)시켜야 하는 경우는 언제인가요?
패닉이 발생하면 현재 고루틴의 스택이 해제(unwind)되면서 지연 실행(deferred) 함수들이 순차적으로 실행됩니다. `recover`는 동일한 고루틴의 지연 함수 내에서 호출될 때만 동작하며, 한 고루틴이 다른 고루틴의 패닉을 복구할 수는 없습니다. 패닉이 복구되지 않으면 프로세스는 즉시 비정상 종료(crash)됩니다. 백엔드 서비스에서는 하나의 요청이나 작업의 실패가 서비스 전체를 다운시키지 않도록 요청 핸들러, RPC(Remote Procedure Call) 미들웨어, 워커 고루틴 진입점과 같은 격리 경계에서 복구를 처리하는 것이 일반적입니다. 하지만 패닉으로 인해 공유 상태가 오염되었거나 프로세스의 정합성을 신뢰할 수 없게 되었다면, 맹목적으로 복구하여 계속 실행하기보다는 프로세스를 종료하고 재시작하도록 두는 것이 더 안전합니다.
17Go에서 nil 채널이란 무엇이며, 어떻게 실수로 코드를 멈추게 하거나 의도적으로 select 케이스를 비활성화할 수 있나요?
nil 채널은 make로 초기화되지 않았거나 명시적으로 nil이 할당되어 값이 nil인 채널 변수를 말합니다. nil 채널에 값을 전송하거나 수신하려고 하면 영구적으로 블로킹됩니다. select 문에서 nil 채널과 관련된 case는 준비 완료 상태가 되지 않으므로, 채널 변수에 nil을 할당해 해당 case를 의도적으로 비활성화할 수 있습니다. 반면 실수로 nil 채널을 사용하면 goroutine이 멈춰 버리거나(hang) select 로직이 수신되어야 할 이벤트를 처리하지 못하게 될 수 있습니다.
var in <-chan int = source
for in != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // disables this receive case
continue
}
fmt.Println(v)
case <-ctx.Done():
return
}
}
18sync/atomic의 원자적 연산은 뮤텍스 기반 동기화와 어떻게 다르며, 언제 사용하는 것이 적절한가요?
sync/atomic은 동기화 및 메모리 순서 보장과 함께 load, store, add, swap, compare-and-swap 같은 단일 메모리 위치에 대한 분할 불가능한 연산을 제공합니다. 반면 뮤텍스는 임계 영역을 보호하므로, 여러 읽기·쓰기나 필드가 얽힌 불변식을 비롯해 임의의 코드를 안전하게 보호할 수 있습니다. 원자적 연산은 카운터, 플래그, 시퀀스 번호 같은 단순하고 독립적인 상태나 세심하게 설계된 락 프리(lock-free) 자료구조에 적합합니다. 반면 여러 연산이 복합적으로 얽혀 있거나, 여러 값 간의 일관성을 유지해야 하거나, 원자적 연산 방식으로는 동작을 추론하거나 정확성을 검증하기 어려운 경우에는 뮤텍스를 사용하는 것이 좋습니다.
package main
import (
"sync"
"sync/atomic"
)
var requests atomic.Int64
func recordRequest() {
requests.Add(1) // one independent counter update
}
type Account struct {
mu sync.Mutex
balance int
limit int
}
func (a *Account) Withdraw(n int) bool {
a.mu.Lock()
defer a.mu.Unlock()
// The check and update must be one protected invariant.
if a.balance-n < -a.limit {
return false
}
a.balance -= n
return true
}
19고루틴 누수(goroutine leak)를 방지하기 위해 채널 소유권과 고루틴의 수명을 어떻게 설계해야 합니까?
고루틴은 명확한 소유자, 분명한 종료 신호, 그리고 보장된 종료 경로를 갖도록 설계해야 합니다. 채널(특히 출력 채널)을 닫는 소유권은 일반적으로 송신자(producer) 측에 있으며, 수신자는 송신자가 여전히 활성 상태일 수 있는 동안 채널을 닫아서는 안 됩니다. 모든 블로킹 방식의 송신, 수신, 루프, 타이머, 외부 호출은 완료가 보장되거나, `context.Context` 또는 완료 채널(done channel)을 통해 취소 시 블로킹이 해제될 수 있어야 합니다. `sync.WaitGroup`, `errgroup` 또는 이와 유사한 동기화 도구를 사용하여 작업자 고루틴이 모두 완료될 때까지 대기하고, 모든 송신자가 종료된 후에만 채널이 닫히도록 관리해야 합니다.
package worker
import (
"context"
"sync"
)
type Job int
type Result int
func StartWorkers(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
r := Result(j * 2)
select {
case results <- r:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
wg.Wait()
close(results) // close after all senders are done
}()
return results
}
20Go 서비스에서 goroutine 누수가 발생하는 일반적인 원인은 무엇이며, 프로덕션 환경에서 어떻게 감지하고 해결하나요?
Go 서비스에서 흔히 발생하는 goroutine 누수 원인으로는 채널 송수신 시 영구적으로 블로킹되는 경우, 취소 메커니즘 없이 다른 블로킹 작업을 대기하는 경우, 마감 시한(deadline) 없이 지연되는 I/O, 중지되지 않는 백그라운드 루프나 ticker, 그리고 요청 수명보다 오래 남아 있는 요청 범위 goroutine 등이 있습니다. 프로덕션 환경에서는 goroutine 수의 지속적인 증가와 관련 이상 징후를 모니터링한 뒤, goroutine 덤프나 `pprof` goroutine 프로파일을 분석하여 goroutine이 어디서 멈춰 있는지 확인합니다. 누수를 해결하려면 goroutine이 정상적으로 종료될 수 있도록 코드를 수정해야 합니다. 즉, 취소 처리와 마감 시한을 추가하고, ticker를 중지하며, 채널을 올바르게 닫고, 분리된 요청 goroutine 생성을 지양하며, 필요한 경우 동시성을 제한해야 합니다.
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
result := <-slowCh // may block forever
_ = result
}()
}