What is race condition
- Race condition 또는 경쟁상태라고 불립니다. 본문에서는 경쟁상태라고 부르겠습니다.
- 결승지점에 도착하는 순서에 따라 등수가 다르게 매겨지는 것처럼 병행하게 진행 중인 프로그램들이 특정 지점에 도착하는 순서에 따라서 다르게 작동하는 것을 말합니다.
- 주로 비동기적 시스템(e.g. multi-process, multi-threaded, microservices)에서 프로그램이 병행하게 진행될 때 흔하게 나타나는 버그입니다.
해당 글에서는 아래의 자판기 시나리오를 통해서 설명하도록 하겠습니다.
해당 시나리오가 병행하게 진행될 때 어떤 경우가 가능한지 확인해 보겠습니다.
- 모든 음료는 천 원이며 현재 갖고 있는 돈은 천 원이라고 가정합니다.
- 음료가 반환되기 전에 버튼을 한번 더 눌렀다고 가정합니다.
- A와 B는 서로 다른 프로그램이며 경쟁상태에 빠져있습니다.
두 시나리오 모두 정상적으로 한 개의 음료만 반환되었습니다.
특징으로는 경쟁 중인 프로그램이 현재 금액을 불러오기 전에 금액 차감이 이루어졌다는 점입니다.
음료가 2번 반환되는 돈 복사 버그가 발생했습니다.
앞선 경우와 다르게 금액 차감 이전에 두 프로그램 모두 현재 금액을 확인했습니다.
금액 차감 이전에 현재 금액을 확인하는 것이 왜 버그를 유발할까?
자판기 시나리오는 GET→Validation→UPDATE 과정을 통해 음료를 반환합니다.
GET
: 현재 금액을 확인한다.Validation
: 현재 금액과 음료 가격을 비교해서 음료를 반환할 수 있는지 확인한다.UPDATE
: 현재 금액에서 음료 가격을 차감한다.
이때 GET을 통해서 확인한 현재 금액이 항상 유효하지 않기 때문에 버그가 발생합니다.
A B 모두 현재 금액이 천 원인 것을 확인하고 금액을 차감하게 된다면 음료는 2개가 반환되고 현재 금액은 마이너스가 됩니다. 따라서 해당 시나리오에서 버그가 발생하는 특정 지점이 바로 GET을 하는 순간입니다.
자판기와 같이 Validation
를 만족했을 때만 UPDATE
가 이루어지는 로직이 존재할 때 해당 시나리오가 병행하게 진행될 수 있다면 Validation에 사용되는 정보들이 유효한지 항상 고민해야 합니다.
즉 다른 곳에서 업데이트될 수 있는 정보는 항상 유효하지 않다는 것을 알고 설계하시는 것을 추천드립니다.
Why hard to deal with
디버깅이 어렵다
경쟁상태
로 인한 버그는 정상적인 디버깅에서 발견하기 어렵습니다.
앞선 예시들이 극단적인 상황이었을 뿐 실제로 자판기의 실행 속도보다 더 빠르게 버튼을 누르는 것은 어렵습니다.
서버 및 네트워크의 상태에 의존한다
코드에서 경쟁상태
에 빠지지 않더라고 네트워크 속도에 의해 경쟁상태
에 빠질 수 있으며 이때 정보가 보낸 순서와 다르게 도착할 수 있습니다.
의도한 바는 현재 금액으로 1000원→0원으로 업데이트하는 것이지만 네트워크 딜레이로 인해서 0원으로 업데이트하는 요청이 먼저 올 수 있습니다.
그럴 경우 여전히 현재 금액이 1000원으로 남는 돈 복사 버그가 발생합니다.
E2E 테스트가 필요하다
Unit테스트로는 시나리오 전체를 확인할 수 없기 때문에 E2E 테스트가 필요합니다.
이때 의도적으로 과부하 상태를 만들어 여러개의 프로그램이 병행하게 돌아갈 수 있도록 해야합니다.
과부하 상태라는 것은 일반적인 상황이 아니라 의도적으로 훨씬 많은 요청을 보내거나 병목현상을 연출하는 등의 상황을 뜻합니다.
또한 매번 버그가 발생하지 않기 때문에 확신이 있을 때까지 배포 이전에 많은 테스트를 해보셔야 합니다.
버그가 발생하더라도 전체 시나리오중 어느 곳에서 버그가 발생했는지 알기 어려워 전체 코드를 살펴봐야 하는 일이 발생하기도 합니다.
수정이 어렵다
경쟁상태
는 설계의 문제입니다. 추후 경쟁상태
에 관한 버그를 수정할 때 설계에 대한 이해 없이 버그만 수정할 경우 다른 곳에서 또 다른 버그가 발견될 확률이 매우 높습니다.
따라서 병행하게 실행됐을 때 어떤 경쟁상태
에 빠질 수 있는지 많은 고민이 필요하며 UPDAT
E 되는 정보가 다른 곳에서 병행하게 UPDATE
될 수 있는지 설계단에서 잡아주도록 고민해야 합니다.
How to deal with
아래 3가지 방법들을 통해 경쟁상태
를 막을 수 있습니다.
Session Block
- 서비스에 연결된 client를 한 명으로 제한합니다
- CRUD에 참여하는 건 한 명이기 때문에
경쟁상태
에 빠지지 않습니다
Session Block의 경우 microservice에서는 사용하기 어렵기 때문에 추가 언급하지 않습니다.
Lock
- 모든 프로세스가 끝날 때까지 자원에 대한 접근을 막습니다.
- 해당 자원이 사용 중이라면 기다릴 수도 있고 에러를 반환할 수도 있습니다.
- 종류로는
비관적 잠금(Pessimistic Lock)
과낙관적 잠금(Optimistic Lock)
이 있습니다. - 비관적 잠금은 잠금 이후 들어오는 요청에 에러를 발생시킵니다.
- 낙관적 잠금은 잠금 이후에도 접근할 수 있지만 잠금이 해제될 때 해당 자원이 다른 곳에서 변경되었는지 파악이 가능합니다.
Isolation
- 명령어들을 Atomic 하게 처리하는 것을 의미합니다.
- Atomic 하다는 것은 서비스의 Queue에 명령어를 한 번에 보내는 것이며 중간에 에러가 발생할 경우 전체 명령어를 Rollback 합니다.
대부분의 경우 서비스에서 제공하는 Transaction기능에 Lock + Isolation 기능이 포함되어 있습니다.
사실 잠금 기능만으로도 경쟁상태
문제를 해결할 수 있습니다. 잠금 기능 이용 시 자원을 선점하여 이후에 접근하는 요청들에 대해서 대기 또는 취소의 액션을 취할 수 있기 때문입니다.
자판기 예시에서는 현재 금액에 대해서 A가 선점하게 되면 B는 현재 금액을 불러올 수 없게 됩니다.
즉 UPDATE까지 이루어진 이후에 잠금을 풀면 경쟁상태
가 생기지 않습니다.
하지만 아래와 같은 경우 반드시 Isolation기능을 포함시켜야 합니다.
- 로직이 복잡해져 명령어가 매우 많아졌을 때
- 외부 서비스를 이용하게 됐을 때 (e.g. Redis, Kafka 등등)
- 오토스켈일링 등의 사유로 서버가 자주 꺼지고 켜질 때
순차적으로 이루어지길 원하는 명령어들은 Atomic(원자적)하게 이루어져야 합니다. 그래야 다른 곳(경쟁 중인 곳)에서 실행된 명령어가 중간에 끼어들지 못합니다.
또한 명령어 중간에 서버가 끊어졌을 때 Rollback 기능을 통해 중간 까지만 실행되는 것을 막을 수 있습니다. 자판기 예시로는 현재 금액만 업데이트되고 음료수 수량은 업데이트되지 않는 경우를 막을 수 있습니다.
Postgresql transactoin vs Redis transcation
두 서비스 모두 Transaction기능에 잠금 기능이 포함되어 있습니다.
Postgresql
의 경우 비관적 잠금이기 때문에 Transaction 안에서 GET을 통한 정보 읽기가 가능합니다. 다른 곳에서 자원에 접근할 수 없기 때문에 정보가 유효하다는 것을 보장하기 때문입니다.
하지만 Redis
의 경우 낙관적 잠금이 이루어집니다. 낙관적 잠금은 우선 자원에 대한 접근을 열어두고 잠금이 시작될 때와 잠금이 해제될 때를 비교하여 다른 곳에서 변경되었다면 모든 명령어를 취소시킵니다.
낙관적 잠금은 잠금 안에서 자원이 변경될 수 있기 때문에 잠금 중에 GET을 통한 정보 읽기가 불가능합니다. 개인적으로 Redis의 낙관적 잠금으로는 경쟁상태를 막기 어렵다고 판단하여 aioredlock
을 통해서 비관적 잠금을 사용했습니다.
Conclusion
- 경쟁상태를 막는 최고의 방법은 설계를 잘하는 것입니다.
- 업데이트하기 위해 사용하는 정보가 다른 곳에서 업데이트된다면 항상 경쟁상태를 의심해야 합니다.
- 경쟁상태를 막기 위해서 GET->Validation->UPDATE 과정이 동시에 일어날 수 없게 해야 합니다.
- 경쟁상태를 막기 위한 방법은 Session-Block, Lock, Isolation 이 존재합니다.
- 한 가지 방법으로는 충분하지 않아 여러 개를 함께 써야 하는 경우도 존재합니다.