애플리케이션에서 DB를 사용할 때 MVCC는 어떻게 동시성을 보장하는가
애플리케이션에서 여러 요청이 동시에 같은 데이터를 조회하고 수정하는 상황은 매우 흔하다.
예를 들어 다음과 같은 요청이 동시에 들어올 수 있다.
사용자 A: 주문 정보를 조회한다.
사용자 B: 같은 주문의 상태를 결제 완료로 변경한다.
사용자 C: 같은 주문을 다시 조회한다.
이때 데이터베이스가 모든 조회와 수정을 하나의 잠금으로 직렬화한다면 데이터 정합성은 지킬 수 있지만, 조회 요청까지 대기해야 하므로 처리량과 응답 시간이 크게 나빠진다.
대부분의 상용 RDBMS는 이를 해결하기 위해 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)를 사용한다.
MVCC의 핵심은 간단하다.
하나의 데이터에 대해 과거 버전과 현재 버전을 관리하고, 각 트랜잭션이 자신에게 보여야 하는 버전을 선택해서 읽게 한다.
덕분에 일반적인 조회는 수정 트랜잭션이 끝날 때까지 기다리지 않고, 자신이 읽을 수 있는 과거의 커밋 버전을 조회할 수 있다.
1. MVCC가 필요한 이유
MVCC가 없다면 데이터베이스는 읽기와 쓰기의 충돌을 주로 잠금으로 제어해야 한다.
다음과 같은 데이터가 있다고 가정하자.
order_id = 100
status = READY
트랜잭션 B가 주문 상태를 변경한다.
UPDATE orders
SET status = 'PAID'
WHERE order_id = 100;
아직 COMMIT하지 않은 상태에서 트랜잭션 A가 같은 주문을 조회한다.
SELECT status
FROM orders
WHERE order_id = 100;
이때 가능한 처리 방식은 크게 세 가지다.
- 트랜잭션 B가 끝날 때까지 A를 대기시킨다.
- B가 아직 커밋하지 않은
PAID를 A에게 보여준다. - A에게 변경 이전의 커밋된 값인
READY를 보여준다.
두 번째 방식은 Dirty Read를 발생시킨다. 트랜잭션 B가 나중에 롤백한다면 A는 실제로 존재하지 않았던 값을 사용하게 된다.
MVCC는 일반적으로 세 번째 방식을 사용한다.
트랜잭션 B가 보는 값: PAID
트랜잭션 A가 보는 값: READY
Oracle은 Undo 데이터를 이용해 특정 시점의 일관된 데이터를 재구성하며, 다른 트랜잭션의 미커밋 데이터를 일반 조회에 노출하지 않는다. MySQL InnoDB 역시 Undo Log를 사용해 과거 행 버전을 구성하고 일관된 읽기를 제공한다.
2. MVCC의 핵심 구성 요소
MVCC의 구현 방식은 DBMS마다 다르지만, 논리적으로는 다음 요소가 필요하다.
1. 트랜잭션 식별자
2. 데이터의 여러 버전
3. 현재 트랜잭션이 바라보는 스냅샷
4. 어떤 버전이 보이는지 판단하는 가시성 규칙
5. 더 이상 필요 없는 과거 버전을 정리하는 작업
이를 하나씩 살펴보자.
3. 트랜잭션 ID
데이터베이스는 각각의 트랜잭션을 구분하기 위해 내부적인 트랜잭션 식별자를 사용한다.
개념적으로 다음과 같은 순서로 트랜잭션이 시작되었다고 가정하자.
Transaction 100: 시작
Transaction 101: 시작
Transaction 102: 시작
행이 수정될 때 DB는 해당 행이 어떤 트랜잭션에 의해 생성되거나 변경되었는지를 기록한다.
MySQL InnoDB의 경우 각 레코드에 다음과 같은 숨겨진 정보를 관리한다.
DB_TRX_ID : 마지막으로 행을 삽입하거나 수정한 트랜잭션 ID
DB_ROLL_PTR : 이전 버전이 기록된 Undo Log 위치
DB_ROW_ID : 적절한 고유 인덱스가 없을 때 사용하는 내부 행 ID
특히 DB_ROLL_PTR는 현재 행에서 이전 버전으로 이동할 수 있는 연결 정보다. InnoDB는 이 포인터와 Undo Log를 이용해 필요한 과거 버전을 복원한다.
개념적으로는 다음과 같은 버전 체인이 만들어진다.
현재 버전
status = PAID
trx_id = 120
|
v
이전 버전
status = READY
trx_id = 100
|
v
더 이전 버전
status = CREATED
trx_id = 80
조회 트랜잭션은 이 체인을 따라가면서 자신에게 보이는 버전을 찾는다.
4. 스냅샷은 무엇인가
스냅샷은 트랜잭션 또는 SQL 문이 데이터를 읽을 때 사용하는 논리적인 데이터베이스 시점이다.
스냅샷이 만들어질 때 데이터베이스는 대략 다음 정보를 기준으로 가시성을 판단한다.
- 스냅샷 생성 이전에 커밋된 트랜잭션
- 현재 실행 중인 트랜잭션
- 스냅샷 이후 시작된 트랜잭션
- 자기 자신의 트랜잭션
다음 상황을 보자.
T1 시작
T2 시작
T2: order 상태 READY → PAID
T1: order 조회
T2 COMMIT
T1: order 재조회
T1이 두 번째 조회에서 READY를 볼지 PAID를 볼지는 격리 수준과 DBMS의 스냅샷 정책에 따라 달라진다.
READ COMMITTED
일반적으로 SQL 문이 실행될 때마다 새로운 스냅샷을 얻는다.
T1 첫 번째 SELECT → READY
T2 COMMIT
T1 두 번째 SELECT → PAID
REPEATABLE READ
일반적으로 트랜잭션에서 정해진 스냅샷을 반복해서 사용한다.
T1 첫 번째 SELECT → READY
T2 COMMIT
T1 두 번째 SELECT → READY
MySQL InnoDB의 REPEATABLE READ에서는 일반적인 일관된 읽기가 첫 번째 일관 읽기 시점에 생성된 스냅샷을 같은 트랜잭션 동안 재사용한다. READ COMMITTED에서는 각각의 일관 읽기가 새로운 스냅샷을 사용한다.
Oracle의 기본 격리 수준인 READ COMMITTED는 문장 단위 읽기 일관성을 제공한다. 즉, 하나의 SQL 문은 SQL 실행이 시작된 특정 시점을 기준으로 일관된 결과를 반환한다.
5. 행을 수정할 때 내부에서 발생하는 일
다음 SQL이 실행된다고 가정하자.
UPDATE accounts
SET balance = 9000
WHERE account_id = 1;
수정 전 값은 다음과 같다.
account_id = 1
balance = 10000
DB 내부에서는 개념적으로 다음 작업이 수행된다.
1. 수정 대상 행에 필요한 잠금을 획득한다.
2. 변경 전 값을 과거 버전 영역에 기록한다.
3. 현재 행을 새로운 값으로 변경한다.
4. 변경 트랜잭션 정보를 행에 연결한다.
5. COMMIT 또는 ROLLBACK을 기다린다.
MySQL InnoDB에서는 변경 전 정보를 Undo Log에 기록하고 현재 레코드를 수정한다.
현재 레코드
balance = 9000
trx_id = 200
roll_ptr ──────┐
v
Undo Log
balance = 10000
trx_id = 150
Oracle 역시 데이터 변경 시 Undo Entry를 Undo Segment에 기록하고, 필요한 경우 Undo 데이터를 사용해 이전 시점의 블록 이미지를 재구성한다.
PostgreSQL은 구조가 다르다. 기존 튜플을 Undo Log로 복원하는 방식이 아니라, UPDATE 시 새로운 튜플 버전을 테이블에 생성하고 기존 버전을 남긴다.
개념적으로 표현하면 다음과 같다.
기존 튜플
balance = 10000
xmin = 150
xmax = 200
신규 튜플
balance = 9000
xmin = 200
xmax = null
여기서 xmin은 해당 튜플을 생성한 트랜잭션을, xmax는 삭제하거나 대체한 트랜잭션을 나타내는 데 사용된다. 각 트랜잭션은 자신의 스냅샷과 이 정보를 비교하여 어떤 튜플이 보이는지 판단한다.
따라서 같은 MVCC라도 물리적 구현은 다르다.
| DBMS | 과거 버전 관리 방식 |
|---|---|
| MySQL InnoDB | 현재 레코드와 Undo Log를 연결 |
| Oracle | 현재 블록과 Undo Segment를 이용해 과거 버전 재구성 |
| PostgreSQL | 테이블에 새로운 튜플 버전을 추가하고 기존 튜플 유지 |
6. 조회 시 어떤 버전을 선택하는가
다음 버전 체인을 가정하자.
Version 3
status = COMPLETED
trx_id = 300
Version 2
status = PAID
trx_id = 200
Version 1
status = READY
trx_id = 100
현재 조회 트랜잭션의 스냅샷이 트랜잭션 250 시점을 기준으로 생성되었다고 하자.
trx 100: 커밋 완료
trx 200: 커밋 완료
trx 300: 스냅샷 이후 변경
조회 과정은 개념적으로 다음과 같다.
Version 3 확인
→ trx 300은 스냅샷 이후 버전
→ 보이지 않음
Version 2 확인
→ trx 200은 스냅샷 이전에 커밋
→ 보임
결과: PAID
의사 코드로 표현하면 다음과 같다.
RowVersion findVisibleVersion(RowVersion current, Snapshot snapshot) {
RowVersion version = current;
while (version != null) {
if (snapshot.isVisible(version.transactionId())) {
return version;
}
version = version.previousVersion();
}
return null;
}
실제 구현은 훨씬 복잡하지만 핵심은 같다.
DB는 항상 가장 최신 버전을 반환하는 것이 아니라, 현재 스냅샷에서 볼 수 있는 가장 최신 버전을 반환한다.
7. COMMIT은 버전을 어떻게 확정하는가
애플리케이션에서 다음 코드가 실행된다고 하자.
@Transactional
public void completePayment(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.completePayment();
}
Spring의 @Transactional은 JDBC Connection의 트랜잭션 경계를 관리한다.
개념적인 실행 흐름은 다음과 같다.
HTTP Request
↓
Spring TransactionInterceptor
↓
Connection.setAutoCommit(false)
↓
SELECT
↓
UPDATE
↓
Connection.commit()
DB에서 COMMIT이 수행되면 해당 트랜잭션의 변경 내용은 커밋된 버전으로 취급된다.
그러나 커밋이 되었다고 해서 과거 버전이 즉시 삭제되는 것은 아니다.
아직 과거 스냅샷을 사용하는 트랜잭션이 있을 수 있기 때문이다.
T1: 오래된 스냅샷 유지
T2: 데이터를 수정하고 COMMIT
T3: 새 스냅샷으로 수정된 값 조회
T1 → 과거 버전 필요
T3 → 최신 버전 조회
따라서 DB는 다음 조건이 만족될 때까지 과거 버전을 보존해야 한다.
해당 과거 버전을 조회할 가능성이 있는 트랜잭션이 더 이상 존재하지 않는다.
InnoDB의 Update Undo Log는 과거 버전을 필요로 하는 스냅샷이 없어질 때까지 제거할 수 없다. PostgreSQL도 모든 활성 트랜잭션에서 더 이상 보이지 않는 오래된 튜플을 VACUUM으로 정리한다.
8. ROLLBACK은 어떻게 동작하는가
다음 트랜잭션이 실패했다고 가정하자.
@Transactional
public void transfer(
Long fromAccountId,
Long toAccountId,
long amount
) {
withdraw(fromAccountId, amount);
deposit(toAccountId, amount);
throw new RuntimeException("외부 시스템 연동 실패");
}
데이터베이스는 변경 전 값을 알고 있어야 트랜잭션을 취소할 수 있다.
MySQL과 Oracle 계열에서는 Undo 정보가 두 가지 용도로 사용된다.
1. 트랜잭션 롤백
2. MVCC를 위한 과거 버전 조회
예를 들어 다음 변경이 발생했다.
변경 전: balance = 10000
변경 후: balance = 9000
Undo에는 이전 값이 기록된다.
Undo Record
balance = 10000
롤백 시 DB는 Undo 정보를 적용해 논리적으로 변경 이전 상태로 되돌린다.
balance = 9000
↓ ROLLBACK
balance = 10000
따라서 Undo Log는 단순한 장애 복구 파일이 아니라, MVCC의 일관 읽기와 트랜잭션 롤백 모두에 사용되는 핵심 구성 요소다.
9. MVCC가 있으면 락은 필요 없는가
MVCC가 조회와 수정 간 충돌을 줄여 주는 것은 맞지만, 락을 제거하지는 않는다.
정확히는 다음과 같이 이해해야 한다.
일반 SELECT와 UPDATE의 충돌
→ MVCC로 상당 부분 완화
UPDATE와 UPDATE의 충돌
→ 행 잠금 필요
SELECT FOR UPDATE
→ 잠금 읽기
DDL과 DML의 충돌
→ 메타데이터 락 또는 테이블 락 필요
고유 제약조건 검사
→ 인덱스 및 잠금 조정 필요
예를 들어 두 트랜잭션이 동시에 같은 계좌를 수정하려 한다.
UPDATE account
SET balance = balance - 1000
WHERE account_id = 1;
첫 번째 트랜잭션이 행을 수정하고 커밋하지 않았다면 두 번째 UPDATE는 일반적으로 같은 행의 잠금을 기다려야 한다.
T1: account_id=1 행 잠금 획득
T1: balance 수정
T2: 같은 행 UPDATE 시도
T2: 잠금 대기
T1: COMMIT
T2: 잠금 획득 후 실행
MVCC의 대표적인 장점인 “읽는 작업이 쓰는 작업을 막지 않는다”는 의미가 “모든 쓰기 작업이 서로 막지 않는다”는 뜻은 아니다.
MVCC는 주로 읽기와 쓰기의 동시성을 높이고, 쓰기와 쓰기의 충돌은 여전히 잠금으로 제어한다.
10. 일반 SELECT와 SELECT FOR UPDATE의 차이
애플리케이션 코드에서 다음 두 조회는 내부 동작이 다르다.
일반 조회
SELECT *
FROM account
WHERE account_id = 1;
일반 조회는 보통 MVCC 스냅샷을 기준으로 과거의 커밋 버전을 읽는다.
다른 트랜잭션이 해당 행 수정 중
→ 기다리지 않고 이전 커밋 버전 조회 가능
잠금 조회
SELECT *
FROM account
WHERE account_id = 1
FOR UPDATE;
SELECT FOR UPDATE는 데이터를 단순히 조회하는 것이 아니라 이후 수정을 전제로 현재 행을 잠근다.
다른 트랜잭션이 해당 행 수정 중
→ 잠금이 해제될 때까지 대기하거나 실패
Spring Data JPA에서는 다음과 같이 사용할 수 있다.
public interface AccountRepository
extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select a
from Account a
where a.id = :id
""")
Optional<Account> findByIdForUpdate(
@Param("id") Long id
);
}
서비스에서는 하나의 트랜잭션 안에서 조회와 수정을 수행한다.
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
public void withdraw(Long accountId, long amount) {
Account account = accountRepository
.findByIdForUpdate(accountId)
.orElseThrow();
account.withdraw(amount);
}
}
이 경우 MVCC만으로 처리하는 것이 아니라 DB의 행 잠금을 명시적으로 사용한다.
11. MVCC만으로 Lost Update를 막을 수 있는가
다음과 같은 코드가 있다고 하자.
@Transactional
public void increaseStock(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.increaseStock();
}
초기 재고가 10이고 두 요청이 동시에 실행된다.
T1: stock = 10 조회
T2: stock = 10 조회
T1: stock = 11 저장
T2: stock = 11 저장
기대 결과는 12지만 실제 결과는 11이 될 수 있다.
T1의 변경이 T2에 의해 덮어써짐
→ Lost Update
MVCC는 각 트랜잭션에 읽기 일관성을 제공하지만, 애플리케이션의 모든 비즈니스 동시성 문제를 자동으로 해결하지는 않는다.
이 문제는 다음 방법으로 해결할 수 있다.
원자적 UPDATE
UPDATE product
SET stock = stock + 1
WHERE product_id = ?;
DB가 하나의 SQL 문으로 연산을 수행하게 만든다.
낙관적 락
@Entity
public class Product {
@Id
private Long id;
@Version
private Long version;
private int stock;
}
업데이트 SQL은 개념적으로 다음과 같이 실행된다.
UPDATE product
SET stock = ?,
version = version + 1
WHERE product_id = ?
AND version = ?;
다른 트랜잭션이 먼저 수정했다면 영향받은 행 수가 0이 되고, 애플리케이션은 충돌을 감지한다.
비관적 락
SELECT *
FROM product
WHERE product_id = ?
FOR UPDATE;
조회 시점부터 다른 트랜잭션의 수정을 차단한다.
따라서 다음을 구분해야 한다.
DB의 MVCC
→ 트랜잭션이 어떤 데이터 버전을 볼지 결정
애플리케이션 동시성 제어
→ 동시에 발생한 비즈니스 변경을 어떻게 직렬화하거나 충돌 처리할지 결정
12. 격리 수준과 MVCC의 관계
격리 수준은 단순히 잠금의 강도를 의미하지 않는다.
MVCC 기반 DB에서는 격리 수준에 따라 스냅샷을 언제 생성하고 얼마나 오래 유지하는지가 달라진다.
| 격리 수준 | 일반적인 스냅샷 특성 | 발생 가능한 현상 |
|---|---|---|
| READ UNCOMMITTED | DB에 따라 제한적 MVCC | Dirty Read 가능 |
| READ COMMITTED | SQL 문마다 새로운 스냅샷 | Non-repeatable Read 가능 |
| REPEATABLE READ | 트랜잭션 동안 스냅샷 유지 | DB 구현에 따라 Phantom 처리 차이 |
| SERIALIZABLE | 직렬 실행과 동등한 결과 보장 | 충돌 실패 또는 강한 잠금 증가 |
READ COMMITTED 예시
T1: SELECT balance → 10000
T2: UPDATE balance = 9000
T2: COMMIT
T1: SELECT balance → 9000
두 번째 SQL은 새로운 스냅샷을 사용하므로 최신 커밋 값을 볼 수 있다.
REPEATABLE READ 예시
T1: SELECT balance → 10000
T2: UPDATE balance = 9000
T2: COMMIT
T1: SELECT balance → 10000
T1은 이전과 같은 스냅샷을 사용하므로 동일한 결과를 볼 수 있다.
하지만 실제 동작은 DBMS별 차이가 있다.
- Oracle의 기본
READ COMMITTED는 문장 단위 읽기 일관성을 제공한다. - MySQL InnoDB의 기본
REPEATABLE READ는 일반적인 일관 읽기에서 트랜잭션 스냅샷을 재사용한다. - PostgreSQL의
READ COMMITTED는 각 SQL 명령이 명령 시작 전에 커밋된 행을 기준으로 조회한다.
13. 장기 트랜잭션이 MVCC 성능을 악화시키는 이유
MVCC는 과거 버전을 보존해야 동작한다.
그런데 하나의 트랜잭션이 매우 오랫동안 스냅샷을 유지하면 DB는 그 트랜잭션이 필요로 할 수 있는 과거 버전을 제거하지 못한다.
10:00 T1 시작
10:01~11:00 다른 트랜잭션이 대량 UPDATE/DELETE
11:00 T1 아직 종료되지 않음
T1이 10시 시점의 데이터를 볼 가능성이 있으므로 DB는 관련 과거 버전을 계속 보존해야 한다.
이로 인해 다음 문제가 발생할 수 있다.
- Undo 영역 증가
- PostgreSQL Dead Tuple 증가
- Purge 또는 Vacuum 지연
- 테이블과 인덱스 팽창
- 버전 체인 탐색 비용 증가
- 디스크 I/O 증가
- 읽기 성능 저하
MySQL 공식 문서도 읽기 전용 트랜잭션을 포함해 트랜잭션을 정기적으로 커밋하지 않으면 필요한 Update Undo Log를 제거할 수 없어 Undo Tablespace가 커질 수 있다고 설명한다.
Oracle에서는 장기 조회에 필요한 Undo 데이터가 재사용되면 ORA-01555: snapshot too old가 발생할 수 있다. Oracle은 Undo를 사용해 과거 시점 데이터를 재구성하므로, 필요한 Undo가 이미 덮어써졌다면 조회 시점을 복원하지 못한다.
PostgreSQL에서는 오래된 튜플 버전을 VACUUM이 정리하며, Visibility Map은 모든 활성 트랜잭션에 보이는 페이지와 동결된 튜플이 있는 페이지를 추적한다.
14. 애플리케이션에서 자주 발생하는 장기 트랜잭션
트랜잭션 안에서 외부 API 호출
@Transactional
public void processPayment(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
paymentClient.approve(order.getAmount());
order.completePayment();
}
외부 결제 API가 5초 걸리면 DB 트랜잭션과 커넥션도 5초 이상 유지된다.
DB 조회
→ 외부 API 대기
→ DB 수정
→ COMMIT
이 구조는 다음 문제를 일으킬 수 있다.
- 커넥션 점유 시간 증가
- 잠금 유지 시간 증가
- MVCC 스냅샷 유지 시간 증가
- Undo 정리 지연
- 장애 발생 시 롤백 범위 증가
가능하다면 외부 호출과 DB 트랜잭션 경계를 분리해야 한다.
public void processPayment(Long orderId) {
PaymentRequest request = loadPaymentRequest(orderId);
PaymentResult result = paymentClient.approve(
request.amount()
);
completePayment(orderId, result);
}
@Transactional(readOnly = true)
public PaymentRequest loadPaymentRequest(Long orderId) {
// 조회
}
@Transactional
public void completePayment(
Long orderId,
PaymentResult result
) {
// 상태 변경
}
다만 트랜잭션을 분리하면 원자성이 사라지므로 Outbox, 보상 트랜잭션, 멱등 처리 등의 설계가 함께 필요할 수 있다.
15. 대량 배치에서 하나의 트랜잭션을 사용하면 안 되는 이유
다음과 같이 백만 건을 하나의 트랜잭션으로 처리한다고 가정하자.
@Transactional
public void migrate() {
List<LegacyData> data = legacyRepository.findAll();
for (LegacyData item : data) {
targetRepository.save(convert(item));
}
}
이 구조는 다음 문제를 만든다.
- 트랜잭션 지속 시간이 매우 길어짐
- 대량의 Undo 또는 과거 버전 생성
- 영속성 컨텍스트 메모리 증가
- 잠금 장기 유지
- 장애 시 전체 롤백
- MVCC 정리 작업 지연
대량 작업은 일반적으로 Chunk 단위로 나누는 것이 적절하다.
public void migrate() {
int page = 0;
int size = 1000;
while (true) {
List<LegacyData> batch =
legacyRepository.findBatch(page, size);
if (batch.isEmpty()) {
break;
}
migrateChunk(batch);
page++;
}
}
@Transactional
public void migrateChunk(List<LegacyData> batch) {
for (LegacyData item : batch) {
targetRepository.save(convert(item));
}
}
처리 단위를 줄이면 다음과 같은 효과가 있다.
- 트랜잭션 수명 단축
- Undo 사용량 감소
- 잠금 범위 축소
- 실패 시 재처리 범위 제한
- 커넥션 반환 주기 단축
16. @Transactional(readOnly = true)가 MVCC를 없애는 것은 아니다
다음과 같은 코드가 있다.
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
return orderRepository.findById(orderId)
.map(OrderResponse::from)
.orElseThrow();
}
readOnly = true는 Spring과 JPA Provider, JDBC Driver, DBMS에 읽기 전용 의도를 전달하거나 Flush 동작을 최적화하는 데 사용될 수 있다.
하지만 조회 트랜잭션도 DB에서 스냅샷을 생성하고 유지할 수 있다.
readOnly = true
≠ 트랜잭션 없음
≠ MVCC 버전 관리 영향 없음
특히 읽기 전용 트랜잭션이 오래 유지되면 과거 버전 정리를 지연시킬 수 있다.
따라서 읽기 전용 메서드라도 다음 작업을 트랜잭션 안에 포함하지 않는 것이 좋다.
- 외부 API 호출
- 대용량 파일 생성
- 긴 반복 계산
- 사용자 입력 대기
- 메시지 브로커 응답 대기
DB 조회가 끝난 뒤 필요한 데이터를 DTO로 변환해 트랜잭션 밖에서 후속 작업을 수행하는 것이 유리하다.
17. DB 커넥션 풀과 MVCC의 관계
애플리케이션의 트랜잭션은 일반적으로 하나의 DB Connection에 종속된다.
@Transactional 시작
→ Connection 획득
→ 스냅샷 생성
→ SQL 실행
→ COMMIT/ROLLBACK
→ Connection 반환
트랜잭션이 길어지면 MVCC뿐 아니라 커넥션 풀에도 영향을 준다.
예를 들어 다음과 같은 환경을 가정하자.
HikariCP maximumPoolSize = 20
각 트랜잭션 평균 점유 시간 = 5초
동시 요청 수 = 100
20개의 트랜잭션이 외부 API나 긴 연산으로 커넥션을 점유하면 나머지 요청은 커넥션을 얻기 위해 대기한다.
따라서 MVCC 문제는 단순히 DB 내부 문제로 끝나지 않는다.
장기 트랜잭션
→ 오래된 스냅샷 유지
→ Undo/Vacuum/Purge 지연
→ DB 커넥션 장기 점유
→ Connection Pool 대기
→ 애플리케이션 응답 지연
→ 타임아웃 증가
실무에서는 다음 지표를 함께 확인해야 한다.
애플리케이션
- Transaction duration
- Connection acquire time
- Active connection
- Pending connection
- API latency
MySQL
- History list length
- Undo tablespace 사용량
- Purge 지연
- Lock wait
PostgreSQL
- Long-running transaction
- Dead tuple 수
- Autovacuum 진행 상태
- Transaction ID age
- Table bloat
Oracle
- Undo tablespace 사용량
- Undo retention
- Long-running query
- ORA-01555 발생 여부
- Row lock contention
18. DB별 MVCC 구현 차이
MySQL InnoDB
InnoDB는 현재 레코드를 테이블에 유지하고 이전 버전을 Undo Log에서 재구성한다.
현재 행
|
DB_ROLL_PTR
|
Undo Record
|
이전 Undo Record
특징은 다음과 같다.
- 일반 SELECT는 Consistent Read 사용
- REPEATABLE READ에서는 같은 스냅샷을 재사용
- READ COMMITTED에서는 SQL마다 새 스냅샷 사용
- UPDATE/DELETE 간 충돌은 행 잠금 사용
- 오래된 Undo는 Purge Thread가 정리
InnoDB는 삭제도 즉시 물리 제거하지 않고 삭제 표시 후, 해당 이전 버전을 필요로 하는 트랜잭션이 없어지면 Purge 과정에서 정리한다.
PostgreSQL
PostgreSQL은 수정 시 기존 행을 덮어쓰지 않고 새로운 튜플을 생성한다.
Tuple V1: 이전 데이터
Tuple V2: 변경 데이터
특징은 다음과 같다.
- 각 튜플에 생성·삭제 트랜잭션 정보 유지
- 스냅샷과 튜플 정보를 비교해 가시성 판단
- 오래된 버전은 VACUUM이 정리
- 정리가 지연되면 Dead Tuple과 Table Bloat 증가
- Transaction ID Wraparound 방지를 위한 Freeze 필요
PostgreSQL의 Visibility Map은 모든 활성 트랜잭션에 보이는 튜플만 포함한 페이지와 모든 튜플이 동결된 페이지를 추적하며, VACUUM 및 Index Only Scan 최적화에 활용된다.
Oracle
Oracle은 데이터 변경 시 Undo Segment에 이전 값을 기록하고, 조회 시 필요한 과거 시점의 블록을 재구성한다.
현재 블록
+
Undo Segment
=
특정 SCN 시점의 Consistent Read Block
특징은 다음과 같다.
- SCN 기반 읽기 일관성
- 기본 격리 수준은 READ COMMITTED
- SQL 문장 단위로 일관된 시점 보장
- Reader와 Writer가 일반적으로 서로를 차단하지 않음
- 필요한 Undo가 사라지면 Snapshot Too Old 발생 가능
Oracle은 하나의 쿼리가 실행되는 동안 해당 쿼리 시작 시점에 일관된 결과를 제공한다. 쿼리 수행 중 다른 트랜잭션이 데이터를 커밋하더라도, 이미 실행 중인 쿼리에는 중간 변경이 섞이지 않는다.
19. 실무에서 가장 많이 오해하는 부분
“MVCC가 있으면 동시성 문제는 자동으로 해결된다”
아니다.
MVCC는 읽기 가시성을 제어하지만 다음 문제까지 자동으로 해결하지 않는다.
- Lost Update
- 중복 주문
- 중복 결제
- 재고 초과 차감
- 상태 전이 충돌
- 메시지 중복 소비
이 문제들은 낙관적 락, 비관적 락, 원자적 UPDATE, Unique Constraint, 멱등 키 등의 추가 설계가 필요하다.
“일반 SELECT는 항상 최신 데이터를 읽는다”
아니다.
트랜잭션의 격리 수준과 스냅샷에 따라 이전 커밋 버전을 읽을 수 있다.
“COMMIT하면 이전 버전은 즉시 삭제된다”
아니다.
과거 스냅샷을 가진 트랜잭션이 존재하면 이전 버전을 계속 보존해야 한다.
“MVCC를 사용하면 잠금이 없다”
아니다.
일반 조회가 쓰기를 덜 차단할 뿐, 쓰기 간 충돌과 잠금 조회에는 여전히 잠금이 사용된다.
“읽기 전용 트랜잭션은 DB에 부담이 없다”
아니다.
오래된 읽기 전용 트랜잭션도 과거 버전 정리를 지연시킬 수 있다.
20. 애플리케이션 설계 시 권장 사항
트랜잭션 범위를 짧게 유지한다
@Transactional
public void updateOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.updateStatus();
}
DB 조회와 수정에 필요한 작업만 포함한다.
외부 I/O를 트랜잭션에서 분리한다
피해야 할 구조
BEGIN
→ DB 조회
→ HTTP 호출
→ MQ 응답 대기
→ 파일 생성
→ DB 수정
→ COMMIT
대량 작업은 Chunk 단위로 커밋한다
100만 건 1회 트랜잭션
→ 1,000건 × 1,000회 트랜잭션
동시 수정 가능성이 있다면 충돌 전략을 명확히 한다
충돌 빈도가 낮음
→ 낙관적 락
충돌 빈도가 높고 순차 처리 필요
→ 비관적 락
단순 증감
→ 원자적 UPDATE
중복 생성 방지
→ Unique Constraint
재처리 가능 요청
→ Idempotency Key
격리 수준을 무조건 높이지 않는다
SERIALIZABLE이 항상 더 좋은 것은 아니다.
격리 수준이 높아지면 다음 비용이 증가할 수 있다.
- 충돌 실패
- 잠금 대기
- 재시도
- 처리량 감소
- 트랜잭션 지연
데이터 정합성 요구사항과 허용 가능한 동시성 수준을 기준으로 선택해야 한다.
운영에서 장기 트랜잭션을 모니터링한다
단순히 Slow Query만 확인해서는 부족하다.
- 오래 열린 트랜잭션
- Idle in Transaction
- 장시간 Connection 점유
- Lock Wait
- Undo 증가
- Vacuum/Purge 지연
을 함께 관찰해야 한다.
21. 전체 동작 흐름 정리
애플리케이션에서 다음 코드가 실행된다고 하자.
@Transactional
public void updateOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete();
}
DB 내부 흐름은 개념적으로 다음과 같다.
1. Spring이 DB Connection을 획득한다.
2. DB 트랜잭션을 시작한다.
3. SELECT 실행 시 스냅샷이 생성되거나 선택된다.
4. DB는 행의 버전 정보를 확인한다.
5. 현재 스냅샷에서 보이는 버전을 반환한다.
6. UPDATE 시 수정 대상 행의 잠금을 획득한다.
7. 변경 이전 버전을 Undo 또는 별도 튜플로 보존한다.
8. 새로운 데이터 버전을 생성한다.
9. COMMIT 시 변경 트랜잭션을 커밋 상태로 확정한다.
10. 이후 새 스냅샷을 가진 트랜잭션은 새 버전을 볼 수 있다.
11. 과거 버전을 필요로 하는 트랜잭션이 없어지면 정리 작업을 수행한다.
12. Spring이 Connection을 Pool에 반환한다.
이를 한 줄로 요약하면 다음과 같다.
MVCC는 변경 이전의 데이터 버전을 보존하고, 각 트랜잭션의 스냅샷을 기준으로 보이는 버전을 선택함으로써 읽기 일관성과 높은 동시성을 제공한다.
마무리
애플리케이션 개발자는 일반적으로 MVCC를 직접 구현하지 않는다.
하지만 다음과 같은 코드를 작성하는 순간 이미 MVCC의 영향을 받고 있다.
@Transactional
public void process() {
repository.findById(id);
repository.save(entity);
}
트랜잭션을 어디서 시작하고 종료하는지, 어떤 격리 수준을 사용하는지, 조회 후 외부 작업을 얼마나 오래 수행하는지에 따라 DB 내부의 스냅샷 유지 시간, Undo 사용량, Vacuum 또는 Purge 지연, 잠금 대기 시간이 달라진다.
따라서 MVCC는 DBA만 알아야 하는 내부 구조가 아니다.
- 트랜잭션 범위를 설계하는 개발자
- 동시성 문제를 해결하는 개발자
- DB 성능을 분석하는 개발자
- 대량 배치를 운영하는 개발자
- 장애 원인을 추적하는 개발자
라면 반드시 이해해야 하는 핵심 원리다.
MVCC를 실무적으로 이해할 때 가장 중요한 것은 다음 세 문장이다.
일반 조회는 항상 현재 물리 행을 그대로 읽는 것이 아니라, 스냅샷에 보이는 버전을 읽는다.
MVCC는 읽기와 쓰기의 충돌을 줄이지만, 쓰기 간 충돌과 비즈니스 동시성까지 자동으로 해결하지는 않는다.
장기 트랜잭션은 과거 버전 정리를 막아 DB와 커넥션 풀 전체의 성능을 악화시킬 수 있다.
참고 자료
- MySQL 8.0 Reference Manual, InnoDB Multi-Versioning
- MySQL 8.0 Reference Manual, Consistent Nonlocking Reads
- PostgreSQL Documentation, Concurrency Control
- PostgreSQL Documentation, Visibility Map
- Oracle Database Concepts, Data Concurrency and Consistency
'나의 주니어 개발 일기 > DB' 카테고리의 다른 글
| DB는 장애가 발생해도 어떻게 데이터를 복구할까? Redo와 Undo의 원리 (0) | 2026.07.23 |
|---|---|
| Supabase 도커(Docker)로 로컬 개발 환경 구축하기 (0) | 2026.02.02 |
| DB 이중화와 MySQL Replication 구조 정리 및 실무 도입 방향성 (0) | 2025.09.05 |
| 샤딩과 파티셔닝, 무엇이 다르고 언제 써야 할까? (1) | 2025.08.29 |
| 커버링 인덱스, 클러스터드 인덱스, 넌클러스터드 인덱스 (1) | 2024.07.26 |
