스크랩

Go 동시성, 이 한 편으로 빠르게 복습하기: 고루틴부터 운영 진단까지

Go 동시성, 이 한 편으로 빠르게 복습하기: 고루틴부터 운영 진단까지

한눈에 보는 요약

Go 동시성의 핵심 개념을 한 번에 훑을 수 있는 대화형 미니북이 공개되었습니다. 고루틴과 채널 같은 기초부터, 동기화 도구 선택, 테스트, 그리고 운영 환경 진단까지 이어지는 구성이 특징입니다. 무엇보다 예제 코드를 직접 수정하고 Run 버튼으로 실행해 볼 수 있다는 점이 인상적이죠.

핵심만 먼저 정리하면 이렇습니다.

  • 채널과 select로 데이터 전달, 완료 알림, 취소, 파이프라인을 구성한다
  • 타이머와 context로 시간 제한과 작업 취소를 다룬다
  • 데이터 경쟁과 경쟁 조건은 다른 문제이며, Go 경쟁 탐지기는 경쟁 조건까지는 잡아주지 않는다
  • 동기화 도구는 목적에 맞게 골라 쓴다: WaitGroup(완료 대기), 뮤텍스(공유 데이터 보호), Once(한 번 실행), Pool(메모리 재사용)
  • synctest와 런타임 진단 도구로 시간 의존 동작까지 테스트하고, 메트릭·프로파일링·실행 추적으로 운영 문제를 분석한다

참고로 이 미니북은 입문서가 아니라 복습용 자료입니다. 정적 예제가 담긴 PDF 버전도 함께 제공되며, AI를 쓰지 않고 제작했다고 밝히고 있습니다. 기초부터 실습으로 배우고 싶다면 저자는 Gist of Go: Concurrency를 권합니다.

고루틴과 완료 대기

고루틴은 go 키워드로 시작하는 함수이고, Go 런타임이 이를 OS 스레드에 배분합니다. OS 스레드보다 훨씬 가벼워서 수백~수천 개를 만들어도 괜찮습니다. 프로그램 시작 시 실행되는 main 역시 하나의 고루틴이며, main이 끝나면 다른 고루틴도 함께 종료된다는 점을 꼭 기억해야 합니다.

그래서 완료를 기다릴 장치가 필요한데, 그 역할이 sync.WaitGroup입니다.

  • Add(n): 내부 카운터를 n만큼 증가
  • Done(): 카운터를 1 감소
  • Wait(): 카운터가 0이 될 때까지 호출한 고루틴을 차단
  • WaitGroup.Go: 카운터 증가 → 고루틴 실행 → 종료 시 감소를 한 번에 처리

주의할 점은 WaitGroup이 고루틴 자체를 추적하지 않는다는 것입니다. 단순한 카운터일 뿐이죠. 모든 메서드는 여러 고루틴에서 안전하게 호출할 수 있고, 여러 고루틴이 동시에 Wait를 호출하면 모두 카운터가 0이 될 때까지 함께 기다립니다. 보통 Add를 모두 호출한 뒤 Wait하지만, 일부 Add를 Wait 이후 다른 고루틴에서 호출하는 구성도 가능합니다.

채널: 전달, 종료, 방향, 버퍼

채널은 고루틴 사이에서 값을 전달하는 통로입니다. 버퍼 없는 채널에서 ch <- val은 다른 고루틴이 <-ch로 값을 받을 때까지 차단됩니다.

실무에서 자주 쓰이는 패턴 하나는 함수가 출력 채널을 반환하고, 내부 고루틴이 그 채널을 채우는 방식입니다. 호출자는 값을 받아 쓰기만 하고, 채널의 제어권(닫기 등)은 생성한 함수에 남기 때문에 실수를 줄일 수 있습니다.

