본문 바로가기

데이터베이스

[transaction 동시성 문제②] 해결 - 1

이 시리즈는 데이터베이스 동시성 문제를 겪은 경험과 그 해결책에 대한 내용이 담겨 있습니다.

 

참고

대여처리는 (userId, cabinetId 포함한 각종 정보를 담은) row를 lent_history 테이블에 추가하는 작업입니다.


해결 방법 

  • lent_history에 version 컬럼 추가
    • 두 컬럼 {version, cabinetId}에 composite unique index
lent_history_id user_id  cabinet_id version started_at ```
  • cabinet에 version 컬럼 추가
cabinet_id  status version max_user ```

이렇게 테이블을 수정하고 기존 동시성 문제 시나리오를 다시 살펴보겠습니다.


cabinet 동시성 문제 1

전제: N번 공유사물함은 최대 3명이 빌릴 수 있습니다. 현재 2명이 대여 중입니다.

 

lent_history_id user_id cabinet_id version started_at ```
1 1 N 1 2023-05-15 ```
2 2 N 2 2023-05-16 ```

상황: 2명의 유저가 동시에 N번 사물함을 대여 시도합니다.

예상 결과: 2명 중 1명은 대여 성공, 나머지 1명은 대여 실패해야 합니다.

 

사용자 A(userId=3)가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 2 -> 대여가능
  • N번 사물함에 대한 lentHistory 중 가장 큰 version 값을 가져옴 → maxVersion = 2
  • INSERT INTO
                 lent_history(
                        lent_history_id, cabinet_id, user_id, version
                 )
                 VALUES (3, N, 3, 2+1)
사용자 B(userId=4)가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 2 -> 대여가능
  • N번 사물함에 대한 lentHistory 중 가장 큰 version 값을 가져옴 → maxVersion = 2
  • INSERT INTO
                 lent_history(
                        lent_history_id, cabinet_id, user_id, version
                 )
                 VALUES (3, N, 4, 2+1)

실제 결과 : 2명의 대여 시도자 중 1명은 대여 성공, 1명은 대여 실패합니다.

실패의 이유는 composite unique index 제약조건 위반으로 트랜잭션 전체가 rollback 되었기 때문입니다.

(위에서 insert into 구문은 '대여처리'에 해당합니다)

 

lent_history_id user_id cabinet_id version started_at ```
1 1 N 1 2023-05-15 ```
2 2 N 2 2023-05-16 ```
3 3 혹은 4 N 3 2023-05-20 ```

cabinet 동시성 문제 2

전제: N번 공유사물함은 최대 3명(maxUser=3)이 빌릴 수 있습니다. 현재 0명이 대여 중입니다.

 

lent_history_id user_id cabinet_id version started_at ```
cabinet_id status version max_user ```
N AVAILABLE 1 3 ```

상황: 3명의 유저가 동시에 N번 사물함을 대여 시도합니다.

예상 결과: 3명 모두 사물함 대여 성공하고, cabinet.status는 FULL이 돼야 합니다.

 

사용자 A가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 0
  • 대여 처리
  • DB에서 N번 사물함의 version 값 읽음
    version = 1
  • activeLentCount = 1이므로
    cabinet.status는 AVAILABLE
  • UPDATE CABINET
    SET status = AVAILABLE,
            version=1+1
    WHERE cabinet_id=N
                   AND version=1
사용자 B가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 0
  • 대여 처리
  • DB에서 N번 사물함의 version 값 읽음
    version = 1
  • activeLentCount = 1이므로
    cabinet.status는 AVAILABLE
  • UPDATE CABINET
    SET status = AVAILABLE,
            version=1+1
    WHERE cabinet_id=N
                   AND version=1
사용자 B가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 0
  • 대여 처리
  • DB에서 N번 사물함의 version 값 읽음
    version = 1
  • activeLentCount = 1이므로
    cabinet.status는 AVAILABLE
  • UPDATE CABINET
    SET status = AVAILABLE,
            version=1+1
    WHERE cabinet_id=N
                   AND version=1

실제 결과 : 3명의 유저 중 1명은 대여 성공, 나머지 2명은 대여 실패합니다.

실패의 이유는 update where 문에 의해 수정된 행이 0개 일 때, 코드상에서 예외를 발생시키게 해두었고,
이 예외로 인해 트랜잭션 전체가 rollback 되었기 때문입니다.

 

이는 이상적인 결과는 아닙니다.

3명 모두 대여를 할 수 있음에도 상황임에도 불구하고, 동시에 대여 시도를 했다는 이유로 2명은 대여를 실패하니까요.

(이에 대해선 맨 밑에서 좀 더 얘기하겠습니다.)

 

