본문 바로가기
프로그래밍/Go

Go select, timeout, close, context

by JLearn 2026. 7. 20.
반응형

Go의 select는 여러 channel 송수신 작업을 동시에 기다리고, 실행 가능한 작업 중 하나를 선택할 때 사용합니다. 여기에 timeout과 channel 종료 처리, context를 함께 사용하면 오래 걸리는 작업을 중단하거나 goroutine을 안전하게 종료하는 흐름을 만들 수 있습니다.


디렉터리 구조는 다음과 같이 구성합니다.

sources/
└── select-cancellation/
    └── main.go

sources 디렉터리에서 실행합니다.

cd sources
go run ./select-cancellation

핵심 개념

구분 역할
select 여러 channel 송수신 작업 중 실행 가능한 작업 하나를 선택
default 준비된 channel 작업이 없을 때 기다리지 않고 즉시 실행
time.After 지정한 시간이 지난 뒤 값을 전달하는 channel 반환
close(ch) 더 이상 값을 보내지 않는다는 사실을 수신 측에 알림
value, ok := <-ch 수신한 값과 channel이 열려 있는지 확인
for value := range ch channel이 닫힐 때까지 값을 반복해서 수신
context.Context deadline, cancellation 신호, 요청 범위의 값을 함수 사이에 전달

select는 일반적인 switch와 비슷한 모양이지만, 각 case에는 조건식이 아니라 channel 송수신 연산이 들어갑니다.

select {
case value := <-resultCh:
    fmt.Println("결과:", value)
case <-timeoutCh:
    fmt.Println("시간 초과")
}

하나 이상의 channel 작업이 실행 가능한 상태라면 Go는 그중 하나를 균등한 의사 난수 방식으로 선택합니다. 작성 순서가 앞선 case가 항상 먼저 실행되는 것은 아닙니다.


select의 동작

다음 예제는 두 goroutine이 서로 다른 시간에 값을 보내도록 구성합니다.

package main

import (
    "fmt"
    "time"
)

func main() {
    firstCh := make(chan string)
    secondCh := make(chan string)

    go func() {
        time.Sleep(300 * time.Millisecond)
        firstCh <- "첫 번째 작업 완료"
    }()

    go func() {
        time.Sleep(100 * time.Millisecond)
        secondCh <- "두 번째 작업 완료"
    }()

    select {
    case message := <-firstCh:
        fmt.Println(message)
    case message := <-secondCh:
        fmt.Println(message)
    }
}

출력:

두 번째 작업 완료

secondCh가 먼저 수신 가능한 상태가 되었으므로 두 번째 case가 실행됩니다. select는 하나의 case를 실행한 뒤 종료되므로, 여러 결과를 계속 처리하려면 for와 함께 사용해야 합니다.

for {
    select {
    case message := <-firstCh:
        fmt.Println(message)
    case message := <-secondCh:
        fmt.Println(message)
    }
}

다만 종료 조건이 없는 반복문은 계속 실행됩니다. 실제 코드에서는 channel 종료, context 취소 또는 별도의 종료 신호를 처리해야 합니다.


default와 비차단 select

selectdefault가 없고 실행 가능한 channel 작업도 없다면, 최소 하나의 작업이 가능해질 때까지 기다립니다.

default가 있으면 기다리지 않고 즉시 default를 실행합니다.

package main

import "fmt"

func main() {
    messageCh := make(chan string)

    select {
    case message := <-messageCh:
        fmt.Println(message)
    default:
        fmt.Println("현재 받을 수 있는 값이 없습니다.")
    }
}

출력:

현재 받을 수 있는 값이 없습니다.

이 형태를 비차단 수신이라고 합니다. channel에 값이 없을 때 goroutine을 멈추지 않고 다른 작업을 계속할 수 있습니다.

select {
case messageCh <- "작업 완료":
    fmt.Println("전송 성공")
default:
    fmt.Println("현재 전송할 수 없습니다.")
}

위 코드는 비차단 송신입니다. 수신자가 준비되지 않았거나 buffered channel에 빈 공간이 없으면 default가 실행됩니다.

