엘릭서에서 캐스트 테스트 요청 시 레이스 컨디션 방지하기

이 글은 독자가 GenServer.cast/2GenServer.call/2 함수가 어떻게 동작하는지 알고 있다는 것을 전제로 작성되었습니다.

TL;DR

GenServer.cast/2 함수를 호출하고 나서 GenServer.call/2 함수를 호출하세요. 그러면 cast/2call/2 양 함수를 호출한 프로세스는 call/2 함수에 대한 응답을 받을 때까지 기다린 뒤에야 call/2 호출 뒤에 이어지는 코드를 실행합니다. 또한 cast/2 함수의 메시지를 받은 프로세스도 해당 메시지를 처리한 뒤에야 call/2로부터 받은 메시지에 대한 응답을 보냅니다. 달리 말해 코드의 순차적 실행을 보장할 수 있습니다. 해당 용도로 사용하기 편리한 범용 함수로 :sys.get_state/1가 있습니다.

첫 해결법

cast/2 함수에 대한 간단한 테스트를 작성하다가 한 가지 문제를 발견했습니다. 함수를 호출하고 예상되는 결과값에 대한 어설션을 확인하는 테스트인데, 전형적인 레이스 컨디션 문제 때문에 테스트가 실패하는 것을 발견했습니다. 분석해보니 cast/2 함수로부터 메시지를 받은 프로세스가 메시지를 처리하기도 전에 테스트에서 어설션을 확인하는 것이 문제였습니다.

이를 해결하기 위해서는 cast/2 메시지가 처리한 뒤에야 어설션을 확인하도록 해야 했습니다. 가장 단순한 해법은 cast/2 함수와 ExUnit.Assertions.assert/1 함수 사이에 Process.sleep/1을 호출해서 지정된 시간 동안 프로세스가 기다리도록 하는 것입니다. 이런 방식으로도 테스트를 통과할 수는 있지만 그래도 이보다는 더 나은 코드를 작성하고 싶었습니다.

좀 더 나은 해결법

다음으로 찾은 해법은 cast/2assert/1 사이에 call/2 함수를 호출하는 것으로, BEAM 프로세스와 프로세스 메일박스, 그리고 call/2 함수가 작동하는 방식을 활용하는 방법입니다.

프로세스 메일박스는 다른 프로세스가 보낸 메시지를 동시다발적으로 수신하지만, 수신한 메시지를 처리하는 것은 순차적으로 합니다. 즉 메일박스는 일종의 메시지 동기화 기능도 수행합니다. 예를 들어 어떤 프로세스가 메시지 A와 B를 순차적으로 받으면 해당 프로세스는 A를 먼저 처리하고 나서 B를 처리합니다.

call/2 함수를 호출한 프로세스는 call/2 함수의 메시지를 수신한 프로세스가 보낸 응답을 받을 때까지 대기한다는 점도 생각해 봅시다. 즉 call/2를 호출하면 call/2 다음에 이어지는 코드는 call/2에 대한 응답을 받은 뒤에야 실행됩니다. 그리고 메시지를 수신한 프로세스는 cast/2의 메시지를 처리한 뒤에야 call/2의 메시지에 대한 응답을 보냅니다. 이러면 레이스 컨디션을 방지하기 충분할 정도로 코드 실행 순서를 명시해줄 수 있습니다.

토이 프로젝트에서 사용한 예시를 여기에서 볼 수 있습니다. 캐시 용으로 ETS (Erlang Term Storage)를 사용했는데 ETS와 통신하는 함수가 모두 cast/2 형식이라서 이런 해법이 필요했습니다.

레이스 컨디션이 발생하는 것 자체를 피합시다

하지만 더 나은 해법도 있습니다. cast/2 함수와 쌍으로 만들어지는 handle_cast/2 함수를 테스트하면 레이스 컨디션이 발생하는 것 자체를 방지할 수 있습니다. 프로세스끼리 메시지를 통신하는 과정에서 레이스 컨디션이 생겨나는데, handle_cast/2 함수만 테스트하면 이런 통신 과정을 다루지 않아도 되기 때문입니다. 유닛 테스트를 작성할 때는 이 접근법이 가장 좋다고 생각합니다.

물론 그럴 수 없을 때도 있습니다. 인테그레이션 테스트를 작성할 경우 여러 프로세스 간의 상호작용을 테스트해야 하는데, 그럴 때는 프로덕션 환경에서 동작하는 모습을 재현하기 위해 프로세스끼리 동기화를 시켜야할 수도 있습니다. 그러기 위해서는 앞서 말한 용도로 call/2를 쓰는 것도 필요합니다. 반면 콜백 함수에 직접 접근할 수 없는 경우도 있습니다. 사실 대부분의 모듈은 콜백 함수를 노출시키지 않습니다. 그럴 경우 메시지 패싱과 콜백 핸들링을 통째로 테스트하는 것 외에는 다른 방법이 없습니다.

그냥 상념

BEAM 설계 형태를 고려해보면, 각 BEAM 프로세스는 별개의 머신에서 동작하고 있을 가능성도 있습니다. 이는 프로세스 간 메시지를 통신할 때 통신 단절, 무응답, 가용성 같은 일반적인 네트워크 문제에도 대응할 수 있어야 한다는 것을 뜻합니다. 하지만 제가 작성한 테스트는 이러한 점을 하나도 고려하지 않았습니다. 그냥 통신 문제가 없을 것이라 가정하고 작성했는데, 프로덕션 환경에서는 항상 생각지도 못한 문제가 발생하죠.

이래도 괜찮은 것일까요? 이 경우에는 모든 프로세스가 하나의 BEAM 인스턴스 안에서 동작할테니 괜찮을 것 같습니다. 하지만 아마 더 복잡한 분산형 소프트웨어를 작성한다면 네트워크 문제를 다루는 테스트도 작성해야 할지도 모르겠습니다.