본문 바로가기
Server

동시성의 기준점 ① – DB 트랜잭션부터 다시 보자

by promedev 2026. 1. 21.

 

TL;DR

동시성 문제는 여러 요청이 동시에 같은 데이터를 읽고 판단/수정하면서 발생한다.
Isolation(격리성)은 성능과의 트레이드오프 때문에 여러 단계로 나뉜다.
DB는 격리 수준을 보장하기 위해 내부적으로 두 가지 전략(2PL, MVCC)을 선택한다.
DB 트랜잭션만으로는 모든 동시성 문제를 해결할 수 없고,
제약조건, 원자적 업데이트, 낙관락/비관락 같은 추가 전략이 필요하다.

 

동시성 문제

동시성 문제는 비슷한 시기에 여러 개의 요청이 한 번에 동일한 DB 자원에 접근하여 발생하는 문제이다.

 

Spring 서버 개발을 하다 보면 흔히 동시성 문제를 겪게 된다.

예를 들어 2개의 요청이 동시에 좋아요 수를 +1 할 때, 결괏값이 +2가 아닌 +1이 되는 상황이 있을 수 있다.

흔히 이런 문제 상황에 대한 해법은 DB 레벨에서 제안된다. DB와 동시성이 어떤 관련이 있을까?

 

동시성 문제는 어플리케이션의 여러 요청이 DB 자원에 동시에 접근하면서 경합이 발생하는 순간 드러난다.
따라서 동시성 문제를 올바르게 다루기 위해서는,

DB 트랜잭션과 격리 수준이 어떤 방식으로 동작하는지를 이해하는 것이 중요한 출발점이 된다.

 

DB 트랜잭션이란?

A database transaction is a single logical unit of work performed within a database management system.

 

DB 트랜잭션은 짧게 말하면 논리적인 단위로 나눈 DB 연산이다.

예를들어, 쇼핑몰에서 사용자가 주문하는 상황을 생각해 보자.

DB 트랜잭션은 주문 생성과 결제 저장을 하나의 트랜잭션으로 묶어서 실행한다.

 

[트랜잭션 시작]

1. 주문 생성
2. 결제 저장

[커밋]

 

왜 이렇게 논리적 단위로 묶어서 처리하는 걸까?

 

DB 트랜잭션 핵심 속성 - ACID

Atomicity: 전부 or 전무
Consistency: 규칙 유지
Isolation: 중간 상태 비공개
Durability: 커밋은 살아남는다

 

트랜잭션으로 연산을 묶어서 처리하는 이유는 핵심 속성을 통해 이해할 수 있다.

 

1. Atomic(원자성)은 같은 트랜잭션 내의 연산은 모두 실패하거나, 모두 성공해야 한다는 속성이다. 만약, 주문은 성공했으나 결제가 저장되지 않았다면 쇼핑몰 사장은 무료로 상품을 보내는 자선 사업을 해야 할지도 모른다. 따라서 이 연산들은 묶여서 처리되는 상태를 보장해야 한다.

 

2. Consistency(일관성)은 트랜잭션 처리 전/후에 데이터가 규칙과 제약조건을 만족하는 상태여야 한다는 것이다. 만약, 결제된 주문은 결제 기록이 있어야 한다는 규칙이 있으면 규칙은 트랜잭션 전/후로 유지되어야 한다.

 

3. Isolation(독립성)은 동시에 실행되는 트랜잭션이 서로의 중간상태를 보지 못하게 격리되는 것을 의미한다.

T1: Order.status = PAID (아직 커밋 안 함)
T2: Order.status = PAID 를 읽음
T1: 롤백

 

위의 상황에서 T2가 중간에 상태를 읽어서 독립성이 깨질 수 있다.

 

4. Durability(지속성)은 트랜잭션이 완료되면 장애가 발생해도 트랜잭션 결과는 유실되지 않음을 보장한다.

 

4가지 속성 중에 독립성은 성능과 동시성의 트레이드오프에 따라 어느 수준까지 보장할지를 선택하는 속성이다.

사용자들의 요청이 모두 완벽한 독립성을 유지하려면 요청은 '동시에'가 아닌 '순차적'으로 실행되어야 할 것이다.

