Issue
서비스에 00시를 기준으로 통계를 위한 데이터를 업데이트 하는 배치를 Amazon EventBridge 를 통해 동작시키고 있는 중이었습니다. 약 일주일간은 전혀 문제없이 동작하고 있었습니다. 그런데, 어느날 통계가 제대로 되지 않는 것 같다는 이슈가 보고되었고, 저는 이를 살펴보기 시작했습니다.
실제로 살펴보니 약 3일간 통계가 제대로 계산되지 않고 있었습니다. 문제는, 배치 로그에는 로직이 정상적으로 실행되었다고 남아있다는 점입니다. 골치 아파질 것을 예감하면서, 원인을 파악하기 시작했습니다.
Problem
원인을 파악하는데 예상보다 꽤 시간이 걸렸습니다. 로직에는 수정사항이 전혀 없었는데 일주일 정도는 문제없이 통계 데이터를 계산했고, 그 후 3일간은 지속적으로 통계가 제대로 계산하고 있지 않던 것이었습니다.
문제는 이 업데이트 배치 한가지에 존재하는 것이 아닐 수 있겠다는 것을 의심하기 시작했습니다. 왜냐하면, 여기에 같은 테이블을 업데이트하는 배치가 하나 더 존재하기 때문입니다.
이해를 돕기 위해 2가지 배치가 하는일을 조금 더 자세하게 설명 드리겠습니다.
먼저, 저희 서비스에는 어떠한 그룹에 속해있는 유저들의 데이터 통계를 대시보드 형태로 보여주는 화면이 존재합니다. 이 화면에 보여주는정보는 크게 2가지로 분류되는데, 하나는 현재까지 업데이트 된 통계정보이고, 나머지 하나는 전일대비 값의 증감을 나타내는 정보입니다.
위와 같은 이미지를 생각하면 됩니다. 이 정보를 표현하기 위해, 각 주식에 현재 업데이트 되고 있는 통계 데이터와, 어제 마지막으로 업데이트 된 통계 데이터 2가지가 필요합니다.
이를 저는 2가지 배치를 활용하여 구현하였습니다. 먼저 한가지는 새벽 00시에만 돌아가는 새로운 주식 통계를 만드는 배치, 그리고 나머지 한가지는 5분 단위로 유저 데이터를 돌며 현재 통계를 위한 데이터를 만드는 배치입니다.
정상대로 동작한다면, 새로운 통계를 만드는 배치가 새롭게 등록되며, 전일 데이터는 더이상 업데이트 되지 않아야 합니다. 문제는, 새로운 통계를 만드는 배치가 동작했지만, 여전히 전일 데이터가 업데이트 되고 있는 상황입니다.
race condition 이 발생하는 상황일 것이라는 직감이 들기 시작했습니다. 이를 확신하게 된 것은, 로그상 에러가 발생한 3일간만 00시에만 돌아가는 배치가 완전히 끝나기 전에 5분 단위 업데이트 배치가 돌아가기 시작했다는 점입니다. 그 전 일주일간은 운이 좋게도 배치가 겹치지 않게 돌고 있었습니다.
처음에 같은 테이블을 업데이트 하는 배치를 2개 설계할 때, race condition 을 고려하지 않았던 것은 아니었습니다. 다만, 두가지 배치 모두 각자의 트랜잭션으로 동작하기 때문에 몇백개만 다르게 업데이트 되는 상황은 일어나지 않을 것이라고 생각하고 넘어 갔었습니다. 그러나 문제는 첫번째 배치의 트랜잭션이 커밋을 하기 전에 두번째 배치 트랜잭션에서 데이터를 모두 읽어가고, 이후 첫번째 배치가 커밋을 한 결과에 두번째 배치가 값을 덮어쓰면서 발생하는 것이었습니다.
위 이미지와 같은 상황입니다. 아주 흔한 동시성 문제이고, 이를 고려하지 못했던 것입니다. 반성합니다. 문제는 파악했으니 이제 이를 어떤식으로 해결해 볼지 고민해 보았습니다.
Solution
동시성 문제에 해법은 다양하게 존재합니다. Database Lock 을 활용할 수도 있고, Redis 등 Distributed Lock 을 활용할 수도 있습니다. 아니면, 아예 Lock 을 사용하지 않고서도 Application 레벨, 로직 구현 으로도 이를 해결할 수 있습니다.
저는 그 중에 Application 레벨에서 비지니스 로직을 통해 이를 방지하는 방법을 선택하였습니다. 왜냐하면, 단순히 한 테이블에 대하여 하루에 한번 발생할 수 있는 문제를 해결하기 위해 Redis 를 활용하는 것은 과해 보였고, Database Lock 을 활용하기에도, 테이블 전체 Lock 을 잡아야하는 상황이 부담스러웠습니다. 비관락을 사용한다면, 해당 시점에 일반적인 API 요청도 데이터를 가져올 수 없기 때문에 지양하는 것이 좋고, 낙관 Lock 을 사용한다면 보다 낫겠지만, 결국 배치가 겹쳐 실패했을 경우 한번 더 테이블을 full-scan 해야 한다는 점이 좋아보이지 않았습니다.
따라서, 제가 해결한 방법은 다음과 같습니다. 먼저, 기존에 배치의 진행상태, 배치 이름 등의 정보를 기록하던 로그 테이블에 배치 그룹 칼럼을 추가하였습니다. 그리고, 문제가 발생하던 위 2가지 배치에 같은 그룹명을 넣어준뒤, 모든 배치가 실행되기전 같은 그룹에 배치가 실행중이라면 기다리도록 로직을 수정하였습니다.
더이상 같은 문제가 발생하지 않는 것을 확인하였습니다.