반복문 안에서 default가 있는 select를 아무 대기 없이 계속 실행하면 CPU를 불필요하게 많이 사용할 수 있습니다. 즉시 처리할 다른 작업이 없다면 blocking select, timer 또는 적절한 대기 방식을 사용하는 편이 좋습니다.


timeout 처리

channel 수신을 무기한 기다리면 상대 작업이 끝나지 않았을 때 현재 goroutine도 계속 멈출 수 있습니다. time.Afterselect와 함께 사용하면 최대 대기 시간을 지정할 수 있습니다.

package main

import (
    "fmt"
    "time"
)

func loadData() <-chan string {
    resultCh := make(chan string, 1)

    go func() {
        time.Sleep(2 * time.Second)
        resultCh <- "조회 완료"
    }()

    return resultCh
}

func main() {
    select {
    case result := <-loadData():
        fmt.Println(result)
    case <-time.After(1 * time.Second):
        fmt.Println("1초 안에 작업이 끝나지 않아 대기를 종료합니다.")
    }
}

출력:

1초 안에 작업이 끝나지 않아 대기를 종료합니다.

time.After(1 * time.Second)는 약 1초 후 현재 시간을 전달하는 수신 전용 channel을 반환합니다. 결과 channel보다 timeout channel이 먼저 준비되었으므로 timeout case가 실행됩니다.

여기서 timeout은 결과를 기다리는 쪽의 대기만 끝냅니다. loadData 내부 goroutine 자체를 자동으로 중단하지는 않습니다. 실행 중인 작업까지 중단하려면 해당 작업이 context의 취소 신호를 확인하도록 만들어야 합니다.

반복문에서 timer를 세밀하게 관리해야 하는 경우에는 time.NewTimer를 사용할 수 있습니다.

timer := time.NewTimer(1 * time.Second)
defer timer.Stop()

select {
case result := <-resultCh:
    fmt.Println(result)
case <-timer.C:
    fmt.Println("시간 초과")
}

channel 닫기와 수신 결과 확인

close(ch)는 channel을 제거하는 명령이 아닙니다. 앞으로 이 channel에 값을 더 보내지 않는다는 사실을 나타냅니다.

messageCh := make(chan string)
close(messageCh)

닫힌 channel에 남아 있는 값이 있다면 먼저 그 값들을 받을 수 있습니다. 저장된 값을 모두 받은 뒤에는 channel 요소 타입의 zero value가 즉시 반환됩니다.

package main

import "fmt"

func main() {
    numberCh := make(chan int, 2)

    numberCh <- 10
    numberCh <- 20
    close(numberCh)

    fmt.Println(<-numberCh) // 10
    fmt.Println(<-numberCh) // 20
    fmt.Println(<-numberCh) // 0
    fmt.Println(<-numberCh) // 0
}

닫힌 channel에서 계속 수신하면 zero value가 반복해서 반환되므로 값만 확인해서는 실제로 전송된 값인지 구분할 수 없습니다.

value, ok := <-numberCh

ok의 의미는 다음과 같습니다.

ok 의미
true 송신 작업으로 전달된 값을 정상적으로 수신
false channel이 닫혔고 더 이상 받을 값이 없어 zero value 반환

예제:

package main

import "fmt"

func main() {
    numberCh := make(chan int, 1)

    numberCh <- 0
    close(numberCh)

    value, ok := <-numberCh
    fmt.Printf("value=%d, ok=%t\n", value, ok)

    value, ok = <-numberCh
    fmt.Printf("value=%d, ok=%t\n", value, ok)
}

출력:

value=0, ok=true
value=0, ok=false

첫 번째 0은 실제로 channel에 전송된 값입니다. 두 번째 0은 닫히고 비어 있는 channel에서 반환된 int의 zero value입니다. 값은 같지만 ok로 두 상황을 구분할 수 있습니다.


range로 닫힐 때까지 수신하기

channel의 모든 값을 처리한 뒤 종료하려면 range를 사용하는 방식이 간단합니다.

package main

import "fmt"

