Search
Duplicate

Cabi 동시 대여 문제와 해결

간단소개
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
Java
Spring
Data Base
Scrap
태그
동시성
9 more properties

개요

한정된 자원인 사물함에 대해 대여 서비스인 Cabi를 진행하면서 동시성 문제를 겪었었다. 트랜잭션과 격리수준에 대해 간략히 설명하고, 이전의 Nest.js에서 해결했던 방법과, 지금 새로이 Spring으로 포팅하면서 해결하고자 한 방법들에 대해서 써보려고 한다.
(장문 주의)

동시성 문제?

유명한 문제 중 하나인 식사하는 철학자 문제와 같이, 정해진 자원에 대해, 스레드나 프로세스가 동시적인 점유와 조작을 시도했을 때, 의도하지 않은 결과가 발생하는 것을 의미한다.
자세한 내용은 이곳을 참조하면 좋을 것 같다.

문제 상황

까비에서 서비스로 제공하는 사물함의 종류는 1인 상한의 개인 사물함, 그리고 3인 상한의 공유 사물함이 있다.
한정된 자원에 대한 점유를 위해 여러 명이 동시에 시도하는 경우, 특별한 조치가 없다면 개인 사물함에 4명이 들어가고, 공유 사물함에 10명도 들어갈 수 있는 문제가 생길 수 있다.
이를 위해 서비스에서 사용될 수 있는 트랜잭션의 방법을 생각해보고, 적용하여 해결해야 한다.

트랜잭션?

트랜잭션(Transaction)은 시스템에서 사용되는 더 이상 쪼갤 수 없는 업무 처리의 최소 단위이다. 예를 들어, A라는 사람이 B라는 사람에게 1,000원을 지급하고 B가 그 돈을 받은 경우, 이 거래 기록은 더 이상 작게 쪼갤 수가 없는 하나의 트랜잭션을 구성한다. 만약 A는 돈을 지불했으나 B는 돈을 받지 못했다면 그 거래는 성립되지 않는다. 이처럼 A가 돈을 지불하는 행위와 B가 돈을 받는 행위는 별개로 분리될 수 없으며 하나의 거래내역으로 처리되어야 하는 단일 거래이다. 이런 거래의 최소 단위를 트랜잭션이라고 한다. 트랜잭션 처리가 정상적으로 완료된 경우 커밋(반영)을 하고, 오류가 발생할 경우 원래 상태대로 롤백(되돌리기)을 한다.

트랜잭션 더 알아보기

격리수준?

격리 수준(Isolation Level)은 동시에 실행되는 여러 트랜잭션 간의 상호 작용을 제어하는 데이터베이스의 속성이다. 일반적으로 지원되는 네 가지 격리 수준으로는 READ UNCOMMITTED, READ COMMITTED, REAPEATABLE READ, SERIALIZABLE이 있다.

격리수준 더 알아보기

이전의 해결 방식 (Nest.js)

