주니어 Go 준비

주니어 Go 백엔드 면접 질문

기초를 명확하고 자신 있게 설명해야 하는 백엔드 개발자를 위한 선별된 주니어 Go 면접 질문 15개

주니어 Go AI 면접 시작하기신용카드가 필요하지 않습니다. 무료 세션 1회 제공.
영어 기술 면접 연습비원어민이 기술 면접 통과를 연습할 수 있는 모드입니다.

타입 시스템

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)
}
AI 코치와 함께 이 질문에 답해 보세요

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
}
AI 코치와 함께 이 질문에 답해 보세요

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))
}
AI 코치와 함께 이 질문에 답해 보세요

4Go에서 포인터는 어떻게 동작하며, C 언어와 비교했을 때 의도적으로 허용되지 않는 연산에는 무엇이 있나요?

Go의 포인터는 다른 값의 메모리 주소를 저장하는 타입이 지정된 값으로, 예를 들어 *int는 int 값을 가리킵니다. 값의 주소를 가져올 때는 &x를 사용하고, 포인터가 가리키는 값을 읽거나 쓰기 위해 역참조할 때는 *p를 사용합니다. 포인터를 전달하거나 저장하면 여러 위치에서 동일한 기본 값을 관찰하거나 수정할 수 있으며, 포인터는 nil 값을 가질 수 있습니다. C 언어와 달리 안전한 Go에서는 포인터 연산 및 임의의 원시 메모리 주소 조작이 의도적으로 금지되어 있으며, 이러한 저수준 작업이 필요한 예외적인 경우에는 unsafe 패키지를 사용해야 합니다.

package main

import "fmt"

func inc(p *int) {
    *p = *p + 1
}

func main() {
    x := 10
    p := &x
    inc(p)
    fmt.Println(x)
}
AI 코치와 함께 이 질문에 답해 보세요

5Go에서 타입이 지정된 상수(typed constant)와 타입이 지정되지 않은 상수(untyped constant)의 차이를 포함하여, 상수와 iota가 어떻게 동작하는지 설명해 주세요.

Go의 상수는 컴파일 시점 값으로 불리언, 문자열 또는 숫자 타입이 될 수 있습니다. 타입이 지정된 상수(typed constant)는 명시적인 구체 타입을 갖습니다. 반면 타입이 지정되지 않은 상수(untyped constant)는 타입이 지정된 컨텍스트에서 사용되기 전까지 고정된 구체 타입이 없으며, 타입이 정해지지 않은 숫자 상수는 그 시점까지 임의 정밀도(exact/high precision)로 표현됩니다. 단, 해당 값은 최종적으로 선택된 타입으로 표현 가능해야 합니다. `iota`는 `const` 선언에서 사용되는 사전 선언된 식별자(predeclared identifier)입니다. 각 const 블록에서 0부터 시작하여 각 상수 명세(specification)마다 1씩 증가하므로 열거형과 유사한 상수나 비트 플래그를 정의할 때 유용합니다.

type Status int

const (
    StatusPending Status = iota // 0, typed Status
    StatusActive                // 1, same expression/type repeated
    StatusClosed                // 2
)

const big = 1 << 100 // untyped integer constant with high precision

var x int64 = 42      // untyped 42 can become int64
// var y int64 = big  // compile error: not representable as int64
AI 코치와 함께 이 질문에 답해 보세요

6Go에서 명시적 타입 변환은 어떻게 동작하며, 왜 Go 언어는 많은 암시적 변환을 지양하나요?

Go에서는 값의 타입을 변경할 때 일반적으로 `int64(i)`나 `MyID(n)`과 같이 `T(x)` 형태의 명시적 변환을 요구합니다. 이는 타입 변경을 코드상에 명확히 드러내어 예상치 못한 숫자, 불리언, 문자열 간의 암시적 변환을 방지합니다. 변환은 Go의 타입 변환 규칙에서 허용하는 경우에만 가능하며, 정수 오버플로/절삭, 부동소수점에서 정수로 변환 시 0을 향한 절삭, 정밀도 손실 등으로 인해 실제 값이 변경될 수 있습니다. 타입이 지정되지 않은 상수(untyped constant)는 더 유연하여, 해당 타입으로 상수 값을 표현할 수 있다면 타입이 지정된 컨텍스트에서도 그대로 사용할 수 있습니다.

var x int64 = 300
var y int8 = int8(x)   // explicit conversion; value changes

var f float64 = 3.9
var i int = int(f)     // truncates toward zero

const c = 42
var n int32 = c        // ok: untyped constant is representable as int32
AI 코치와 함께 이 질문에 답해 보세요

자료 구조

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))
}
AI 코치와 함께 이 질문에 답해 보세요

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
    }
}
AI 코치와 함께 이 질문에 답해 보세요

메모리 관리

9Go에서 구조체나 기타 값을 값으로 전달(pass by value)해야 하는 경우와 포인터로 전달(pass by pointer)해야 하는 경우는 각각 언제인가요?