close()로 완료 알리기

  • close()는 "더 보낼 데이터가 없다"를 수신자에게 알립니다
  • val, ok := <-ch로 상태 확인 — 닫혔고 남은 값도 없으면 제로 값과 false
  • 이미 닫힌 채널을 다시 닫거나 값을 보내면 패닉
  • 완료를 알릴 필요가 없다면 굳이 닫지 않아도 됩니다. 사용하지 않는 채널은 닫힘 여부와 무관하게 GC가 회수합니다
  • 채널의 range는 값을 반복 수신하고 채널이 닫히면 종료됩니다. 슬라이스의 range와 달리 값 하나만 반환합니다

방향 지정 채널

타입으로 잘못된 사용을 막을 수 있습니다.

| 표기 | 의미 | | --- | --- | | chan T | 양방향 | | chan<- T | 송신 전용 (읽기 불가) | | <-chan T | 수신 전용 (쓰기·닫기 불가) |

보통 양방향으로 생성한 뒤 함수 매개변수에서 방향을 지정하며, 변환은 Go가 알아서 해줍니다.

버퍼 채널과 nil 채널

버퍼 채널은 고정 크기 FIFO 큐처럼 동작합니다. 빈 공간이 있으면 송신이 차단되지 않고, 값이 남아 있으면 수신이 차단되지 않습니다. 버퍼 크기를 생략하면 용량 0, 즉 비버퍼 채널이 됩니다. len()은 저장된 값의 수, cap()은 버퍼 용량을 반환하며, 닫힌 뒤에도 버퍼에 남은 값은 ok == true와 함께 수신되고 모두 소진한 뒤에야 제로 값과 false가 나옵니다.

nil 채널은 채널의 제로 값으로, 읽기와 쓰기가 무기한 차단되고 닫으면 패닉입니다. 버그처럼 보이지만 select에서는 아주 유용하게 쓰입니다.

select로 통신 흐름 제어하기

select는 여러 채널 연산 중 실행 가능한 분기를 고릅니다.

  • 여러 분기가 준비되면 무작위로 하나를 실행
  • 모두 차단 상태면 default 실행, default도 없으면 하나가 준비될 때까지 대기

활용 예를 보면 감이 옵니다.

  1. 입력 병합: 두 채널의 값을 하나의 출력 채널로 모읍니다. 종료된 입력을 nil로 바꿔두면 그 수신 분기는 더 이상 선택되지 않고, 두 입력이 모두 끝나면 출력을 닫습니다. (앞서 본 nil 채널의 쓸모가 바로 이것입니다)
  2. 취소: 출력 송신과 취소 채널 수신을 함께 기다려, 취소 신호가 오면 작업 함수를 종료합니다
  3. 비차단 연산: 송신 분기와 default를 조합하면, 채널이 바쁠 때 기다리는 대신 busy 오류를 즉시 반환할 수 있습니다

파이프라인과 오류 처리

파이프라인은 각 단계가 채널로 입력을 받아 처리하고 결과를 다시 채널로 내보내는 연속 작업입니다.

  • 읽기 단계: 파일·DB·네트워크에서 데이터를 가져옴
  • 처리 단계: 변환, 필터링, 집계, 외부 정보를 이용한 데이터 보강
  • 쓰기 단계: 결과를 파일·DB·네트워크에 저장

완료와 취소는 채널 패턴으로 표현합니다. 출력 채널은 결과를 전달하면서 닫힘으로 완료를 알리고, 결과가 필요 없다면 done 채널을 닫아 완료만 알립니다. 반대로 호출자가 cancel 채널을 닫으면 작업 고루틴이 이를 감지해 조기 종료합니다.

오류 처리 방식은 세 가지로 정리됩니다.

  1. 첫 오류에서 종료하고 별도 오류 채널로 전달 (정상 완료 시에는 nil 전송)
  2. 값과 오류를 함께 담은 Result 타입을 출력 채널로 전달
  3. 정상 결과는 출력 채널로, 오류는 별도 채널에 수집

시간 제한, 타이머, 주기 실행

