우리가 프로그램을 사용하다 보면 여러 사람의 요청이 충돌하는 경우가 있다. 예를 들면 한정 판매(콘서트, 극장 예매)나 수강 신청 같은 경우 말이다. 이런 요청들을 데이터베이스 및 시스템 관점에서 트랜잭션(Transaction)
이라고 부른다.
트랜잭션이란?
트랜잭션은 데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 논리적인 단위다.
프로그램의 데이터는 일반적으로 데이터베이스(DB)
에서 관리되는데 이 DB의 데이터에 여러 트랜잭션이 동시에 접근하고 변경할 때, 별도의 조치를 하지 않는다면 여러 오류가 발생할 수 있다. 물건 A의 수량이 1개 남았는데 그것을 2명이 모두 주문에 성공했다면 어떻게 될까? 판매자 입장에선 상당히 난처하겠지만 어느 정도 고객 응대로 해결 할 수 있는 문제다. 하지만 자릿수가 정해진 극장이나 지하철 티켓 예매의 경우는 어떨까? 더 나아가서 경쟁이 매우 치열한 콘서트 티켓 예매 같은 경우는 어떨까?
이렇게 무수히 많은 불특정 다수의 사람들이 동시에 하나의 데이터에 접근해서 수정을 시도하면 어떻게 될까? 1좌석에 2명 이상의 사람이 예약되는 등 데이터가 꼬여서 엉망이 돼버릴 수도 있을 것이다.
그만큼 데이터의 변경과 접근에는 특별한 관리가 필요한데 이런 경우 사용되는 핵심적인 기법이 바로 동시성 제어(Concurrency Control)다.
동시성 제어란?
동시성 제어란 여러 개의 트랜잭션이 동시에 실행될 때, 데이터베이스의 일관성 (Consistency)과 무결성 (Integrity)을 유지하기 위한 기법이다.
동시성 제어의 목적
동시성 제어의 주된 목적은 다음과 같다.
데이터 일관성 유지
여러 트랜잭션이 동시에 실행되더라도 데이터베이스의 상태가 모순되지 않고 일관된 상태를 유지하도록 한다.
여러 사람이 동시에 마지막 남은 한 좌석을 예매하려고 시도하는 상황을 생각해보자. 동시성 제어가 없다면, 시스템은 A라는 사람에게도 예매 가능으로 보이고, 동시에 B라는 사람에게도 예매 가능으로 보일 수 있다. 두 사람 모두 결제를 시도하고, 최악의 경우 두 사람 모두에게 예매 성공 메시지가 뜨지만 실제 좌석은 하나뿐인 모순된 상황이 발생할 수 있다.
또는, 좌석 정보가 실시간으로 정확히 반영되지 않아 이미 판매된 좌석이 계속해서 빈 좌석으로 보이거나, 그 반대의 경우가 생길 수 있다.
티케팅 시스템에서 동시성 제어는 마치 안내원과 같다. 여러 예매 요청이 동시에 들어오더라도, 각 요청이 데이터베이스의 규칙을 위반하지 않고 올바르게 처리되도록 안내하여, 하나의 좌석은 단 한 명의 구매자에게만 할당되는 등 데이터베이스(좌석 정보)의 상태가 항상 모순 없이 일관되게 유지되도록 관리한다.
데이터 무결성 보장
데이터베이스에 저장된 데이터의 정확성과 완전성을 보장한다.
데이터 무결성은 티켓팅 과정에서 데이터가 정의된 규칙과 제약 조건을 준수하는 것을 의미한다.
예를 들어, 존재하지 않는 구역이나 좌석 번호로 티켓이 예매되지 않도록 하거나(도메인 무결성), 한 좌석에 대해 여러 개의 유효한 티켓이 발급되지 않도록 하며(개체 무결성 및 사용자 정의 무결성), 티켓 구매 시 회원 정보나 결제 정보가 올바르게 연결되도록 보장한다(참조 무결성).
동시성 제어는 여러 트랜잭션(예매 시도)이 동시에 발생할 때, 각각의 트랜잭션이 이러한 유효한 규칙과 제약 조건을 어기지 않도록 관리하여, 데이터베이스에 기록되는 모든 티켓 정보가 정확하고 완전하며 유효한 상태를 유지하도록 돕는다. 이는 동시에 발생하는 변경 작업들이 데이터의 유효성을 훼손하지 않도록 보장하는 핵심적인 역할이다.
시스템 활용도 및 처리량 향상
여러 트랜잭션이 동시에 병렬적으로 실행될 수 있도록 하여 시스템 자원의 활용도를 높이고 전체 처리량을 향상시킨다.
인기 가수의 티케팅은 수많은 접속자가 동시에 몰리는 극한의 상황이다. 만약 시스템이 한 번에 하나의 예매 요청만 처리할 수 있다면(즉, 동시성 제어가 없다면), 사용자들은 하염없이 기다려야 하고, 티켓 오픈 후 몇 시간, 며칠이 지나도 예매를 완료하지 못하는 사람이 부지기수일 것이다. 이는 시스템 자원(서버, 네트워크 등)을 효율적으로 사용하지 못하는 것이다.
동시성 제어는 단순히 순서를 지키는 것뿐만 아니라, 여러 사용자가 동시에 시스템에 접속하여 티켓을 조회하고 예매를 시도할 수 있도록 지원한다. 마치 여러 개의 창구를 동시에 열어 많은 사람들의 요청을 병렬적으로 처리함으로써, 시스템 전체의 응답 속도를 높이고 단위 시간당 더 많은 예매를 처리(처리량 향상)할 수 있게 해 준다. 물론, 이 과정에서 앞서 말한 일관성과 무결성이 깨지지 않도록 하는 것이 핵심이다.
🔒 잠금 전략
그리고 이 동시성 제어를 구현하는 대표적인 두 가지 전략이 비관적 잠금(Pessimistic Locking)과 낙관적 잠금(Optimistic Locking)이다.
🚫비관적 잠금 (Pessimistic Locking)
비관적 잠금은 데이터 충돌 가능성을 높게 보고, 미리 잠금(Lock)을 설정하여 다른 트랜잭션의 접근을 막는 전략이다. 특정 자원에 대한 동시 접근을 제어하는 데 사용한다.
이 방식은 마치 은행 번호표를 뽑고 기다리거나 놀이 공원에서 하는 줄 서기 방식과 유사하다.
- 어떤 고객이 은행 창구에서 업무를 보려고 한다면, 먼저 번호표를 뽑는다.
- 이 번호표는 해당 창구에 대한 잠금 권한을 얻는 것과 같다.
- 번호표를 뽑은 고객이 업무를 처리하는 동안에는 다른 고객들은 다음 번호표를 뽑고 자신의 차례를 기다려야 한다.
- 먼저 온 고객의 업무가 완료되어 창구에서 나올 때까지, 다른 고객들은 해당 창구를 이용할 수 없다.
이처럼 데이터에 대한 수정 작업이 시작되면, 해당 데이터는 잠금 상태가 되어 다른 트랜잭션들이 접근할 수 없게 된다. 이는 동시성 문제를 해결하고 데이터의 일관성(Consistency)을 유지하는 데 효과적이다.
작동 방식
- 트랜잭션이 데이터를 읽을 때부터 잠금을 걸고, 이 잠금은 해당 트랜잭션이 완료(커밋 또는 롤백)될 때까지 유지된다.
- 잠금이 걸린 데이터에 다른 트랜잭션이 접근하려고 하면, 잠금이 풀릴 때까지 대기하거나(Waiting) 오류를 반환(Error)한다.
주요 사용처
- DB 수준에서 제공 : 대부분의 관계형 데이터베이스(RDBMS)에서
SELECT FOR UPDATE
와 같은 SQL 구문을 통해 직접 지원한다. - 경쟁이 심한 환경 : 하나의 데이터에 대한 동시 업데이트가 매우 빈번하게 일어나는 환경(예: 재고 관리 시스템에서 인기 상품의 재고 차감, 은행 계좌 이체)에 적합하다. 대기열이 사용자에게 명시적으로 보이지 않아도, 비관적 잠금이 걸린 DB 레코드를 처리하기 위해 백엔드에서 암묵적인 대기가 발생할 수 있는 경우가 여기에 해당한다.
명시적인 대기열과는 다르다
부연 설명을 하자면 우리가 흔히 보는 대기열은 비관적 잠금과 다르다. 아래와 같은 대기열은 별도로 로직을 구현해서 저런 화면을 만든 것이다. 비관적 잠금에서의 대기열은 백엔드에서 처리되는 부분이라 사용자에게 직접적으로 노출되지 않는다.
'시스템 대기열 메시지'나 '로딩 스피너' 등의 아이디어 자체는 비관적 잠금과 같은 '잠금(Locking)' 메커니즘에서 파생된 개념이라고 할 수 있다. 하지만 실제로 데이터를 잠그는 처리 자체는 아니다. 이들은 어디까지나 사용자에게 시스템의 현재 상태를 알려주는 역할을 하는 UI/UX 요소일 뿐이다.
장점
- 데이터 무결성 강력 보장 : 잠금이 걸려 있으므로 데이터 충돌이 발생할 여지가 없어 데이터의 일관성이 매우 높다.
- 복잡한 충돌 해결 로직 불필요 : 충돌을 미리 방지하므로 애플리케이션에서 충돌 후 재시도(Retry) 같은 복잡한 로직을 구현할 필요가 적다.
단점
- 성능 저하 및 병목 현상: 잠금이 많이 발생하면 다른 트랜잭션들이 대기해야 하므로 시스템의 동시 처리량이 줄어들고 전체적인 성능이 저하될 수 있다.
- 교착 상태(Deadlock) 위험 : 여러 트랜잭션이 서로가 잠근 데이터를 얻으려고 대기하면서 무한정 멈추는 교착 상태가 발생할 수 있다.
🚀낙관적 잠금 (Optimistic Locking)
'충돌이 자주 일어나지 않을 것이다'라고 낙관적으로 가정하고, 일단 잠금 없이 데이터를 읽고 수정 작업을 진행한다. 그리고 커밋(Commit) 시점에 다른 트랜잭션에 의해 데이터가 변경되었는지 확인하고, 만약 변경되었다면 수정을 취소하고 재시도하는 방식이다.
작동 방식
- 데이터에 버전(Version) 번호나 타임스탬프(Timestamp) 같은 컬럼을 추가한다.
- 트랜잭션이 데이터를 읽을 때는 잠금을 걸지 않는다.
- 트랜잭션이 데이터를 변경하고 커밋하려고 할 때, 읽어왔을 때의 버전 번호(또는 타임스탬프)와 현재 DB에 저장된 데이터의 버전 번호가 일치하는지 확인한다.
- 일치하면 : 충돌이 없었다고 판단하고 데이터를 업데이트하며, 버전 번호를 증가시킨다.
- 일치하지 않으면 : 다른 트랜잭션이 그 사이에 데이터를 변경했다는 의미이므로, 충돌이 발생했다고 판단하고 현재 트랜잭션을 롤백(Rollback)시킨 후, 애플리케이션에서 재시도(Retry)하거나 사용자에게 오류를 알린다.
주요 사용처
- 읽기 작업이 많은 환경 : 쓰기 작업보다 읽기 작업이 훨씬 많은 시스템에서 성능 이점을 얻을 수 있다. 잠금이 없으므로 읽기 작업에 대한 병목 현상이 발생하지 않는다.
- 웹 기반 애플리케이션 : 짧은 시간 동안만 데이터에 접근하고 커밋하는 웹 요청 환경에 잘 맞는다. 사용자 입장에서는 충돌이 발생하면 단순히 '저장에 실패했습니다. 다시 시도해 주세요.'와 같은 메시지를 보게 된다.
- 경쟁이 낮은 환경 : 데이터 충돌이 자주 발생하지 않을 것으로 예상되는 환경에 적합하다. 예를 들어, 게시판 글 수정, 사용자 프로필 업데이트 등 동시 업데이트 빈도가 낮은 경우에 유리하다.
장점
- 높은 동시성: 데이터를 미리 잠그지 않으므로, 여러 트랜잭션이 동시에 자유롭게 접근할 수 있어 시스템의 동시 처리량이 높다.
- 교착 상태 없음: 잠금을 사용하지 않으므로 교착 상태의 위험이 없다.
단점
- 충돌 시 롤백 및 재시도 : 충돌이 발생하면 트랜잭션을 롤백하고 처음부터 다시 시작해야 하므로, 충돌이 잦으면 비효율적일 수 있다.
- 애플리케이션 로직 복잡성 : 충돌 감지 및 재시도 로직을 애플리케이션에서 구현해야 하므로 개발 복잡성이 높아질 수 있다.
경쟁이 심한 환경에서의 낙관적 잠금 활용
그런데 최근에는 경쟁이 매우 심한 환경, 예를 들어 티케팅(Ticketing)이나 재고 관리와 같은 분야에서도 낙관적 잠금(Optimistic Locking)을 활발하게 활용하는 추세다. 이는 전통적으로 비관적 잠금이 더 적합하다고 여겨져 왔던 영역이지만, 낙관적 잠금이 제공하는 성능 및 확장성 이점 때문에 고도화된 전략과 함께 적용된다. 핵심은 높은 동시성(High Concurrency)과 확장성(Scalability)을 확보하기 위함이다.
- 잠금 오버헤드 최소화 : 비관적 잠금은 데이터를 수정하기 전에 미리 잠금을 걸기 때문에, 동시에 많은 요청이 들어올 경우 잠금 경합이 심해지고 병목 현상(Bottleneck)이 심화될 수 있다. 이는 특히 선착순 티케팅처럼 특정 시점에 엄청난 수의 동시 요청이 발생하는 시나리오에서 시스템 전체의 성능을 저하시킬 수 있다. 낙관적 잠금은 일단 충돌이 발생하지 않을 것이라고 가정하고 작업을 진행하므로, 불필요한 잠금으로 인한 오버헤드를 줄인다.
- 교착 상태(Deadlock) 방지 : 비관적 잠금은 여러 트랜잭션이 서로 자원을 기다리면서 무한정 멈추는 교착 상태의 위험이 있다. 낙관적 잠금은 애초에 잠금을 걸지 않으므로 이러한 교착 상태의 위험이 없다.
- 분산 환경에서의 이점 : 최근 시스템들은 분산 환경(Distributed System)으로 구축되는 경우가 많다. 여러 서버에 걸쳐 데이터를 관리하고 처리하는 분산 환경에서 비관적 잠금처럼 강력한 잠금을 관리하는 것은 매우 복잡하고 성능 저하를 야기하기 쉽다. 낙관적 잠금은 분산 환경에서도 비교적 쉽게 구현하고 확장할 수 있는 장점이 있다.
💡언제 어떤 잠금 전략을 선택할까?
- 비관적 잠금:
- 충돌 빈도가 높을 때
- 읽기보다 쓰기(Write) 작업이 많을 때
- 데이터 일관성이 최우선일 때
- 짧은 트랜잭션에 유리
- 낙관적 잠금:
- 충돌 빈도가 낮을 때
- 쓰기보다 읽기(Read) 작업이 많을 때
- 동시 처리량이 중요할 때
- 긴 트랜잭션에 유리
두 잠금 방식은 서로의 단점을 보완하는 관계에 있으며, 실제 서비스에서는 데이터의 특성과 트랜잭션의 종류에 따라 적절한 잠금 전략을 선택하거나, 필요에 따라 혼합하여 사용하기도 한다.
기준 | 비관적 잠금 (Pessimistic Locking) | 낙관적 잠금 (Optimistic Locking) |
충돌 빈도 | 높을 때 | 낮을 때 (하지만 고도화된 전략으로 높을 때도 사용) |
작업 유형 | 읽기보다 쓰기(Write) 작업이 많을 때 | 쓰기보다 읽기(Read) 작업이 많을 때 |
목표 | 데이터 일관성이 최우선일 때 | 동시 처리량이 중요할 때 |
트랜잭션 길이 | 짧은 트랜잭션에 유리 | 긴 트랜잭션에 유리 |