Go 기술 면접: Goroutine, Channel, 동시성 패턴 완벽 가이드

Go 기술 면접에서 자주 출제되는 goroutine, channel, 동시성 관련 질문을 다룹니다. 프로덕션 수준의 코드 예제와 각 답변의 설계 근거를 2026년 면접 대비용으로 상세히 설명합니다.

Go 기술 면접 대비 goroutine channel 동시성 패턴

Go 기술 면접에서 goroutine, channel, 동시성에 관한 질문은 가장 난이도가 높은 주제로 꼽힙니다. 이러한 개념에 대한 깊은 이해가 시니어 Go 엔지니어와 학습 단계의 개발자를 구분하는 핵심 요소입니다. 이 가이드에서는 2026년 면접에서 실제로 출제되는 질문을 프로덕션급 코드 예제 및 답변의 근거와 함께 설명합니다.

면접관이 실제로 평가하는 영역

Go 동시성 면접은 세 가지 영역에 집중됩니다: goroutine 라이프사이클 관리, channel 시맨틱스(버퍼 있는/없는, 방향 타입), 패턴 조합(fan-out/fan-in, 워커 풀, context 취소). 문법 암기만으로는 부족하며, 레이스 컨디션과 데드락에 대한 논리적 추론 능력이 요구됩니다.

Goroutine 기본 개념: 면접 필수 질문

면접의 첫 단계에서는 후보자가 goroutine의 본질을 이해하고 있는지 확인합니다. 단순히 goroutine을 생성하는 방법을 아는 것만으로는 충분하지 않습니다.

Q: goroutine이란 무엇이며, OS 스레드와 어떻게 다릅니까?

goroutine은 운영체제가 아닌 Go 런타임 스케줄러에 의해 관리되는 경량 동시 실행 함수입니다. Go 런타임은 M:N 스케줄링 모델(M개의 goroutine을 N개의 OS 스레드에 매핑)을 사용하여 수천 개의 goroutine을 소수의 OS 스레드에 다중화합니다.

goroutine_basics.gogo
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	// Print the number of OS threads available
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Each goroutine starts with ~2-8KB stack
			// OS threads typically start with 1-8MB
			_ = id
		}(i)
	}

	wg.Wait()
	fmt.Println("All 10,000 goroutines completed")
}

면접에서 반드시 언급해야 할 주요 차이점은 다음과 같습니다. goroutine은 동적으로 확장되는 2-8KB 스택으로 시작되는 반면, OS 스레드는 고정된 1-8MB 스택을 가집니다. goroutine 간의 컨텍스트 스위칭은 Go 스케줄러에 의해 유저 공간에서 처리되므로, OS 스레드의 커널 레벨 컨텍스트 스위칭에 따른 비용을 피할 수 있습니다. 따라서 10만 개의 goroutine을 생성하는 것은 현실적이지만, 10만 개의 OS 스레드는 시스템 리소스를 고갈시킵니다.

Q: goroutine에서 panic이 발생하면 어떻게 됩니까?

goroutine 내에서 복구되지 않은 panic은 프로그램 전체를 크래시시킵니다. Java나 Python의 예외와 달리, panic은 해당 goroutine 자체의 콜 스택만을 따라 전파됩니다(생성한 goroutine의 스택이 아닙니다). 이를 잡는 유일한 방법은 동일 goroutine 내의 defer 함수에서 recover()를 호출하는 것입니다.

panic_recovery.gogo
package main

import "fmt"

func safeGo(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("recovered from panic:", r)
			}
		}()
		fn() // execute the actual work
	}()
}

func main() {
	safeGo(func() {
		panic("something went wrong")
	})

	// Give goroutine time to complete
	select {}
}

면접관은 프로덕션 Go 서비스에서 goroutine 실행을 복구 패턴으로 감싸는 관행에 대한 인식을 확인합니다. errgroup 같은 라이브러리를 사용하면 더 세련된 처리가 가능합니다.

Channel 시맨틱스: 버퍼 있는/없는 채널과 방향 타입

channel 관련 질문은 후보자가 Go의 동시성 모델을 진정으로 이해하고 있는지, 아니면 패턴만 암기한 것인지를 판별합니다.

Q: 버퍼 있는 channel과 버퍼 없는 channel의 차이점은 무엇입니까?

버퍼 없는 channel(make(chan T))은 송신측과 수신측이 동시에 준비되어 있어야 합니다. 송신은 다른 goroutine이 수신할 때까지 블로킹됩니다. 버퍼 있는 channel(make(chan T, n))은 블로킹 없이 최대 n개의 값을 송신할 수 있습니다.

channel_semantics.gogo
package main

import "fmt"

