장미정원
교내 프로젝트 운영중 발생한 동시성 문제와 IntegerOverFlow 문제 해결기 본문
배경설명
교내에서 진행한 GSM GOGO라는 프로젝트를 운영하는 도중 발생한 이슈를 해결해 나가는 과정에 대한 글입니다. GSM GOGO는 교내 체육대회 관리 서비스이며 체육대회에 관련된 여러 편의기능과 경기에 포인트를 배팅하여 얻거나 잃을 수 있는 서비스입니다.
해당 프로젝트를 운영 중 주요 기능인 "배팅" 기능에서 큰 문제들이 발생했습니다.
문제: 배팅이 두번 되는 현상 발생 (race condition)
2차 릴리즈를 적용한 후 배팅이라는 기능을 출시하게 되었습니다. 해당 기능에 대해 간단하게 소개하자면 체육대회 경기에 승리팀을 예측하여 포인트를 배팅할 수 있는 기능입니다.
배팅은 한 경기에 한 번만 배팅이 가능하며 보유 포인트보다 많은 포인트를 배팅할 수 없습니다.
위의 코드는 실제 배팅 로직을 핵심 부분만 추린 코드입니다. 트랜잭션이 시작된 후 로직을 위에서부터 아래로 따라가보면
- 배팅 포인트가 음수인지 확인합니다.
- 배팅하려는 경기에 이미 배팅하지 않았는지를 확인합니다.
- 보유 포인트 이하로 배팅하였는지 여부를 확인합니다. (맞다면 포인트 차감)
이러한 작업이 진행되는 로직입니다.
예상대로 진행된다면 A경기에 배팅 요청을 한 후 또 다시 A경기에 배팅 요청을 한다면 2번 검증식에 걸려 400 예외를 던지게 됩니다. 이렇게 중복된 배팅 요청이 진행되지 않도록 설계하였습니다.
하지만 한 친구가 약 10만 포인트를 배팅하였는데 해당 경기 이후 40만 포인트 이상을 획득하게 되었습니다. 이에 이상함을 느껴 배당공식으로 계산을 해보니 정확히 예상 보상 금액에 2배의 수치가 된 것을 확인하였습니다.
위의 배팅 로직을 살펴보아도 큰 이상이 없어 보였지만 설마 하는 생각에 배팅 완료 버튼을 빠르게 2번 눌러보았더니 배팅 성공 알림이 2번 출력되는 것을 확인하였습니다.
그 후 데이터베이스에서 배팅 정보를 쿼리하여 확인해보았는데 한 경기에 두개의 배팅 튜플이 삽입되어있었습니다.
문제 해결: 비관적 락도 락이다
해당 문제는 트랜잭션간 발생한 동시성 문제입니다. 동시성 문제는 여러 프로세스나 스레드가 공유자원에 접근하려할 때 발생할 수 있는 문제입니다.
위의 문제발생 시나리오 대로 어떻게 문제가 발생하였는지 설명하도록 하겠습니다.
일반적으로 배팅은 위와 같은 흐름으로 진행됩니다.
A경기에 배팅을 한다고 가정했을 때 해당 요청은 하나의 쓰레드를 할당받고(Thread 1) 로직을 처리합니다. 먼저 A경기에 대한 배팅 여부를 select 합니다. 그 후 배팅하지 않았다면 배팅 포인트를 차감하고 배팅 정보를 insert 합니다.
하지만 만약 배팅 요청을 빠르게 두번 보낼 경우, 스레드 1에서 A경기에 배팅 여부를 확인 후 아직 배팅 정보 저장 insert 쿼리가 commit되지 않아 데이터베이스 반영되지 않았는데, 스레드 2에서 A경기 배팅 여부를 확인하여 배팅이 되지 않았다고 판단하여 배팅 정보가 2번 insert 되는 문제가 발생하였습니다.
해당 문제는 동시성 문제 중 경쟁상태(race condition)에 해당하는 문제입니다. 이렇게 공유 자원에 여러 프로세스나 스레드가 동시 접근할 때 순서나 시간에 따라 결과값에 영향을 줄 수 있는 상태를 경쟁상태라고 합니다.
해당 상황을 해결하기 위해 락 기능을 사용하였습니다.
락은 데이터베이스에 공유 자원을 여러 프로세스나 스레드가 접근하는 상황에서 데이터의 무결성과 일관성을 지키기 위해 자원에 접근할 때 다른 프로세스에서 동일한 자원의 접근을 막는 것 입니다.
락을 사용하는 방법은 크게 비관적 락, 낙관적 락으로 나뉩니다. 이름 그대로 경쟁상태를 비관적으로, 낙관적으로 본다는 의미입니다.
간단하게 두가지의 방법을 설명한다면
- 비관적 락은 데이터베이스 단에서 직접 락을 걸기 때문에 성능이 많이 저하되며 데드락이 일어날 가능성이 있습니다. 그렇기 때문에 데이터의 무결성이 중요할 때, 데이터의 충돌이 많이 발생할 것으로 예상될 때 사용합니다.
- 낙관적 락은 애플리케이션 단에서 락을 제어하기 때문에 충돌이 적게 일어나는 상황에서는 성능이 더 좋지만 충돌이 일어나는 상황에선 충돌이 발생했을 땐 개발자가 수동으로 롤백처리를 해줘야하는 상황이 발생할 수도 있기 때문에 데이터의 충돌이 자주 일어나지 않을 것으로 예상 될때 사용합니다.
해당 문제 상황에 비교해 보았을 때 충돌이 빈번하게 발생할 것으로 예상되고 충돌 상황이 발생시 비용이 크다고 생각하여 비관적 락을 적용하는 방법을 선택하게 되었습니다.
분산락?
추가로 해당 상황이 발생하여 락에 대해 조사 중 redis를 활용하여 분산락이라는 것을 적용하여 비용이 크게 드는 데이터베이스에 직접 락을 걸지 않고 락을 제어하여 많은 양의 데이터를 성능이 우수하게 동시성 문제를 해결할 수 있다는 것을 알게되었습니다.
또한 scale-out된 상황이나 msa, 데이터베이스 분산 환경에서도 동시성 문제를 쉽게 제어할 수 있는 방식입니다.
JPA 비관적 락 적용
Spring Data JPA를 사용하면서 비관적 락을 적용하는 방법은 @Lock 어노테이션을 활용하여 LockModeType.PESSIMISTIC_WRITE 이라는 락 타입을 주면 됩니다. 해당 락 타입을 적용하면 어노테이션이 붙은 메서드를 실행시 발생하는 쿼리에서 배타락이 걸려 해당 트랜잭션이 commit될 때 까지 다른 트랜잭션에서 해당 자원에 대한 읽기, 쓰기가 불가합니다.
비관적 락 적용 후 A경기에 배팅 여부 확인 쿼리에서 배타락이 걸려 다른 트랜잭션에서 충돌이 발생하는 상황이 해결되었습니다. 이렇게 스레드 1에서 배팅 요청 후 바로 스레드 2에서 동일한 배팅 요청이 들어와도 스레드 1에서 배타락이 걸려있으므로 스레드 2는 대기 상태에 빠지게 됩니다. 이후에 스레드 1이 커밋이되어 배팅 정보가 데이터베이스에 반영된 이후에 스레드 2가 대기가 걸려있던 배팅 여부 확인 쿼리를 할 때는 배팅 정보가 저장되어 있기 때문에 중복된 배팅 정보가 저장되지 않습니다.
해당 PR: https://github.com/GSM-GOGO/GSM-GOGO-Server/pull/168
문제: 20억 포인트 달성
배팅 기능을 릴리즈 한 후 기분 좋은 마음으로 기숙사에 쉬고 있던 도중 팀원 친구가 다급하게 불러 가보니 심각한 이슈가 발생해 있었습니다...
아직 경기결과 정산이 되지도 않았고 포인트를 늘릴 수 있는 방법이 없었는데 조금도 아니고 말도 안되는 수치인 20억 포인트를 넘은 어이가 없는 상황이 발생한 것입니다. 당장 20억 포인트가 된 정상혁에게 가서 문제를 발생시킨 상황에 대해 물었습니다.
정상혁 친구는 배팅시 보유 배팅액 보다 훨씬 큰 수를 넣어 배팅 요청을 하니 갑자기 보유 포인트가 말도 안되게 높게 변했다고 하였습니다. 같은 문제를 발생시킨 이진헌 친구도 동일한 진술을 하였습니다.
진술을 종합하여 배팅시 보유 포인트가 높아지는 문제를 해결하기 위해 해당 부분 코드를 분석하였습니다.
문제 해결: long vs int
문제 발생 당시 배팅 비즈니스 로직에서 보유 포인트 이하로 배팅을 하였는지 검증 및 포인트 차감 로직입니다. 로직에 대해 간단하게 설명한다면, 배팅 요청으로 받은 배팅할 포인트를 유저 엔티티 안에있는 betPoint()라는 메서드 매개변수에 넣어 보냅니다. betPoint()는 유저의 포유 포인트 - 배팅할 포인트를 하여 음수인지 확인하여 isBet이라는 변수에 담습니다.
isBet이 false라면 보유 포인트보다 많은 양을 배팅하려 하였으므로 아래의 조건식에서 false를 반환하여 배팅 비즈니스 로직에서는 false를 반환받아 예외가 발생하게 됩니다. 반대로 true라면 이래 조건식에 따라 유저의 보유 포인트에서 배팅할 포인트를 차감 후 true를 반환합니다.
여기서 문제점은 배팅 비즈니스 로직에서 유저 엔티티 안에 있는 betPoint() 메서드를 실행시킬 때 발생합니다. 매개변수로 넘기고 있는 값을 보면 .intValue()라는 메서드로 배팅할 포인트를 Int형으로 형변환을 하고 있는것을 확인할 수 있습니다.
이 말은 요청으로 받는 DTO 객체에서는 포인트를 Long타입, 유저 엔티티에서 갖고 있는 포인트는 Int타입이라는 것을 의미합니다.
long 자료형의 값의 범위는 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 이며 int 자료형의 범위는 -2,147,483,648에서 2,147,483,647 입니다.
요청으로 받는 DTO 객체에서는 포인트를 다루는 필드의 자료형이 Long형이기 때문에 betPoint() 메서드로 넘길때 Int형으로 강제로 형변환을 진행합니다. 만약 30억 포인트를 배팅시 Int형으로 강제 형변환 되어 Int형의 양수 최대 범위인 약 21억을 넘어 예상하지 않은 값인 음수값이 됩니다. (integer overflow)
음수 값으로 넘어온 약 -13억은 betPoint() 메서드의 로직에 따라 유저의 보유 포인트 - 배팅할 포인트(-10억) 이라는 식을 계산 시 양수가 되어 isBet값이 true가 되게 됩니다.
그 후 아래의 조건식을 통과하게 되며 보유 포인트 - (-10억) 이라는 작업이 실행되며 유저는 10억 포인트를 얻게 됩니다.
문제 원인을 파악한 이후 배팅 DTO 객체의 포인트 필드의 자료형을 Int로 맞추어 문제를 해결하였습니다.
해당 PR: https://github.com/GSM-GOGO/GSM-GOGO-Server/commit/ea43bdff5acbc8c3cef8dc46ce9e27a556043c1a
이후 이야기