값이 작거나, 함수 호출 동안 읽기 전용으로만 사용되거나, 독립적인 복사 시맨틱이 필요할 때는 값으로 전달합니다. 반면 함수가 호출자의 값을 직접 수정해야 하거나, 복사 비용이 크거나, 해당 타입을 복사해서는 안 되거나, nil 또는 공유된 식별성(shared identity) 표현이 API 설계의 일부인 경우에는 포인터로 전달합니다. 포인터가 항상 더 빠르다고 가정해서는 안 됩니다. 포인터는 앨리어싱(aliasing), 힙 이스케이프, GC(가비지 컬렉터) 부하를 유발하고 캐시 친화적이지 않은 메모리 접근을 만들 수 있습니다. 먼저 명확한 의미론을 우선시하고, 성능이 중요한 부분에서는 벤치마크를 통해 결정하는 것이 좋습니다.

package main

import "fmt"

type Counter struct{ N int }

func Read(c Counter) int {
    c.N++
    return c.N
}

func Inc(c *Counter) {
    c.N++
}

func main() {
    c := Counter{N: 5}
    fmt.Println(Read(c), c.N)
    Inc(&c)
    fmt.Println(c.N)
}
AI 코치와 함께 이 질문에 답해 보세요

언어 의미론

10Go에서 구조체, 배열, 맵, 슬라이스의 대입과 복사는 어떻게 동작하나요?

Go에서 대입 연산은 대상 값을 복사하지만, 구체적인 동작 방식은 타입에 따라 다릅니다. 구조체 대입은 모든 필드를 복사하고, 배열 대입은 모든 요소를 복사합니다. 슬라이스 대입은 포인터, 길이, 용량으로 구성된 슬라이스 헤더만 복사하므로, 대입된 슬라이스들은 일반적으로 동일한 기본 배열(backing array)을 공유합니다. 맵 대입은 참조 역할을 하는 맵 디스크립터를 복사하므로 두 변수가 동일한 맵 데이터를 가리킵니다. 독립적인 소유권을 가지려면 방어적 복사를 수행해야 합니다. 예를 들어 슬라이스는 `copy`나 `slices.Clone`을 사용하고, 맵은 새 맵을 생성한 뒤 엔트리를 복사해야 합니다.

package main

import "fmt"

type Point struct{ X int }

func main() {
    a := Point{X: 1}
    b := a
    b.X = 2

    x := [2]int{1, 2}
    y := x
    y[0] = 99

    fmt.Println(a.X, b.X)
    fmt.Println(x, y)
}
AI 코치와 함께 이 질문에 답해 보세요

11Go에서 클로저는 변수를 어떻게 캡처하며, 루프 및 고루틴(goroutine)과 관련해 어떤 버그가 발생할 수 있습니까?

Go에서 클로저는 자신을 둘러싼 어휘적 스코프(lexical scope)의 변수를 참조하는 함수 값입니다. 클로저는 변수의 복사본이 아니라 변수 자체를 캡처하므로 변수의 변경 사항이 클로저에 관찰되며, 클로저가 탈출(escape)하면 캡처된 변수가 생성 함수의 수명보다 오래 유지될 수 있습니다. 루프 및 고루틴과 관련된 버그는 클로저가 변경된 이후의 변수를 공유하거나 관찰할 때 발생하며, 이로 인해 고루틴이나 지연 실행되는 콜백이 의도치 않은 값을 보거나 공유 상태에 대한 데이터 레이스(data race)를 유발하곤 합니다. 일반적인 해결책은 의도한 값을 클로저의 인수로 전달하거나 반복마다 새로운 로컬 복사본을 만드는 것이며, 필요한 경우 동시 접근을 동기화하는 것입니다. Go 1.22부터는 `for`/`range`에서 선언된 루프 변수가 반복마다 새로 생성되므로 기존의 전형적인 루프 변수 캡처 버그가 대다수 제거되었으나, 루프 외부에서 재사용되는 변수나 다른 변경 가능한 공유 상태에서는 여전히 문제가 발생할 수 있습니다.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(v int) {
            defer wg.Done()
            fmt.Println(v)
        }(i)
    }
    wg.Wait()
}
AI 코치와 함께 이 질문에 답해 보세요

12배열(array), 슬라이스(slice), 맵(map), 문자열(string), 채널(channel)에 대한 `range`의 동작 방식을 설명해 주세요.

`range`는 피연산자의 타입에 따라 순회 동작이 결정됩니다. 배열이나 슬라이스에 대해 사용할 경우 인덱스와 요소 값을 반환하며, 이때 요소 값은 복사본이므로 여기에 새 값을 할당해도 원본 컬렉션은 수정되지 않습니다. 배열 값을 순회할 때는 순회를 위해 배열 전체가 복사되지만, 슬라이스를 순회할 때는 슬라이스 헤더를 사용하여 내부의 기반 배열을 인덱싱합니다. 맵을 순회할 때는 순서가 정해지지 않은 상태로 키와 값을 반환하며, 값은 복사본입니다. 문자열을 순회할 때는 rune 위치 인덱스가 아닌 바이트 인덱스와 디코딩된 유니코드 코드 포인트(`rune`)를 반환합니다. 채널에 대해 사용할 때는 채널이 닫히고 내부 버퍼가 모두 빌 때까지 값을 수신하며, nil 채널에 대한 `range` 순회는 영구적으로 블로킹됩니다.