func main() {
	// Unbuffered: send blocks until receive is ready
	ch := make(chan string)
	go func() {
		ch <- "hello" // blocks here until main reads
	}()
	msg := <-ch
	fmt.Println(msg)

	// Buffered: send does not block until buffer is full
	buf := make(chan int, 3)
	buf <- 1 // does not block (buffer has space)
	buf <- 2 // does not block
	buf <- 3 // does not block
	// buf <- 4 would block — buffer is full

	fmt.Println(<-buf, <-buf, <-buf) // 1 2 3
}

면접관이 자주 추가하는 질문은 "언제 어떤 것을 선택합니까?"입니다. 버퍼 없는 channel은 동기화를 강제하므로, 송신측이 수신측의 처리 완료를 확인해야 하는 경우에 적합합니다. 버퍼 있는 channel은 송신측과 수신측의 타이밍을 분리하므로, 워크 큐나 속도 제한처럼 어느 정도의 여유가 허용되는 상황에 적합합니다.

Q: channel을 close하면 어떻게 됩니까?

channel을 close하면 더 이상 값이 송신되지 않음을 알립니다. close된 channel에서의 수신은 제로 값을 즉시 반환합니다. close된 channel에 송신하면 panic이 발생합니다. range 루프는 channel이 close되면 자동으로 종료됩니다.

close_channel.gogo
package main

import "fmt"

func producer(ch chan<- int, count int) {
	for i := 0; i < count; i++ {
		ch <- i
	}
	close(ch) // signal: no more values
}

func main() {
	ch := make(chan int, 5)
	go producer(ch, 5)

	// range exits automatically when channel closes
	for val := range ch {
		fmt.Println("received:", val)
	}

	// Reading from closed channel returns zero value + false
	val, ok := <-ch
	fmt.Printf("after close: val=%d, ok=%v\n", val, ok)
}

핵심 주의사항으로, channel을 close하는 것은 송신측만 해야 하며 수신측이 close해서는 안 됩니다. 다른 goroutine이 아직 쓰기 중인 channel을 close하면 panic이 발생합니다.

Go 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

select 문: channel 다중화

Q: select는 어떻게 동작하며, 여러 case가 동시에 준비된 경우 어떻게 됩니까?

select 문은 channel 연산 중 하나가 실행 가능해질 때까지 블로킹됩니다. 여러 case가 동시에 준비된 경우, Go는 무작위로 하나를 선택합니다. 이를 통해 특정 case가 기아 상태에 빠지는 것을 방지합니다.

select_multiplex.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

func fetchFromAPI(ctx context.Context, url string) (string, error) {
	resultCh := make(chan string, 1)
	errCh := make(chan error, 1)

	go func() {
		// Simulate API call
		time.Sleep(200 * time.Millisecond)
		resultCh <- fmt.Sprintf("data from %s", url)
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-ctx.Done():
		// Context cancelled or timed out
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	result, err := fetchFromAPI(ctx, "https://api.example.com/data")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

면접관이 select로 평가하는 것은 두 가지입니다. 무작위 선택 규칙에 대한 이해, 그리고 channel을 context.Context와 결합하여 타임아웃 및 취소 패턴을 구현하는 능력입니다.

면접에서 출제되는 동시성 패턴

시니어 레벨 Go 면접에서는 아래 패턴 중 하나를 처음부터 구현하는 문제가 거의 반드시 출제됩니다.

Fan-Out/Fan-In 패턴

Q: 항목을 동시에 처리하는 fan-out/fan-in 파이프라인을 구현하십시오.

fan-out은 여러 goroutine에 작업을 분배합니다. fan-in은 여러 goroutine의 결과를 하나의 channel로 수집합니다.

fanout_fanin.gogo
package main

import (
	"fmt"
	"sync"
)

// generator produces values on a channel
func generator(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// square reads from input, squares each value
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

// fanIn merges multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	merged := make(chan int)

	for _, ch := range channels {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for val := range c {
				merged <- val
			}
		}(ch)
	}

	go func() {
		wg.Wait()
		close(merged) // close after all inputs are drained
	}()

	return merged
}

func main() {
	in := generator(2, 3, 4, 5, 6)

	// Fan out: two goroutines reading from same channel
	c1 := square(in)
	c2 := square(in)

	// Fan in: merge results
	for result := range fanIn(c1, c2) {
		fmt.Println(result)
	}
}

면접관이 기대하는 핵심 통찰은 다음과 같습니다. generator의 channel은 c1c2 사이에 공유되므로, 각 값은 정확히 하나의 워커에 의해서만 처리됩니다(중복 처리되지 않음). fanIn 함수는 WaitGroup을 사용하여 모든 입력 channel이 소진된 후에 병합된 channel을 close합니다.

errgroup을 활용한 워커 풀

Q: 에러 핸들링이 포함된 제한된 워커 풀을 어떻게 구현합니까?

golang.org/x/sync/errgroup 패키지(Go 확장 표준 라이브러리)가 이 문제를 깔끔하게 해결합니다. goroutine 라이프사이클 관리, 첫 번째 에러 수집, context와의 통합을 통한 취소를 처리합니다.

worker_pool.gogo
package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func processItem(ctx context.Context, id int) error {
	// Check for cancellation before heavy work
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if id == 7 {
		return fmt.Errorf("failed to process item %d", id)
	}
	fmt.Printf("processed item %d\n", id)
	return nil
}

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(3) // maximum 3 concurrent goroutines

	for i := 0; i < 10; i++ {
		id := i
		g.Go(func() error {
			return processItem(ctx, id)
		})
	}

	// Wait blocks until all goroutines finish
	// Returns the first non-nil error
	if err := g.Wait(); err != nil {
		fmt.Println("pipeline error:", err)
	}
}