문제 해결 후에 데이터베이스의 배팅 관련 테이블이 엉망이 되어 데이터베이스를 초기화 한 후 학교 디스코드에 사과글을 올렸습니다..
롤백 쿼리를 할 수도 있었겠지만 당시 릴리즈 후 몇시간 채 지나지 않아 빠른 복구를 위해 데이터베이스를 초기화 하였는데 지금 다시 생각해보면 유저들의 배팅 기록을 저장한 테이블도 존재하여 충분히 롤백 쿼리를 할 수 있었던 상황이라 아쉬움이 남은 이슈 해결이였습니다.
이후에 경기 직전에 경기가 취소되는 일이 발생하여 해당 경기에 배팅한 유저들에게 배팅액을 돌려주는 롤백쿼리를 한 상황도 있었습니다.
UPDATE user
SET point = point + COALESCE(
(
SELECT bet_point
FROM bet
WHERE bet_match_id = 19
AND user.user_id = bet.bet_user_id
), 0
);
마무리
교내 프로젝트를 운영 중 발생한 문제를 원인파악 -> 해결하는 과정에 대해 회고해보았습니다. 원인을 생각해보면 간단한 문제도 많았지만 별거 아니라고 지나친 문제들이 운영 중에는 정말 큰 문제가 되어 다가오니 이번에 경험하게 된 동시성 이슈나 자료형에 대한 문제들에 대해 더 많이 고민할 수 있게 된 계기가 된것 같았고 운영 중에 문제가 발생했더라도 빠르게 문제 원인을 파악하여 해결할 수 있는 침착함이 필요하다고 생각하였습니다.
'Back-end' 카테고리의 다른 글
✨ 스프링을 사용하는 이유 (0) | 2024.07.29 |
---|---|
✨ JDBC와 DataSource (0) | 2024.07.01 |
교내 프로젝트 느려터진 조회성능 향상기 (preflight, N+1, QueryDSL) (0) | 2024.06.04 |
✨ JVM 가상머신 (0) | 2024.05.28 |
✨ Servlet, Dispatcher Servlet - 서블릿과 디스패처 서블릿 (0) | 2024.02.21 |