tech

에러를 리턴하는 goroutine 테스트 코드 쉽게 제어하기

‘고루틴(goroutine)’은 ‘Go 언어’에서 제공하는 동시성 제어 패러다임으로, 간단하고 효율적으로 구현할 수 있는 강력한 도구이다. goroutine은 Go 언어 내부에서 go 키워드를 활용하여 런타임시 함수를 비동기적으로 쉽게 실행할 수 있게 한다.

goroutine의 특징
가벼움: 매우 작은 메모리 스택을 가지고 시작하며, 필요에 따라 동적으로 확장
동시성: goroutine을 사용하여 쉽게 동시에 실행되는 작업 관리
채널(Channels): 통신은 채널을 통해 이루어지며, 안전하고 간단한 데이터 교환 가능
자동 스케줄링: 여러 스레드에 자동으로 스케줄링해 개발자의 스레드 관리 불필요
goroutine을 활용하는 방법
동시성 처리: 대규모 데이터 처리, 웹 크롤링, 네트워크 요청 등 동시 처리
병렬 처리: 멀티 코어 CPU를 활용한 프로세스 성능 최적화
비동기 작업: I/O, 데이터베이스 쿼리 등 오래 걸리는 작업의 비동기 작업

goroutine 덕분에 멀티스레드 환경에서 복잡한 비동기 작업을 간단하게 처리할 수 있지만, 이 과정에서 발생하는 에러를 제대로 처리하지 않으면 예상치 못한 행동이나 시스템의 불안정을 초래할 수 있다. 따라서 운영 환경의 안전성을 강화하기 위해 goroutine을 실행하고 이를 검증하는 테스트 코드를 작성해야 한다.

goroutine 내부에서 에러가 발생하는 경우, 이를 테스트하는 것은 생각보다 까다롭다. 내부 에러 처리가 어려운 이유는 여러 가지가 있지만, 이 기사에서는 ‘goroutine 내부 함수의 에러 타이밍에 대한 불확실성’이라는 주제를 중점적으로 다루고 해결하고자 한다. 특히, 다음의 예시에서 이러한 문제를 주로 발견할 수 있다.

예시는 goroutine 내부에서 실행되는 함수의 에러 처리에 대한 문제점을 보여준다. 코드는 간단하지만, goroutine의 실행 타이밍과 에러 발생 타이밍이 결정적이지 않기 때문에 문제가 발생할 수 있다. 문제의 종류는 다음과 같다.

  • 경쟁 상태: testRunner.Run()injectError(testRunner)가 동시에 testRunner에 접근하기 때문에 경쟁 상태가 발생하면 두 작업이 동일한 자원에 접근하게 되어 데이터 무결성을 보장할 수 없다.
  • 비결정적 에러 타이밍: testRunner.Run()내부에서 에러가 발생하는 시점과 injectError(testRunner)가 실행되는 시점은 go 내부 런타임 스케줄링에 따라 명확히 결정되지 않는다. 따라서 결과의 일관성을 보장할 수 없다.
  • 에러 및 종료 대기: injectError(testRunner)이후에서 testRunner.Run()으로 발생한 에러와 올바르게 goroutine으로 실행한 컨텍스트가 종료됐는지 감지하지 않으면 goroutine 내부에서 에러가 발생했는지 확인할 수 없다.

이 문제를 해결하기 위해 테스트 대상인 testRunner와 위 테스트 코드를 각각 수정해야 한다.

1. 테스트 대상

위의 코드에서 testRunnergoroutine 내부에서 testRunner.Run()가 비동기로 실행되고 에러가 주입된다. 이때 발생할 수 있는 문제는 에러가 주입되는 시점이 언제인지 알 수 없는 것이다. 만약 테스트 시 testRunner.Run()이 무조건 호출된 이후 injectError(testRunner)가 호출되어야 한다면, 위에서 작성한 테스트 코드로 이 상황을 확실하게 보장할 수 없다. 에러 주입뿐만 아니라 testRunner.Run()호출 이후에 어떠한 로직 실행을 테스트하는 경우도 마찬가지다. 로직 호출이 testRunner.Run()이후에 일어나야 한다면, goroutine으로 실행되기 때문에 동작의 성공을 보장할 수 없다.

물론 이렇게 time.Sleep과 같은 방법을 활용할 수 있다.

그러나 이렇게 time.Sleep을 활용하면 테스트 시간이 길어지고 어느 정도 대기해야 하는지 확실하지 않다.

따라서 해결 방법은 동작을 실행하기 위한 준비 단계를 goroutine을 호출하기 전에 수행하는 것이다. 생성자에 준비 단계를 포함시키거나, 준비 단계를 나타내는 메서드를 직접 호출해야 한다. 이러한 방식으로 구현하면 경쟁 상태 문제를 효과적으로 해결할 수 있다.

예를 들어, TCP 서버의 경우 listen과정까지 goroutine 생성 전에 호출하여 서버를 대기하고 accept 이후의 로직을 goroutine으로 실행하는 testRunner.Run()에 구현한다. goroutine 생성 다음에 클라이언트의 요청 로직을 실행하거나 서버에 에러를 주입하는 로직을 수행한다. 즉 다음 로직처럼 구현한다.

TCP 서버 내부에는 ‘backlog queue’라 불리는 클라이언트의 3-way handshake가 완료된 소켓이 대기하게 된다. 이로 인해 서버에서 발생하는 이벤트는 잠시 backlog queue에 대기하게 되고, testRunner.Run()이 실행되는 시점에 처리된다. 여기서 가장 핵심적인 부분은 바로 backlog queue이다. backlog queue 덕분에 goroutine이 즉시 실행되지 않더라도 로직이 진행되는 시점에 queue를 활용하여 임시 버퍼 역할을 하므로 문제가 발생하지 않는다.