time.After()는 지정 시간이 지나면 값을 받는 채널을 반환합니다. 작업 완료 채널과 함께 select에서 기다리면 "완료 또는 타임아웃 중 먼저 일어난 일"을 처리할 수 있습니다. 미니북 예제는 최대 50ms만 기다리기 때문에, 100ms가 걸리는 작업에는 시간 초과 오류를 반환합니다.

  • time.Timer: 만료 시 C 채널로 현재 시각을 보냅니다. Stop()은 아직 만료되지 않은 타이머를 중지하면 true, 그렇지 않으면 false
  • time.AfterFunc(d, f): d 경과 후 f를 실행하고, 반환된 타이머로 실행 시작 전에 취소 가능
  • time.Ticker: 중지할 때까지 일정 간격으로 C에 시각을 보냅니다. 주기 작업에 쓰며 반드시 Stop()으로 자원을 해제해야 하고, 수신자가 속도를 못 따라가면 일부 틱을 건너뜁니다

💡 성능 팁: 반복문 안에서 매번 새 타이머를 만들기보다 하나를 Reset()으로 재사용하는 편이 좋습니다. 입력이 1시간 동안 없으면 경고하는 예제에서 값 10,000개를 처리했을 때 메모리 사용량은 4KB, 할당 횟수는 6회에 불과했습니다.

context로 취소와 기한 전파하기

context.Context의 주된 목적은 작업 취소입니다. 함수는 Done() 채널로 취소를 감지하고 Err()로 원인을 확인합니다.

  • context.WithCancel(): 수동 취소 → 오류는 context.Canceled
  • context.WithTimeout() / context.WithDeadline(): 시간 제한 / 특정 기한 → 오류는 context.DeadlineExceeded

컨텍스트는 불변이며 부모/자식 계층으로 구성됩니다. 속성을 추가할 때 기존 컨텍스트를 바꾸지 않고 자식을 새로 만들죠. 여기서 중요한 규칙 하나는 부모와 자식 중 더 짧은 시간 제한이 적용된다는 점입니다. 자식은 부모의 제한을 줄일 수만 있고 늘릴 수는 없습니다. 그리고 cancel()은 여러 번 호출해도 안전하며, 첫 호출 이후는 무시됩니다.

조금 더 나아간 기능도 있습니다.

  • WithCancelCause(), WithTimeoutCause(), WithDeadlineCause()로 지정한 사용자 정의 원인은 context.Cause()로 확인
  • context.AfterFunc()로 취소 시 실행할 정리 함수 등록
  • context.WithValue()로 키별 값 전달은 가능하지만, 일반적으로는 명시적 매개변수나 사용자 정의 구조체를 쓰는 편이 좋습니다

데이터 경쟁 ≠ 경쟁 조건

이 글에서 가장 강조할 만한 부분입니다. 두 개념은 자주 혼동되지만 엄연히 다릅니다.

데이터 경쟁 (data race)

여러 고루틴이 공유 데이터에 접근하고, 그중 하나 이상이 수정할 때 발생합니다. 항상 런타임 패닉으로 드러나지 않기 때문에 -race 옵션으로 경쟁 탐지기를 켜야 합니다. test, run, build, install 명령이 모두 이 옵션을 지원합니다. 재미있는 건, 두 고루틴의 total++ 결과가 우연히 정상값 2가 나와도 탐지기는 데이터 경쟁을 찾아낸다는 점입니다.

막는 방법은 세 가지입니다. 동시 수정을 아예 피하거나, 뮤텍스로 접근을 동기화하거나, 원자적 연산만 사용하는 것. 참고로 채널은 동시 읽기·쓰기에 안전해서 데이터 경쟁 없이 값을 전달할 수 있습니다.

경쟁 조건 (race condition)

예측할 수 없는 연산 순서 때문에 시스템이 잘못된 상태에 빠지는 문제입니다. 미니북의 예시가 명확합니다.

잔액 50에서 두 고루틴이 각각 40을 인출하려 합니다. 잔액 확인과 차감의 실행 순서가 엇갈리면 두 고루틴 모두 "잔액 충분" 판정을 받고, 결과적으로 잔액이 -30이 될 수 있습니다.