func main() {
    numberCh := make(chan int)

    go func() {
        defer close(numberCh)

        for number := 1; number <= 3; number++ {
            numberCh <- number
        }
    }()

    for number := range numberCh {
        fmt.Println(number)
    }

    fmt.Println("channel 수신 종료")
}

출력:

1
2
3
channel 수신 종료

for number := range numberCh는 값이 도착할 때까지 기다리고, channel이 닫힌 뒤 남아 있는 값을 모두 처리하면 반복을 종료합니다.

channel을 닫지 않으면 수신 측은 다음 값이 올 가능성이 있다고 판단하므로 range가 끝나지 않습니다.


channel은 누가 닫아야 하는가

일반적으로 channel은 더 이상 값을 보내지 않는다는 사실을 확실히 아는 송신 측에서 닫습니다.

func produce() <-chan int {
    resultCh := make(chan int)

    go func() {
        defer close(resultCh)

        for number := 1; number <= 3; number++ {
            resultCh <- number
        }
    }()

    return resultCh
}

수신 측이 임의로 channel을 닫으면 아직 값을 보내려는 goroutine에서 panic이 발생할 수 있습니다.

panic: send on closed channel

다음 동작도 panic을 발생시킵니다.

close(closedCh) // 이미 닫힌 channel을 다시 닫음
close(nilCh)    // nil channel을 닫음

모든 channel을 반드시 닫아야 하는 것은 아닙니다. close는 수신 측에 더 이상 값이 없다는 사실을 알려야 할 때 사용합니다. 프로그램이 종료될 때까지 사용되고 종료 신호가 필요하지 않은 channel이라면 단순히 닫기 위해 복잡한 동기화를 추가할 필요는 없습니다.

여러 goroutine이 하나의 channel로 값을 보내는 경우에는 개별 송신 goroutine이 channel을 닫아서는 안 됩니다. 모든 송신이 끝났음을 확인하는 하나의 조정 지점에서 닫아야 합니다.

var wg sync.WaitGroup
resultCh := make(chan int)

for workerID := 1; workerID <= 3; workerID++ {
    wg.Add(1)

    go func(id int) {
        defer wg.Done()
        resultCh <- id
    }(workerID)
}

go func() {
    wg.Wait()
    close(resultCh)
}()

select에서 닫힌 channel 처리하기

닫힌 channel의 수신은 즉시 실행 가능하므로 select 안에서 계속 선택될 수 있습니다.

for {
    select {
    case value := <-resultCh:
        fmt.Println(value)
    case value := <-messageCh:
        fmt.Println(value)
    }
}

resultCh가 닫히면 첫 번째 case가 계속 즉시 실행되면서 zero value를 출력할 수 있습니다. 두 번째 반환값으로 종료를 확인해야 합니다.

for {
    select {
    case value, ok := <-resultCh:
        if !ok {
            resultCh = nil
            continue
        }
        fmt.Println("result:", value)

    case message, ok := <-messageCh:
        if !ok {
            messageCh = nil
            continue
        }
        fmt.Println("message:", message)
    }

    if resultCh == nil && messageCh == nil {
        break
    }
}

nil channel의 송수신 작업은 준비 상태가 되지 않습니다. 따라서 닫힌 channel 변수를 nil로 바꾸면 이후 select에서 해당 case가 사실상 비활성화됩니다.


context로 취소 신호 전달하기

context.Context는 API 경계와 함수 호출 흐름을 따라 다음 정보를 전달합니다.

  • deadline
  • cancellation 신호
  • 요청 범위의 값

HTTP 요청, 데이터베이스 조회, 외부 API 호출처럼 하나의 요청 흐름에 속한 작업을 함께 취소할 때 주로 사용합니다.

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

context.WithCancel은 부모 context에서 파생된 새로운 context와 취소 함수를 반환합니다. cancel()을 호출하면 ctx.Done() channel이 닫히고, 이를 기다리는 함수가 취소 사실을 확인할 수 있습니다.

func runWorker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker 종료:", ctx.Err())
            return
        default:
            fmt.Println("작업 처리 중")
            time.Sleep(200 * time.Millisecond)
        }
    }
}

ctx.Done()은 취소되기 전까지 수신을 기다리는 channel이며, 취소되면 닫힙니다. 취소 이유는 ctx.Err()로 확인합니다.