2026년 초 기준 현재 안정 릴리스인 Go 1.24에서도 이 패턴이 권장 접근법으로 유지되고 있습니다. SetLimit 메서드는 Go 1.20에서 추가되어, 세마포어 기반 동시성 제한을 수동으로 구현할 필요가 없어졌습니다.

레이스 컨디션과 sync 프리미티브

Q: Go에서 레이스 컨디션을 어떻게 감지하고 방지합니까?

Go는 -race 플래그로 활성화되는 내장 레이스 디텍터를 제공합니다. 런타임에 공유 메모리에 대한 비동기화된 동시 접근을 감지합니다.

race_condition.gogo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// BAD: race condition — do not use in production
func unsafeCounter() int {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // DATA RACE: concurrent read/write
		}()
	}
	wg.Wait()
	return counter // result is non-deterministic
}

// GOOD: atomic operations for simple counters
func atomicCounter() int64 {
	var counter atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1) // thread-safe atomic increment
		}()
	}
	wg.Wait()
	return counter.Load() // always 1000
}

// GOOD: mutex for complex shared state
type SafeMap struct {
	mu sync.RWMutex
	data map[string]int
}

func (m *SafeMap) Set(key string, val int) {
	m.mu.Lock()         // exclusive lock for writes
	defer m.mu.Unlock()
	m.data[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
	m.mu.RLock()         // shared lock for reads
	defer m.mu.RUnlock()
	v, ok := m.data[key]
	return v, ok
}

func main() {
	fmt.Println("unsafe:", unsafeCounter())  // unpredictable
	fmt.Println("atomic:", atomicCounter())  // always 1000
}

면접 답변은 세 가지 동기화 전략을 다루어야 합니다. 복잡한 공유 상태에는 sync.Mutex/sync.RWMutex, 단순 카운터와 플래그에는 sync/atomic, goroutine 간 통신에는 channel("메모리를 공유하여 통신하지 말고, 통신으로 메모리를 공유하라")을 사용합니다. go test -race ./... 실행은 모든 CI 파이프라인에 포함되어야 합니다.

Context와 취소 패턴

Q: context.Context가 goroutine의 라이프사이클을 어떻게 제어하는지 설명하십시오.

context 패키지는 취소 시그널, 데드라인, 요청 범위 값을 goroutine 경계를 넘어 전파하는 메커니즘을 제공합니다. 장시간 실행되는 모든 goroutine은 첫 번째 매개변수로 context.Context를 받아야 합니다.

context_cancellation.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

// worker simulates a long-running task
func worker(ctx context.Context, id int, results chan<- string) {
	select {
	case <-time.After(time.Duration(id*100) * time.Millisecond):
		results <- fmt.Sprintf("worker %d: done", id)
	case <-ctx.Done():
		results <- fmt.Sprintf("worker %d: cancelled (%v)", id, ctx.Err())
	}
}

func main() {
	// Parent context with 250ms deadline
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()

	results := make(chan string, 5)

	// Launch 5 workers with increasing durations
	for i := 1; i <= 5; i++ {
		go worker(ctx, i, results)
	}

	// Collect all results
	for i := 0; i < 5; i++ {
		fmt.Println(<-results)
	}
}

워커 1과 2는 250ms 데드라인 내에 완료됩니다. 워커 3, 4, 5는 ctx.Done()을 통해 취소 시그널을 수신합니다. 이 패턴은 Go에서 복원력 있는 HTTP 서버와 마이크로서비스를 구축하는 데 근본이 되는 것입니다. 모든 요청 핸들러는 클라이언트 연결이 끊어질 때 취소가 전파되는 context를 수신합니다.

면접에서 자주 나오는 함정

context.Context를 구조체 필드에 저장해서는 안 됩니다. Go 공식 문서에는 "Context를 구조체 타입 내부에 저장하지 마십시오. 대신, Context가 필요한 각 함수에 명시적으로 전달하십시오"라고 명시되어 있습니다. 면접관은 이 질문으로 후보자가 Go 관례를 따르는지 확인합니다.

데드락 감지: 면접 난이도 높은 질문

Q: 이 코드는 데드락이 발생합니까? 그 이유는 무엇입니까?

데드락 질문은 후보자가 goroutine 스케줄링과 channel 연산에 대해 추론하는 능력을 테스트하므로 자주 출제됩니다.

deadlock_example.gogo
package main

func main() {
	ch := make(chan int)
	ch <- 42 // DEADLOCK: unbuffered send with no receiver
	// The main goroutine blocks here forever
	// Go runtime detects this: "fatal error: all goroutines are asleep"
}

수정 방법은 간단합니다. channel을 버퍼 있는 것으로 변경하거나(make(chan int, 1)), 송신 전에 수신용 goroutine을 생성합니다. Go 런타임은 모든 goroutine이 블로킹된 경우 데드락을 감지하지만, 모든 goroutine이 슬립 상태인 경우에만 해당됩니다. 단 하나의 goroutine이라도 실행 중이면(예: 백그라운드 HTTP 서버), 런타임은 부분적 데드락을 감지하지 못합니다.

부분적 데드락은 감지되지 않는다

Go 런타임이 데드락을 감지하는 것은 프로그램 내의 모든 goroutine이 블로킹된 경우에만 해당됩니다. HTTP 서버나 백그라운드 워커가 있는 실제 애플리케이션에서는 데드락 상태의 goroutine 누수가 런타임 디텍터에 의해 감지되지 않습니다. 프로덕션 환경에서 이러한 문제를 진단하려면 pprof와 goroutine 덤프(runtime.Stack) 같은 도구가 필요합니다.

고급 패턴: 속도 제한 동시 처리

Q: 속도 제한이 적용된 동시 API 호출을 어떻게 구현합니까?

이 질문은 여러 동시성 프리미티브를 하나의 통합된 솔루션으로 조합하는 능력을 평가합니다.

rate_limited.gogo
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// RateLimiter controls concurrent and temporal access
type RateLimiter struct {
	semaphore chan struct{}   // limits concurrency
	ticker    *time.Ticker    // limits rate
}

func NewRateLimiter(maxConcurrent int, interval time.Duration) *RateLimiter {
	return &RateLimiter{
		semaphore: make(chan struct{}, maxConcurrent),
		ticker:    time.NewTicker(interval),
	}
}

func (rl *RateLimiter) Execute(ctx context.Context, fn func() error) error {
	// Wait for rate limit tick
	select {
	case <-rl.ticker.C:
	case <-ctx.Done():
		return ctx.Err()
	}

	// Acquire concurrency slot
	select {
	case rl.semaphore <- struct{}{}:
	case <-ctx.Done():
		return ctx.Err()
	}

	defer func() { <-rl.semaphore }() // release slot
	return fn()
}

func main() {
	rl := NewRateLimiter(3, 100*time.Millisecond)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			err := rl.Execute(ctx, func() error {
				fmt.Printf("[%v] processing %d\n", time.Now().Format("04:05.000"), id)
				time.Sleep(150 * time.Millisecond) // simulate work
				return nil
			})
			if err != nil {
				fmt.Printf("item %d: %v\n", id, err)
			}
		}(i)
	}
	wg.Wait()
}

