DB와 동시성(비관적, 낙관적 잠금)
- 하나의 트랜잭션에 포함된 모든 쓰기는 모두 적용(커밋)되거나 모두 최소(롤백)된다.
- 만능같아 보이지만 이 트랜잭션은 동시성 문제에서는 무관하다.
- 동시에 여러 수정 요청이 오면 서로 간섭되는 경우 데이터 정합성에 문제가 생기는것.
- 대부분의 DB는 명시적인 잠금 기법 제공. 이런 방식을 선점 잠금, 비관적 잠금이라 부름.
- 선점 잠금을 사용하면 동일한 레코드에 대해 한 번에 하나의 트랜잭션만 접근 하도록 제어 가능.
- 반면 값을 비교해서 수정하는 방식은 비선점 잠금, 낙관적 잠금이라 하며, 쿼리 실행 자체는 안막으면서도 데이터가 잘못 변경됨을 방지 가능.
비관적, 낙관적이라는 용어를 쓰는 이유
- 왜 비관적이고 낙관적일까?
- 여기서 "비관적"은 실패할 가능성이 높아서 비관적이다.
- 다수가 데이터 변경을 시도하면 데이터를 정상적으로 변경할 가능성이 떨어질테니 이를 비관적이라고 표현한 것.
- "낙관적"은 반대로 다수가 데이터 변경해도 성공할 가능성이 높아서 낙관적이란 것.
- 실패 가능성이 높은 비관적인 상황에서는 동시성 문제 해결위해 한 번에 한 개의 클라만 접근할 수 있는 배타적 잠금을 사용하며, 이게 바로 비관적 잠금.
- 반대로, 성공 가능성 높은 낙관적 상황에선 동시성 문제를 해결하기 위해 배타적 잠금까지는 안쓴다. 대신 값을 비교하는 방식으로 동시성 문제에 대응.
- 실제로 잠금을 쓰지 않지만 비관적 잠금에 대응하는 용어로 낙관적 잠금이란 용어를 사용.
선점(비관적) 잠금
- 선점 잠금은 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식.
- 선점 잠금 획득위한 쿼리는 다음과 같다. 오라클, MySQL 기준
SELECT *
FROM 테이블
WHERE 조건
FOR UPDATE- 이 쿼리는 조건에 해당하는 레코드 조회하면서 동시에 잠금을 획득.
- 한 트랜잭션이 특정 레코드에 대한 잠금을 획득한 경우, 잠금을 해제할 때까지 다른 트랜잭션은 동일 레코드에 대한 잠금을 획득 못하고 대기해야한다.
- 두 트랜잭션이 동시에 같은 데이터를 수정하면서 데이터 일관성이 깨지는 문제를 방지해준다.
분산 잠금
- distributed lock 은 여러 프로세스가 동시에 동일한 자원에 접근하지 못하도록 막는 방법.
비관적(낙관적) 잠금
- 이건 명시적으로 잠금 안쓴다
- 대신 데이터를 조회한 시점 값과 수정하려는 시점의 값 같은지 ==비교==하는 방식으로 동시성 문제 처리함.
- 보통 비선점 잠금 구현할 때는 정수 타입의 버전 칼럼을 씀.
- 버전 칼럼 이용해서 잠금 구현하는 방식은 아래와 같다.
- SELECT 쿼리실행시 version 컬럼 함께 조회
SELECT ..., version
FROM TABLE
WHERE ID = id
- 로직 수행
- UPDATE 쿼리 실행시 version 칼럼을 1 증가. 이때 version 칼럼 감ㅅ이 1에서 조회한 값과 같은지 비교하는 조건을 WHERE절에 추가.
UPDATE TABLE SET ..., version = version + 1
WHERE ID = id AND version = [1에서 조회한 version 값]
- UPDATA 결과로 변경된 행 개수가 0이면, 이미 다른 트랜잭션이 version값 증가시킨것이므로 데이터 변경에 실패한것. 이 경우 트랜잭션을 롤백함.
- 만약 0보다 크면 커밋하면된다. 다른 트랜잭션보다 먼저 데이터 변경에 성공했으니
비선점의 경우는 락 대기 과정이 없어서 실패하는 경우 바로 유저에게 더 빠른 응답이 가능함. 버전값을 조회 조건으로 두는것
외부 연동과 잠금(생각꺼리 있음)
- 트랜잭션 범위 내에서 외부 시스템과 연동해야 한다면, 비선점(낙관형) 잠금보다는 선점(비관적) 잠금을 고려하는 게좋다.
- 예를 들어, 주문 최소 과정에서 외부 PG 시스템을 호출해서 결제까지 함께 취소해야되는 상황 생각해보자.
- 이때 비선점 잠금 쓰면 이미 결제는 취소되었는데, 데이터 변경에 실패해서 트랜젝션이 롤백되는 문제가 발생할 수 있다.
- 주문 취소가 외부 시스템인것임.
책의 177p, 그림6.10 참고!
- 비선점 잠금을 굳이 쓰려면 비동기 기법 - 트랜잭션 아웃박스 패턴을 적용해서 외부 연동을 처리하는 방법도 존재.
- 아웃박스 패턴쓰면 변화를 계속 바라보니 괜찮은듯?
외부 연동과 잠금: 트랜잭션 아웃박스 패턴이 비선점 잠금과 함께 작동하는 이유
1. 근본적인 문제: 트랜잭션 범위의 불일치
- 비선점 잠금과 외부 시스템 직접 호출의 조합이 위험한지 다시 한번 정리
- DB 트랜잭션: 원자성(Atomicity)을 보장합니다. 즉, 트랜잭션 내의 모든 작업은 전부 성공하거나(Commit) 전부 실패(Rollback)합니다.
- 외부 시스템 호출 (e.g., PG 결제 취소 API): DB 트랜잭션의 제어 범위 밖에 있습니다. 한번 호출에 성공하면 되돌릴 수 없는(irreversible) 작업입니다.
문제 시나리오 (책에서 언급된 내용)
- 트랜잭션 시작
- 사용자 A가 주문(ID: 100, 버전: 1)을 조회합니다.
- 외부 PG 시스템에 결제 취소 API를 호출합니다. -> 성공! (이제 되돌릴 수 없습니다.)
- DB의 주문 상태를 'CANCELLED'로 변경하고 버전을 2로 올리려고
UPDATE쿼리를 실행합니다.UPDATE orders SET status = 'CANCELLED', version = 2 WHERE id = 100 AND version = 1; - 문제 발생: 그 사이에 다른 트랜잭션이 해당 주문 정보를 수정하여 버전이 이미 2가 되었습니다.
UPDATE쿼리는WHERE절 조건 불일치로 실패합니다(0개 행 변경). - 비선점 잠금 메커니즘에 따라, 데이터 변경에 실패했으므로 DB 트랜잭션은 롤백됩니다.
- [트랜잭션 롤백]
최종 결과: 데이터 불일치
- 외부 PG 시스템: 결제가 취소됨.
- 내부 DB: 주문 상태는 여전히 '결제 완료' (롤백되었으므로).
- 이처럼 시스템 간의 상태가 정합성이 깨지는 심각한 문제가 발생합니다.
- 선점(비관적) 잠금은 3번 단계 이전에 데이터에 락을 걸어버리므로 5번과 같은 문제가 원천적으로 발생하지 않아 안전
2. 해결책: 트랜잭션 아웃박스 패턴의 역할
- 트랜잭션 아웃박스 패턴은 이 문제의 근본 원인인 "외부 시스템 호출"을 메인 트랜잭션에서 분리(Decoupling)하는 방식으로 해결합니다.
동작 방식:
- 외부 시스템을 직접 호출하지 않습니다.
- 대신, "외부 시스템에 어떤 요청을 보내야 한다"는 '이벤트' 또는 '메시지'를 DB 내의 특별한 테이블(outbox 테이블)에 저장합니다.
- 이 '메시지 저장' 행위는 메인 비즈니스 데이터 변경(주문 상태 변경)과 동일한 트랜잭션 내에서 원자적으로 실행됩니다.
아웃박스 패턴을 적용한 시나리오:
- [트랜잭션 시작]
- 사용자 A가 주문(ID: 100, 버전: 1)을 조회합니다.
- 주문 상태를 'CANCELLED'로 변경하고, 동시에
outbox테이블에 '결제 취소 요청' 메시지를 저장하는 로직을 준비합니다.-- 1. 주문 상태 변경 UPDATE orders SET status = 'CANCELLED', version = 2 WHERE id = 100 AND version = 1; -- 2. 아웃박스 테이블에 메시지 삽입 INSERT INTO outbox (topic, payload) VALUES ('payment.cancel.request', '{"orderId": 100, ...}'); - 문제 발생: 그 사이에 다른 트랜잭션이 주문 정보를 수정하여 버전이 이미 2가 되었습니다.
- 트랜잭션 커밋 시점에
UPDATE쿼리가 실패합니다(0개 행 변경). - DB 트랜잭션 전체가 롤백됩니다.
- [트랜잭션 롤백]
최종 결과: 완벽한 데이터 일관성
orders테이블의 상태 변경이 롤백되었습니다.outbox테이블에 '결제 취소 요청' 메시지를INSERT하는 작업 또한 함께 롤백되었습니다.- 결과적으로 외부 PG 시스템은 아예 호출되지도 않았습니다. 시스템의 모든 상태는 트랜잭션 시작 이전과 동일하게 일관성을 유지합니다.
이제 애플리케이션은 이 실패를 감지하고, 최신 버전의 데이터를 다시 읽어 주문 취소 로직을 재시도할 수 있습니다.
그 후의 과정 (비동기 처리):
- 별도의 프로세스(Message Relay, Poller, CDC 등)가 주기적으로
outbox테이블을 확인합니다. outbox테이블에 처리되지 않은 메시지가 있으면, 그 메시지를 읽어 실제 외부 PG 시스템의 결제 취소 API를 호출합니다.- 호출이 성공하면 해당 메시지를 '처리 완료'로 표시하거나 삭제합니다.
결론: 왜 아웃박스 패턴을 쓰면 비선점 잠금이 괜찮을까?
"외부 시스템에 대한 호출"이라는 되돌릴 수 없는 부수 효과(Side Effect)를, "호출하겠다"는 되돌릴 수 있는 '의도(Intent)'로 바꾸어 DB 트랜잭션에 포함시켰기 때문입니다.
| 구분 | 직접 호출 방식 (위험) | 트랜잭션 아웃박스 패턴 (안전) |
| :--- | :--- | :--- |
| 외부 호출 시점 | 메인 트랜잭션 내부 | 메인 트랜잭션이 성공적으로 커밋된 후, 비동기적으로 |
| 트랜잭션 범위 | DB 변경만 트랜잭션 범위에 포함 | DB 변경 + 외부 호출 의도 저장이 트랜잭션 범위에 포함 |
| 비선점 잠금 실패 시 | 외부 호출은 성공, DB 변경은 롤백 -> 데이터 불일치 | DB 변경과 외부 호출 의도 저장이 함께 롤백 -> 데이터 일관성 유지 |
| 시스템 상태 | 강한 일관성(Strong Consistency)을 깨뜨림 | 최종적 일관성(Eventual Consistency)을 보장 |
결론적으로, 아웃박스 패턴은 비선점 잠금의 약점인 '커밋 시점의 실패 가능성'과 외부 연동의 약점인 '롤백 불가능' 사이의 충돌을 우아하게 해결합니다. 이를 통해 메인 트랜잭션은 오직 DB 데이터의 일관성에만 집중할 수 있게 되어, 비선점 잠금을 안전하게 사용할 수 있는 환경을 만들어 줍니다.
증분 쿼리
//subject 조회
Subject subject = jdbcTemplate.queryForObject(
"select id, joinCount, ... from SUBJECT where id = ?", mapperCode, id);
)
//참여 데이터 추가
joinToSubject(joinData, subject); //SUBJECT_JOIN 테이블에 추가
// 주제 데이터의 참여자 수 증가
jdbcTemplate.update(
"update SUBJECT set joinCount = ? where id = ?", subject.getJoinCount()+1, subject.getId()
);
잠금을 사용하지 않으면서도 참여자 수를 동시성 이슈에 벗어나게 하며 증분 쿼리를 사용하는법.
UPDATE subject SET joinCount = joinCount +1 WHERE id = ?