TL;DR
- CockroachDB의
SELECT ... FOR UPDATE는SERIALIZABLE트랜잭션에서 정확성을 보장하는 잠금이 아니라, 경합이 심한 행의 동시 접근을 줄이는 최선 노력형 대기 수단임. SERIALIZABLE격리 수준은 충돌하는 트랜잭션 가운데 최대 하나만 커밋하도록 보장하며, 나머지는 처음부터 다시 실행됨.- 올바른 행을 트랜잭션 초기에 읽거나 쓰면 정확성을 위한 잠금 없이도 직렬화 가능성이 유지됨.
- 경합이 심한 행을 잠금 없이 갱신하면 트랜잭션 전체를 재시도하는 비용이 커질 수 있어
FOR UPDATE가 성능 완화책으로 쓰임. - 잠금은 빠른 메모리 내 비복제 잠금이며, 리스 이전이나 범위 분할·병합 시 해제될 수 있어 단독으로 정확성을 보장하지 못함.
최선 노력형 배타적 잠금이라니?
- 동료가 Kratos에서 서로 다른 요청이 같은 일회용 코드를 사용하지 못하도록 데이터베이스 행에 배타적 잠금을 구현했으며, 이 사례에서는 정확성이 핵심임.
- PostgreSQL과 MySQL에서는
SELECT ... FOR UPDATE로 선택된 행을 잠그며, 일반SELECT는 계속 가능하지만 잠금 방식의 읽기나 쓰기는 트랜잭션이 커밋 또는 롤백될 때까지 대기함. - CockroachDB도 같은 구문을 지원하지만,
SERIALIZABLE격리 수준에서는SELECT ... FOR UPDATE와SELECT ... FOR SHARE를 최선 노력형으로 취급하므로 정확성 보장에 의존해서는 안 됨. - 동시 접근의 의도된 순서가 유지되지 않을 수 있음. 예를 들어 테이블 T를 다루는 트랜잭션 B가 트랜잭션 A 뒤에서 기다려야 하더라도 실제로 기다리지 않을 수 있음.
- 동료는 처음에 트랜잭션 격리 수준을
READ COMMITTED로 낮추려 했으며, 이 수준에서는FOR UPDATE가 기대한 완전한 배타적 잠금으로 동작함. PostgreSQL의 기본값은READ COMMITTED이고 CockroachDB의 기본값은SERIALIZABLE임. - 하지만 낮은 격리 수준에서는 여러 동시성 이상 현상이 발생할 수 있으며, 특히 코드베이스에 서로 다른 격리 수준의 트랜잭션이 있고 같은 행에 접근하면 애플리케이션 로직의 정확성을 유지하기 어려워짐.
- 간헐적으로만 작동하는 뮤텍스나 때때로만 방수되는 우비, 때때로만 제동되는 자동차처럼 보일 수 있지만, 여기서 핵심은 잠금이
SERIALIZABLE트랜잭션 안에서 동작한다는 점임. - CockroachDB는 같은 행을 대상으로 하는 동시 트랜잭션의 읽기-쓰기 또는 쓰기-쓰기 충돌을 감지함.
- 충돌이 발생하면 최대 하나의 트랜잭션만 커밋하고 나머지는 처음부터 재시작하도록 함.
- 일부 경우에는 동시 쓰기가 대기하다가 상대 트랜잭션이 끝난 뒤 이어서 완료됨.
- SQL 표준의 정의에 따르면
SERIALIZABLE격리 수준에서 동시 실행한 트랜잭션은 직렬 실행과 같은 결과를 보장함. 직렬 실행은 각 트랜잭션이 완료된 뒤 다음 트랜잭션이 시작되는 순차 실행임. - 따라서
SERIALIZABLE트랜잭션이 시작할 때 필요한 모든 행에 접근한다면, 예를 들어 더미SELECT나UPDATE my_table SET id = id ...를 수행한다면 잠금 자체는 필요하지 않음. 재시도되는 트랜잭션이 결국 성공하므로 정확성이 유지됨.
왜 존재하는가?
- 콘서트 티켓 판매가 시작될 때 1만 건의 동시 요청이 같은 행을 갱신해 티켓을 사려는 상황에서는 잠금 없는 방식의 재시도 비용이 커질 수 있음.
- 모든 트랜잭션이 같은 행을 먼저 읽고 결제 처리 등 비용이 큰 작업을 수행한 다음 티켓 판매 수를 갱신하면, 하나만 커밋되고 나머지는 트랜잭션 시작부터 재시도됨.
- 이 방식은 최종적으로 올바른 판매 수를 보장하며 이중 증가나 증가 누락을 방지하지만, 전체 트랜잭션을 처음부터 다시 실행하는 비용이 발생함. CockroachDB는 경우에 따라 애플리케이션에 드러내지 않고 서버 측에서 자동 재시도할 수 있음.
- 경합은 작업 자체가 비용이 큰 것이 아니더라도 모두에게 지연을 유발할 수 있음. 가능하다면 차례가 올 때까지 행 갱신을 기다리는 편이 나음.
- CockroachDB의
SELECT ... FOR UPDATE는 바로 이 대기를 유도해 행 갱신의 경합을 줄이는 용도임. - 둘 이상의 트랜잭션이 동시에 시도하더라도 괜찮으며, 모든 트랜잭션이 한꺼번에 작업하는 상황을 줄이는 완화 전략임. 절반만 기다리더라도 성능상 큰 이점이 될 수 있음.
- 최적화된 흐름에서는 트랜잭션 시작 부분에서
SELECT ... FOR UPDATE로 행에 접근해 최선 노력형 잠금을 얻고, 동시 트랜잭션이 그 지점에서 대기한 뒤 결제 처리를 진행하고 티켓 판매 수를 갱신함.
정확히 무엇이 최선 노력형인가?
- CockroachDB 문서에 따르면
SELECT ... FOR UPDATE와SELECT ... FOR SHARE는 빠른 메모리 내 비복제 잠금으로 구현됨. - 잠금이 걸린 범위에서 리스 이전이나 범위 분할·병합이 발생하면 잠금이 해제됨.
- 따라서 이 잠금은 빠르고 단순하지만, 그 자체만으로 정확성을 보장하기에는 완전히 불충분함.
- 잠금은 노드 충돌 같은 장애가 없어도 해제될 수 있음.
결론
SERIALIZABLE은 강력하지만 때로 비용이 큼.- 상황에 따라 쓰기 조건을 추가하거나, 경합이 심한 범위가 아닌 다른 범위에 쓰는 방식이 해법일 수 있음.
- 다른 경우에는 작은 규모의 최선 노력형 대기만으로 충분할 수 있음.
- 관련 세부 사항은 CockroachDB가 키 구간(key interval) 단위로 충돌을 관찰한다는 설명에 있음.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요