이 패턴은 channel 기반 세마포어(동시성 제한)와 ticker(속도 제한)를 결합합니다. context 체크가 포함된 이중 select가 그레이스풀 셧다운을 보장합니다. 이와 같은 프로덕션 수준의 답변이 시니어 후보자를 차별화합니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

  • goroutine은 M:N 스케줄링을 가진 Go 런타임 관리 유저 공간 스레드이며, 생성된 goroutine 내에서 panic을 반드시 복구해야 한다
  • 버퍼 없는 channel은 송신측과 수신측을 동기화하고, 버퍼 있는 channel은 타이밍을 분리한다. 송신측이 확인을 필요로 하는지에 따라 선택한다
  • select 문은 무작위 선택으로 channel 연산을 다중화한다. 타임아웃 처리에는 context.Context와 결합하여 사용한다
  • fan-out/fan-in과 워커 풀(errgroup.SetLimit 사용)이 면접에서 가장 자주 출제되는 동시성 패턴이다
  • 복잡한 공유 상태에는 sync.Mutex, 단순 카운터에는 sync/atomic, goroutine 간 통신에는 channel을 사용한다
  • 데이터 레이스 감지를 위해 CI에서 go test -race를 반드시 실행한다. 부분적 데드락 진단에는 pprof가 필요하다
  • context.Context를 구조체에 저장하지 않고, 첫 번째 함수 매개변수로 전달한다
  • Go의 속도 제한은 channel 세마포어와 ticker를 결합하고, context 인식 select 문으로 래핑한다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#go
#golang
#interview
#goroutines
#channels
#concurrency

공유

관련 기사