본문 바로가기
트러블슈팅

MySQL 격리 레벨에 의한 동시성 이슈 해결기

by JiwonChoi 2024. 8. 14.

최근 회사 운영 환경에서 동시성 이슈가 발생했습니다. 문제의 원인을 찾고 해결하는 과정을 기록해 두면 좋을 거 같아서 글을 작성해 봅니다.

문제 상황

평화롭게 코딩을 하던 어느 날 사수로부터 동시성 문제가 발생했다고 전해 들었습니다.

동시성 문제가 발생한 코드를 살펴보니 락을 건 상태에서 로직을 진행함에도 불구하고 동시성이 발생하는 상황이었습니다.

 

회사 비즈니스라 자세한 로직은 설명하지 못하지만 대충 설명하자면 다음과 같이 로직이 진행됩니다.

 

1. 회원을 조회합니다. (select)

    1-1. 만약 회원이 조회되지 않는다면 회원 정보를 등록합니다. (insert)

2. 회원을 비관적 락으로 조회하여 락을 겁니다. (select for update)

3. 해당 회원과 연결된 테이블의 가장 최신 데이터(top)를 찾고 (select) 해당 데이터를 가지고 로직을 실행 후 그 로직의 값을 데이터베이스에 저장합니다. (insert)

 

대충 뭐가 문제인지 눈치를 채셨다면 그만 읽으셔도 좋습니다. 더 좋은 아티클이 많으니까요~

문제 재현하기

로컬에 세팅한 Gatling으로 동시 요청을 보내 문제를 재현해 보며 다음과 같은 정보를 알아냈습니다.

재현하면서 로그도 찍어보고, 영속성 컨텍스트도 비워보고 별 짓 다 해봤습니다.

  • 락은 아주 잘 걸리는 상황 (A, B 요청이 동시에 들어와도 먼저 회원을 락으로 조회한 요청이 뒤에 요청 다 막아줌)
  • 영속성 컨텍스트의 1차 캐시 데이터 문제인가 싶어 중간에 flush를 하더라도 문제 발생

여기서 락이 원인이 아니고 다른 무언가가 원인이라는 것을 파악하고 여러 자료들을 찾아봤습니다.

문제 원인

암만 찾아봐도 문제의 원인이 안 보여서 MySQL 문서에 들어가서 확인해 보니 MySQL의 기본 트랜잭션 격리 레벨(REAPETABLE_READ)로 인한 문제임을 알아냈습니다.

MySQL의 공식 문서에 있는 REAPETABLE_READ 부분을 보면 다음과 같이 적혀있습니다.

MySQL 공식 문서 (https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html)

번역
동일한 트랜잭션 내에서 일관된 읽기는 첫 번째 읽기에 의해 설정된 스냅샷을 읽습니다. 즉, 동일한 트랜잭션 내에서 일반(비잠금) SELECT 문을 여러 번 실행하는 경우 이러한 SELECT 문은 서로에 대해서도 일관성을 유지합니다.

즉, 회사 로직에서 처음 회원을 조회하고 가입시키는 로직에서 회원을 그냥 select로 조회하고 가입시킬 때의 스냅샷 데이터를 바탕으로 로직이 진행되었기에 락이 걸리더라도 업데이트 된 데이터가 아닌 스냅샷 기준의 데이터를 읽게 되어 문제가 발생했음을 알아냈습니다.

엥 근데 REAPETABLE_READ는 팬텀 리드가 발생하지 않나요? 그러면 팬텀 리드로 최신 데이터 꺼내오는 거 아닌가~~
MySQL의 InnoDB 엔진은 MVCC (Multi Version Concurrency Control)를 지원하여 언두 로그 데이터를 바탕으로 트랜잭션이 시작한 시점의 스냅샷 데이터를 가지고 데이터를 가져옵니다~
다만 select for update로 데이터를 조회하면 select for update는 락을 걸어야 하기 때문에 언두 로그를 안 읽고 테이블 데이터를 읽어 팬텀 리드가 발생할 수 있습니다.

원인을 알았으니 검증하기

스프링 트랜잭션 어노테이션 @Transactioanl의 isolation을 READ_COMMITTED로 조정하여 문제를 재현해봤습니다.

기존에 발생하던 동시성 이슈 없이 아주 매끄럽게 문제가 해결되었습니다.

해결 방법

이제는 해결 방법을 찾아봐야겠죠? 제가 생각하는 해결 방법은 다음과 같습니다.

  1. 업데이트 또는 삽입할 테이블에 비관적 락 걸어서 로직 진행하기
    1. 쿼리가 인덱스를 잘 타기 때문에 락 걸어도 괜찮을 거라 판단됩니다.
    2. 데이터 변경을 하고자 하는 테이블에 select for update를 하게 되면 팬텀 리드가 발생하여 위의 문제가 발생하지 않음 물론 그전에 락에서 막히겠지만
  2. 네임드락, 레디스 분산락 도입하기
    1. 분산락의 경우 단일 장애 지점이 될 수 있고 팀에서 단일 장애 지점을 극도로 혐오하여 현재 상황으로는 쉽지 않아 보입니다.
    2. 네임드락의 경우 MySQL에 스트레스를 줄 수 있는 문제가 있습니다. 그래도 분산락보다는 도입 가능성이 높아 보입니다.
  3. READ_COMMITTED로 바꾸기
    1. 특정 트랜잭션만 격리 레벨을 바꾼다면 어떤 사이드 이펙트가 발생할지 모르기 때문에 하면 안 된다고 생각합니다.

우선은 빠르게 핫픽스가 필요한 상황이어서 1번 방법으로 해결했습니다. 추후에 재논의해서 다른 방법을 도입할 예정입니다.

깨달은 점

비즈니스 로직과 히스토리를 모르는 상황에서 다른 사람이 짠 코드를 보는 게 쉽지 않음을 많이 깨달았습니다. 앞으로 이런 부분을 신경 쓰면서 개발해야겠네요~

데이터베이스 공부를 더 열심히 해야겠다고 느꼈습니다. 분명히 옛날에 RealMySQL에서 봤던 부분인데 기억을 못 했다는 게 조금 아쉽네요.