😎 Daily
[42 Cabi] 사물함 대여/반납 동시성 문제와 해결 과정
date
Mar 27, 2023
slug
42cabi-concurrency
author
status
Public
tags
42Cabi
SQL
summary
42 Cabi 프로젝트 수행 시, RDB의 동시성 문제에 직면하고 이를 해결한 과정입니다.
type
Post
thumbnail
category
😎 Daily
updatedAt
Apr 28, 2023 01:59 AM
이 글은 2022년 10월 경 42 Cabi 프로젝트에서 사물함 대여와 반납 기능 테스트 중 발생한 에러와 그를 해결하기 위한 과정을 백앤드 팀원분들과 공동으로 정리한 글입니다.
🫠 개요
Cabi 서비스는 사물함을 대여하고 반납하는 기능이 필수적입니다. 이 서비스는 다수의 사용자가 이용하며, 하나의 사물함을 여러 명이 사용할 경우 적절하게 처리해야 합니다.
최근에는 만족할만한 수의 사물함이 부족하다는 문제가 지속되어, 이에 대한 대응으로 공유 사물함 기능을 개발하였습니다. 이 기능은 하나의 사물함을 여러 명의 사용자가 공유하여 사용하는 것을 의미합니다. 그러나 이 기능을 공식 배포하기 전에 테스트를 수행해보니 문제가 발생했습니다.

