이 글에서는 설문조사 웹 서비스 백엔드를 개발하면서 해결했던 INSERT 동작에 동시성 문제를 해결하는 과정을 소개합니다.
문제 파악: 의도치 않은 동일 설문조사 생성 방지
설문조사 생성 시 포인트가 필요하기 때문에 불필요한 중복 생성은 사용자 경험에 악영향을 미칠 수 있습니다.
예를 들어, 동일 사용자가 동일한 설문조사 요청을 여러 개 보낼 경우를 생각해 보면 사용자가 설문조사를 생성하는 버튼을 여러 번 클릭할 수 있고, 이는 네트워크 지연으로 인해 응답이 늦어질 때 더 흔하게 발생할 수 있습니다.
따라서 중복 생성이 발생하지 않도록 효과적인 동시성 제어가 필요했습니다. 이를 해결하지 않으면 사용자에게 의도치 않은 포인트 소모 및 중복 데이터 생성 문제가 발생할 위험이 있었습니다.
문제 상황을 다시 정의하면, "한 사용자가 동일한 설문조사의 생성 동시에 여러 개를 요청했을 때 하나의 설문조사만 생성하도록 보장한다."입니다. 그런데 이러한 데이터베이스 INSERT 동작에 비관적 락을 사용하면 사실 별 의미가 없습니다.
비관적 잠금은 주로 FOR UPDATE나 FOR SHARE 같은 구문을 통해 데이터가 이미 존재하는 행에 대해 적용되므로, 삽입에는 잘 사용되지 않습니다. 비관적 잠금은 데이터를 조회하거나 업데이트할 때 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 잠가주는 역할을 합니다. 하지만 삽입 작업에서는 아직 존재하지 않는 데이터를 대상으로 하기 때문에, 비관적 잠금의 효과가 미미하거나 필요하지 않을 수 있습니다. 오히려, 비관적 락은 잘못 사용했다가 데드락 문제가 발생할 수 있습니다.
MySQL에서 DEADLOCK 발생 문제
https://bezzang2.tistory.com/191
여기서 자세히 다루었는데, 간단히 상황을 요약하자면 한 트랜잭션에는 비관적 락을 사용하여 조회 쿼리를 했을 경우와, 다른 트랜잭션에서 동일한 테이블에 INSERT 동작을 했을 때입니다.
위 상황의 경우, InnoDB는 비관적 락을 사용한 조회 쿼리를 실행할 때 인덱스 페이지의 끝을 표시하는 역할을 하는 supremum 레코드의 lock을 얻었고, INSERT를 수행하는 트랜잭션이 Insert intention Lock을 획득하기 위해 supremum 레코드의 Lock을 얻으려 하며, 서로의 Lock을 기다리게 되는 데드락 상황이 발생합니다.
사실 조회 쿼리에 비관적 락을 적용하는 상황이 흔치 않겠지만, 이런 예기치 않은 상황이 발생할 수 있음을 보여드리기 위해 예시로 추가했습니다.
참고로, PostgreSQL에서는 위와 같은 데드락이 발생하지 않습니다.
PostgreSQL은 MVCC를 사용하여 레코드 버전을 관리하고, 팬텀 리드를 방지하면서도 supremum 레코드 없이 일관성을 보장하기 때문에 InnoDB와 동일한 상황에서 데드락이 발생하지 않습니다.
대안 2: 낙관적 락 사용하기
낙관적 락을 적용하여, 설문조사 생성 시점에서 관련 User 엔티티에 @Version 필드를 수정하는 방법이 있습니다.
트랜잭션 시작 시점에 User 엔티티의 버전을 읽어두고, 설문조사 생성이 완료되기 전까지 다른 트랜잭션이 동일한 User의 Version을 수정하면 버전 불일치가 발생해 예외가 발생합니다. 이를 통해 중복 생성이 방지되지만, 좋은 방식은 아닙니다.
JPA에서는 @Version 필드가 데이터의 동시성을 자동으로 관리하도록 설계되어 있기 때문에 직접 값을 변경하는 것은 권장되지 않습니다. JPA가 @Version 필드를 직접 관리하여, 트랜잭션마다 자동으로 버전이 증가하기 때문입니다.
대안 3: Redisson 분산 락으로 애플리케이션 수준에서 동시성 제어
위 대안들은 데이터베이스에 맡기는 대안들입니다. 이번에는 애플리케이션 수준에서 동시성을 제어하는 방법입니다.
특히 Redisson에서 구현되어 있는 RLock은 Redisson의 RLock은 효율적이고 반응성이 높은 락을 제공합니다.
- pub/sub로 불필요한 대기 최소화: Redis의 pub/sub를 통해 락의 해제를 즉시 알리므로, 불필요한 대기를 줄이고 필요한 순간에만 락을 재시도합니다.
- 순환 락으로 빠른 락 획득: 메시지 수신 직후 짧은 대기와 재시도를 통해 락을 최대한 빨리 획득하려고 하므로, 다수의 클라이언트가 락을 요청하더라도 빠르게 분산 락을 처리할 수 있습니다.
코드 구현은 다음과 같이 할 수 있겠습니다.
위 코드는 "createSurveyLock"이라는 이름의 분산 락을 획득하고, validateCreateSurvey라는 메서드에서 User가 최근 생성한 설문조사 시간이 30초 이내라면, Exception을 발생시키도록 구현했습니다.
주의할 점은, 트랜잭션이 commit 된 이후에 lock을 놓도록 해야 합니다! 위 코드에서는 바로 lock을 놓지만, 이는 lock을 놓고 트랜잭션이 커밋하기 전에 다른 트랜잭션이 락을 획득하여 validateCreateSurvey를 수행한다면 통과될 수 있습니다.
하지만 이는 PostgreSQL에는 해당되지 않습니다. Postgres의 MVCC 동작은 더티 리드(dirty read)가 READ_UNCOMMITTED 에서도 발생하지 않는데, 트랜잭션이 시작한 시점의 스냅샷에서 행의 버전을 유지하기 때문입니다.
트랜잭션 독립 수준을 READ_COMMITED로
그보다 윗 수준인 REPEATABLE_READ로 설정하면 무슨 일이 벌어질까요? REPEATABLE READ는 트랜잭션이 시작된 이후 다른 트랜잭션이 커밋한 변경 사항을 볼 수 없도록 보장합니다. 이 격리 수준에서는 트랜잭션이 동일한 레코드를 여러 번 조회할 때 그 값이 항상 동일하게 유지되며, Non-repeatable read 문제가 발생하지 않도록 동작합니다.
만약 이 격리 수준에서 위와 같이 User가 최근 생성한 설문조사 시간을 조회한다면, 다른 트랜잭션에서 설문조사를 생성했더라도 현재 트랜잭션에서는 알 수 없습니다. 결과적으로 최근 생성 시간을 기준으로 동시에 여러 설문조사가 생성되는 것을 막을 수 없습니다.
따라서 READ_COMMITTED를 사용합니다.
결과: 대안 3 채택
대안 3은 오직 트랜잭션 독립 수준과, Redisson 분산 락을 사용하고 데이터베이스 수준의 락을 사용하지 않았다는 점에서 채택하였습니다.
이는 성능, 분산 환경에서 일관성을 지키는 효과를 가졌습니다.
이 대안은 처음 정의한 문제를 다음과 같이 해결합니다.
- 설문조사 생성을 할 수 있는지 검사하는 로직을 락을 사용하여 임계 영역으로 만듭니다.
- 만약 최근 생성한 설문조사 시간이 30초 이내라면, Exception을 발생시켜 만들지 못하도록 합니다.
- 락을 기다리고 있던 다른 트랜잭션은 락 획득 후, 앞서 트랜잭션이 최근 생성한 설문조사 시간을 업데이트했기 때문에, 설문조사를 생성할 수 없습니다.
- 결과적으로 한 사용자가 동일한 설문조사의 생성 동시에 여러 개를 요청했을 때 하나의 설문조사만 생성하도록 보장하도록 하였습니다.
마치며
위 방식보다 더 나은 방식이 있을 것 같습니다. 토스에서는 낙관적 락을 함께 사용하는 방식을 사용하는 것 같습니다.
참고
https://www.youtube.com/watch?v=UOWy6zdsD-c&t=430s
'Development > Diary' 카테고리의 다른 글
[Diary] 신입 개발자의 오픈소스 컨트리뷰트 해보기 - Spring Data JPA (2) | 2024.11.21 |
---|---|
[Diary] Elastic Search로 100만 데이터 검색 속도 향상시키기 (0) | 2024.11.12 |
[Diary] 300만 데이터 전체 조회 성능 개선기 (projection, 테이블 최적화) (3) | 2024.10.26 |
[Diary][Spring]GitHub Actions로 단위 테스트 환경 구성하기 (Github Service Container) (3) | 2024.10.16 |
[Diary] Apache Cassandra에서 MongoDB 전환기 (4) | 2024.10.14 |