Go 동시성 패턴 실무에 적용하기

Go 언어의 동시성 패턴을 실제 사내 API에 적용한 사례를 공유합니다

by Husky

May 10, 2021 | 4 min read

golang

기존 코드

새로 입사한 회사 레이지 소사이어티에서 백엔드 재설계를 담당하고 있는 중이다. 더 빠르고 안정적인 설계를 위해 Go 언어를 채택했는데, 아직까지 Go 언어의 진짜 장점인 동시성을 적용해 본 사례가 없어서 아쉬워하고 있었다.

그런데 좋은 기회가 생겼다. 쿠폰을 데이터베이스에 대량 생산하는 메서드에서 쿠폰 객체를 생성하는 부분을 고루틴으로 처리하면 성능이 향상될 거라고 생각된 것이다.

기존 코드는 다음과 같다.

먼저 CouponPaperInput 배열을 선언한 뒤, quantity 숫자만큼 CouponPaperInput 객체를 생성하여 배열 안에 집어넣는 코드이다.

quantity가 10개 정도라면 순차적으로 실행하는 것도 별 문제가 되지 않겠지만, 그 양이 500개에서 1000개가 넘어가게 되면 순차적으로 실행하는 것이 부담이 될 수도 있는 코드가 될 것이다.

따라서 이 부분을 고루틴을 이용해 처리하면 해당 코드를 동시적으로 처리할 수 있게 될 것이다.

고루틴이란?

고루틴이란 Go 언어에서 동시성 코드를 작성하기 위해 사용하는 기법으로, 어떤 함수든 그 앞에 go 키워드를 붙이기만 하면 실행시킬 수 있다. 고루틴은 현재 수행 흐름과 무관한 새로운 실행 흐름 속에서 수행되는 경량 쓰레드이다.

동시적으로 코드를 처리한다는 것은 병렬적으로 코드를 처리한다는 것과는 약간 다른 개념이다. 병렬적 처리는 완전히 다른 두 물리적 실행 주체가 두 가지 작업을 실행한다는 뜻이지만 동시적 처리는 하나의 물리적 실행 주체가 두 가지 작업을 논리적으로 나누어 실행한다는 것이라고 볼 수 있다. 커피를 마시면서 타자를 친다거나 신문을 보면서 대화를 나누는 것처럼 말이다. 컴퓨터의 시점에서 설명하자면 하나의 코어가 여러 작업을 동시에 수행하는 것을 동시적 실행이라고 볼 수 있는데, 이게 가능하기 때문에 지금 우리가 컴퓨터를 이용해서 여러 작업을 동시에 처리할 수 있게 되는 것이다. 인터넷을 켜 놓으면서 동시에 노래를 틀고, 또 그와 동시에 워드프로세서를 실행하는 것과 같은 것이다.

Go 언에에서는 이런 동시적 실행을 아주 쉽게 구현할 수 있도록 언어적으로 준비가 되어 있다.

// 순차적 실행
someCalc(x, y)

// 동시적 실행
go someCalc(x, y)

이렇게 go 키워드를 붙이는 것만으로 해당 함수는 현재 실행흐름에서 벗어나 동시적으로 실행되게 된다. 그런데 그러자면 현재 실행 흐름과 어떻게 상태나 메모리를 공유할 수 있을까?

go 언어의 철학 중에 유명한 격언이 하나 있다. “Don’t communicate by sharing memory, share memory by communicating.” 한국어로 풀이하면 “메모리를 공유함으로써 커뮤니케이션하지 말고, 커뮤니케이션을 통해 메모리를 공유하라.” 라는 뜻이다. 이는 동시성 프로그래밍에서 흔히 벌어질 수 있는 경쟁 조건을 예방하기 위한 go 의 처리 방식을 표현하는 문구이다.

메모리 0x00에 여러 개의 쓰레드가 동시에 접근해 값을 증가시킨다면 어떤 일이 벌어질까? 쓰레드 1은 쓰레드 2가 증가시킨 값을 확인하지 못한 채 원본 메모리의 값을 증가시키는 행위를 반복할 것이다. 그 사이에 쓰레드 2가 자신이 증가시킨 값을 다시 메모리에 덮어씌우면 쓰레드 1이 했던 작업은 지워져버리고 말 것이다. 그래서 우리가 원하는 만큼 값이 증가되지 않는 일이 벌어질 것이다.

a라는 변수를 순차적으로 증가시키는 코드가 있다. 여기에 go 키워드를 붙여 동시성 처리를 하면 똑같이 100으로 증가할까?

이렇게 되면 100개의 고루틴이 동시에 생성되면서 a 값을 아무렇게나 증가시키게 된다.

정확하게 100을 증가시키기 위해선 여러 동기화 방법이 있는데, 여기서는 mutex라는 방법을 사용하도록 하겠다.

mutex를 사용하면 그 순간의 실행흐름을 “잠금”할 수 있어 다른 고루틴이 작업이 실행 중인 메모리에 동시에 접근하는 것을 막을 수 있다.

실무 코드 업그레이드 하기

실무 코드에서는 “채널”이라는 방법을 사용할 것이다. 채널은 고루틴이 메모리를 공유하는 용도로 만들어진 일종의 큐이다. 따라서 고루틴이 각기 다른 실행흐름 가운데 값을 만들어낸다 하여도 채널을 통해 밀어넣은 값은 동기화가 보장된다.

고루틴의 다른 실행흐름 가운데 생성된 쿠폰 페이퍼 객체는 채널 큐에 담기게 되고, 고루틴 밖에서는 그 채널을 순회하면서 날아들어온 객체를 배열에 합치면 된다.

성능 차이

순차적 코드와 동시성 코드로 동시에 2000개의 쿠폰을 생성하는 테스트를 실행했을 때, 두 코드 모두 거의 평균 200ms 가량의 시간이 소모되었고 눈에 띄는 편차는 발견되지 않았다.

아무래도 단순 배열 생성 코드이기 때문에 그렇지 않나 싶다. 앞으로도 더 다양한 코드에서 동시성 패턴을 적용해봐야 할 것 같다…