새롬관 2F 142 사물함은 최대 3명이 이용 가능한 사물함인데, 동시에 여러 명의 인원이 대여 시도를 할 때 6명 모두 대여가 되는 문제가 발생했습니다.🪨 문제 분석
사물함의 대여 로직과 그에 따른 쿼리 실행 순서입니다.
- 대여하는 사람이 기존에 대여했는지 확인하기 위해, 대여 기록을 저장하는 테이블(lent)과, 사물함 테이블(cabinet)을 조회함.
- 대여하고자 하는 사물함이 대여중인지 확인
- 대여 기록에 대여 정보 삽입
그리고, 위 요청을 처리할 때엔 반드시 아래 두가지 상황은 지켜져야 합니다.
- 동시에 여러 사용자가 대여나 반납을 수행한다고 하더라도 동작이 서로간 중첩되어선 안된다. 먼저 들어온 요청을 모두 처리하고 난 후 다음 요청을 처리해야 한다.
- 하나의 요청을 처리하다가 문제가 발생(대여에는 성공했는데 캐비넷의 상태 변경에 실패 등)했을 때 데이터의 무결성을 지키기 위해 해당 트랜잭션 자체를 롤백시켜야 한다.
코드로 위 로직을 설계할 때엔, 위의 동작을 하나의 트랜잭션으로 묶었고,
트랜잭션 처리를 했으니 알아서 잘 되겠지 라는 안일한 생각으로 구현하였으나, 실제 동작은 다음과 같이 일어났기에 중복 대여가 발생했습니다.위의 흐름은 두명의 클라이언트가 하나의 사물함을 대여할 때 발생하는 상황입니다.
- 클라이언트 1이 사물함을 대여 시도함. 곧바로 클라이언트 2가 동일한 사물함에 대여 시도함.
- 클라이언트 1이 대여 처리 됨. 하지만 클라이언트 2가 대여 시도를 하는 시점에서, 클라이언트 1이 대여 하기 전의 정보를 가지고 있으므로, 클라이언트 2도 대여 처리를 함.
- 따라서 정원보다 많은 클라이언트가 사물함을 대여함.
트랜잭션에 대한 깊은 이해 없이 트랜잭션 처리를 하면 동작이 atomic하게 발생할 것이라 생각했지만, 실제로는 하나의 트랜잭션으로 감싸져 있다고 하더라도 atomic하게 동작되지 않기 때문에 문제가 발생했습니다.
문제를 인지하고, 팀원분들끼리 문제 해결을 위해 트랜잭션에 대해 더 자세히 학습하기로 하였습니다.
📖 트랜잭션의 원칙과 격리 수준
팀원 sichoi님께서 관련 내용을 조사하여 이슈로 남겨주셨습니다. https://github.com/innovationacademy-kr/42cabi/issues/474#issuecomment-1287243004
트랜잭션
트랜잭션은 여러 쿼리를 하나로 묶음 처리를 하여 쿼리 실행 중 에러가 발생하면
ROLLBACK을 시키거나, 성공하면 DB에 반영하는 COMMIT 을 실행하는 동작의 논리적 단위입니다.트랜잭션의 속성 → ACID 원칙
트랜잭션은 여러 쿼리로 이루어져 있으므로, 이에 대해 발생할 수 있는 이슈가 있기 때문에 다음 원칙을 고려해야 합니다.
Atomicity트랜잭션으로 묶인 작업이 쪼갤 수 없는 원자성을 가지고 있어야 합니다. ex) 사물함의 정원만큼 대여가 발생하면, 사물함의 상태는 반드시사물함 정원 초과로 설정되어야 합니다.
Consistency트랜잭션의 동작이 무결성을 보증하는, 일관성이 있어야 하는 원칙입니다. ex) 2명이 대여중인 사물함에 3번째 사람의 대여 요청 트랜잭션이 커밋되면 해당 사물함의 상태는사물함 정원 초과로 설정되어야 하고, 그 때 모든 대여자의 만료 시간이 설정되어야 합니다.
Isolation트랜잭션이 진행되는 도중의 데이터를 다른 트랜잭션에서 읽어 올 수 없도록 보장하는 원칙입니다. ex) 아직 대여가 진행중인데 다른 트랜잭션에서 해당 사물함을 대여중인 인원을 읽어서는 안된다.
Durability트랜잭션이 성공했을 경우 해당 결과의 영속성이 보장되어야 하는 원칙입니다. ex) 대여라는 트랜잭션이 성공한 경우 서비스가 갑작스럽게 멈추거나 장애가 생기더라도 디스크에 저장되어 영구적으로 저장되어야 합니다.
트랜잭션의 격리 수준
트랜잭션은 항상 ACID 원칙을 지키게 설정되어야 합니다. 하지만 모든 상황에 대해 ACID 원칙이 적용된다면 성능상 이슈가 발생할 수 있습니다. 예를 들면 모든 트랜잭션이 원자성을 엄밀히 지킨다면, 한번에 하나의 트랜잭션만 실행하게 되므로 성능 상의 이슈가 발생할 것입니다. 반대로 동시성을 지키기 위해 원자성을 무시한다면, 특정 쿼리에선 현재 사물함 대여 이슈처럼, 문제가 발생할 수 있습니다.
따라서 트랜잭션은 상황에 따라 유연하게 ACID 원칙을 지킬 수 있으며 InnoDB 기준 4가지의 격리 수준을 지원합니다.
READ UNCOMMITTED트랜잭션 내에서SELECT쿼리 실행시 다른 트랜잭션에서 커밋되기 전 업데이트 된 데이터를 가져올 수 있습니다. 하지만 업데이트 되기 전의 값을 가져오기 때문에DIRTY READ현상이 발생할 수 있습니다.- 장점 : 동시 처리 시에 성능이 가장 좋음.
- 단점 : 격리 관련 이슈가 발생할 확률이 가장 높음 (
DIRTY READ,PHANTOM READ,NON-REPEATABLE READ)
READ COMMITTED트랜잭션 내에서SELECT쿼리 실행시 다른 트랜잭션에서 커밋이 완료된 결과만 조회할 수 있기 때문에DIRTY READ현상이 발생하지 않습니다. 보통 아무런 격리 수준을 걸지 않으면 이 격리 수준으로 설정됩니다.- 장점 : 반영되지 않은 값을 함부로 가져오지 않아,
DIRTY READ가 발생하지 않음. - 단점 : 트랜잭션 내에서 특정 테이블에 대한 값을 여러번 조회하면, 동시성 이슈가 생길 수 있음. (
PHANTOM READ,NON-REPEATABLE READ)
REPEATABLE READ하나의 트랜잭션에서SELECT쿼리 실행시 그때 당시의 조회 결과를 해당 트랜잭션의 커밋이 끝날 때까지 유지합니다. 그래서 해당 트랜잭션에서 읽어오는 값은 다른 트랜잭션의 영향을 받지 않습니다. 예를 들면, 하나의 트랜잭션에서 1번 사물함을 대여중인 유저의 수를 2라고 읽었다면 몇 번을 다시 조회해도 트랜잭션이 종료되기 전까지는 2라고 읽습니다.- 장점 : 트랜잭션 내에서 특정 테이블에 대한 값을 여러번 조회하는 경우 동일한 값을 반환함.
- 단점 : 트랜잭션이 시작될 때 가져온 값을 토대로 동작하기 때문에,
PHANTOM READ가 발생하여 동시성 이슈가 생길 수 있음.
SERIALIZABLE하나의 트랜잭션에서SELECT쿼리 실행시 그 트랜잭션이 끝날때까지 다른 트랜잭션에서SELECT쿼리를 실행한 테이블에 업데이트를 못하게 막습니다.- 장점 :
PHANTOM READ도 발생하지 않아, 원자성을 가장 완벽하게 지킴. - 단점 : 성능이 가장 낮으며, 잘못된 쿼리 작성 시 데드락이 발생할 수 있음.
현재 대여 또는 반납 트랜잭션 설정은
READ COMMITTED 이기 때문에, PHANTOM READ 가 발생하여 결함이 발생하였습니다.🤔 1차 문제 해결 과정
🫠 대여 로직 리팩토링
Cabi 백앤드 코드는 DDD를 적용하여, 여러 도메인으로 기능을 분할하였고, 트랜잭션을 여러 도메인에 걸쳐서 실행합니다. 때문에 대여 처리를 할 때 위의 설명보다 실제로 여러개의 쿼리를 실행하게 됩니다.
팀원 joopark님이 조사한 바에 따르면, 트랜잭션 내엔 방드시 필요한 쿼리를 삽입하는 것이 좋다고 합니다. 따라서 대여 처리는 Domain 단위로 분할하는 것을 조금 포기하되, 원활한 트랜잭션 관리를 위해 대여에 꼭 필요한 부분만 트랜잭션으로 묶도록 리팩터링을 진행하였습니다.
🫠 SERIALIZABLE 설정과 이유
팀원분들과 지식 공유 후, 기존의 대여 트랜잭션에서
DIRTY READ 가 발생하기 때문에 동시성 문제가 생기는 것으로 이해하였습니다. 따라서 문제를 해결하기 위해 트랜잭션에 대해 SERIALIZABLE 을 적용하였습니다.DIRTY READ 가 발생했는데 SERIALIZABLE 을 적용한 이유는, 위 도표에서 SQL 쿼리의 실행은 백앤드 코드 상에서 실행이 되며, SELECT문 실행시 값을 받아 코드 내 변수에 저장하며 그 값을 토대로 다음 쿼리를 실행합니다. 이 과정에서 자연스럽게 DIRTY READ 가 발생하며 이는 READ COMMITTED 나 REPEATABLE READ 로 막을 수 없습니다.따라서 다른 트랜잭션에서 값을 업데이트하여 발생하는 에러를 막기 위해
SERIALIZABLE 격리 수준을 적용하였습니다.🚨 동시 대여는 괜찮지만, 동시 반납시 문제 발생
기존에는 팀원들이 직접 모여 수동으로 동시 대여, 반납을 테스트 하였지만, 너무 번거롭기 때문에 curl을 사용하여 동시에 요청을 날리는 스크립트를 제작하여 테스트를 진행해보았습니다.
테스트 결과 동시 대여에 대해선 문제가 해결된 것으로 보였습니다. 하지만 동시 반납에 대해선 간헐적으로
500 Server Error가 발생하였습니다.로그를 찾아보니, RDB 내에서 데드락이 발생하여 서버 에러가 발생한 것이였습니다.
🪨 2차 문제 분석
RDB 상에서 데드락 문제를 맞닥뜨린건 처음이라, 면밀하게 분석하기 위해 데드락이 발생하는 조건에 대해 쿼리를 추출하여, 서로 다른 클라이언트에서 한 줄씩 테스트를 진행해 보았습니다.
실행한 쿼리와 데드락이 발생하는 상황은 다음과 같습니다.
- 클라이언트 1이 사물함을 대여중인 인원 확인을 위해
lent테이블에S Lock
- 클라이언트 2이 사물함을 대여중인 인원 확인을 위해
lent테이블에S Lock
- 클라이언트 1이
lent테이블에 insert를 하기 위해X Lock시도 (S Lock이 걸려있으므로 대기 상태에 빠짐)
- 클라이언트 2이
lent테이블에 insert를 하기 위해X Lock시도 (S Lock이 걸려있으므로 대기 상태에 빠짐)
- 데드락 발생!!!
간단하게 말하면, 두개 이상의 클라이언트가
lent 테이블의 동일한 컬럼에 대해 점유 대기를 하는 상황에서 서로가 lock을 풀기 위해 기다리는 상해가 되어 데드락이 발생하였습니다.팀원분들끼리 문제 해결을 위해 DB Lock에 대해 더 자세히 학습하기로 하였습니다.
📖 RDBMS의 Lock (InnoDB 기준)
Lock
같은 자원에 접근하는 다중 트랜잭션 환경에서 일관성과 무결성을 유지하기 위해 자원에 Lock을 걸 수 있습니다.
공유 Lock (Shared Lock → S Lock)
공유 Lock이 걸린 데이터에 대해서 여러 트랜잭션에서 읽기를 허용합니다. 공유 Lock이 걸린 데이터에 대해선 배타적 Lock을 걸 수 없습니다.
배타적 Lock (eXclusive Lock → X Lock)
데이터를 변경할 때, 다른 트랜잭션에서 읽기를 허용하지 않습니다.
Blocking
X Lock이 걸린 데이터에 대해 다른 트랜잭션에서 대기하는 상황입니다.
Deadlock
두개 이상의 트랜잭션에서 하나의 데이터에 대해 S Lock을 건 상태에서 X Lock을 걸기 위한 시도를 하면, 경합이 발생합니다.
InnoDB의 Lock
FOR SHARE: read 작업 시, 각 row에 S Lock을 겁니다.
FOR UPDATE, UPDATE, DELETE: write 작업 시, 각 row에 X Lock을 겁니다.
SERIALIZABLE 의 Lock
SERIALIZABLE 격리 수준에서, InnoDB는 read / write 각 상황에 대해 위와 같은 Lock을 겁니다.원인은 2-Phase Locking
트랜잭션 내에서 여러 Lock을 걸 때, 모든 Lock을 모든 Unlock보다 먼저 수행하도록 하는 프로토콜입니다.
트랜잭션 간, Expanding Phase (락을 취득하는 절) 과 Shrinking Phase (락을 반환하는 절) 이 충돌하였기 때문에 데드락이 발생하는 것입니다.
🤔 2차 문제 해결 시도 (Conservative 2PL)
🫠 점유 대기 부정 시도…
Deadlock의 방지 방법 중
Hold and Wait 부정 이 있습니다. 이는 사용할 리소스를 점유한 상태에서 새 리소스를 획득하는 것이 아니라, 하나의 프로세스가 모든 프로세스를 점유하게 보장하여 점유 대기 상태를 부정하여 데드락을 방지하는 방법입니다.이 해결 방법을 도입하기 위해 conservative 2PL을 도입하려 시도하였습니다. 접근하는 모든 row에 대해 lock을 걸어, 성능 하락은 존재하겠지만 데드락 자체를 방지하기 위함입니다.
SERIALIZABLE 격리 수준에선, SELECT 쿼리 실행시에 S Lock을 겁니다. 따라서 다른 트랜잭션이 동일한 쿼리를 점유할 수 있기 때문에, 트랜잭션 시작 시 접근하는 테이블에 대해 X Lock을 걸어 다른 트랜잭션이 실행 중일 때, 접근 자체를 하지 못하도록 막도록 시도하였습니다.비록 효율은 떨어지지만, 한번에 하나의 요청만 처리하는 방식이 됩니다.
🚨 여전히 Deadlock 발생
하지만 기대와 다르게 여전히 Deadlock이 발생하였습니다. 원인을 찾기 위해 쿼리문을 순차적으로 다시 실행하며 분석해 보았습니다.
아래는 joopark, sichoi, eunbikim이 사물함을 반납할 때 테이블과 실제 실행을 간소화한 시뮬레이션입니다.
User 테이블
user_id | intra_id |
1 | joopark |
2 | sichoi |
3 | eunbikim |
Cabinet 테이블
cabinet_id |
1 |
Lent 테이블
lent_id | lent_user_id | lent_cabinet_id |
1 | 1 | 1 |
2 | 2 | 1 |
3 | 3 | 1 |
- A 트랜잭션에서 user_id=1인 유저가 빌린 lent와 cabinet 정보를 조회한다. (
SELECT … FROM Lent JOIN User, Cabinet WHERE user_id = 1) 🔐 A 트랜잭션에서 Lent 테이블의 lent_id=1인 row에X Lock을 건다. 🔐 A 트랜잭션에서 Cabiner 테이블의 cabinet_id=1인 row에X Lock을 건다.
- A 트랜잭션에서 cabinet_id=1인 사물함을 대여중인 lent의 개수를 조회한다. (
SELECT COUNT(*) FROM Lent WHERE lent_cabinet_id = 1)
A 트랜잭션에서 lent_id=1인 값을 확인한다. (이미 X Lock이 걸려있으므로 Pass)
🔐 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 정보를 조회한다. (
SELECT … FROM Lent JOIN User, Cabinet WHERE user_id = 2) ⛔️ A 트랜잭션에서 lent_id=2인 row에X Lock을 걸었으므로 unlock할 때까지 기다린다.
- C 트랜잭션에서 user_id=3인 유저가 빌린 lent와 cabinet 정보를 조회한다. (
SELECT … FROM Lent JOIN User, Cabinet WHERE user_id = 2) ⛔️ A 트랜잭션에서 lent_id=3인 row에X Lock을 걸었으므로 unlock할 때까지 기다린다.
- A 트랜잭션에서 lent_id=1인 값을 삭제하고 반납 프로세스를 처리한 다음 commit한다. (
DELETE Lent WHERE lent_id = 1) 🔓 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할 때까지 기다린다.간단하게 요약하면, 기대와 다르게
Hold and Wait 부정 이 이루어지지 않았습니다. 왜냐하면 트랜잭션 시작 시, 락을 row에 걸기 때문에 서로 다른 row에 접근하면 한번에 하나의 트랜잭션만 실행되지 않습니다.이 때문에 쿼리문을 실행하며 복잡하게 얽혀있는
join 문들 간 lock이 걸려 데드락이 발생하였습니다.💡 근본적인 문제 해결을 위한 해결책들
문제를 해결하기 위해 좀 더 근본적인 해결책이 필요하다 생각하여, 팀원분들끼리 대안들을 생각해 보았습니다.
대안 1. 쿼리문 단축 → 변경할 테이블에 대해 삽입 전 조회하지 않기
- 해당 방법은 현실성이 부족합니다. 왜냐하면 현재 상태를 알아야 다음 상태를 정의할 수 있기 때문입니다.
대안 2. FOR UPDATE 키워드 활용
- 기존 대여 프로세스의 변경을 하지 않고, 적용할 수 있다는 장점이 있습니다.
- 하지만 현재 문제는 여러 테이블의 join으로 예상하지 못한 테이블에 lock이 걸려서 발생하였기 때문에, 해당 키워드가 반드시 해결을 보장해주지 않습니다.
대안 3. 트랜잭션 로직 더 간소화
- 현재는 트랜잭션 내에서 user_id를 통해
Lent테이블에서Cabinet테이블을 찾아갑니다.
- 트랜잭션 외부에서 user_id를 통해 캐비넷 ID를 가져오고, 트랜잭션 내부에서
Lent테이블과Cabinet테이블을 찾아갑니다.
대안 4. 불필요한 join 최소화
- 현재 문제는 예상하지 못한 row에 lock이 걸린 것이 근본적인 원인입니다. 따라서 불필요한 join을 최소화합니다.
💡 최종 해결 방법 (데드락 회피)
최종 해결 방법으로 Deadlock Prevention (데드락 방지) 가 아닌, Deadlock Avoidance (데드락 회피) 를 선택하였습니다. 데드락이 발생할 수 있다는 것을 인정하되, 교착 상태를 최대한 방지합니다.
교착 상태를 회피하기 위한 방법들은 아래와 같습니다.
- 트랜잭션 단위를 최소화
대여중인지 아닌지는
lent테이블에서 대여중인cabinet_id를 통해 판단하고, 판단하는 로직은 트랜잭션 밖에서 처리합니다.
- 불필요한 테이블 조인 최소화 캐비넷 정보를 가져올 때 불필요한 user 테이블에도 조인을 하지 않도록 수정하여, 예상치 못한 Lock을 방지합니다.
- 트랜잭션 내에서 테이블 조회 최소화
트랜잭션 내에서 딱 한 번만 조회가 발생하고, 조회를 할 때 반납에 필요한 모든 자원에
X Lock을 걸도록 합니다.
해결 방법을 토대로 로직을 개선하였습니다. 자세한 도표는 아래와 같습니다.
먼저, 유저가 대여중인 캐비넷이 있는지 확인을 트랜잭션 밖에서 진행하고 대여중인 사물함이 존재하면 해당
cabinet_id를 얻게 됩니다.여기서 얻은
cabinet_id를 바탕으로 트랜잭션 안에서 단 한 번만 SELECT 문으로 조회하며, 불필요한 Lock을 방지합니다.이때
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가지 를 지킬 수 있게 되었습니다.1. 동시에 여러 사용자가 대여나 반납을 수행한다고 하더라도 동작이 서로간 중첩되어선 안된다. 먼저 들어온 요청을 모두 처리하고 난 후 다음 요청을 처리해야 한다. 2. 하나의 요청을 처리하다가 문제가 발생(대여에는 성공했는데 캐비넷의 상태 변경에 실패 등)했을 때 데이터의 무결성을 지키기 위해 해당 트랜잭션 자체를 롤백시켜야 한다.