package main

import "fmt"

func main() {
    xs := []int{1, 2, 3}
    for _, v := range xs {
        v *= 10 // modifies only the copy
    }
    fmt.Println(xs)

    for i := range xs {
        xs[i] *= 10 // modifies the slice element
    }
    fmt.Println(xs)
}
AI 코치와 함께 이 질문에 답해 보세요

메서드

13값 수신자(value receiver)와 포인터 수신자(pointer receiver)의 차이점은 무엇이며, 수신자 선택이 가변성, 복사, 인터페이스 만족 여부에 어떤 영향을 미치나요?

값 수신자 메서드는 수신자의 복사본을 전달받으므로, 읽기 전용 동작이나 불변 스타일의 작은 타입에 적합하며, 복사된 수신자를 변경해도 호출자에게 반영되지 않습니다. 반면 포인터 수신자 메서드는 수신자를 가리키는 포인터의 복사본을 전달받으므로 원본을 변경할 수 있고, 크기가 크거나 복사하면 안 되는 값의 복사를 방지할 수 있습니다. 수신자 선택은 메서드 집합(method set)에도 영향을 미칩니다. 값 수신자를 가진 메서드는 T와 *T의 메서드 집합 모두에 포함되지만, 포인터 수신자를 가진 메서드는 *T의 메서드 집합에만 포함됩니다. 따라서 포인터 수신자 메서드를 요구하는 인터페이스는 T가 아닌 *T에 의해서만 만족됩니다. 이는 주소 지정이 가능한 T 타입의 값이 일반 메서드 호출 문법을 통해 해당 메서드를 호출할 수 있더라도 마찬가지입니다.

package main

import "fmt"

type Counter struct{ N int }

func (c Counter) AddValue() { c.N++ }
func (c *Counter) AddPointer() { c.N++ }

func main() {
    c := Counter{N: 1}
    c.AddValue()
    fmt.Println(c.N)
    c.AddPointer() // compiler rewrites to (&c).AddPointer() because c is addressable
    fmt.Println(c.N)
}
AI 코치와 함께 이 질문에 답해 보세요

인터페이스

14Go에서 인터페이스가 동작하는 방식을 설명하고, 어떤 타입이 인터페이스를 암시적으로 만족한다는 것이 무엇을 의미하는지 설명해 주세요.

Go 인터페이스는 필요한 메서드 집합을 정의합니다. 구체 타입(concrete type)의 메서드 집합에 해당 메서드들이 포함되어 있다면 별도의 `implements` 선언 없이도 그 인터페이스를 암시적으로 만족합니다. 인터페이스 변수는 해당 인터페이스를 만족하는 구체 타입의 동적 값을 담을 수 있으며, 인터페이스 메서드 호출은 그 동적 값의 실제 구현체로 디스패치됩니다. `interface{}` 또는 보통 `any`로 작성되는 빈 인터페이스는 요구하는 메서드가 없으므로 모든 타입이 이를 만족합니다. `T`, `*T`, 또는 둘 다 인터페이스를 만족하는지 여부는 각 타입의 메서드 집합과 리시버 정의 방식에 따라 결정됩니다.

package main

import "fmt"

type Greeter interface {
    Greet() string
}

type User struct{ Name string }

func (u User) Greet() string { return "hello, " + u.Name }

func Say(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    Say(User{Name: "Ana"}) // no 'implements Greeter' declaration needed
}
AI 코치와 함께 이 질문에 답해 보세요

15타입 단언(type assertion)과 타입 스위치(type switch)는 인터페이스 값과 함께 어떻게 동작하나요?

타입 단언(type assertion)은 인터페이스에 저장된 동적 값을 확인합니다. `v := x.(T)`는 인터페이스 값의 동적 타입이 `T`이거나, 동적 값이 구현하는 인터페이스가 `T`인 경우에 성공합니다. 단일 반환값 형태는 실패 시 패닉(panic)을 발생시킵니다. `v, ok := x.(T)` 형태인 콤마-ok(comma-ok) 구문은 패닉 없이 성공 여부를 반환합니다. 타입 스위치(type switch)인 `switch v := x.(type)`는 인터페이스 값의 동적 타입을 기준으로 분기하며, 일치하는 case에 따라 `v`의 타입이 결정됩니다.

func describe(x any) string {
    if s, ok := x.(string); ok {
        return "string: " + s
    }

    switch v := x.(type) {
    case int:
        return fmt.Sprintf("int: %d", v)
    case fmt.Stringer:
        return "stringer: " + v.String()
    case nil:
        return "nil interface"
    default:
        return fmt.Sprintf("unknown %T", v)
    }
}
AI 코치와 함께 이 질문에 답해 보세요