queue를 중심으로 살펴보면, 비동기로 동작하는 테스트를 작성할 때 테스트가 다음과 같이 분리되어야 함을 알 수 있다.

  1. queue 준비
  2. queue의 구독자를 goroutine으로 실행
  3. queue 발행자를 주 컨텍스트에서 실행

이 예시를 보다 일반화하여 코드 형식을 다음과 같이 구성할 수 있다.

2. 테스트 코드

위에서 테스트 대상을 어떻게 구현해야 테스트 코드를 작성하기 편한지 알아봤다. 이번에는 테스트 코드를 직접 수정하여 goroutine이 포함된 테스트를 수행하기 쉽게 변경하는 방법에 대해 알아보겠다.

goroutine이 사용되는 테스트 코드에서 에러를 처리하는 간단한 방법은 sync.WaitGroup을 이용하는 것이다. 위의 예시 코드를 sync.WaitGroup을 활용하면 다음과 같이 변경할 수 있다.

위의 테스트 코드에서 sync.WaitGroup을 이용하여 비결정적 에러 타이밍을 결정적으로 변경할 수 있다. 또한 goroutine 종료 시점에 에러를 캡처하여 wg.Wait() 부분에서 goroutine의 결과를 대기하도록 할 수 있다. 주요하게 추가된 부분은 다음 두 가지이다.

  1. goroutine 내부에서 발생하는 에러를 goroutine 외부로 전파할 수 있어야 한다.
  2. goroutine이 종료되었을 때, goroutine 외부에서 종료를 감지할 수 있어야 한다.

예시 코드는 sync.WaitGroupvar err error를 goroutine 실행 전에 선언하여 이러한 문제를 해결했다.

그러나 이렇게 에러를 전파하는 경우, goroutine으로 실행되는 대상이 많을 때 관리하기가 어렵다. 이는 goroutine 외부에서 err를 다중으로 선언하여 각 goroutine마다 에러를 캡처해야 하기 때문이다. 따라서 sync.WaitGroup처럼 goroutine 대기를 제어하기 쉽고 에러를 전파하기 편리한 구조를 사용하는 것이 필요하다. 이를 위해 다음과 같은 구조체를 선언하여 문제를 해결할 수 있다.

구현한 구조체를 이용하여 다음과 같이 테스트 코드를 수정한다.

이렇게 Waiter 구조체를 이용하여 sync.WaitGroup과 유사한 방법으로 goroutine 내부에서 에러를 반환하는 함수에 대한 테스트를 작성했다.

Waiter 구현에서 특이한 부분은 SendError 메서드이다.

이 메서드에서는 내부에 if err != nil로 에러 존재 여부를 체크하고, 에러가 존재할 경우에만 채널에 전달한다. 이로 인해 정상 상황에서 goroutine 내부 컨텍스트가 종료되는 경우에는 defer waiter.Done()을 활용해야 한다. nil 체크를 하지 않아도 되지만, err가 명확히 존재할 때만 전달한다면 다음과 같이 테스트의 상태를 분리할 수 있다.

  1. 테스트가 종료되지 않고 무한 대기하는 경우: 이 부분에서 의도하지 않은 goroutine이 에러를 리턴하지 않고 종료된 경우, 즉 동작하길 바라는 goroutine이 의도하지 않게 종료된 경우
  2. 테스트가 종료됐는데 에러가 발생한 경우: 의도하지 않은 에러가 테스트 코드에서 검출된 경우
  3. 정상 종료: 모든 goroutine이 정상적으로 종료된 상태

여러 goroutine을 사용하는 테스트의 경우, 의도하지 않은 정상 종료가 발생한다면 이를 테스트 코드가 진행되지 않고 대기하여 쉽게 검증할 수 있다.


goroutine 내부에서 발생하는 에러를 효율적으로 테스트하고 관리하기 위해서는 에러 전파와 goroutine 동기화를 명확히 처리할 수 있는 구조가 필요하다. goroutine으로 동작하는 대상의 구현 방향과 sync.WaitGroup, 그리고 직접 구현한 Waiter를 활용하여 에러 처리를 간단하게 관리할 수 있다. 지금까지 구현한 방법을 통해 다음의 세 가지 문제를 해결했다.

  1. 경쟁 상태 방지: 동작 준비 단계를 goroutine 실행 이전에 완료하여 경쟁 상태를 방지한다. 자원의 접근 순서를 명확히 하여 테스트 결과의 일관성을 보장한다.
  2. 비결정성 해결: goroutine의 실행 및 에러 발생 시점이 비결정적인 경우에도 sync.WaitGroupWaiter를 통해 동작을 순차적으로 제어할 수 있도록 한다.
  3. 에러 및 종료 대기 구현: 에러를 캡처하거나 채널 기반으로 전달하여 올바르게 컨텍스트가 종료된 후 에러 유무를 검증한다.

이와 같이 goroutine을 활용한 비동기 테스트에서 신뢰할 수 있는 테스트 코드를 구현하는 방법을 살펴보았다. 위에서 소개한 방법을 통해 goroutine을 활용하는 시스템의 안정성과 유지보수성을 높일 수 있기를 바란다.

김영준 기자

이 기사가 비동기 테스트를 작성하는 분들에게 조금 더 수월하게 도움을 줄 수 있기를 바랍니다.


TOP