해당 문서는 Cabi 서비스의 핵심이라고 할 수 있는 사물함 대여/반납
기능을 구현중 트랜잭션 관리 문제
로 발생했던 이슈와 그 해결과정을 정리한 것이다.
Cabi 서비스는 기본적으로 다수의 사용자가 존재한다. 즉, 한 번에 여러명이 동시에 요청을 하는 경우가 발생할 수 있다. 그래서 아래 2가지는 반드시 지켜져야 한다.
사물함의 상태
에 관한 자세한 설명은 아래 링크에서 확인하실 수 있습니다.
https://www.notion.so/hyunja/e9b27681cc7740e6be339558c1af4f5d?pvs=4
따라서 대여/반납
은 하나의 트랜잭션 단위로 동작
되도록 구현하였으며 시스템의 대여 절차는 다음과 같다.
도표에는 생략된 절차 (공유 사물함 인원이 다 찰 경우, 대여 이후 캐비넷의 상태를 SET_EXPIRE_FULL
로 업데이트 하는 등)가 있지만, 핵심 로직은 위와 같다.
처음 대여, 반납을 설계할 때에는 트랜잭션에 대한 이해가 부족해 위의 절차(와 생략된 부가 절차) 를 단순히 하나의 트랜잭션으로 묶어서 처리하면 위에서 적은 1번과 2번을 준수하며 문제 없이 처리될 것으로 기대했었다.
그러나…
기능 구현 후 실제로 테스트 해 본 결과 공유 사물함을 대여할 때 제한된 인원수보다 많은 인원이 한 사물함을 대여할 때 인원수를 초과해 대여하는 경우
가 발생하였다.
위 사진처럼 3명이 이용 가능한 공유 사물함에 동시에 6명이 하나의 사물함을 대여하는 요청을 날릴 때 6명 모두 다 대여가 된 것이다..!
이러한 문제가 발생한 원인을 파악하다 보니, 단순히 대여의 과정을 하나의 트랜잭션으로 묶는다면 아래와 같은 문제가 발생할 수 있다는 것을 알게 됐다.
위 상황은 클라이언트 1, 2가 잔여 자리가 하나만 남은 사물함을 동시에 빌릴 때 발생할 수 있는 상황이다.
단순히 어떠한 작업을 하나의 트랜잭션으로 묶으면 여러 요청이 동시에 들어오더라도 독립적으로 처리할 것으로 기대했지만, 실제로는 그렇게 간단한 일이 아니었다...
조사 결과 Cabi팀이 기대하는 대로 트랜잭션을 독립적으로 구성하기 위해서는 트랜잭션 격리 수준
이라는 것을 설정해야 한다는 것을 알게되었다.
팀원 sichoi님께서 관련 내용을 조사하여 이슈로 남겨주셨습니다.
https://github.com/innovationacademy-kr/42cabi/issues/474#issuecomment-1287243004
트랜잭션의 원칙 중 ACID
라는 것이 있다. ACID 원칙은 다음과 같다.
Atomicity
트랜잭션 내의 작업이 일부만 성공하는 일이 없도록 보장하는 원칙이다.
ex) 사물함의 정원만큼 대여가 발생했는데 사물함의 상태가 SET_EXPIRE_FULL
로 설정이 되지 않는 경우는 없어야 한다.
Consistency
트랜잭션이 끝날 때 정책적으로 정한 DB의 제약 조건에 부합하는 상태를 보장하는 원칙이다.
ex) 2명이 대여중인 사물함에 3번째 사람의 대여 요청 트랜잭션이 커밋되면 해당 사물함의 상태는 SET_EXPIRE_FULL
로 설정되어야 하고, 그 때 모든 대여자의 만료 시간이 설정되어야 한다.
Isolation
트랜잭션이 진행되는 도중의 데이터를 다른 트랜잭션에서 Read할 수 없도록 보장하는 원칙이다.
ex) 아직 대여가 진행중인데 다른 트랜잭션에서 해당 사물함을 대여중인 인원을 읽어서는 안된다.
Durability
트랜잭션이 성공했을 경우 해당 결과의 영속성이 보장되어야 하는 원칙이다.
ex) 대여라는 트랜잭션이 성공한 경우 서비스가 갑작스럽게 멈추거나 장애가 생기더라도 대여 결과가 영구적으로 적용되어야 한다.
하지만 ACID 원칙
을 타이트하게 지키면 퍼포먼스가 떨어지므로 Database Engine
은 보통 트랜잭션의 격리 수준을 여러 단계로 지원한다. 때문에 사용 목적에 맞게 트랜잭션의 격리 수준을 유동성 있게 적용해야 한다.
표준으로 정의된 트랜잭션의 격리 수준은 다음과 같다. (모두 InnoDB 기준입니다)
READ UNCOMMITTED
트랜잭션 내에서 SELECT
쿼리 실행시 다른 트랜잭션에서 커밋되기 전 업데이트 된 데이터를 가져올 수 있다. 트랜잭션에 문제가 생겨 업데이트 내용이 롤백이 될 경우 DIRTY READ
현상이 발생할 수 있다.
READ COMMITTED
트랜잭션 내에서 SELECT
쿼리 실행시 커밋이 완료된 결과만 조회할 수 있다.
그렇기 때문에 DIRTY READ
현상이 발생하지 않는다. 커밋이 완료되기 전의 기록이 담길 수 있는 실제 테이블에서 값을 가져오지 않고 UNDO 영역
에 백업된 데이터를 가져오기 때문이다.
이 격리 수준에서는 PHANTOM READ
가 발생할 수 있다.
REPEATABLE READ
하나의 트랜잭션에서 SELECT
쿼리 실행시 그때 당시의 조회 결과를 해당 트랜잭션의 커밋이 끝날 때까지 유지한다. 해당 트랜잭션에서 읽어오는 값은 다른 트랜잭션의 영향을 받지 않는다.
ex) 하나의 트랜잭션에서 1번 사물함을 대여중인 유저의 수를 2라고 읽었다면 몇 번을 다시 조회해도 트랜잭션이 종료되기 전까지는 2라고 읽는다.
이 격리 수준에서는 PHANTOM READ
가 발생하지 않는다.
SERIALIZABLE
하나의 트랜잭션에서 SELECT
쿼리 실행시 그 트랜잭션이 끝날때까지 다른 트랜잭션에서 SELECT
쿼리를 실행한 테이블에 업데이트를 못하게 막는다.
이 격리 수준에선 S Lock
과 X Lock
이라는 것이 있다.
S Lock
: Shared Lock의 줄임말로, Read만 가능하게 Lock을 거는 것이다.
X Lock
: Exclusive Lock의 줄임말로, 특정 트랜잭션에서 데이터를 변경할 때 다른 트랜잭션에서 데이터를 읽거나 쓰지 못하게 Lock을 거는 것이다. (일종의 뮤텍스)
SELECT
쿼리 수행 시, S Lock
이 걸리게 되고, UPDATE
나 INSERT
쿼리 수행 시, X Lock
이 걸리게 된다.
해당 격리 수준에서는 Lock이라는 기능 때문에 잘못된 쿼리를 짜면 데드락
이 발생할 수 있다.
격리 수준을 설정하지 않으면 Database Engine
의 default로 설정된 격리 수준을 따르게 된다.
Cabi팀은 MariaDB
를 사용했기 때문에 MariaDB
의 기본 격리 수준인 REPEATABLE READ
로 설정이 됐었던 것이었다.
위의 격리수준 단계가 있다는 것을 팀원들끼리 공유 및 인지 후, 여러 트랜잭션이 발생할 때 커밋 반영 전 데이터를 가져오기 때문에 문제가 발생했던 것으로 확인했다.
따라서 트랜잭션끼리 순서대로 처리할 것을 명시하는 의도로 SERIALIZABLE
격리 수준을 적용하였다.
이 격리 수준을 적용하면 동시에 여러 요청이 들어오게 되면 요청이 조금이라도 빨리 들어온 순서대로 트랜잭션을 구성하고, 공유 자원에 Lock을 걸 것이기 때문에 다른 트랜잭션의 요청이 중간에 끼어들지 않을 것이라고 생각했다.
즉, 정원보다 많은 인원이 사물함을 대여하는 일은 발생하지 않을 것이라고 판단했다. 실제로 수동으로 여러 클라이언트가 하나의 사물함을 대여할 때 테스트를 수행하면 정상적으로 동작하는 것으로 보였다…!
하지만 동일한 테스트를 몇번 더 수행해보니 이전과는 다른 에러가 발생하였다. 이전에는 논리적으로 문제 가 발생했었지만 조치 후 발생한 문제는 위에서 우려했었던 데드락이 발생
에러였다...
데드락이 발생한 상황 하에서 실행했던 쿼리 로그를 살펴볼 때 처음 생각했던 문제는 다음과 같다.
격리 수준은 적절하게 정리하였는데 트랜잭션이 균일하게 적용되지 않고, 중복된 쿼리문이 많아 문제가 발생한 것이다.
이렇게 판단한 이유는 아래 2가지이다.
디버깅 과정에서 사물함 상태나 유저 상태를 가져올 때 불필요하게 중복으로 데이터를 가져오거나 불필요한 테이블 조인을 하게 구성이 되어있었던 것을 확인했다.
또한 Cabi팀은 기획 단계에서 Domain Driven
방식으로 설계하고자 했기 때문에 최대한 기능을 잘게 쪼개어 Lent
모듈에서 User
, Cabinet
모듈을 호출하는 방식으로 기능 구현을 했다.
이 때문에 하나의 Repository
메소드에서 모든 대여 로직이 처리되는 것이 아닌 다른 Service
메소드를 호출하는 방식으로 로직이 동작했다.
팀원 joopark님의 의견으로 대여 처리는 Domain 단위로 분할하는 것을 조금 포기하되, 원활한 트랜잭션 관리를 위해 최대한 Lent
모듈에서 대여를 처리하고 다른 모듈에서 불러오는 메소드가 불필요한 테이블 조인을 할 경우 해당 메소드는 새로 만드는 것으로 조치를 하였다. 그리고 중복된 테이블 조회와 핸들링되지 않는 예외상황 처리 등의 조치를 하였다.
위와 같이 대여 로직을 리팩토링
하고 나서 다시 테스트를 해 본 결과 문제가 발생하지 않는 것으로 보였다.
사물함 반납 과정에 대해서도 SERIALIZABLE
설정을 하고 나니 새로운 문제가 발생하였다. 동시에 여러 사용자가 동시에 반납을 하도록 테스트를 하였더니 약 10번 중 1번 꼴로 데드락이 발생했다...
팀원들끼리 의아해 했던 점은 대여와 반납 모두 동일하게 SERIALIZABLE
처리를 하였는데 반납에 대해서만 데드락이 발생했다는 점이었다.
따라서 동시에 대여/반납 요청을 할 수 있도록 스크립트를 작성하고, 다시 동시에 대여 요청에 대해 테스트를 진행하니, 대여를 할 때도 데드락이 발생했다. 위에서 진행했던 대여 로직 리팩토링으로는 문제를 해결하지 못했던 것이었다.
이 때문에 Cabi팀은 SERIALIZABLE
격리 수준에 대해 다시금 고민을 하게 되었다..!
문제 직면 후 앱 단에서 문제를 재현하는 것이 아닌 실제로 실행된 쿼리문을 3개의 클라이언트에서 트랜잭션을 적용하여 실행해 보는 실험을 진행했다.
실험 결과, 데드락이 발생하는 조건은 다음과 같다.
1. 클라이언트 1이 사물함을 대여중인 인원 확인을 위해 lent
테이블에 S Lock
2. 클라이언트 2이 사물함을 대여중인 인원 확인을 위해 lent
테이블에 S Lock
3. 클라이언트 1이 lent
테이블에 insert를 하기 위해 X Lock
시도 (S Lock
이 걸려있으므로 대기 상태에 빠짐)
4. 클라이언트 2이 lent
테이블에 insert를 하기 위해 X Lock
시도 (S Lock
이 걸려있으므로 대기 상태에 빠짐)
5. 데드락 발생!!!
두개의 클라이언트가 하나의 대상에 대해 S Lock
을 건 상태에서, 두 클라이언트 모두 X Lock
을 시도하여 모두 대기 상태에 빠졌기 때문에 이러한 일이 발생했다. 이를 변환 교착(Conversion Deadlock)
이라고 한다.
Row-level Lock
S Lock
(Shared Lock)S Lock
을 건다.X Lock
(Exclusive Lock)X Lock
을 건다.Lock과 관련된 규칙은 다음과 같다.
1. 여러 트랜잭션이 동시에 한 row에 S Lock을 걸 수 있다. (여러 트랜잭션이 동시에 하나의 row를 읽을 수 있음)
2. S Lock이 걸려있는 row에 다른 트랜잭션이 X Lock을 걸 수 없다. (다른 트랜잭션이 read하고 있는 row에 대해 write 작업을 할 수 없음)
3. X Lock이 걸려있는 row에 다른 트랜잭션이 S Lock/X Lock을 걸 수 없다. (다른 트랜잭션이 write하고 있는 row에 대해 read/write 작업 모두 불가)
Record Lock
S Lock
과 X Lock
이 존재한다.SERIALIZABLE
격리 수준에서는 Row-level Lock
이 걸린다.
클라이언트 1에서 SELECT 문으로 사물함을 대여중인 인원수를 얻기 위해 lent
테이블을 조회할 때 해당 테이블의 모든 row에 S Lock
을 걸게 된다.
따라서 클라이언트 2도 클라이언트 1의 요청이 끝나기 전에 lent
테이블의 모든 row에 S Lock
을 걸어 접근할 수 있다.
이와 같이 또다른 트랜잭션에서 lent
테이블에 접근이 가능했던 것이 변환 교착
을 일으키는 원인이었다.
따라서 중간에 다른 트랜잭션이 접근하지 못하게 막도록 SELECT .. FOR UPDATE
쿼리문을 이용하여 lent
테이블에 Lock을 걸 때 X Lock
을 걸도록 수정하였다.
select 쿼리문을 실행할 시 X lock
이 걸리도록 명시해주었기 때문에 다른 트랜잭션에서 동일한 row를 접근 하지 못하게 lock이 걸릴 것을 기대했으나 아래와 같은 상황이 발생했다.
아래와 같이 user_id=1
인 유저 joopark가 cabinet_id=1
인 공유 사물함을 대여 중이고 해당 lent_id=1
이고,
user_id=2
인 유저 sichoi가 cabinet_id=1
인 공유 사물함을 대여 중이고 해당 lent_id=2
이고,
user_id=3
인 유저 eunbikim이 cabinet_id=1인
공유 사물함을 대여 중이고 해당 lent_id=3
이라고 가정하겠다.
테이블의 상태는 아래와 같다.
User 테이블
user_id | intra_id |
---|---|
1 | joopark |
2 | sichoi |
3 | eunbikim |
Lent 테이블
lent_id | lent_user_id | lent_cabinet_id |
---|---|---|
1 | 1 | 1 |
2 | 2 | 1 |
3 | 3 | 1 |
Cabinet 테이블
cabinet_id |
---|
1 |
이때 쿼리문이 수행된 결과는 다음과 같다.
A 트랜잭션에서 user_id=1인 유저가 빌린 lent와 cabinet 정보를 조회한다.
🔐 A 트랜잭션에서 lent_id=1인 row에 X Lock
을 건다.
🔐 A 트랜잭션에서 cabinet_id=1인 row에 X Lock
을 건다.
A 트랜잭션에서 cabinet_id=1인 사물함을 대여중인 lent의 개수를 조회한다.
A 트랜잭션에서 lent_id=1인 값을 확인한다.
🔐 A 트랜잭션에서 lent_id=2인 row에 X Lock
을 건다.
A 트랜잭션에서 lent_id=2인 값을 확인한다.
🔐 A 트랜잭션에서 lent_id=3인 row에 X Lock
을 건다.
A 트랜잭션에서 lent_id=3인 값을 확인한다.
A 트랜잭션에서 lent의 개수로 3을 얻는다.
B 트랜잭션에서 user_id=2인 유저가 빌린 lent와 cabinet 정보를 조회한다.
⛔️ A 트랜잭션에서 lent_id=2인 row에 X Lock
을 걸었으므로 unlock할 때까지 기다린다.
C 트랜잭션에서 user_id=3인 유저가 빌린 lent와 cabinet 정보를 조회한다.
⛔️ A 트랜잭션에서 lent_id=3인 row에 X Lock
을 걸었으므로 unlock할 때까지 기다린다.
A 트랜잭션에서 lent_id=1인 값을 삭제하고 반납 프로세스를 처리한 다음 commit한다.
🔓 A 트랜잭션에서 lent_id=1, 2, 3인 row에 걸었던 lock을 해제한다.
🔓 A 트랜잭션에서 cabinet_id=1인 row에 걸었던 lock을 해제한다.
lent_id=3인 row와 cabinet_id=1인 row에 걸린 lock이 해제되었으므로
user_id=3인 유저가 빌린 lent와 cabinet 정보를 조회하기 위해
🔐 C 트랜잭션에서 cabinet_id=1인 row에 X Lock
을 건다.
🔐 C 트랜잭션에서 lent_id=3인 row에 X Lock
을 건다.
B 트랜잭션에서 user_id=2인 유저가 빌린 lent와 cabinet 정보를 조회하기 위해
🔐 B 트랜잭션에서 lent_id=2인 row에 lock을 건다.
⛔️ C 트랜잭션에서 cabinet_id=1인 row에 X Lock
을 걸었으므로 unlock할 때까지 기다린다.
C 트랜잭션에서 cabinet_id=1인 사물함을 대여중인 lent의 개수를 조회한다.
⛔️ B 트랜잭션에서 lent_id=2인 row에 X Lock
을 걸었으므로 unlock할 때까지 기다린다.
7번에서 B 트랜잭션은 C 트랜잭션이 cabinet_id=1인 row에 걸었던 X Lock
을 해제할 때까지 기다리고,
7번에서 C 트랜잭션은 B 트랜잭션이 lent_id=2인 row에 걸었던 X Lock
을 해제할 때까지 기다리므로 deadlock이 발생한다...!
…
SERIALIZABLE
레벨에서는 SELECT가 자동으로 FOR SHARE
, 즉 S Lock
이 되기 때문에 UPDATE와 같은 수준의 X Lock
을 걸어주면 해결될거라 생각했었다.
하지만 이처럼 발생할 수 있는 경우를 충분히 고려하지 않으면 단순히 X Lock
을 거는 것만으로는 데드락 문제를 피할 수 없었다...
문제 상황을 다시 겪은 후 팀원들끼리 기존 코드를 보며 로직에는 문제가 없는지 확인했다.
그 결과 lent
테이블은 다른 테이블과의 조인이 불필요하게 많이 일어난다는 사실과
꼭 트랜잭션 내에 포함되지 않아도 되는 쿼리문이 있다는 사실을 깨닫게 되었고,
트랜잭션 내에서 동일한 row를 한 번만 접근하여 정보를 가져올 수 있는 방법에 대해서도 생각해낼 수 있었다.
해당 문제를 해결하기 위해서 근본적인 해결책이 필요하다 판단해 다음과 같은 대안들을 생각해냈다.
반납 혹은 대여 로직에 대해 변경하는 테이블에 대해 삽입하기 전 조회를 하지 않기
SQL 키워드 활용(eunbikim님이 고안해주신 방법)
변환 교착을 방지할 수 있는 SQL 키워드를 사용하는 방법이다.
lent 테이블 접근 순서 변경
lent
테이블에서 cabinet
을 찾아가는 순서cabinet_id
에 해당하는 모든 lent row에 X Lock
을 건다.user_id
와 일치하는 row를 반납 처리한다.SQL 키워드를 이용하여 교착 상태에 빠지지 않게 방지하는 방법
https://kuaaan.tistory.com/100
lent
테이블에서 대여중인 cabinet_id
를 통해 판단하고, 판단하는 로직은 트랜잭션 밖으로 빼낸다.X Lock
을 걸도록 한다.자세한 도표는 아래와 같다.
유저가 대여중인 캐비넷이 있는지 확인을 트랜잭션 밖에서 진행하고 대여중인 사물함이 존재하면 해당 cabinet_id
를 얻게 된다.
여기서 얻은 cabinet_id
를 바탕으로 트랜잭션 안에서 단 한 번만 SELECT
문으로 조회한다.
이때 cabinet_id
에 해당하는 row와, 해당 캐비넷을 대여중인 모든 lent_id
에 해당하는 row에 X Lock
이 걸린다. (위에서 설명한 예시로 하면 cabinet_id=1인 row, lent_id=1, 2 ,3인 row에 X Lock
이 걸린다.)
트랜잭션 내에서 조회가 한 번만 발생하기 때문에 특정 row에 Lock을 건 상태에서 다른 row에 걸린 Lock이 해제되기를 기다리는 상태가 발생하지 않게 된다.
대여 로직도 비슷한 방식으로 수정하니 이제 정말 대여/반납 과정에서 개요에서 언급했던 반드시 지켜야할 2가지
를 지킬 수 있게 되었다.
- 동시에 여러 사용자가 대여나 반납을 수행한다고 하더라도 동작이 서로간 중첩되어선 안된다. 먼저 들어온 요청을 모두 처리하고 난 후 다음 요청을 처리해야 한다.
- 하나의 요청을 처리하다가 문제가 발생(대여에는 성공했는데 캐비넷의 상태 변경에 실패 등)했을 때 데이터의 무결성을 지키기 위해 해당 트랜잭션 자체를 롤백시켜야 한다.
내가 사용하고 있는 기술에 대해서 정확히 이해하고 사용하는게 얼마나 중요한 것인지 깨닫게 된 것 같습니다.
데이터베이스와 트랜잭션에 대해서 거의 이해하지 못한 상태로 사용법만 익히고 접근하니, 예상하지 못한 문제가 발생했고 그 발생 원인 조차 제대로 파악하기가 어려웠습니다.
뿐만 아니라 발생한 원인을 모른 채로 이런 문제에 직면했을 때 혼자였으면 문제 원인 파악 및 해결을 위해 굉장히 오랜 시간을 쏟았어야 할 것입니다.
하지만 같이 문제 원인을 파악할 동료가 있고 서로 이 문제를 해결하기 위해 필요한 개념들을 공유하다보니 빠르게 이슈를 해결할 수 있었던 것 같습니다! 꼭 이 이슈 해결 뿐만 아니라 프로젝트를 같이 진행하면서 정말 많은 도움을 받은 것 같아 항상 감사합니다 ㅎㅎ 🥹
트랜잭션이라는 것이 알아서 격리를 잘 해줄것이라는 막연한 믿음을 가지고 있었습니다. 아마 트랜잭션의 격리 수준과 같은 내용은 책에서 훓어 지나갔던 거 같았습니다. 당시엔 그런게 있구나 정도로 넘어갔던 것으로 기억합니다.
실제로 여러 사용자가 접근해 DB의 내용을 수정하는 사례를 마주치고, 엄밀한 설계 없이 기능을 구현하여 버그가 발생함을 확인하니 기본기가 중요하다라는 생각을 다시금 깨닳게 되었습니다.
그리고 문제가 발생할 때 문제에 대한 로그 기록을 남기는 일이 중요하다고 생각했습니다. 만약 쿼리 로그를 남기지 않았다면 디버깅할 때 원인을 찾기 어려웠을 것이었습니다. 문제 해결 방식에 대해서도 다시금 생각해보게 되었습니다. 어떤 현상이 발생할 때 그 현상에 대한 원인을 탐구하는 방식에 대해 고민하게 되었습니다.
그리고 좋은 팀원들과 디버깅을 하며 여러 지식들을 공유하게 되서 좋았습니다! 😁
이 문제를 접하기 전에는 트랜잭션에 대해서도 완벽한 이해 없이 도입하고, 코드 리뷰를 했던 것 같습니다. 하지만 이런 문제가 생김으로 인해 팀원분들과 트랜잭션에 대해(격리 수준 등)에 대해 내용을 정리해 문서로 남기거나 직접 설명해주면서 이해도가 높아지고, 필요한 부분에 제대로 도입을 할 수 있게 되었습니다.
혼자서 문제를 해결할 때는 제가 생각하는 방법으로만 문제를 해결했겠지만, 팀원분들과 회의하며 문제 해결을 진행하니 여러 아이디어가 나오고 어떤 방법이 가장 최적일지에 대해 생각하며 문제를 해결하다 보니 문제에 대해 더 깊게 파악하고 좋은 방법을 찾을 수 있었습니다.
실제 사용자가 있는 사이트다 보니 상당히 다양한 경우에 대해 처리해야 하는 부분이 많았는데 이를 통해 많이 배울 수 있었고 테스트의 중요성도 알 수 있었습니다.
열정 있고 실력 좋은 팀원들과 함께하여 제가 혼자 했더라면 적당한 수준에서 타협했을 문제들을 제대로 해결하고 다양한 지식과 경험을 얻을 수 있었습니다! 감사합니다👍