ctx.Err() 의미
context.Canceled 취소 함수 호출 또는 부모 context 취소
context.DeadlineExceeded deadline 또는 timeout 초과

context timeout 적용하기

context.WithTimeout을 사용하면 지정 시간이 지난 뒤 자동으로 취소되는 context를 만들 수 있습니다.

package main

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

func process(ctx context.Context) error {
    select {
    case <-time.After(2 * time.Second):
        fmt.Println("작업 완료")
        return nil

    case <-ctx.Done():
        return ctx.Err()
    }
}

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

    if err := process(ctx); err != nil {
        fmt.Println("작업 중단:", err)
    }
}

출력:

작업 중단: context deadline exceeded

process는 2초가 필요한 작업을 기다리지만 context의 timeout은 1초입니다. 따라서 ctx.Done()이 먼저 닫히고 함수가 종료됩니다.

defer cancel()은 timeout이 먼저 발생하더라도 작성하는 것이 좋습니다. 작업이 timeout 전에 끝난 경우 context가 사용하는 timer와 관련 자원을 즉시 해제할 수 있기 때문입니다.


context를 함수에 전달하는 방식

Context는 일반적으로 함수의 첫 번째 매개변수로 전달하고, 구조체 필드에 장기간 저장하지 않습니다.

func loadUser(ctx context.Context, userID int64) error {
    // ctx를 데이터베이스 조회나 외부 요청에 전달
    return nil
}

관례적인 형태:

func DoSomething(ctx context.Context, argument string) error

context를 전달받은 함수는 다음 단계의 함수에도 같은 context 또는 그 context에서 파생한 context를 전달해야 합니다.

func handleRequest(ctx context.Context) error {
    if err := validate(ctx); err != nil {
        return err
    }

    return save(ctx)
}

context.Background()는 보통 main, 초기화 코드, 테스트 코드처럼 가장 상위 context가 필요한 곳에서 사용합니다. 기존 context를 전달받을 수 있는데 함수 내부에서 새로 context.Background()를 만들면 상위 요청의 취소와 deadline이 끊길 수 있습니다.


context는 optional parameter 저장소가 아니다

context.WithValue는 요청 범위에서 반드시 함께 전달되어야 하는 값을 보관할 수 있지만, 일반 함수 인자를 대신하기 위한 기능은 아닙니다.

피해야 할 형태:

ctx = context.WithValue(ctx, "pageSize", 100)
ctx = context.WithValue(ctx, "sortOrder", "desc")

명시적인 함수 인자가 더 적절합니다.

func search(ctx context.Context, pageSize int, sortOrder string) error {
    return nil
}

WithValue는 인증 주체, 요청 ID, trace 정보처럼 하나의 요청 흐름을 따라 API 경계를 넘어 전달되어야 하는 값에 제한적으로 사용하는 것이 좋습니다.

키 충돌을 피하려면 문자열 같은 기본 타입을 context key로 직접 사용하지 않고 package 내부 전용 타입을 정의합니다.

type contextKey string

const requestIDKey contextKey = "requestID"

func withRequestID(ctx context.Context, requestID string) context.Context {
    return context.WithValue(ctx, requestIDKey, requestID)
}

전체 예제

다음 예제는 작업 생성, channel 종료, select, timeout, context 취소를 한 흐름으로 보여 줍니다.

sources/select-cancellation/main.go:

package main

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

type JobResult struct {
    JobID int
    Value string
}

func produceJobs(ctx context.Context, jobCount int) <-chan int {
    jobCh := make(chan int)

    go func() {
        defer close(jobCh)

        for jobID := 1; jobID <= jobCount; jobID++ {
            select {
            case jobCh <- jobID:
                fmt.Printf("작업 전달: %d\n", jobID)

            case <-ctx.Done():
                fmt.Println("작업 생성 중단")
                return
            }
        }
    }()

    return jobCh
}

