TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 25 READS

고루틴에서 컨텍스트까지: Go 동시성의 핵심을 다시 정리하다

고루틴에서 컨텍스트까지: Go 동시성의 핵심을 다시 정리하다
SOURCE IMAGE · HACKER NEWS

Go 언어가 서버 개발과 클라우드 인프라 영역에서 넓게 자리 잡은 배경에는 언어 차원에서 다듬어진 동시성 모델이 있다. Anton Zhiyanov가 공개한 인터랙티브 미니북 'Go Concurrency Distilled'는 이 주제를 처음 배우는 입문서가 아니라, 이미 기본기를 갖춘 실무자가 개념을 빠르게 되짚도록 설계된 요약 자료다. 고루틴, 채널, select, 파이프라인, 컨텍스트, 뮤텍스, 세마포어, 아토믹, 진단 도구까지 동시성의 주요 주제를 각각 실행 가능한 예제와 함께 배치했고, 정적 예제로 구성된 PDF 버전도 함께 제공한다. 각 개념이 코드 한 조각으로 바로 확인되는 구성이라, 흩어져 있던 지식을 한자리에서 점검하기에 적합하다.

고루틴과 채널이라는 토대

Go 동시성의 출발점은 go 키워드로 시작하는 고루틴이다. 런타임이 이 고루틴들을 CPU 코어 위에서 도는 운영체제 스레드에 분배하며, OS 스레드보다 훨씬 가볍기 때문에 수백에서 수천 개를 만들어도 부담이 적다. main 함수 자체도 암묵적으로 시작되는 고루틴이며, main이 끝나면 나머지 고루틴도 함께 종료된다는 점은 실무에서 자주 간과되는 지점이다. 그래서 작업 완료를 기다리려면 sync.WaitGroup 같은 장치가 필요하다. WaitGroup은 내부 카운터를 두고 Add로 증가, Done으로 감소시키며 Wait는 카운터가 0이 될 때까지 호출한 고루틴을 막는다. 최근 추가된 WaitGroup.Go 메서드는 카운터 증가와 고루틴 실행, 완료 후 감소를 한 번에 처리해 반복되는 보일러플레이트를 줄여준다.

고루틴 사이의 값 전달은 채널로 이뤄진다. 값을 채널에 넣으면 받는 쪽이 나타날 때까지 보내는 쪽이 대기하는 동기 연산이라는 점이 핵심이다. 함수가 내부 고루틴으로 채널을 채우면서 그 채널을 반환하는 패턴은 소유권은 함수가 쥐고 값은 호출자가 받는 구조를 만들어 자주 쓰인다. 모든 데이터를 다 보냈음을 알리려면 close로 채널을 닫고, 읽는 쪽은 comma-ok 형태로 상태를 확인하거나 range로 순회한다. 다만 채널을 닫는 유일한 이유는 '전송 완료 신호'이며, 읽는 쪽이 그 신호를 필요로 하지 않으면 굳이 닫지 않아도 가비지 컬렉터가 자원을 회수한다. 이미 닫은 채널을 다시 닫거나 거기에 쓰면 패닉이 발생하므로 방향 지정 채널로 실수를 예방하는 습관이 권장된다.

흐름 제어와 시간 다루기

select 문은 채널 전용 switch처럼 동작하며 파이프라인의 데이터 흐름을 조율한다. 파이프라인은 각 단계의 입력과 출력이 채널인 연산의 연쇄로, 완료를 알리는 done 채널이나 조기 종료를 위한 cancel 채널을 함께 쓴다. time 패키지도 동시성 프로그램의 시간 제어에 유용하다. time.After는 지정 시간 뒤 값을 받는 채널을 돌려줘 타임아웃 구현에 쓰이고, Timer와 Ticker는 각각 일회성 예약과 주기적 실행을 담당한다. 특히 반복문 안에서는 매번 새 타이머를 만들기보다 하나를 만들어 Reset하는 편이 낫고, Ticker는 반드시 Stop으로 정리해야 하며 읽는 쪽이 느리면 틱을 건너뛴다는 특성도 기억할 만하다.

