하나의 트랜잭션에 포함된 모든 쓰기는 모두 적용(커밋)되거나 모두 최소(롤백)된다.

만능같아 보이지만 이 트랜잭션은 동시성 문제에서는 무관하다. 동시에 여러 수정 요청이 오면 서로 간섭되는 경우 데이터 정합성에 문제가 생기는것.

대부분의 DB는 명시적인 잠금 기법 제공. 이런 방식을 선점 잠금, 비관적 잠금이라 부름. 선점 잠금을 사용하면 동일한 레코드에 대해 한 번에 하나의 트랜잭션만 접근 하도록 제어 가능.

반면 값을 비교해서 수정하는 방식은 비선점 잠금, 낙관적 잠금이라 하며, 쿼리 실행 자체는 안막으면서도 데이터가 잘못 변경됨을 방지 가능. 선점(비관적) 잠금, 비선점(낙관형) 잠금

비관적, 낙관적이라는 용어를 쓰는 이유

왜 비관적이고 낙관적일까?

여기서 “비관적”은 실패할 가능성이 높아서 비관적이다. 다수가 데이터 변경을 시도하면 데이터를 정상적으로 변경할 가능성이 떨어질테니 이를 비관적이라고 표현한 것.

“낙관적”은 반대로 다수가 데이터 변경해도 성공할 가능성이 높아서 낙관적이란 것.

실패 가능성이 높은 비관적인 상황에서는 동시성 문제 해결위해 한 번에 한 개의클라만 접근할 수 있는 배타적 잠금을 사용하며, 이게 바로 비관적 잠금.

반대로, 성공 가능성 높은 낙관적 상황에선 동시성 문제를 해결하기 위해 배타적 잠금까지는 안쓴다. 대신 값을 비교하는 방식으로 동시성 문제에 대응. 실제로 잠금을 쓰지 않지만 비관적 잠금에 대응하는 용어로 낙관적 잠금이란 용어를 사용.

선점(비관적) 잠금

  • 선점 잠금은 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식.
  • 선점 잠금 획득위한 쿼리는 다음과 같다. 오라클, MySQL 기준
SELECT *
FROM 테이블
WHERE 조건
FOR UPDATE

이 쿼리는 조건에 해당하는 레코드 조회하면서 동시에 잠금을 획득.

한 트랜잭션이 특정 레코드에 대한 잠금을 획득한 경우, 잠금을 해제할 때까지 다른 트랜잭션은 동일 레코드에 대한 잠금을 획득 못하고 대기해야한다.

두 트랜잭션이 동시에 같은 데이터를 수정하면서 데이터 일관성이 깨지는 문제를 방지해준다.

분산 잠금

distributed lock 은 여러 프로세스가 동시에 동일한 자원에 접근하지 못하도록 막는 방법.

비관적(낙관적) 잠금

  • 이건 명시적으로 잠금 안쓴다
  • 대신 데이터를 조회한 시점 값과 수정하려는 시점의 값 같은지 비교하는 방식으로 동시성 문제 처리함.
  • 보통 비선점 잠금 구현할 때는 정수 타입의 버전 칼럼을 씀.
  • 버전 칼럼 이용해서 잠금 구현하는 방식은 아래와 같다.
  1. SELECT 쿼리실행시 version 컬럼 함께 조회
SELECT ..., version
FROM TABLE
WHERE ID = id
  1. 로직 수행

  2. UPDATE 쿼리 실행시 version 칼럼을 1 증가. 이때 version 칼럼 감ㅅ이 1에서 조회한 값과 같은지 비교하는 조건을 WHERE절에 추가.

UPDATE TABLE SET ..., version = version + 1
WHERE ID = id AND version = [1에서 조회한 version 값]
  1. UPDATA 결과로 변경된 행 개수가 0이면, 이미 다른 트랜잭션이 version값 증가시킨것이므로 데이터 변경에 실패한것. 이 경우 트랜잭션을 롤백함.
  2. 만약 0보다 크면 커밋하면된다. 다른 트랜잭션보다 먼저 데이터 변경에 성공했으니

비선점의 경우는 락 대기 과정이 없어서 실패하는 경우 바로 유저에게 더 빠른 응답이 가능함.

버전값을 조회 조건으로 두는것

외부 연동과 잠금(생각꺼리 있음)

트랜잭션 범위 내에서 외부 시스템과 연동해야 한다면, 비선점(낙관형) 잠금보다는 선점(비관적) 잠금을 고려하는 게좋다.