func processJobs(ctx context.Context, jobCh <-chan int) <-chan JobResult {
    resultCh := make(chan JobResult)

    go func() {
        defer close(resultCh)

        for jobID := range jobCh {
            select {
            case <-time.After(400 * time.Millisecond):
                result := JobResult{
                    JobID: jobID,
                    Value: fmt.Sprintf("job-%d 완료", jobID),
                }

                select {
                case resultCh <- result:
                case <-ctx.Done():
                    fmt.Println("결과 전달 중단")
                    return
                }

            case <-ctx.Done():
                fmt.Println("작업 처리 중단")
                return
            }
        }
    }()

    return resultCh
}

func run() error {
    ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
    defer cancel()

    jobCh := produceJobs(ctx, 5)
    resultCh := processJobs(ctx, jobCh)

    for {
        select {
        case result, ok := <-resultCh:
            if !ok {
                fmt.Println("모든 결과 수신 완료")
                return nil
            }

            fmt.Printf("결과 수신: id=%d, value=%s\n", result.JobID, result.Value)

        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

func main() {
    if err := run(); err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            fmt.Println("전체 작업 제한 시간 초과")
            return
        }

        if errors.Is(err, context.Canceled) {
            fmt.Println("전체 작업 취소")
            return
        }

        fmt.Println("오류:", err)
    }
}

실행:

cd sources
go run ./select-cancellation

출력 예:

작업 전달: 1
작업 전달: 2
결과 수신: id=1, value=job-1 완료
작업 전달: 3
결과 수신: id=2, value=job-2 완료
작업 전달: 4
결과 수신: id=3, value=job-3 완료
전체 작업 제한 시간 초과
작업 처리 중단

실제 출력 순서는 goroutine scheduling 시점에 따라 일부 달라질 수 있습니다.

이 예제의 흐름은 다음과 같습니다.

  • produceJobs는 작업 번호를 jobCh로 전달하고 송신이 끝나면 channel을 닫습니다.
  • processJobsrange로 작업을 수신하고 결과를 resultCh로 전달합니다.
  • 각 goroutine은 ctx.Done()을 확인하므로 전체 제한 시간이 지나면 작업을 중단할 수 있습니다.
  • run은 결과 channel과 context 취소를 하나의 select에서 기다립니다.
  • resultCh가 정상적으로 닫히면 ok == false를 확인하고 종료합니다.
  • 제한 시간이 먼저 지나면 context.DeadlineExceeded를 반환합니다.

정리

select는 여러 channel 작업을 기다리면서 준비된 작업 하나를 선택합니다. 실행 가능한 작업이 없고 default도 없으면 기다리며, default가 있으면 즉시 비차단 방식으로 처리합니다.

channel을 닫으면 더 이상 값이 전송되지 않는다는 사실을 수신 측에 알릴 수 있습니다. 닫힌 channel은 즉시 zero value를 반환하므로 value, ok := <-ch 또는 range로 종료 상태를 확인해야 합니다.

time.Aftertime.Timer는 특정 channel 작업의 대기 시간을 제한할 때 사용할 수 있습니다. 다만 대기를 끝내는 것과 실제 작업을 중단하는 것은 다릅니다.

여러 함수와 goroutine에 취소와 deadline을 전파해야 한다면 context를 사용합니다. 각 작업은 ctx.Done()을 확인해야 하며, Context는 optional parameter를 숨겨서 전달하는 저장소가 아니라 요청 생명주기를 전달하는 수단으로 사용해야 합니다.

select  = 준비된 channel 작업 중 하나 선택
close   = 더 이상 송신하지 않음을 알림
ok      = 실제 전송값인지, 닫힌 channel의 zero value인지 구분
range   = channel이 닫힐 때까지 반복 수신
timeout = 기다릴 수 있는 최대 시간 제한
context = deadline과 취소 신호를 작업 흐름 전체에 전달

참고 자료

반응형

'프로그래밍 > Go' 카테고리의 다른 글

Go defer  (0) 2026.07.22
Go context  (0) 2026.07.21
Goroutine과 channel  (1) 2026.07.18
Go embedding과 메서드 승격 이해하기  (0) 2026.07.15
Go 인터페이스의 암시적 구현과 Java 인터페이스와의 차이  (0) 2026.07.15

댓글