작업 취소의 표준 수단은 context다. 함수는 컨텍스트를 인자로 받아 Done 채널로 취소 신호를 듣고, 수동 취소는 context.Canceled, 타임아웃과 데드라인은 context.DeadlineExceeded 오류를 낸다. 컨텍스트는 불변이며 계층적이어서 자식은 부모의 타임아웃을 줄일 수는 있어도 늘릴 수는 없다. 취소를 여러 번 호출해도 안전하고, WithCancelCause 등으로 취소 원인을 지정하거나 AfterFunc로 취소 시 실행할 함수를 등록할 수도 있다. 반면 WithValue로 값을 넘기는 기능은 존재하되, 명시적 인자나 구조체를 쓰는 편이 낫다고 저자는 분명히 선을 긋는다.

데이터 경쟁과 경쟁 상태의 구분

실무에서 가장 혼동되는 대목은 데이터 경쟁(data race)과 경쟁 상태(race condition)의 차이다. 데이터 경쟁은 여러 고루틴이 공유 데이터에 접근하며 최소 하나가 이를 수정할 때 생기고, 반드시 런타임 패닉으로 드러나지는 않는다. 그래서 race 플래그로 켜는 레이스 디텍터가 필요하다. 그러나 이 도구는 개별 연산이 동시성 안전하면 문제를 잡지 못하므로, 연산 순서의 불확실성에서 비롯되는 경쟁 상태는 탐지하지 못한다. 동시성 환경의 불확실성 자체는 없앨 수 없지만, 복합 연산을 뮤텍스로 보호하거나 아토믹 compare-and-set을 적용해 경쟁 상태는 예방할 수 있다. 이 구분을 흐릿하게 이해하면 도구가 통과시킨 코드를 안전하다고 오판하기 쉽다.

보호 수단의 선택지도 폭넓다. sync.Mutex는 Lock과 Unlock 사이 코드를 한 번에 한 고루틴만 실행하도록 보장하며, 블로킹 없이 시도만 하는 TryLock도 있다. 읽기와 쓰기를 구분하는 RWMutex는 '한 명의 쓰기, 다수의 읽기' 구조를 만든다. 두 타입 모두 sync.Locker 인터페이스를 구현하므로, 컴포넌트를 특정 잠금 구현에 묶지 않고 클라이언트가 잠금 방식을 고르게 할 수 있다. 뮤텍스 대신 채널로 데이터를 보호하는 접근, 버퍼 채널로 만드는 단순 세마포어, 더 복잡한 상황을 위한 golang.org/x/sync/semaphore 패키지도 함께 소개된다.

신호 전달과 한계

고루틴 간 협력 패턴으로는 두 고루틴이 서로를 기다리는 랑데부와 이를 N개로 일반화한 배리어가 있으며, 둘 다 WaitGroup으로 간단히 구현된다. 조건 변수 sync.Cond는 준비 상태를 알리는 Signal과 대기하는 Wait를 제공하고, 모든 대기자를 깨우는 Broadcast도 있다. 다만 조건 변수는 데이터가 아니라 신호만 보내며 일회성이라는 제약이 있어, 이런 한계가 없는 발행/구독 구조가 필요하면 채널이 더 낫다. 일회성 초기화나 정리에는 sync.Once가 적합하며, 여러 고루틴이 동시에 Do를 호출해도 함수는 한 번만 실행되고 나머지는 반환을 기다린다.

이 미니북의 가치는 개념을 나열하는 데 그치지 않고, 언제 무엇을 쓰지 말아야 하는지를 함께 짚는다는 점에 있다. 채널을 굳이 닫지 않아도 되는 경우, 컨텍스트로 값을 넘기지 말라는 권고, 레이스 디텍터가 잡지 못하는 사각지대 같은 대목은 문법을 아는 것과 안전한 동시성 코드를 짜는 것 사이의 간극을 보여준다. 다만 어디까지나 이미 기초를 아는 사람을 위한 복습용 자료이므로, 각 패턴을 처음 익히거나 실전 연습이 필요한 개발자라면 저자가 언급한 별도의 심화 자료 'Gist of Go: Concurrency'를 함께 참고하는 편이 현실적이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://antonz.org/go-concurrency-distilled/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...