예를 들어, 주문 최소 과정에서 외부 PG 시스템을 호출해서 결제까지 함께 취소해야되는 상황 생각해보자. 이때 비선점 잠금 쓰면 이미 결제는 취소되었는데, 데이터 변경에 실패해서 트랜젝셩이 롤백되는 문제가 발생할 수 있다.

주문 취소가 외부 시스템인것임.

책의 177p, 그림6.10 참고!

비선점 잠금을 굳이 쓰려면 비동기 기법 - 트랜잭션 아웃박스 패턴을 적용해서 외부 연동을 처리하는 방법도 존재. 아웃박스 패턴쓰면 변화를 계속 바라보니 괜찮은듯?

외부 연동과 잠금: 트랜잭션 아웃박스 패턴이 비선점 잠금과 함께 작동하는 이유

질문하신 내용의 핵심을 정확히 짚어주셨습니다. 왜 트랜잭션 아웃박스 패턴(Transactional Outbox Pattern)을 사용하면, 외부 연동이 있는 시나리오에서도 비선점(낙관적) 잠금을 사용할 수 있는지 자세히 설명해 드리겠습니다.

1. 근본적인 문제: 트랜잭션 범위의 불일치

먼저, 왜 비선점 잠금과 외부 시스템 직접 호출의 조합이 위험한지 다시 한번 정리해 보겠습니다.

  • DB 트랜잭션: 원자성(Atomicity)을 보장합니다. 즉, 트랜잭션 내의 모든 작업은 전부 성공하거나(Commit) 전부 실패(Rollback)합니다.
  • 외부 시스템 호출 (e.g., PG 결제 취소 API): DB 트랜잭션의 제어 범위 밖에 있습니다. 한번 호출에 성공하면 되돌릴 수 없는(irreversible) 작업입니다.

문제 시나리오 (책에서 언급된 내용):

  1. [트랜잭션 시작]
  2. 사용자 A가 주문(ID: 100, 버전: 1)을 조회합니다.
  3. 외부 PG 시스템에 결제 취소 API를 호출합니다. 성공! (이제 되돌릴 수 없습니다.)
  4. DB의 주문 상태를 ‘CANCELLED’로 변경하고 버전을 2로 올리려고 UPDATE 쿼리를 실행합니다.
    UPDATE orders SET status = 'CANCELLED', version = 2 WHERE id = 100 AND version = 1;
  5. 문제 발생: 그 사이에 다른 트랜잭션이 해당 주문 정보를 수정하여 버전이 이미 2가 되었습니다. UPDATE 쿼리는 WHERE 절 조건 불일치로 실패합니다(0개 행 변경).
  6. 비선점 잠금 메커니즘에 따라, 데이터 변경에 실패했으므로 DB 트랜잭션은 롤백됩니다.
  7. [트랜잭션 롤백]

최종 결과: 데이터 불일치

  • 외부 PG 시스템: 결제가 취소됨.
  • 내부 DB: 주문 상태는 여전히 ‘결제 완료’ (롤백되었으므로).

이처럼 시스템 간의 상태가 정합성이 깨지는 심각한 문제가 발생합니다. 선점(비관적) 잠금은 3번 단계 이전에 데이터에 락을 걸어버리므로 5번과 같은 문제가 원천적으로 발생하지 않아 안전합니다.


2. 해결책: 트랜잭션 아웃박스 패턴의 역할

트랜잭션 아웃박스 패턴은 이 문제의 근본 원인인 **“외부 시스템 호출”을 메인 트랜잭션에서 분리(Decoupling)**하는 방식으로 해결합니다.

동작 방식:

  1. 외부 시스템을 직접 호출하지 않습니다.
  2. 대신, “외부 시스템에 어떤 요청을 보내야 한다”는 ‘이벤트’ 또는 ‘메시지’를 DB 내의 특별한 테이블(outbox 테이블)에 저장합니다.
  3. ‘메시지 저장’ 행위는 메인 비즈니스 데이터 변경(주문 상태 변경)과 동일한 트랜잭션 내에서 원자적으로 실행됩니다.

아웃박스 패턴을 적용한 시나리오:

  1. [트랜잭션 시작]
  2. 사용자 A가 주문(ID: 100, 버전: 1)을 조회합니다.
  3. 주문 상태를 ‘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, ...}');
  4. 문제 발생: 그 사이에 다른 트랜잭션이 주문 정보를 수정하여 버전이 이미 2가 되었습니다.
  5. 트랜잭션 커밋 시점에 UPDATE 쿼리가 실패합니다(0개 행 변경).
  6. DB 트랜잭션 전체가 롤백됩니다.
  7. [트랜잭션 롤백]

최종 결과: 완벽한 데이터 일관성

  • 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 = ?