Development
Firestore Transaction - 비관적 락의 해석
2025-04-28

재직중인 회사에서 운영하는 애플리케이션에서 동시성 문제가 발생하였습니다. Firestore의 트랜잭션 기능을 급하게 달아놓았지만 서버 라이브러리는 비관적 락으로 동작하므로 다음과 같은 요소들을 추가로 조사해 볼 필요가 있었습니다.
1. 잠금의 단위는 무엇인가? 문서 단위인가? 아니면 특정 속성만 잠금이 가능한가?
2. 잠금이 걸린 문서는 다른 프로세스에서 읽을 수 있는가? 트랜잭션을 사용하지 않은 프로세스에서는 어떻게 동작하는가?
3. 데드락이 걸린다면 타임아웃은 몇 초인가?
경합 상태 시뮬레이션 코드 작성
위 요소를 파악하기 위해 고의로 동시성 문제를 만드는 코드를 작성하였습니다.
const increase = async (threadId) => {
await store.runTransaction(async (tx) => {
console.log(`tx[${threadId}] start`);
const docRef = store.collection('Product').doc('test');
const doc = await tx.get(docRef);
console.log(`tx[${threadId}] read done`);
const newValue = doc.data().value + 1;
console.log(
`tx[${threadId}] update from ${doc.data().value} to ${newValue}`
);
await new Promise((resolve) => setTimeout(resolve, 3000)); //대기
tx.update(docRef, {
[`value`]: newValue,
});
console.log(`tx[${threadId}] end`);
});
}
increase(1);
await new Promise((resolve) => setTimeout(resolve, 1000));
increase(2);increase()는 test 문서의 value을 1 증가시킵니다. 이 과정에서 동시성 문제를 발생시키기 위해 트랜잭션을 바로 커밋하지 않고 3초 대기합니다. threadId를 받아 콘솔에 출력하여 트랜잭션 간 로그를 구분해서 볼 수 있도록 합니다.
이 경우 비관적 락으로 동작하므로 두번째 트랜잭션의 읽기를 대기시킬 것이라 생각했습니다. 예상되는 로그는 다음과 같습니다.
하지만 결과는 완전히 달랐습니다.
Firestore의 서버 라이브러리도 클라이언트 라이브러리와 마찬가지로 낙관적 락과 재시도 방식으로 동작하는 것 처럼 보였습니다. 제가 테스트 해보려고 했던 모든 내용이 무의미해졌습니다.


눈을 씻고 다시 읽어봐도 서버 클라이언트 라이브러리는 이렇게 동작하면 안될텐데라는 생각만 들었습니다. 뭔가가 잘못되었다는 생각에 구글링을 하다 관련 이슈를 찾게 되었습니다.
비관적 락의 해석
Documentation describing transactions as pessimistics is wrong, should be optimistic 이라는 제목의 깃허브 이슈입니다.
이슈 발행자는 Firestore의 서버 라이브러리의 락은 비관적 락이 아닌 낙관적 락이라며, 문서가 잘못되었음을 주장하고 있습니다.
The way runTransaction works is it tries to commit changes at the end of the transaction and if it is unable to because of conflicts it retries - that is optimistic. You can have 2 transactions running at the same time operating on the same document
동일한 문서를 서로 다른 두 트랜잭션이 동시에 읽을 수 있고, 상황에 따라 재시도가 발생하는 점을 근거로 들고 있습니다. 이는 일반적인 데이터베이스에서 SELECT ... FOR UPDATE를 사용한 비관적 락의 동작 방식과 비교해 볼 때 충분히 납득할 수 있는 주장입니다. 보통 SELECT ... FOR UPDATE 쿼리를 통해 락을 설정하는 순간, 다른 세션에서 동일한 레코드를 SELECT ... FOR UPDATE로 읽으려는 시도는 락이 해제될 때까지 대기하게 되기 때문입니다.
한편 라이브러리의 기여자는 다음과 같은 이유로 Firestore 서버 라이브러리는 비관적 락이며, 문서를 수정할 계획이 없다고 하였습니다.
If you have two transactions running that both try to read the same document, the first one will succeed and the second one will hang until the first one completes.
두 트랜잭션이 같은 문서를 읽을 때, 첫번째 트랜잭션은 성공할 것이고 두번째 트랜잭션은 첫번째 트랜잭션이 완료될 때 까지 기다린다는 것을 그 이유로 들었습니다. 이를 이해하기 위해 새로운 케이스를 시뮬레이션 해보았습니다.
const increase = async (threadId, ms) => {
await store.runTransaction(async (tx) => {
console.log(`tx[${threadId}] start`);
const docRef = store.collection('Product').doc('test');
const doc = await tx.get(docRef);
console.log(`tx[${threadId}] read done`);
const newValue = doc.data().value + 1;
console.log(
`tx[${threadId}] update from ${doc.data().value} to ${newValue}`
);
await new Promise((resolve) => setTimeout(resolve, ms)); //대기
tx.update(docRef, {
[`value`]: newValue,
});
console.log(`tx[${threadId}] end`);
});
}
increase(1, 5000);
await new Promise((resolve) => setTimeout(resolve, 1000));
increase(2, 1000);트랜잭션 도중에 대기하는 시간을 파라미터로 받는다는 점만 바뀌었습니다. 첫번째 트랜잭션은 5초의 시간을 두었고, 두번째 트랜잭션은 1초의 시간만 두었습니다. 이는 두 번째 트랜잭션의 작업이 더 빨리 종료되게 만들기 위함입니다. 결과는 다음과 같습니다.
tx[2]가 먼저 작업을 마쳤음에도, 해당 문서에 대한 쓰기 락은 tx[1]이 가지고 있었기에 대기하는 모습을 보였습니다. tx[1]이 커밋된 이후 tx[2]는 문서의 변경을 감지하고 재시도 합니다.
결국 핵심은 다음과 같습니다.
서버 트랜잭션(비관적 락)은 쓰기 잠금을 획득할 때 까지 기다린 후 쓰기를 시도하고, 필요에 따라 재시도한다. 반면, 클라이언트(낙관적 락)는 즉시 쓰기를 시도한다.
일단 문서에 대한 락을 획득하면 다른 스레드에서는 해당 문서에 대한 쓰기 작업을 수행할 수 없습니다. 쓰기 권한을 얻을 때까지 대기해야합니다. 이러한 근거에 따르면 Firestore의 서버 라이브러리의 락이 비관적 락이라는 것은 합당해 보입니다. 저도 처음에는 아이러니 했으나, 동작 방식을 살펴보고 나니 이에 공감이 됩니다.
이는 낙관적 락으로 동작하는 클라이언트 라이브러리와 비교해보면 그 차이가 더 분명하게 드러납니다. 클라이언트 라이브러리에서는 쓰기에 대한 권한을 점유하는 개념이 존재하지 않습니다. 그저 트랜잭션이 커밋되는 순간 문서의 버전을 감지하여 그 사이에 변경이 되었다면 재시도하고, 아니라면 저장을 할 뿐입니다.
그럼에도 불구하고 문서가 잘못 작성되었다는 점에서도 매우 동의합니다. 이 문제는 서버 라이브러리의 동작이 낙관적 락이냐 비관적 락이냐를 떠나있습니다. Firestore의 공식 문서는 동작을 예상하기에 어렵고 오해할 수 있는 많은 소지를 갖고 있는 것 같습니다.