lent_history_id user_id cabinet_id version started_at ```
1 1 혹은 2 혹은 3 N 1 2023-5-20  
cabinet_id status version max_user ```
N AVAILABLE 2 3 ```

cabinet 동시성 문제 3

전제: N번 공유사물함은 최대 3명(maxUser=3)이 빌릴 수 있습니다. 현재 2명(=X, Y)이 대여 중입니다.

 

lent_history_id user_id cabinet_id version started_at ```
1 1 N 1 2023-5-15 ```
2 2 N 2 2023-5-16 ```
cabinet_id status version max_user ```
N AVAILABLE 3 3 ```

상황: X가 N번 사물함 반납 시도하고, 동시에 유저 A가 N번 사물함을 대여 시도합니다

예상 결과: X는 반납 성공, A는 대여 성공합니다. 그리고 cabinet.status = AVAILAVBLE 이 돼야 합니다.

 

사용자 X가 N번 사물함 반납 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 2
  • 반납처리
  • DB에서 N번 사물함의 version 값 읽음
    version = 3
  • activeLentCount - 1 = 1 이므로
    cabinet.status는 AVAILABLE
  • UPDATE CABINET
    SET status = AVAILABLE,
            version= 3+1
    WHERE cabinet_id=N
                   AND version=3
사용자 A가 N번 사물함 대여 시도
  • DB로부터 현재 대여중인 인원수를 읽음
  • activeLentCount = 2
  • 대여 처리
  • DB에서 N번 사물함의 version 값 읽음
    version = 3
  • activeLentCount  + 1 = 3 이므로
    cabinet.status는 FULL
  • UPDATE CABINET
    SET status = FULL,
            version= 3+1
    WHERE cabinet_id=N
                   AND version=3

실제 결과: (X는 반납 성공, A는 대여 실패) 혹은 (X는 반납 실패, A는 대여 성공) 하는 결과가 나옵니다.

실패의 이유는 update where 문에 의해 수정된 행이 0개 일 때, 코드상에서 예외를 발생시키게 해두었고,
그로 인해 트랜잭션 전체가 rollback 되었기 때문입니다.

 


생각해 볼 만한 점

다음의 문제가 해결되었습니다.

  • 예를들어 maxUser=3 이지만 3명을 초과한 인원이 사물함을 빌리는 이른바 초과 인원 문제.
  • 대여, 반납에 의해 cabinet.status가 적절히 업데이트 되어야 하는데, 잘못 업데이트 되는 문제.

하지만 위 해결방법에는 단점이 있습니다.

  • lent_history에서 version 
    • SELECT max(version) FROM lent_history WHERE cabinet_id=N;
    • 위 쿼리를 통해 max_version 값을 읽어 들이고 +1 한 값을 새 row가 insert 될 때 넣어해줘야 합니다.
    • 이러한 version 관련 코드가 비지니스 로직에 추가되어야 합니다. 
  • cabinet에서 version
    • lent_history와 유사한 방식으로 해줘야 합니다.
    • 하지만 cabinet에서는 insert 쿼리가 아니라, update 쿼리입니다.
    • 따라서, JPA의 @Version을 사용하면 비지니스 로직에 version 관련 코드가 들어가지 않아도 됩니다.
      (단점이라고 했는데, 단점이라기 보다 해결 가능한 단점이라고 보면 좋을 거 같습니다)
  • 현재 3명이 빌릴 수 있는 상태에서 3명이 동시에 대여를 시도하면, 1명만 대여 성공하고 2명은 대여 실패합니다.
    • 만약 사용자수가 많고, 사물함 대여에 대한 경쟁이 심한 상황이라면 이런 상황은 바람직 하지 않습니다.
    • 선착순, 티켓팅 서비스에선 절대 이렇게 하면 안 될거 같습니다.

비관적 락을 사용하면 어떨까요?

  • for update 키워드를 사용하여 "DB로부터 현재 대여중인 인원수를 읽음" 부터 락을 걸고 들어갑니다.
  • 그래서 동시에 대여를 API를 날려도, 오직 하나의 트랜잭션만 실행되고, 나머지 트랜잭션은 대기상태가 됩니다.
  • 이렇게 하면 위에서 얘기한 선착순 문제도 해결되고, version 값을 직접 다루지 않아도 됩니다.

하지만 비관적 락 방법에도 생각해 봐야 할 부분이 있습니다.

  • 한 사물함의 대여 기간은 14일입니다. 대여, 반납이 빈번히 일어나지 않는 것입니다.
    따라서 동시성 문제가 발생할 가능성이 낮습니다. 실제로 서비스 출시 이후 6개월 동안 동시성 문제는 단 한 번 발생하였습니다.
    (동시성 문제-1 이 발생하였습니다.) 
  • 동시성 문제가 발생할 가능성이 낮음에도 비관적 락을 사용하는 것은 불필요하게 DB 성능을 낮추는 것으로 볼 수 있습니다.

 

낙관적 락 그리고 비관적 락 모두 장,단점이 있습니다.

저희 서비스는 동시성 문제가 발생한 횟수가 매우 적다는 것에 근거하여 낙관적 락을 사용하는게 합리적이라고 판단하였습니다.

 

다음 글에서 계속...