개발자는 기록이 답이다
비관적락, 낙관적락 그리고 이벤트 소싱에 대한 정리 본문
비관적락과 낙관적 락은 어떤 상황에 적합할까요? 선정 기준이 어떻게 되나요?
4개월 전 면접 당시 2번이상 들었던 질문입니다. 당시 면접관님께서 감사하게도 저의 의견을 반대로 역질문하면서, 설명해주신 내용을 듣고 많이 배웠습니다. 그래서 해당 포스팅은 제 머릿속에 있는 개념을 보완하고자 작성한 내용입니다.
먼저 선정 기준을 알기 전에 비관적락과 낙관적락이란 무엇인지 정의가 필요합니다.
비관적락이란?
특정 데이터에 접근할 때 충돌이 발생할 가능성을 비관적으로 본다는 말에 유래 되었습니다. 즉, 데이터 충돌이 발생할 가능성이 매우 높다고 생각하기 때문에 미리 잠금을 걸어 충돌을 예방하려는 것(다른 사용자가 수정하지 못하도록 막는 것)입니다.
낙관적락이란?
특정 데이터에 접근할 때 충돌이 거의 일어나지 않을 것이라고 낙관적으로 보는 접근 방식에서 유래 되었습니다. 즉, 데이터를 수정하는 시점에만 충돌을 확인하고, 충돌이 발생하면 그때 처리하는 방식입니다.
그렇다면 충돌이란 무엇일까?
충돌이란 여러개의 서로 다른 트랜잭션이 동일한 데이터에 대한 변경 시도를 할 경우 일관성이 깨지는 문제입니다. 반드시 동시에 발생하지 않더라도, 시간차가 존재할 때에도 발생할 수 있습니다.
이러한 정의가 왜 중요한 것일까요?
"concurrency control platforms"를 키워드로 구글링해보면 아래와 같은 사진이 나옵니다.
이전에는 단순히 비관적락은 DB에서 직접 잠금 설정을 하는 것이고, 낙관적락은 잠금을 걸지 않고, 충돌을 감지하는 방식으로 코드 레벨에서 처리한다고 생각했는데 아니었습니다. 그렇게 이해하면 위의 다이어그램을 이해할 수가 없습니다.
1. 비관적 동시성 제어 기술
- 격리 수준 (isolation Levels) : 격리 수준은 데이터베이스가 트랜잭션을 독립적으로 수행하도록 보장하는 방법입니다. 이는 동시성 충돌을 줄이는 데 도움을 줍니다.
Connection connection = DriverManager.getConnection(url, user, password);
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
- 2단계 잠금 프로토콜(Two-Phase Locking) : 트랜잭션이 데이터에 대한 잠금을 걸고(획득 단계), 트랜잭션이 완료되면 잠금을 해제(해제 단계)하는 방식입니다.
synchronized (lockObject) {
// 임계 구역 내에서 작업 수행
sharedResource.modify();
}
- 분산 잠금 관리자 (Distributed Lock Manager) : 분산된 시스템에서 여러 노드가 동일한 데이터를 동시에 수정할 경우, 일과성을 유지하기 위해 분산 잠금 관리자가 잠금을 제어합니다.
RedissonClient redisson = Redisson.create();
RLock lock = redisson.getLock("resource-lock");
lock.lock();
try {
// 작업 수행
} finally {
lock.unlock();
}
- 다중 세분성 잠금 (Multiple Granularity Lock) : 잠금을 거는 범위를 테이블, 페이지, 행 단위로 세분화하여 필요에 따라 적절한 수준에서 동시성을 제어합니다.
Statement stmt = connection.createStatement();
stmt.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE");
2. 낙관적 동시성 제어 기술
- 타임스탬프 기반(Time-Stamp Based) : 각 트랜잭션에 고유한 타임스탬프를 부여하고, 트랜잭션이 커밋될 때 때 타임 스탬프를 비교하여 충돌을 감지하는 방식입니다.
long timestamp = System.currentTimeMillis();
if (currentTimestamp < storedTimestamp) {
throw new ConcurrentModificationException("충돌 발생");
}
- 다중 버전 동시성 제어 (MVCC) : 데이터의 여러 버전을 관리하여, 각 트랜잭션이 자신만의 스냅샷을 읽고 작업을 수행하도록 합니다. 이를 통해 충돌을 최소화합니다.
@Entity
public class Account {
@Id
@GeneratedValue
private Long id;
@Version
private int version;
private BigDecimal balance;
}
- 스냅샷 격리 (Snapshot Isolation) : 트랜잭션이 시작될 때의 스냅샷을 기반으로 작업을 수행하, 다른 트랜잭션이 변경한 데이터를 보지 않습니다.
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
- CRDTs (Conflict-Free Replicated Data Types) :분산 환경에서 동기화 없이도 일관성을 유지할 수 있는 데이터 구조를 활용하여, 데이터 충돌 없이 복제 및 병합을 가능하게 합니다.
ORSet<String> orSet = new ORSet<>();
orSet.add("Node1", "Value1");
📌 각각의 장단점과 선정 기준
비관적 락은 단일 트랜잭션 기준으로 동시성 제어를 수행하므로, 높은 데이터 일관성이 요구되는 금융 시스템 등에 적합합니다. 하지만 락을 잡는 순서에 따라 데드락(Deadlock)이 발생할 위험이 있으며, 락 대기로 인해 성능 저하가 발생할 수 있습니다.
낙관적 락은 트랜잭션 종료 시 충돌을 감지하여 해결하기 때문에 확장성이 뛰어나며, 대량의 트랜잭션을 병렬로 처리하기 용이합니다. 하지만 충돌이 빈번하게 발생할 경우, 실패한 트랜잭션이 다시 시도되면서 리소스 사용량이 증가하여 성능이 저하될 수 있습니다. 예를 들어, 1000개의 요청이 들어와서 1개가 실패하면 나머지 999개의 요청은 재시도되고, 타임아웃이나 재시도 횟수에 제한을 걸지 않는 이상 나머지 요청들이 모두 완료될때까지 계속 재시도를 시도하면서 CPU 사용량이 많아질것입니다.
단순히 Blocking으로 인해 비관적 락이 성능이 나쁠 것이라 예상할 수 있지만, 낙관적 락에서도 트랜잭션 충돌이 발생하면 여러 개의 트랜잭션이 동시에 재시도하게 되므로 더 많은 리소스를 소비할 수 있습니다. 따라서, 트랜잭션 충돌 가능성이 높은 환경에서는 오히려 비관적 락이 성능적으로 유리할 수도 있습니다.
각각의 특징과 장단점을 생각하며, 애플리케이션의 특성과 트랜잭션 충돌 가능성에 따라 적절한 방식을 선택해야 합니다.
- 충돌 가능성이 높은 환경 (예: 은행 계좌 이체, 재고 관리) → 비관적 락 사용 추천
- 읽기 중심이거나 확장성이 중요한 환경 (예: 게시판, 시간차가 발생한 경우) → 낙관적 락 사용 추천
3. 정리
- 비관락과 낙관락 차이는 충돌 가능성에 대한 관점 차이다. 즉, 충돌을 어떻게 예측하고 처리할 것인지에 따라 다른 방식이 사용된다.
- 그래서 어떤 플랫폼을 사용하든(RDB, REDIS...) 충돌을 미리 예방하는 경우를 비관적락 이라고 말한다.
- 충돌이란, 단순히 동시에 공유자원에 접근하는 경우 발생하는 race condition에만 국한되지 않고, 시간에 관계 없이 동일한 데이터에 여러 작업이 접근해서 발생하는 일관성 문제이다.
둘다 성능이 별로라면, 성능을 고려하면서 정합성을 해결할 수 있는 방법이 있을까요?
이벤트 소싱을 사용하면 락을 걸지 않고도 정합성을 유지할 수 있습니다.
이벤트 소싱은 현재 상태를 직접 저장하지 않고, 상태 변경을 나타내는 이벤트(Event)들을 저장합니다.
그래서 전통적인 즉시적 일관성(Immediate Consistency) 이 아닌, 최종적 일관성(Eventual Consistency)을 보장합니다.
즉, 이벤트 소싱에서는 상태를 직접 저장하지 않고, 모든 변경 내역을 이벤트 로그처럼 기록한 후, 필요할 때 이를 순차적으로 적용하여 최종 상태를 계산합니다.
1. 이벤트 소싱 방식의 동작 과정
📌 상태 변경 과정 (로그처럼 저장)
예를 들어, 계좌(Account)에 대해 다음과 같은 이벤트가 발생했다고 가정하겠습니다.
이벤트ID | 이벤트 유형 | 금액 변화 | 적용된 금액 |
1 | 입금(Deposit) | +100 | 100 |
2 | 출금(Withdraw) | -30 | 70 |
3 | 입금(Deposit) | +50 | 120 |
이때 계좌(Account)의 최종 상태를 직접 저장하지 않고, 위의 이벤트 리스트(이벤트 ID, 이벤트 유형, 금액 변화까지)만 저장합니다.
2. 현재 상태를 알고 싶을 때 Event Replay 과정
애그리게이트 상태의 변화 기록인 도메인 이벤트를 데이터베이스에 빠짐없이 기록했으면 이벤트를 리플레이해서 애그리게이트의 현재 상태로 복원할 수 있습니다. 도메인 이벤트로 상태를 복원하는 것을 재수화(Rehydratation)이라고 합니다.
이제 getBalance()를 호출하면 저장된 모든 이벤트를 순차적으로 적용하여 최종 잔액을 계산할 수 있습니다.
import java.util.ArrayList;
import java.util.List;
abstract class AccountEvent {
protected int amount;
public AccountEvent(int amount) {
this.amount = amount;
}
public abstract int apply(int balance); // 현재 상태에 적용하여 새로운 잔액 반환
}
// 입금 이벤트
class DepositEvent extends AccountEvent {
public DepositEvent(int amount) {
super(amount);
}
@Override
public int apply(int balance) {
return balance + amount;
}
}
// 출금 이벤트
class WithdrawEvent extends AccountEvent {
public WithdrawEvent(int amount) {
super(amount);
}
@Override
public int apply(int balance) {
return balance - amount;
}
}
// 계좌 클래스
class Account {
private final List<AccountEvent> eventLog = new ArrayList<>(); // 이벤트 저장소
// 이벤트 추가 (상태를 즉시 변경하지 않음)
public void applyEvent(AccountEvent event) {
eventLog.add(event);
}
// 최종 잔액을 계산하는 순간 모든 이벤트를 적용 (Replay)
public int getBalance() {
int balance = 0;
for (AccountEvent event : eventLog) {
balance = event.apply(balance); // 이전 상태에 이벤트를 순차 적용
}
return balance;
}
}
public class EventSourcingExample {
public static void main(String[] args) {
Account account = new Account();
// 이벤트 추가 (상태를 즉시 변경하지 않고 저장)
account.applyEvent(new DepositEvent(100)); // +100
account.applyEvent(new WithdrawEvent(30)); // -30
account.applyEvent(new DepositEvent(50)); // +50
// getBalance() 호출 시 모든 이벤트를 읽고 최종 상태 계산
System.out.println("현재 잔액: " + account.getBalance()); // 120
}
}
3. 정리
- 기존에 사용하던 락(Lock) 기반 방식은 강한 일관성(Strong Consistency)을 보장하지만, 시스템 성능 저하 한계가 있다.
- 반면, 이벤트 소싱(Event Sourcing)은 최종적 일관성(Eventual Consistency)을 유지하는 방법 중 하나로, 락을 사용하지 않으면서도 데이터 정합성을 유지할 수 있다.
- 최종적 일관성(Eventual Consistency)은 메시지 큐(Kafka, RabbitMQ)에서만 적용되는 개념이 아니다.
- 이벤트 소싱을 사용하면, 애그리거트(Aggregate) 이력이 보존되므로 데이터 변경 내역을 감사 로깅(Audit Logging)처럼 활용할 수 있다.
- 이벤트 소싱은 개념적으로 깊이 있는 주제이므로, 추가적인 학습이 필요하며 별도 포스팅에서 다루는 것이 좋다.
참고 :
https://www.geeksforgeeks.org/concurrency-control-in-dbms/