MariaDB를 사용하고, 기본 격리 수준인 Repetable Read(하나의 트랜잭션에서 당시의 조회 결과를 커밋이 끝날 때까지 유지)로 설정되어 있었고, 해당 읽기와 쓰기에 대한 별도의 상호 배제(Lock)가 없었다. 때문에, 대여와 반납에 대한 트랜잭션을 진행하더라도 아래와 같은 동시성 문제가 발생하였다.
비어 있는 개인 사물함(0/1)을 생각해보자.
별도의 상호배제 조건이 없으므로, 두 클라이언트(C1, C2) 중 다른 세션(C1)이 트랜잭션을 수행하고 있어도, C2가 읽는 것이 가능하다.
C1, C2가 INSERT 이전의 대여자 수를 기준(0/1)으로 대여를 진행한다. (Repeatable Read)
두 세션 다 사물함의 이용자 수가 (0/1)로 인식되므로, 대여가 성공하게 된다.
따라서, (1/1)이어야 하는 개인 사물함의 이용자 수가, (2/1)이 되는 동시성 문제가 발생한다.
이에 대한 대처로 격리 수준을 높여, SERIALIZABLE(하나의 트랜잭션이 끝날때까지 다른 트랜잭션에서 해당 트랜잭션의 테이블에 업데이트를 못 하거나, 읽지 못 함) 로 설정했다. SERIALIZABLE에서 락(S Lock과 X Lock 등, 나중에 설명)을 통한 상호 배제로 해결하고자 하였고, 그 과정에서 Lock으로 인한 교착 상태(Dead Lock)와 같은 문제들을 기존의 트랜잭션 조정과 쿼리문 최적화로 해결했다.
이전의 대여와 반납에서 겪었던 문제는 sichoi님의 블로그(https://velog.io/@sichoi/동시-대여반납-문제)에 상세하게 나타나있다.

현재의 해결 방식 (Spring)

이전의 해결방식에서 SERIALIZABLE 격리 수준을 사용하면서 이후에 생기는 문제들을 해결했지만, 팀원들과 다음과 같은 의문점을 공유하게 되었다.
“빈번하게 일어나지 않는 케이스를 위해서 매번 Lock을 거는 경우는 비효율적인 것이 아닐까?” ”직접적인 상호배제 없이 해결할 수 있는 방법은 없을까?”

낙관적 락?

그렇게 방법을 찾아보았고, REPEATABLE READ 격리 수준에서 낙관적 락을 사용하여 해결할 수 있다는 것을 팀원들과 알게 되었다.
낙관적 락(Optimistic Locking)데이터의 충돌이 발생할 가능성이 적다고 가정하고, 트랜잭션의 충돌 검출을 트랜잭션 완료 시점으로 미루는 전략이다.
낙관적 락에서는 트랜잭션이 데이터를 읽어오고 수정하기 전에는 락을 걸지 않는다. 대신, 트랜잭션이 수정을 시도할 때 다른 트랜잭션이 해당 데이터를 이미 수정했는지 확인한다.
데이터를 수정하는 시점에 충돌이 발생하면, 해당 트랜잭션은 롤백되거나 충돌을 해결하는 방식으로 처리한다.
비관적 락?

낙관적 락을 적용했다면..

일반적으로 낙관적 락을 사용할 때, 버전(Version) 컬럼을 추가하여 관리한다. 간단히 얘기하자면, 해당 테이블에 변경사항(수정)이 생겼을 때 버전이 올라가는 것이다.
다음과 같은 방식으로 낙관적 락이 수행된다.
1.
트랜잭션이 데이터를 읽을 때, 해당 데이터의 버전 정보도 함께 읽어온다.
2.
트랜잭션이 데이터를 수정할 때, 데이터의 현재 버전과 트랜잭션 시작 시점의 버전을 비교한다.
버전이 일치하면(0 → 0, 누군가 수정하지 않았다면), 트랜잭션을 계속 진행하고, 트랜잭션의 결과로 데이터를 업데이트하고 버전을 증가시다.
버전이 일치하지 않으면(0 → 1, 누군가 수정했다면), 충돌이 발생한 것으로 간주하고, 트랜잭션을 롤백하거나 충돌을 해결하는 방식으로 처리한다.
이렇게 낙관적 락을 구현하면, 동시에 실행되는 트랜잭션들이 동일한 데이터를 수정하려고 할 때 충돌을 감지하게 되고, 이에 대한 처리를 할 수 있는 것이다.
우리의 케이스인 REPEATABLE READ의 격리 수준에서 낙관적 락을 적용했다고 생각하고, 위의 문제를 다시 살펴보자.
비어 있는 개인 사물함(0/1)이 이있다. 버전은 0인 상태라고 하자.
별도의 상호배제 조건이 없으므로, 두 클라이언트(C1, C2) 중 다른 세션(C1)이 트랜잭션을 수행하고 있어도, C2가 읽는 것이 가능하다.
C1, C2가 INSERT 이전의 대여자 수를 기준(0/1)으로 대여를 진행한다. (Repeatable Read) → 이 때, C1과 C2는 동일한 버전 값(0)을 갖게 된다.
두 세션 다 사물함의 이용자 수가 (0/1)로 인식되므로, 대여를 적용하여 커밋한다. → 이 때, C1과 C2는 버전 컬럼을 동일하게 증가시키므로, 결과적으로 같은 버전 값(1)을 갖게 된다.
따라서, (1/1)이어야 하는 개인 사물함의 이용자 수가, (2/1)이 되는 동시성 문제가 발생한다. 따라서, 커밋 시점에 이미 같은 버전으로 업데이트 되어 있으므로, 충돌이 일어난다. 이에 대해서 롤백하거나 재요청 처리를 하면 동시성 문제를 해결할 수 있다.

테스트로 알아보자(Spring) - 문제 상황

@Test void 동시_대여_테스트_대여자_존재() throws InterruptedException { // 4명이 동시에 3인 상한의 공유 사물함을 대여하는 경우 int nThreads = 4; ExecutorService executorService = Executors.newFixedThreadPool(nThreads); // 대여 가능한 상황의 유저 ID = [23, 24, 25, 26] Long user1 = 23L; Long user2 = 24L; Long user3 = 25L; Long user4 = 26L; // 대여하려는 (1/3) 상태의 공유 사물함 ID = 12 Long cabinetId = 12L; // 스레드를 이용해, 동시적으로 대여를 시도한다. Future<?> future1 = executorService.submit( () -> lentService.startLentCabinet(user1, cabinetId)); Future<?> future2 = executorService.submit( () -> lentService.startLentCabinet(user2, cabinetId)); Future<?> future3 = executorService.submit( () -> lentService.startLentCabinet(user3, cabinetId)); Future<?> future4 = executorService.submit( () -> lentService.startLentCabinet(user4, cabinetId)); try { future1.get(); future2.get(); future3.get(); future4.get(); } catch (Exception e) { } // (3/3)이 우리가 원하는 상황이다. assertEquals(3, lentRepository.findAllActiveLentByCabinetId(cabinetId).size()); executorService.shutdown(); }
Java
복사
사용자 수가 (1/3)인 공유 사물함의 상황에서, 4개의 스레드가 동시적으로 대여를 시도했을 때, 3명만 대여를 하는 것을 목표로 했다.
하지만, 4명 모두 대여를 해버리는 상황이 발생했다. 심지어, 5명이 찼음에도 사물함의 상태가 FULL이 아닌, AVAILABLE(대여가 가능한 상태) 상태로 남아 있었다.

문제 이유 살피기

@Override public void startLentCabinet(Long userId, Long cabinetId) { // 1. 대여에 필요한 데이터 가져오기 Date now = new Date(); Cabinet cabinet = cabinetRepository.getCabinet(cabinetId); User user = userRepository.getUser(userId); int userActiveLentCount = lentRepository.countUserActiveLent(userId); List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId, DateUtil.getNow()); // 2. 대여 가능한 유저인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList)); List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId( cabinetId); // 3. 대여 가능한 캐비넷인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now)); // 4. 캐비넷 상태 변경 cabinet.specifyStatusByUserCount(lentRepository.countCabinetActiveLent(cabinetId) + 1); Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet, cabinetActiveLentHistories); LentHistory lentHistory = LentHistory.of(now, userId, cabinetId); // 5. 만료 시간 적용 및 대여 lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt); lentRepository.save(lentHistory); }
Java
복사
주석과 코드에 따라 흐름을 살펴보자.
1.
Cabinet과 User, BanHistory를 통해서, 사물함과 대여를 시도한 해당 유저가 대여가 가능한지에 대한 여부를 체크한다.
2.
대여가 가능한 유저인지 확인한다.
3.
대여 가능한 사물함인지 확인한다.
(데이터에 변화가 생기는 부분은 후에, 4 - 5번에서 나타난다.)
4.
현재 사물함에 대한 대여 row의 수(대여자 수)에 + 1을 하여 사물함의 상태를 변경한다. → 현재의 (1/3)인 경우에, 모든 세션은 2명의 결과를 갖게 되고(기존 1 + 본인 1), 이에 따라서 사물함의 상태는 변하지 않는다(AVAILABLE) → 즉, Cabinet 엔티티의 상태는 변경(UPDATE) 되지 않는다.
5.
정책에 따라 만료시간을 설정하고 save, 즉 새로 생성된 LentHistory 엔티티를 INSERT 한다. → 4개의 새로운 row가 INSERT 된다.
우리의 정책(공유 사물함은 최대 3인)이 무너지는 동시성 문제가 발생한다.

해결하기 - Version으로 낙관적 락 적용하기

Spring Data에는 @Version 어노테이션이 있다. 이는 위에서 설명한 낙관적 락 수행을 위한 Version 컬럼임을 나타낸다. 이 어노테이션을 이용하면 자동적으로 해당 엔티티의 상태가 변할 때 버전을 증가시키고, 동일한 버전이 있다면 현재 연결된 DB 어플리케이션에서 충돌을 일으킨다.
@Entity @Table(name = "CABINET") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Cabinet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "CABINET_ID") private Long cabinetId; /** * 사물함의 상태가 변경될 때 증가하는 버전입니다. * <p> * 동시성 문제 해결을 위한 낙관적 락을 위해 사용됩니다. */ @Version // <---------------- 추가된 Version 컬럼 @Getter(AccessLevel.NONE) private Long version = 1L; // ..........생략..........
Java
복사
위와 같이 설정할 수 있다.
하지만 이 설정만으로는 부족하다. 왜냐하면, 위의 테스트 상황에서 Cabinet 엔티티의 상태는 변경되지 않기 때문(Version 증가 X)이다.
즉, 매 대여마다 Cabinet 엔티티의 상태를 바꾸어 Version을 증가시켜야, 위의 동시성 문제를 해결할 수 있는 것이다.
이를 위해, 매 대여마다 업데이트 될 수 있게끔, 사용자 수인 user_count라는 컬럼을 Cabinet 테이블에 추가하고, 엔티티를 수정해보자.
@Entity @Table(name = "CABINET") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Cabinet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "CABINET_ID") private Long cabinetId; /** * 사물함의 상태가 변경될 때 증가하는 버전입니다. * <p> * 동시성 문제 해결을 위한 낙관적 락을 위해 사용됩니다. */ @Version @Getter(AccessLevel.NONE) private Long version = 1L; // ..........중략.......... @Column(name = "USER_COUNT", nullable = false) // <--------- 매 대여시 업데이트되는 컬럼 추가 private Integer userCount;
Java
복사
매 대여시마다 Cabinet 엔티티의 상태(userCount)를 변경시켜야 하므로, 대여 코드를 변경한다.
@Override public void startLentCabinet(Long userId, Long cabinetId) { // 1. 대여에 필요한 데이터 가져오기 Date now = new Date(); Cabinet cabinet = cabinetRepository.getCabinet(cabinetId); User user = userRepository.getUser(userId); int userActiveLentCount = lentRepository.countUserActiveLent(userId); List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId, DateUtil.getNow()); // 2. 대여 가능한 유저인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList)); List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId( cabinetId); // 3. 대여 가능한 캐비넷인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now)); // 4. 캐비넷 상태 변경 cabinet.increaseUserCount(); //<------------------ 추가된 부분 cabinet.specifyStatusByUserCount(cabinet.getCountUser()); //<------------------ 변경된 부분 Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet, cabinetActiveLentHistories); LentHistory lentHistory = LentHistory.of(now, userId, cabinetId); // 5. 만료 시간 적용 및 대여 lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt); lentRepository.save(lentHistory); }
Java
복사
cabinet.increaseUserCount()를 통해서, Cabinet 엔티티의 상태(userCount)를 변경한다. 이를 통해 Version이 증가하고, 동시 대여가 수행될 때, 버전 충돌을 일으켜 롤백하는 방식으로 동시성 문제를 해결할 수 있을 것이다.
자 이제, 버전 컬럼을 통한 낙관적 락이 잘 수행되는지 기존의 테스트를 통해 다시 살펴보자.
@Test void 동시_대여_테스트_대여자_존재() throws InterruptedException { // 4명이 동시에 3인 상한의 공유 사물함을 대여하는 경우 int nThreads = 4; ExecutorService executorService = Executors.newFixedThreadPool(nThreads); // 대여 가능한 상황의 유저 ID = [23, 24, 25, 26] Long user1 = 23L; Long user2 = 24L; Long user3 = 25L; Long user4 = 26L; // 대여하려는 (1/3) 상태의 공유 사물함 ID = 12 Long cabinetId = 12L; // 스레드를 이용해, 동시적으로 대여를 시도한다. Future<?> future1 = executorService.submit( () -> lentService.startLentCabinet(user1, cabinetId)); Future<?> future2 = executorService.submit( () -> lentService.startLentCabinet(user2, cabinetId)); Future<?> future3 = executorService.submit( () -> lentService.startLentCabinet(user3, cabinetId)); Future<?> future4 = executorService.submit( () -> lentService.startLentCabinet(user4, cabinetId)); try { future1.get(); future2.get(); future3.get(); future4.get(); } catch (Exception e) { } // 낙관적 락을 이용해서, 먼저 커밋한 버전만 성공하고, 이외의 것들은 실패한다. -> 하나만 성공한다. (2/3) assertEquals(1, lentRepository.findAllActiveLentByCabinetId(cabinetId).size()); executorService.shutdown(); }
Java
복사
낙관적 락을 수행하는 경우, 먼저 커밋한 하나의 대여가 성공하고, 그 이후에 commit되는 대여 시도들은 버전 충돌이 일어난다. 따라서, 사용자 수가 (1/3)이었던 공유 사물함에 4명의 동시 대여가 요청 됐을 때, 한명만 성공하고 나머지 셋은 실패하는 (2/3)이 되어야 하는 것이다.
성공!!!!!…. 인 줄 알았다.
하지만..

또 다른 문제상황 - 교착상태(Deadlock)의 발생

갑자기 데드락이 등장했다..!
먼저 이 문제를 이해하기 위해서는 S LockX Lock, 그리고 데드락에 대해 이해해야 한다.
S Lock과 X Lock 알아보기
데드락 알아보기
“아니.. REPEATABLE READ 격리 수준에서 S Lock을 걸지도 않았는데, 데드락이 나타난다고..?”가 이 로그를 보고 처음 든 생각이었다.
나의 시나리오는 이랬다.
1. (S Lock 없이) Cabinet 엔티티를 가져온다. 2. 대여를 시도하고, Cabinet 엔티티가 업데이트(userCount++)된다. 3. 동시 대여가 이뤄질 때, 버전 충돌이 일어나서 하나의 트랜잭션만 성공하고, 나머지는 실패한다.
하지만 버전 충돌이 아닌 데드락이 날 반겼다..
그리고 이 문제에 대한 삽질과 탐구가 계속되었다..
마침내 다음과 같은 사실들을 알 수 있었다..

InnoDB(MySQL, MariaDB)는 기본 형태의 SELECT 쿼리에서 별도의 잠금을 사용하지 않는다.

현재 애플리케이션은 MariaDB를 사용하고 있다. 문제 해결을 위해 검색을 하는 와중, REPEATABLE_READ일 때, SELECT의 경우 S Lock을 획득한다는 말을 보았다. 당연한 얘기처럼 느껴지지만, 이후에 공식문서, MySQL 서적을 찾아보아도 그런 말은 없었으며, 오히려 아무런 Lock을 획득하지 않는다는 사실을 알게되었다.
MySQL 공식문서
MariaDB 공식문서
“REPEATABLE-READ 이하의 트랜잭션 격리 수준에서 InnoDB 테이블에 대한 SELECT 쿼리는 기본적으로 아무런 잠금을 사용하지 않는다” - 개발자와 DBA를 위한 Real MySQL p.710
문제 해결을 차근차근 하기 위해서는, 토대가 되는(흔들릴 여지가 없는) 전제가 필요했고, 이 경우에서는 SELECT시의 S Lock 획득의 여부였다. 기본적인 SELECT는 Lock을 가지지 않는다.

InnoDB는 외래키 제약 조건을 기본적으로 검사한다.

외래키 제약 조건(Foreign Key Constraints)은 두 테이블의 데이터 간 연결을 설정, 적용하여 외래 키 테이블에 저장될 수 있는 데이터를 제어하는 데 사용되는 규칙이다.
난데없이 외래 키 제약 조건 검사를 왜 얘기하냐면…
외래 키 검사의 경우 관련 테이블에 대해 공유 읽기 전용 잠금(LOCK TABLES READ)이 수행됩니다.” 라고 공식문서에 써있다.
즉, 외래 키 제약조건으로 인해서, 기본적으로 외래 키 제약 조건을 체크하는 InnoDB의 경우, 데이터 정합성을 위한 잠금이 연관된 테이블로 전파되면서 데드락이 발생할 수 있다.
예를 들어, 우리의 경우 cabinet_id(42)를 FK로 갖는 lent_history(id = 1)의 새로운 로우를 INSERT할 때, 해당 트랜잭션 세션 내에서는 id=1인 lent_history row에 대해서 X Lock을, cabinet 테이블의 cabinet_id가 42인 레코드에 대해서 S Lock을 획득하는 것이다..!!

데드락 살펴보기

기존에는 Lock을 획득하지 않는 SELECT, 단순한 UPDATE로 커밋 이후의 버전 충돌로 해결될 줄 알았다.
하지만 위의 내용을 살펴보면, ‘외래 키 제약조건으로 인한 잠금 전파’로 인해 S Lock과 X Lock 획득 시도로 인해 데드락이 발생하는 것으로 의심해볼 필요가 있다.
이를 알아보기 위해, 테스트를 진행하는 MariaDB 컨테이너에서 데드락 로그를 파보았다.
# 번잡한 정보들은 생략하였다. # show engine innodb status; | InnoDB | | ===================================== 2023-06-06 00:04:47 0xffff90b9c130 INNODB MONITOR OUTPUT ===================================== ------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-06-05 23:56:23 0xffff90a25130 *** (1) TRANSACTION: TRANSACTION 1960, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1 MySQL thread id 340, OS thread handle 281473109221680, query id 2819 172.26.0.1 root Updating update cabinet set cabinet_place_id=6, col=1, row=0, lent_type='SHARE', max_user=3, memo=null, status='AVAILABLE', status_note=null, title=null, user_count=2, version=18, visible_num=12 where cabinet_id=12 and version=17 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1960 lock_mode X locks rec but not gap waiting Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0 *** (2) TRANSACTION: TRANSACTION 1963, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1 MySQL thread id 337, OS thread handle 281473108300080, query id 2821 172.26.0.1 root Updating update cabinet set cabinet_place_id=6, col=1, row=0, lent_type='SHARE', max_user=3, memo=null, status='AVAILABLE', status_note=null, title=null, user_count=2, version=18, visible_num=12 where cabinet_id=12 and version=17 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1963 lock mode S locks rec but not gap Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 7 page no 3 n bits 184 index PRIMARY of table `test_db`.`cabinet` trx id 1963 lock_mode X locks rec but not gap waiting Record lock, heap no 108 PHYSICAL RECORD: n_fields 15; compact format; info bits 0 *** WE ROLL BACK TRANSACTION (2)
SQL
복사
첫 번째 로그 - T1 (트랜잭션 ID: 1960):
이 트랜잭션은 cabinet 테이블의 레코드를 업데이트하려고 한다.
해당 테이블에서 cabinet_id가 12이고 version이 17인 레코드에 대한 업데이트를 시도한다.
이 트랜잭션은 cabinet_id가 12인 레코드X Lock 을 요청하고 있다.
그러나, 이 레코드에는 이미 다른 트랜잭션(1963)이 S Lock을 보유하고 있다.
따라서, 이 트랜잭션은 레코드 락을 기다리고, 다른 트랜잭션의 완료를 기다린다.
두 번째 로그 - T2 (트랜잭션 ID: 1963):
이 트랜잭션도 cabinet 테이블의 레코드를 업데이트하려고 한다.
해당 테이블에서 cabinet_id가 12이고 version이 17인 레코드에 대한 업데이트를 시도한다.
이 트랜잭션이 먼저 S Lock을 보유하고 있다. (HOLDS THE LOCK(S))
그러나, 이 트랜잭션은 X Lock을 요청하고 있다. (WAITING FOR THIS LOCK TO BE GRANTED)
이는 자신이 보유하고 있는 S Lock에서 X Lock으로 락 업그레이드를 시도하는 것을 의미한다.
정리하면, T1은 (1)T2가 S Lock을 걸어 놓은 (2)cabinet record의 X Lock을 얻기 위해 대기하고 있고, (3)T2는 T1이 획득할 X Lock을 얻기 위해 대기하는 교착 상태에 빠지는 것이다.
(TMI)근데 왜 트랜잭션(1960)은 S Lock 획득을 하지 않은 것이지?

중간 정리

너무 많은 헤매임이 있었다. 천천히 정리해보자.
1.
동시 대여 문제를 해결하기 위해, 낙관적 락을 적용하였다.
2.
낙관적 락은, version 컬럼이 있는 엔티티(Cabinet)에 변경사항이 있어야 하므로 userCount라는 컬럼을 추가했다.
3.
매 대여 시마다, userCount가 바뀌므로(UPDATE), Version이 변경된다.
4.
처음 성공한 트랜잭션이 커밋되고, 그 이후에 버전 충돌이 일어나 다른 트랜잭션들은 롤백된다.
5.
하지만 데드락이 발생한다. → 그 이유는, lent_history의 row를 insert하고(여기까지 문제없음), 외래 키 제약조건으로 연관되어 있는 cabinet row의 값을 READ(S Lock 획득), UPDATE(X Lock)할 때 상호 대기 상태에 놓여있게 되기 때문이다.
그림으로 살펴보면 다음과 같다.
(TMI)같이 INSERT하는데 다른 PK를 가지는 이유

다시 해결해보기 - 비관적 락과 낙관적 락

비관적 락으로 해결하는 방법은 꽤 간단하다.
애초에 S Lock을 걸지 못하게, 처음부터 대여 시에 X Lock을 획득한 상태로 처리되게끔 하는 것이다. 이런 식으로 진행한다면, X Lock을 획득하고자 하는 후속 트랜잭션들이 차례차례 처리될 것이다.
@Lock(LockModeType.PESSIMISTIC_WRITE) //<---- 추가된 annotation @Query("SELECT c " + "FROM Cabinet c " + "WHERE c.cabinetId = :cabinetId") Optional<Cabinet> findByIdForUpdate(Long cabinetId);
Java
복사
기존의 대여 서비스에서 Cabinet 엔티티를 CabinetRepository에서 받아오는 메서드를, 위와 같이 @Lock을 걸어주면 각 세션에서 X Lock을 획득하여 이용할 수 있다.
물론, 격리 수준을 더 높게 설정해도 비슷하겠지만, 필요한 부분에 대해서 예외적으로 잠금을 통해 처리한다면 평소에 잠금으로 인한 비용을 더 줄일 수 있을 것이다.
@Override public void startLentCabinet(Long userId, Long cabinetId) { System.out.println("startLentCabinet!!"); Date now = new Date(); // Cabinet cabinet = cabinetRepository.getCabinet(cabinetId); Cabinet cabinet = cabinetRepository.getCabinetForUpdate(cabinetId); // <---- @Lock이 설정된 Cabinet User user = userRepository.getUser(userId); int userActiveLentCount = lentRepository.countUserActiveLent(userId); List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId, DateUtil.getNow());
Java
복사
MySQL에서 사용하는 FOR UPDATE와 동일하게, 해당 읽어온 레코드에 대해서 X Lock을 부여한 상태로 대여 로직을 진행하게 되면, 트랜잭션 하나하나에서 해당 Cabinet에 대한 X Lock을 대기하고, 대기하는 순서대로 처리된다.
getCabinet을 getCabinetForUpdate로 변경해서, 해당 트랜잭션 세션에서 Cabinet 엔티티에 대해 X Lock을 획득하도록 설정한다.
@Test void 동시_대여_테스트_대여자_존재() throws InterruptedException { // 3명이 동시에 3인 상한의 공유 사물함을 대여하는 경우 // 대여 가능한 상황의 유저 ID = [23, 24, 25] Long user1 = 23L; Long user2 = 24L; Long user3 = 25L; // 대여하려는 (1/3) 상태의 공유 사물함 ID = 12 Long cabinetId = 12L; // 스레드를 이용해, 동시적으로 대여를 시도한다. CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user1, cabinetId)); CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user2, cabinetId)); CompletableFuture<Void> future3 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user3, cabinetId)); try { CompletableFuture.allOf(future1, future2, future3).join(); } catch (Exception e) { e.printStackTrace(); } // 비관적 락을 통해, 동시적 대여가 X Lock을 획득하는 순으로 처리된다. (3/3) assertEquals(3, lentRepository.findAllActiveLentByCabinetId(cabinetId).size()); } // 기존의 Future가 꽤 오래된 라이브러리인 것을 찾아보다가 알게 되어서, 최신 라이브러리인 CompletableFuture로 변경했다.
Java
복사
낙관적 락과는 다르게, 요청되는 사항들에 대해서 순차적으로 처리하므로, 3명이 꽉 차고 끝나는 것을 확인할 수 있다.
그림으로 설명하자면 아래와 같다.
낙관적 락을 사용하여 해결해보자.
이 문제를 해결하려면, S Lock과 X Lock 획득요청으로 인한 데드락을 막아야하므로, S Lock이나 X Lock이 걸리는 상황을 제거해야 할 것이다.
하지만, UPDATE를 하는 레코드에 대해서 X Lock은 수반되므로, X Lock을 배제할 수는 없고, S Lock을 배제하여야 한다.
이를 위해서는, S Lock을 사용하지 않게끔 설정하는 방식을 생각해보아야 한다.
다음과 같은 방법을 생각해보자.
1.
lent_history의 cabinet에 대한 외래키 제약 조건을 포기한다. → 외래키 제약조건으로 인해서, cabinet의 레코드까지 S Lock이 전파된 것이므로, 외래키로 이어진 연관관계를 끊으면 S Lock이 걸리지는 않을 것이다. 하지만, 이는 데이터 정합성을 위한 부분을 별도적으로 처리해주어야 하는 방식임과 동시에, RDB의 큰 기능을 포기하는 것으로 느껴져서 직관적으로 썩 좋은 방법같지는 않다.
2.
S Lock과 X Lock이 같이 요청되지 않게끔 코드를 재구성한다. → 사실 이렇게 하는 방법을 생각하지 못하고 있다가, 문득 떠올렸다. 단순히 DB의 구조로 인한 방식이 문제라기보다는, 여러 가지 코드적인 부분에서 조정하여서 사용할 수 있을 것이라는 생각이 들었다.
1번에 대한 생각은 접었으니, 코드를 바꿔보자.
@Override public void startLentCabinet(Long userId, Long cabinetId) { Date now = new Date(); Cabinet cabinet = cabinetExceptionHandler.getCabinet(cabinetId); // <---- 지금은 Non-Locking이지만, Commit시에 S Lock이 걸릴 것이다. // Cabinet cabinet = cabinetExceptionHandler.getCabinetForUpdate(cabinetId); User user = userExceptionHandler.getUser(userId); int userActiveLentCount = lentRepository.countUserActiveLent(userId); List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId, DateUtil.getNow()); // 대여 가능한 유저인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList)); List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId( cabinetId); // 대여 가능한 캐비넷인지 확인 lentExceptionHandler.handlePolicyStatus( lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now)); // 캐비넷 상태 변경 cabinet.increaseUserCount(); cabinet.specifyStatusByUserCount(cabinet.getUserCount()); // <---- 사물함 엔티티의 상태 변경 => UPDATE 예정 cabinetRepository.saveAndFlush(cabinet); // <---- JPA에서 제공하는 기본 메서드, 실행되는 부분에서 알아서 동기화 해준다. Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet, cabinetActiveLentHistories); LentHistory lentHistory = LentHistory.of(now, userId, cabinetId); // 연체 시간 적용 lentPolicy.applyExpirationDate(lentHistory, cabinetActiveLentHistories, expiredAt); lentRepository.save(lentHistory); }
Java
복사
기존과 다른 부분은, UPDATE 쿼리를 수행할(X Lock이 걸릴) 부분에 대해서, repository.save()가 아닌, repository.saveAndFlush()를 추가했다는 점이다.
save()는 엔티티를 영속성 컨텍스트(작은 커밋)에 추가하고, 트랜잭션을 커밋할 때까지 데이터베이스에 실제 저장되지 않는 것이고(지연 저장), saveAndFlush()엔티티를 저장하고, 영속성 컨텍스트를 즉시 플러시하여 데이터베이스에 변경 내용을 동기화하는 메서드라는 것이다. 즉, 트랜잭션의 과정에서 맨 나중에 UPDATE하는 것이 아닌, 미리 UPDATE를 먼저 진행하는 것이다. → 이 방법을 이용하면, 마지막에 S Lock 이후에 X Lock을 요청하는 기존의 상황이 아닌, 미리 UPDATE를 진행하여 외래 키 제약 조건으로 인한 cabinet의 S Lock과 update로 인한 X Lock이 겹칠 일이 없게끔 할 수 있고, 이를 통해서 데드락이 발생하지 않게 하는 것이다.
saveAndFlush()를 이용하면 데드락이 걸리지 않는 이유
@Test void 동시_대여_테스트_대여자_존재() throws InterruptedException { // 3명이 동시에 3인 상한의 공유 사물함을 대여하는 경우 // 대여 가능한 상황의 유저 ID = [23, 24, 25] Long user1 = 23L; Long user2 = 24L; Long user3 = 25L; // 대여하려는 (1/3) 상태의 공유 사물함 ID = 12 Long cabinetId = 12L; // 스레드를 이용해, 동시적으로 대여를 시도한다. CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user1, cabinetId)); CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user2, cabinetId)); CompletableFuture<Void> future3 = CompletableFuture.runAsync(() -> lentService.startLentCabinet(user3, cabinetId)); try { CompletableFuture.allOf(future1, future2, future3).join(); } catch (Exception e) { e.printStackTrace(); } // 버전 충돌을 이용한 낙관적 락을 수행한다. -> 먼저 커밋된 하나만 성공한다. (2/3) assertEquals(2, lentRepository.findAllActiveLentByCabinetId(cabinetId).size()); }
Java
복사
낙관적 락을 수행한 것에 알맞게, 먼저 커밋이 성공한 한명만 대여를 성공하고, 나머지는 실패했다. + 버전 충돌로 인한 ObjectOptimisticLockingFailureException이 발생한 것을 볼 수 있다.
이에 따라 버전 충돌에 대해 원하는 후 처리를 설정하면 될 것이다.
그림으로 보면 다음과 같다.

정리

간단하게 해결할 수 있는 동시성 문제인줄 알았으나, 막상 찾아보니 알아야 할 것들이 너무나 많았다.
하지만 치명적인 문제임과 더불어서 항상 정답은 없되 올바른 판단을 내려야하는 사항이다보니, 더 집중해서 해결해보려고 했다.
비록 이전의 대여/반납 방식은 기존 사용자들이 알아서 반납하고, 알아서 대여하는 방식이어서 낙관적 락이 더 유효했다.
하지만, 곧 바뀔 방식은 특정 시간에 대여 가능한 사물함이 개방되는 방식을 구상하고 있어서, 동시성 문제가 일어날 여지가 기존보다 높다.
더 고민해보고 낙관적 락을 적용할지, 비관적 락을 적용할지 결정할 것 같다.
장장 일주일에 거쳐서 테스트를 수십번 찍어보고, 무한 구글링과 GPT, 공식문서와 참고 도서를 왔다갔다 했는데, 제발 내가 이해한 게 맞았으면 좋겠다.

참고자료