순차 처리는 지연을 발생시키기 때문에 동시성과 지연은 트레이드오프 관계가 있다.

따라서, 트랜잭션 간섭을 어디까지 허용할지를 선택하는 4가지 격리 단계로 나누어서 트레이드오프를 조절한다.

 

DB 트랜잭션 격리 레벨 4가지와 발생할 수 있는 문제

Uncommitted Read, Committed Read, Repeatable read, Serializable

 

1. Read Uncommitted : 커밋되지 않은 트랜잭션에도 접근해서 값을 읽는다.

   - Dirty Read 문제: 트랜잭션이 롤백되는 상황 → 읽은 값은 유효하지 않은 값이다.

Dirty Read 문제

 

 

2.. Read Committed: 커밋이 완료된 값만 읽는다.

    - Non-Repeatable Read 문제: 같은 트랜잭션 내에서 읽을 때마다 값이 달라지는 문제

Non-Repeatable Read 문제

 

 

3. Repeatable Read: 같은 트랜잭션 내에서 값을 여러 번 읽더라도 값이 변하지 않는다.

    - Phantom Read 문제: 같은 트랜잭션에서 조회 row 집합 자체가 달라지는 문제

Phantom Read 문제

 

 

4. Serializable: 여러 트랜잭션을 순차적으로 실행한 것과 동일하게 처리된다.

    - 굉장히 강한 동시성을 지원하지만, 느리다.

 

각 단계별로 하나의 문제만 발생하는 것은 아니다. 단계별로 발생하는 문제를 정리한 표는 다음과 같다.

문제 유형 \ 격리 수준 Read
Uncommitted
Read
Committed
Repeatable
Read
Serializable
Dirty Read 허용 X (차단) X (차단) X (차단)
Non-Repeatable Read 허용 허용 X (차단) X (차단)
Phantom Read 허용 허용 허용 X (차단)
Write Skew 허용 허용 허용 X (차단)

 

DB는 강한 동시성 보장을 위해서 Serializable 수준을 선택할 수도 있고,

빠른 속도를 위해 더 낮은 레벨의 격리 수준을 선택할 수도 있다.

 

그렇다면 우리가 흔히 사용하는 DB는 트랜잭션 격리 수준을 보장하기 위해 내부적으로 어떤 선택을 할까?

 

DB의 두 가지 선택지: Lock vs Version

DB가 선택할 수 있는 방법은 크게 두 가지다.
1. 충돌을 막는다 (Lock - 2PL)
2. 충돌을 피한다 (Version - MVCC)

 

1. 2PL: 충돌을 막는 전략

2PL(2 Phase Lock)은 락 획득과 해제 단계 순서를 제한함으로써 동시성을 제어하는 방법이다.

 

https://www.geeksforgeeks.org/dbms/two-phase-locking-protocol/

 

  • Growing Phase: 락 획득 단계. 잠금을 해제할 수 없다.
  • Shrinking Phase: 락 해제 단계. 새로운 잠금을 획득할 수 없다.

 

일반적인 Locking 규칙은 다음과 같다.

  1. 읽기는 Shared Lock을 필요로 한다.
  2. 쓰기는 Exclusive Lock을 필요로 한다.

 

2가지 잠금의 종류 (S, X)

  • Shared Lock: 읽기 전용 잠금. 또 다른 Shared Lock을 허용한다.
  • Exclusive Lock: 잠금이 유지되는 동안 다른 트랜잭션의 잠금을 모두 허용하지 않는다.
LOCK TYPES Shared Lock Exclusive Lock
Shared Lock 허용 차단
Exclusive Lock 차단 차단

 

 

Lock을 통해 동시성 제어할 수 있지만, Lock을 거는 방법에는 단점이 있다.

  • 데드락 가능성이 높다. 서로의 잠금을 기다리는 상태에 데드락이 발생한다.
  • 연쇄 롤백: 롤백이 발생하는 상황에서, 의존이 생긴 트랜잭션들이 연쇄적으로 롤백된다.
  • 제한된 동시성: 성능 저하, 대기 시간이 길어진다.
  • 잠금 경쟁