여기서 핵심은 개별 잔액 읽기/쓰기가 각각 동시성 안전하다면 경쟁 탐지기는 아무 문제도 찾지 못한다는 것입니다. -race를 통과했다고 안심할 수 없는 이유죠. 잔액 확인과 차감을 하나의 단위로 묶어 뮤텍스로 보호하면 예제의 최종 잔액은 정상값 10이 됩니다.

뮤텍스 없이 일부 경쟁 조건을 막는 방법으로 비교 후 변경(compare-and-set) 도 있습니다. 예상한 이전 상태와 현재 상태가 같을 때만 변경하고, 다르면 아무것도 하지 않는 방식입니다. CompareAndSet, CompareAndSwap, CompareAndDelete 같은 변형이 제공됩니다.

뮤텍스와 읽기/쓰기 잠금

sync.Mutex는 Lock()과 Unlock() 사이 코드를 한 번에 한 고루틴만 실행하도록 보장합니다. 여러 고루틴이 같은 데이터를 수정할 때, 또는 한 고루틴이 수정하는 동안 다른 고루틴이 읽을 때 필요합니다. 반대로 모든 고루틴이 읽기만 한다면 뮤텍스는 필요 없습니다.

TryLock()은 잠금을 즉시 얻을 수 없으면 대기하지 않고 false를 반환합니다. 100개 고루틴이 동시에 잠금을 시도하는 예제에서는 잠금을 얻은 하나만 카운터를 증가시킵니다.

sync.RWMutex는 단일 쓰기/다중 읽기를 지원합니다.

  • Lock()으로 잠그면 다른 Lock()과 RLock() 모두 차단
  • RLock() 중에는 다른 읽기 잠금은 허용, 쓰기 잠금은 차단
  • 해제는 각각 Unlock(), RUnlock()

두 뮤텍스 타입은 모두 sync.Locker 인터페이스(Lock()/Unlock())를 구현합니다. 구체적인 뮤텍스 타입 대신 이 인터페이스를 받으면 호출 측에서 잠금 구현을 고를 수 있습니다. 덧붙여 용량 1의 버퍼 채널도 뮤텍스처럼 쓸 수 있습니다. 송신으로 잠금을 획득하고 수신으로 해제하는 방식이죠.

세마포어, 랑데부, 배리어

세마포어 (semaphore)

사용 가능한 슬롯 수를 제한하는 장치입니다. 획득 시 빈 슬롯을 차지하고, 슬롯이 없으면 대기합니다. 해제하면 슬롯을 돌려주고, 기다리던 고루틴 중 하나가 이를 차지해 재개합니다.

용량 N의 버퍼 채널에 송신해 획득하고 수신해 해제하는 방식으로 간단하게 만들 수 있습니다. 동시 실행 수를 4로 제한해 1ms 작업 100개를 처리한 예제의 경과 시간은 25ms였습니다(100 ÷ 4 × 1ms). 더 복잡한 요구사항에는 golang.org/x/sync/semaphore 패키지를 사용하면 됩니다.

랑데부 (rendezvous)

두 고루틴이 서로 준비될 때까지 기다리는 동기화 방식입니다. 한쪽만 준비되면 대기하고, 양쪽 모두 신호를 보내야 함께 진행합니다. 카운터를 2로 설정한 WaitGroup으로 구현할 수 있습니다.

마무리

Go 동시성은 go와 chan만 알면 시작할 수 있지만, 실전에서는 어떤 도구를 언제 쓸지 고르는 판단이 훨씬 중요합니다. 특히 이 미니북이 짚은 "데이터 경쟁과 경쟁 조건은 다른 문제이고, -race는 후자를 잡아주지 않는다"는 지적은 한 번쯤 곱씹어볼 만합니다.

이미 Go로 동시성 코드를 작성해 본 경험이 있다면, 개념을 정리하고 빈틈을 메우는 용도로 훑어보기 좋은 자료입니다. 코드를 직접 고쳐 실행할 수 있으니, 궁금한 동작은 바로 실험해 보세요.


출처: GeekNews - 개발/기술/스타트업 뉴스 서비스


원문: https://news.hada.io/topic?id=34350