채움의 길
[DeepDive] 동시성 제어 기법을 알아보자 본문
RDBMS 동시성 제어의 정석
동시성 제어 : 여러 사용자가 동시에 데이터베이스에 접근할 때 데이터의 일관성과 무결성을 유지하기 위한 중요한 개념
1. 데이터 경합과 동시성 문제 유형 분석
데이터 경합(Data Contention) : 여러 트랜잭션이 동일한 데이터에 동시에 접근하려 할 때 발생
Critical Section (임계 영역)
- 진입 (Entry)
- 프로세스가 임계 영역에 진입하기 전에 다른 프로세스가 임계 영역을 사용하고 있는지 확인
- 만약 사용 중이라면 해당 프로세스는 대기
- 임계 영역 수행(Execution)
- 임계 영역이 비어 있음을 확인하면 프로세스는 임계 영역에 진입하여 공유 자원을 사용
- 이 단계에서 다른 어떤 프로세스도 임계 영역에 진입할 수 없음
- 퇴장(Exit)
- 작업을 마친 프로세스는 임계 영역에서 나오면서 다른 프로세스가 진입할 수 있도록 잠금을 해제
- 대기 중이던 다른 프로세스가 임계 영역에 접근할 수 있게 됨
→ 이러한 단계를 통해, 여러 프로세스가 동시에 공유 자원에 접근하려 할 때 발생하는 경쟁 조건(Race Condition)을 피하고 데이터의 일관성을 유지
그럼에도 불구하고 Race Condition이 발생하는 이유?
Race Condition (경쟁 조건)
여러 트랜잭션이 공유 자원의 상태를 확인하고 그 상태를 변경하는 작업이 원자적(Atomic)이지 않을 때 발생.
즉, 상태 확인과 상태 변경 사이에 다른 트랜잭션이 끼어들어 작업을 방해
- 정상적인 플로우가 깨지는 지점
- '진입 - 수행' 과정에서 "어느 프로세스도 없다고 인지"하는 그 순간에 이미 다른 트랜잭션이 들어올 수 있는 가능성이 있음
- SELECT 문은 기본적으로 읽기만 할 뿐, 해당 데이터에 대한 잠금을 설정하지 않기 때문
- 발생하는 문제
- 여러 트랜잭션이 임계 영역(Critical Section)에 동시에 접근할 때 발생하는 문제 상황을 총칭하는 용어
동시성 문제 유형 분석
- 갱신 손실 (Lost Update)
- 두 트랜잭션이 같은 공유 자원을 읽고 각자 다른 값으로 업데이트 하여, 결과적으로 한 트랜잭션의 변경 내용이 사라지는 문제
1. 트랜잭션 A가 계좌 잔액($100)을 읽습니다. 2. 트랜잭션 B도 같은 계좌 잔액($100)을 읽습니다. 3. 트랜잭션 A가 $50을 입금하여 잔액을 $150으로 업데이트합니다. 4. 트랜잭션 B가 $30을 입금하여 잔액을 $130으로 업데이트합니다. → 이 과정에서 3번의 변경 내용이 사라짐 - 비현실적인 읽기 (Unrepeatable Read)
- 한 트랜잭션이 동일한 데이터를 여러 번 읽을 때, 그 사이에 다른 트랜잭션이 데이터를 수정하고 커밋하여 두 번의 읽기 결과가 달라지는 현상 ⇒ "반복 가능한 읽기"가 보장되지 않는다는 말과 같음
1. 트랜잭션 A가 `book_id=101`인 책의 가격을 $100으로 읽습니다. 2. 트랜잭션 B가 같은 책의 가격을 $120으로 업데이트하고 커밋합니다. 3. 트랜잭션 A가 다시 `book_id=101`인 책의 가격을 읽으면, 이제 $120으로 나옵니다. → 트랜잭션 A는 하나의 트랜잭션 내에서 같은 데이터를 읽었음에도, 값이 일치하지 않는 비일관적인 현상이 발생함 - 더티 읽기 (Dirty Read)
- 한 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 데이터를 읽는 문제
- 만일 다른 트랜잭션이 롤백될 경우, 읽은 데이터는 실제로는 존재하지 않는 Dirty Data가 됨
1. 트랜잭션 A가 계좌 잔액($100)을 $200으로 업데이트하지만, 아직 커밋하지 않습니다. 2. 트랜잭션 B가 계좌 잔액을 읽어 $200이라고 인식합니다. 3. 트랜잭션 A가 어떤 이유로 롤백되어 변경 사항이 취소됩니다. 4. 트랜잭션 B는 존재하지 않는 $200이라는 잘못된 데이터를 기반으로 작업을 계속하게 됩니다. - 유령 읽기 (Phantom Read)
- 한 트랜잭션이 특정 조건으로 데이터를 조회했을 때, 그 사이에 다른 트랜잭션이 조건에 맞는 새로운 레코드(행)를 삽입하여 다음 조회 시 이전에 없던 '유령' 레코드가 나타나는 문제
- 읽기 작업만 하는 트랜잭션이 여러 번 조회할 경우, 결과 집합이 변경되어 예측할 수 없는 상황이 벌어지는 것 → 한 트랜잭션 내에서 일관성이 깨지는 현상이 핵심
- 트래잭션 원칙 중 하나인 ‘고립성’이 성립하지 않게 됨.
- 고립성 : 하나의 트랜잭션이 진행되는 동안 다른 트랜잭션의 영향을 받지 않고 고립되어야 함
- 유령 읽기는 기존 레코드가 수정되는 비현실적인 읽기(Unrepeatable Read)와 달리 새로운 레코드가 삽입되어 조회 결과의 집합 자체가 바뀌는 문제
2. 트랜잭션과 잠금(공유/배타 락)의 기본 원리
공유 락과 배타 락
- 공유 락(Shared Lock, S-Lock)
- 읽기 작업을 위한 잠금
- 핵심 원리는 데이터를 변경하지 않는 읽기 작업은 서로에게 영향을 주지 않는다는 점에 기반
- 작동 방식
- 여러 트랜잭션의 동시 접근 허용 특정 데이터에 공유 락이 걸려 있으면 다른 트랜잭션들도 자유롭게 같은 데이터에 공유 락을 걸고 읽기 작업을 할 수 있음
- 배타 락과의 충돌 하지만 어떤 트랜잭션이 이미 공유 락이 걸린 데이터를 변경하려고 하면(배타 락을 걸려고 하면) 그 트랜잭션은 대기해야 함. 이는 공유 락이 데이터의 일관된 상태를 유지해주기 때문 → 읽고 있는 동안 데이터가 변경되지 않도록 보호하는 역할
사용 예시 - 비현실적인 읽기(Unrepeatable Read) 방지 트랜잭션 A가 특정 상품의 재고를 조회한 후, 다음 작업으로 넘어가려고 합니다. 그런데 그 사이에 트랜잭션 B가 재고를 수정하고 커밋해버리면, 트랜잭션 A는 일관성 없는 데이터를 보게 됩니다. 이런 상황을 막기 위해, 트랜잭션 A가 `SELECT`를 할 때 공유 락을 걸어놓으면, 트랜잭션 B의 `UPDATE` 작업이 대기하게 되어 A는 안정적으로 작업을 수행할 수 있습니다. - 배타 락(Exclusive Lock, X-Lock)
- 쓰기(변경) 작업을 위한 잠금
- 데이터를 변경하는 작업은 단독으로 이루어져야 한다는 원칙에 기반
- 작동 방식
- 단 하나의 트랜잭션만 허용 배타 락이 걸린 데이터에는 다른 어떤 종류의 락(공유 락, 배타 락 등)도 걸 수 없음. 데이터를 수정하는 동안 다른 트랜잭션이 읽거나 수정할 수 없도록 완전히 격리
- 갱신 손실 방지 갱신 손실(Lost Update)과 같은 동시성 문제를 해결하는 데 필수. 여러 트랜잭션이 동시에 데이터를 수정하려 할 때, 가장 먼저 배타 락을 획득한 트랜잭션이 안전하게 작업을 수행하고 나머지는 대기
사용 예시 - 갱신 손실(Lost Update) 방지 두 트랜잭션이 거의 동시에 계좌 잔액을 업데이트하려 합니다. 1. 트랜잭션 A가 `UPDATE`를 시작하며 잔액 데이터에 배타 락을 겁니다. 2. 이때 트랜잭션 B가 같은 데이터에 `UPDATE`를 시도하지만, A가 건 배타 락 때문에 대기하게 됩니다. 3. 트랜잭션 A가 작업을 마친 후 락을 해제하면, B가 락을 획득하고 작업을 시작합니다. 이러한 과정을 통해 A의 변경 사항이 B에 의해 덮어씌워지는 것을 막을 수 있습니다.
ACID
- 원자성 (Atomicity)
- 트랜잭션 내의 모든 작업이 완전히 실행되거나, 아예 실행되지 않거나 둘 중 하나만 보장하는 속성
- 구현 방법 : 로그 파일
- 일관성 (Consistency)
- 트랜잭션이 성공적으로 완료되면 데이터베이스가 항상 논리적으로 일관된 상태를 유지해야 한다는 속성
- 구현 방법 : 트리거, 제약 조건
- 고립성 (Isolation)
- 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션이 다른 트랜잭션의 작업에 간섭받지 않고 독립적으로 실행되어야 한다는 속성
- 구현 방법 : 잠금, 다중 버전 동시성 제어(MVCC)
- 지속성 (Durability)
- 트랜잭션이 성공적으로 커밋된 후에는 시스템에 영구적으로 저장되어, 시스템에 오류가 발생하더라도 데이터가 유실되지 않도록 보장하는 속성
- 구현 방법 : 트랜잭션 로그 파일, 체크포인트
3. 트랜잭션 격리 수준별 동시성 제어 범위 이해
- ANSI/ISO SQL 표준은 네 가지 격리 수준을 정의하고 있음
- 높은 수준으로 갈수록 동시성 문제는 줄어들지만, 성능 저하가 발생할 수 있음
- 각 수준은 이전 단계에서 해결하지 못한 동시성 문제를 해결하도록 설계
격리 수준 해결하는 문제 발생 가능한 문제
| READ UNCOMMITTED | - | 더티 읽기, 비현실적인 읽기, 유령 읽기 |
| READ COMMITTED | 더티 읽기 | 비현실적인 읽기, 유령 읽기 |
| REPEATABLE READ | 더티 읽기, 비현실적인 읽기 | 유령 읽기 |
| SERIALIZABLE | 더티 읽기, 비현실적인 읽기, 유령 읽기 | - |
- READ UNCOMMITTED가 해결하는 문제가 하나도 없는데도 존재하는 이유
- 동시성 문제를 해결하는 것이 아니라, 동시성 문제를 허용하는 대신 최고의 성능을 추구하는 방식 → 데이터의 일관성이라는 중요한 트랜잭션 속성을 포기하고 성능과 속도를 택한 방식
- 금융 거래 시스템처럼 데이터의 정확성이 매우 중요한 경우에는 절대 사용해서는 안 됩
- 예시 - 데이터의 정확성보다 속도가 훨씬 중요한 상황에서 사용
- 웹 서버 로그 분석 : 수많은 웹 서버의 접속 로그를 실시간으로 집계할 때, 1~2개의 잘못된 데이터가 섞여도 전체적인 트래픽 추이를 파악하는 데는 문제 없음
- 실시간 통계 및 대시보드 : 주식 시장의 실시간 거래량이나 웹사이트의 방문자 수와 같이 데이터가 초 단위로 계속 갱신되는 경우. 잠시 동안의 부정확한 데이터보다는 최신 상태를 가능한 한 빨리 보여주는 것이 더 중요
- 대용량 데이터 읽기 : 특정 시점의 정확한 데이터가 필요하지 않은 단순히 대량의 데이터를 빠르게 읽어와 다른 시스템으로 옮기는 ETL(Extract, Transform, Load) 작업에도 활용될 수 있음
4. SELECT ... FOR UPDATE를 이용한 명시적 행 잠금
비관적 락 (Pessimistic Lock)
- 데이터를 수정하기 전, 충돌이 일어날 것이라 판단하고(비관적) 미리 해당 트랜잭션에 잠금을 걸어두는 방식
- 장점 : 데이터 충돌을 확실하게 방지하여 데이터 일관성을 보장
- 단점 : 잠금이 걸린 동안 다른 트랜잭션들이 대기해야 하므로 동시성이 낮아지고 성능 저하 가능성
@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락(Exclusive Lock)을 겁니다.
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
- @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용하면 JPA 구현체(Hibernate 등)가 해당 JPQL쿼리를 데이터베이스의 SQL 구문으로 변환할 때 FOR UPDATE 구문을 자동으로 추가
- 실제 실행되는 쿼리
- SELECT * FROM product WHERE id = ? FOR UPDATE;
@Lock 어노테이션의 역할
- JPA를 통해 선언적으로 잠금을 제어할 수 있게 해주는 기능
- @Lock(LockModeType.PESSIMISTIC_WRITE)
- PESSIMISTIC_WRITE는 비관적 락의 쓰기 모드
- SELECT ... 쿼리 뒤에 데이터베이스 방언에 맞는 배타적 잠금(Exclusive Lock) 구문을 추가. 대부분의 경우 이는 FOR UPDATE로 변환됨
- @Lock(LockModeType.PESSIMISTIC_READ)
- PESSIMISTIC_READ는 비관적 락의 읽기 모드
- SELECT ... FOR SHARE와 같이 공유 잠금(Shared Lock)을 추가하여 다른 트랜잭션의 변경을 막음
- @Lock(LockModeType.PESSIMISTIC_WRITE)
'지식 채우기 > 동아리' 카테고리의 다른 글
| [DeepDive] Circuit Breaker의 목적과 동작 방식 알아보기 (0) | 2026.01.07 |
|---|---|
| [DeepDive] 시스템이 이벤트를 이해하는 방식 (0) | 2025.10.28 |
| [DeepDive] CQRS와 Projection으로 보는 조회 모델의 진화 (0) | 2025.10.14 |