(본 글에서는 2PL에 대해 깊게 다루지 않는다. Lock Conversion 등 2PL을 더 알아보고 싶으면 다음의 링크를 참고하길 바란다. https://www.geeksforgeeks.org/dbms/two-phase-locking-protocol/)

 

2. MVCC: 충돌을 피하는 전략

버전을 관리(스냅샷)함으로써 동시성을 제어하는 방법

 

MVCC는 정해진 방법이 있다기 보단 DB에 따라 구현 방법이 다양하다.

 

크게는 2가지 종류의 스냅샷이 있다.

 

1. Query level: 쿼리마다 스냅샷을 저장한다. 짧은 스냅샷 주기 때문에 undo log를 오래 관리하지 않는다. 

2. Transaction level: 트랜잭션마다 스냅샷을 저장한다. 스냅샷이 오래 유지되기 때문에 undo log을 상대적으로 오래 관리해야 한다. 장기 트랜잭션은 MVCC의 최대 적이다.

 

각 DB의 구현 방법을 살펴보는 것도 재밌겠지만, 간단하게 개념적인 부분만 짚고 넘어가려고 한다.

 

개념적인 흐름 (구현은 DB마다 다름)

1. 수정할 데이터에 V1 버전을 명시한다.
2. V2 버전 데이터를 생성하고 수정한다.
3. V2 작업이 커밋되기 전까지 사용자들은 V1 데이터를 읽는다.
4. V2 작업이 커밋되면 사용자들은 더 높은 버전인 V2 데이터를 읽는다.
5. V1 데이터는 더 이상 참조되지 않으면 purge 스레드에 의해 정리된다.

 

MVCC 방법은 위에 예제에서 볼 수 있듯이 추가적인 저장공간이 필요로 하는 방법이다.

또한, 읽기 성능을 높이기 위해 여러 트랜잭션이 서로 다른 시점의 데이터를 보는 것을 허용한다.

이는 Skew 문제로 이어진다.

 

Read skew - 같이 보면 안 되는 사실을 같이 봤다

 

예를 들어, 주문 상태가 PAID면 결제 내역이 반드시 존재해야 하는 비즈니스 규칙이 있을 때,

→ 주문 상태 반영 트랜잭션은 커밋
→ 결제 내역은 이미 커밋되었지만, 조회 트랜잭션이 다른 시점의 스냅샷을 섞어 읽은 경우

 

주문 상태가 PAID이지만 결제 내역이 존재하지 않는 말이 안 되는 상황이 발생할 수 있다.

 

Write skew - 각자 옳은 판단이 모여 잘못된 결과를 만들었다

 

예를 들어, 항상 최소 1명의 당직 의사가 필요하다는 비즈니스 규칙이 있을 때,

→ 트랜잭션1: 의사 A 퇴근
→ 트랜잭션2: 의사 B 퇴근

 

모든 의사(A, B)가 퇴근해 버려 규칙이 위반되는 상황이 발생할 수 있다.

두 트랜잭션은 서로 다른 row를 수정했기 때문에 DB 입장에서는 충돌이 발생하지 않았다.

 

정리 - DB 트랜잭션은 모든 것을 해결해주지 않는다

 

Lock, MVCC 같은 동시성 제어 전략을 사용할 수 있지만, 모든 문제를 해결할 수는 없다.

예를 들어, MySql은 MVCC에 갭락을 추가하여 문제를 해결하기도 한다.

따라서 DB 트랜잭션만으로 해결하려 하기보다 제약조건, 원자적 업데이트, 낙관락/비관락 설계를 고려해야 한다.

다음 글에서는 동시성 문제를 보완하기 위한 수단에는 어떤 것들이 있는지 다뤄보려고 한다.

 

Ref

https://en.wikipedia.org/wiki/Database_transaction

https://www.youtube.com/watch?v=onYjxRcToto

https://www.youtube.com/watch?v=iM71d2krbS4

https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html

https://www.geeksforgeeks.org/dbms/two-phase-locking-protocol/

 

 

 

*잘못된 내용은 댓글로 알려주시면 글에 반영하도록 하겠습니다. 감사합니다.