Go 동시성 실전 — 채널이냐 뮤텍스냐, 언제 무엇을

🌐 English

Go를 배우면 "메모리를 공유해 소통하지 말고, 소통해서 메모리를 공유하라"는 격언을 듣습니다. 멋진 말이지만, 실전에서 이걸 모든 경우에 채널을 써라로 오해하면 코드가 오히려 복잡해집니다. 진실은 이렇습니다. 공유 상태를 지키는 데는 뮤텍스가, 작업을 나눠 주는 데는 채널이 맞습니다.

공유 상태 보호 — 뮤텍스가 맞다

봇은 여러 고루틴이 같은 값을 읽고 씁니다. 예를 들어 헬스체크 고루틴이 RPC 노드 순위표를 갱신하는 동안, 거래 고루틴이 그 표를 읽죠. 이렇게 여러 고루틴이 공유하는 단순한 데이터는 뮤텍스로 감싸는 게 가장 단순하고 빠릅니다.

type Bot struct {

    rpcMu    sync.RWMutex

    rpcNodes []RPCNode // 여러 고루틴이 공유하는 순위표

}

// 쓰기: 배타 잠금

func (b *Bot) setNodes(n []RPCNode) {

    b.rpcMu.Lock()

    b.rpcNodes = n

    b.rpcMu.Unlock()

}

// 읽기: 공유 잠금 (여러 리더가 동시에 읽어도 됨)

func (b *Bot) bestNode() (RPCNode, bool) {

    b.rpcMu.RLock()

    defer b.rpcMu.RUnlock()

    if len(b.rpcNodes) == 0 {

        return RPCNode{}, false

    }

    return b.rpcNodes[0], true

}

RWMutex는 읽기가 많고 쓰기가 드문 봇 상태에 특히 잘 맞습니다. 리더 여럿이 동시에 읽되, 쓸 때만 잠깐 배타적으로 막죠. 이걸 채널로 흉내 내려면 값을 관리하는 전용 고루틴과 요청/응답 채널을 만들어야 하는데 — 그냥 뮤텍스가 낫습니다.

작업 분배 — 채널이 맞다

반대로, 수백 개의 후보 경로를 여러 워커가 나눠 견적 계산하는 상황이라면 채널이 정답입니다. 채널은 "일감 큐"이자 워커 간 안전한 전달 통로 역할을 동시에 합니다.

func (b *Bot) scanCycles(ctx context.Context, ids []uint64) []Opportunity {

    jobs := make(chan uint64)

    out := make(chan Opportunity)

    // 워커 N개 기동

    var wg sync.WaitGroup

    for i := 0; i < workerCount; i++ {

        wg.Add(1)

        go func() {

            defer wg.Done()

            for id := range jobs {          // 채널이 닫힐 때까지 일감을 꺼낸다

                if op, ok := b.quoteCycle(ctx, id); ok {

                    out <- op

                }

            }

        }()

    }

    // 일감 투입 → 다 넣으면 채널을 닫아 워커에게 "끝"을 알림

    go func() {

        for _, id := range ids {

            jobs <- id

        }

        close(jobs)

        wg.Wait()   // 모든 워커가 끝나길 기다렸다가

        close(out)  // 결과 채널을 닫는다

    }()

    var res []Opportunity

    for op := range out {   // 결과가 닫힐 때까지 수집

        res = append(res, op)

    }

    return res

}

이 패턴의 아름다움은 워커 수로 동시성을 조절한다는 점입니다. RPC 한도가 빡빡하면 워커를 4개로, 여유로우면 16개로. 일감 채널 range가 닫힘을 감지해 알아서 종료되니 종료 처리도 깔끔합니다.

select — 여러 채널과 취소를 함께 다루기

봇은 "일감을 기다리되, 취소 신호가 오면 즉시 빠져나가야" 합니다. select가 이걸 우아하게 처리합니다. 여러 채널 중 먼저 준비되는 쪽을 고르죠.

select {

case op := <-out:

    handle(op)

case <-ticker.C:

    b.refreshHealth(ctx)      // 주기적 헬스체크

case <-ctx.Done():

    return ctx.Err()          // 취소 신호 → 즉시 종료

}

모든 장기 실행 루프에 <-ctx.Done() 케이스를 넣는 습관을 들이면, 봇을 언제든 깔끔하게 멈출 수 있습니다.

데드락을 피하는 세 가지 규칙

  • 잠금을 잡은 채 채널을 기다리지 마라. 뮤텍스를 Lock한 상태에서 채널 송신을 블록하면, 그 잠금을 기다리는 다른 고루틴과 얽혀 교착이 납니다. 락 구간은 짧게, 채널 대기는 락 밖에서.
  • 버퍼 없는 채널의 송신자와 수신자를 짝지어라. 받는 쪽 없는 채널에 보내면 영원히 멈춥니다. 워커풀처럼 소비자가 확실할 때만 무버퍼를 쓰세요.
  • 닫는 책임은 송신자에게. 채널은 보내는 쪽이 닫습니다. 받는 쪽이 닫으면 다른 송신자가 닫힌 채널에 보내다 패닉이 납니다.

정리 — 선택의 기준

  • 공유 상태를 보호한다 → sync.Mutex / RWMutex. 단순하고 빠르다.
  • 작업을 분배하거나 소유권을 넘긴다 → 채널. 워커풀·파이프라인에 최적.
  • 여러 이벤트·취소를 조율한다 → select + context.

둘은 경쟁 상대가 아니라 도구함의 서로 다른 연장입니다. "무조건 채널"도, "무조건 뮤텍스"도 아닙니다. 지금 지키려는 게 상태인지 옮기려는 게 작업인지 — 그 질문이 답을 정합니다. 참고로 경합을 의심할 땐 go run -race로 돌려 보세요. 눈에 안 보이던 데이터 레이스를 잡아 줍니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기