티스토리 뷰
호텔 예약 서비스를 만들며 어떠한 방법으로 동시성 처리를 해야 하는 가에 대한 고민이 생겼다.
단 하나 남은 재고를 보장하기 위해 동시적으로 접속하는 사용자의 요청은 어떻게 처리해야 하는가?
데이터베이스는 어떻게 동작하길래 이러한 동시성 문제가 발생하는 것일까?
Dateabase Lock?
Database Lock 이란 데이터베이스에서 여러 트랜잭션이 동시에 같은 데이터에 접근할 때, 데이터의 무결성을 보장하기 위해 사용되는 메커니즘이다.
데이터베이스 개념 잡기
데이터 무결성 이란?
데이터의 정확성, 일관성, 유효성이 유지되는 것을 의미한다.
- 데이터 무결성 제약조건
- 개체 무결성 (Entity Integrity): 모든 테이블은 고유하며 Not Null 한 고유 키(PK)를 가진다.
- 참조 무결성 (Referential Integrity): 모든 외래 키는 다른 기본키를 하거나 Null 상태를 가진다.
- 도메인 무결성 (Domain Integrity): 필드 타입에 대한 조건을 정의하여 무결성을 보장한다.
데이터 무결성 문제
Dirty Read
- 앞선 트랜잭션이 커밋 전 롤백이 일어날 경우, 다른 트랜잭션은 잘못된 정보를 읽는 상황이 됨
- 한 트랜잭션이 데이터를 수정 중일 때, 다른 트랜잭션이 ‘커밋 전 변경 중인 데이터’를 읽는 상황
Non-repeatable Read
- 한 트랜잭션이 데이터를 읽는 중, 다른 트랜잭션이 그 데이터를 수정하고 커밋하며 첫 번째 트랜잭션이 동일한 데이터를 다시 읽을 때 값이 달라지는 상황
Phantom Read
- 동일한 쿼리가 두 번 요청될 때, 두 번째 응답에서 다른 결과가 응답되는 상황. 즉 없던 데이터가 생기는 현상
Lost Update
- 두 개의 트랜잭션이 동시에 같은 데이터를 수정하려고 할 때, 한 트랜잭션의 수정 내용이 다른 트랜잭션에 의해 덮어쓰어져 사라지는 상황
해당 내용을 그림을 잘 정리해 놓은 블로그
[DB] Dirty Read, Non-Repeatable Read, Phantom Read 예시 및 Snapshot Isolation Level | LIM
DB의 Transaction 들이 동시에 실행될 때 발생할 수 있는 이상 현상들에 대해 정리하고 예시를 통해 더 자세히 파악해보고자 한다. 이전에 Transaction Isolation Level 에 대해서는 정리해 둔 포스팅이 있다
amazelimi.tistory.com
왜 이러한 무결성 문제가 발생할까?
트랜잭션 처리 중 조회/수정되는 순서가 뒤섞이거나, 트랜잭션이 중단되는 문제로 인하여 데이터의 일관성이 보장되지 못할 경우 무결성 문제가 발생한다.
트랜잭션 Transaction
하나 이상의 쿼리가 있는 하나의 논리적인 작업 셋이 100% 적용되거나 아무것도 실행되지 않아야 함을 보장해 주는 것
트랜잭션은 비즈니스 로직에서 데이터를 관리하는 일종의 단위와도 같다고 생각할 수 있다.
데이터베이스의 일관성과 무결성을 유지하기 위해 트랜잭션은 중요한 개념이다.
(트랜잭션은 ACID 속성을 준수함으로써 데이터의 무결성을 보장한다.)
트랜잭션을 설정할 때는 가능한 작은 단위로 설정하여 장애 전파를 방지할 수 있어야 한다.
동일하게 외부 API 사용 등 네트워크를 통해 원격 통신하는 작업은 트랜잭션 내에서 제거하는 것이 좋다.
아래 예시를 참고하여 트랜잭션이 나눠지는 예시를 확인하자
[사용자가 게시판에 글을 작성한 후 저장 버튼을 눌렀을 때 데이터 처리 프로세스]
1. 처리시작
2. 사용자의 로그인 여부 확인
3. 사용자의 글쓰기 내용의 오류 여부 확인
4. 첨부로 업로드된 파일 확인 및 저장
======> 데이터베이스 커넥션 생성
======> 트랜잭션 시작
5. 사용자의 입력 내용을 DBMS에 저장
6. 첨부 파일 정보를 DBMS 에 저장
<====== 트랜잭션 종료
7. 저장된 내용 또는 기타 정보를 DBMS 에서 조회
8. 게시물 등록에 대한 알림 메일 발송
======> 트랜잭션 시작
9. 알림 메일 발송 이력을 DBMS 에 저장
<====== 트랜잭션 종료
<====== 데이터베이스 커넥션 종료(또는 커넥션 풀에 반납)
10. 처리완료
*단순 조회 작업은 트랜잭션에 포함될 필요가 없다.
만약, 게시물 정보 저장 및 알림 메일 발송까지 성공적으로 진행하였지만 알림 메일 이력을 DBMS에 저장하는데 오류가 발생한다면 무결성 문제가 발생할 수 있다. 사용자는 게시물이 등록되었다는 알림을 받게 되지만 관리자 기준에서는 사용자가 알림을 받지 못했다고 인식하게 되는 것이다.
구체적으로 무결성 문제는 어떻게 발생하는 걸까?
잠금 없는 일관된 읽기 (Non-Locking Consistent Read)
Oracle과 MySql 에서는 MVCC (Multi Version Concurrency Control)기술을 사용하여 잠금을 걸지 않고 읽기 작업을 수행할 수 있다.
즉, 다른 트랜잭션이 가지고 있는 잠금을 기다리지 않고 읽기 작업이 가능하다.
(격리 수준이 SERIALIZABLE 이면 불가능)
언두 로그
DML(INSERT, UPDATE, DELETE)로 데이터를 변경했을 때, 변경되기 이전 버전의 데이터를 별도로 백업하여 보관한다. 롤백 시 언두 영역에 백업된 데이터를 복구한다.
MySql의 InnoDB 에서는 변경되기 전에 데이터를 읽기 위해 언두 로그를 사용한다.
언두 영역은 필요로 하는 트랜잭션이 더 없을 때 삭제된다.
- 언두 로그를 읽는 READ COMMITTED 격리 수준 예시
TABLE T
--------------
| no | name |
| 5000 | Lara |
--------------
1. 사용자 A 가 테이블 T 에 UPDATE 쿼리를 보낸다.
UPDATE t SET name = 'Toto' WHERE no = 5000;
TABLE T <Undo Log> TABLE T
-------------- --------------
| no | name | --- | no | name |
| 5000 | Toto | --- | 5000 | Lara |
-------------- --------------
2. 테이블 T 는 내용을 변경하고 변경 전 데이터를 언두 로그로 복사한다.
3. 사용자 A 가 커밋을 수행하기 전, 사용자 B 가 테이블 T 의 no=5000 인 사람의 이름을 조회한다.
4. 사용자 B 는 언두 영역에 백업된 'Lara' 를 받아온다.
5. 사용자 A 가 트랜잭션을 종료한다.
6. 사용자 C 가 테이블 T 의 no=5000 인 사람의 이름을 조회한다.
4. 사용자 C 는 'Toto' 를 받아온다.
하지만 언두로그를 사용하면서도 Non-Reapeatable Read 가 발생할 수 있다.
사용자 A가 커밋을 하는 동안 사용자 B가 하나의 트랜잭션 동안 두 번의 조회를 한다고 가정하자.
- READ COMMITTED 격리 수준 시, Non-Reapeatable Read 가 발생하는 시나리오
TABLE T
--------------
| no | name |
| 5000 | Lara |
--------------
1. 사용자 B 가 트랜잭션을 시작한다.
2. 사용자 B 가 테이블 T 의 no=5000 인 사람의 이름을 조회한다. 'Lara' 를 받아온다.
3. 사용자 A 가 테이블 T 에 UPDATE 쿼리를 보낸다.
UPDATE t SET name = 'Toto' WHERE no = 5000;
TABLE T <Undo Log> TABLE T
-------------- --------------
| no | name | --- | no | name |
| 5000 | Toto | --- | 5000 | Lara |
-------------- --------------
4. 테이블 T 는 내용을 변경하고 변경 전 데이터를 언두 로그로 복사한다.
5. 사용자 A 가 커밋하고 트랜잭션을 종료한다.
TABLE T
--------------
| no | name |
| 5000 | Toto |
--------------
6. 사용자 B 가 트랜잭션을 유지하며 테이블 T 의 no=5000 인 사람의 이름을 다시 조회한다.
7. 사용자 B 는 커밋된 'Toto' 를 받아온다.
8. 사용자 B 가 트랜잭션을 종료한다.
격리 수준을 한 단계 올려보자
(MySQL 은 REPEATABLE_READ, Oracle 은 READ_COMMITTED 가 기본 격리 수준이다.)
REPEATABLE_READ 수준에서는 Non-Reapeatable Read 가 발생하지 않는다.
모든 트랜잭션은 고유한 트랜잭션 번호 (순차적으로 증가하는 값 Auto Increment) 를 가지며, 언두 영역에 백업되는 여러 버전의 언두 로그도 트랜잭션 번호가 포함된다.
REPEATABLE_READ 는 현재 자신의 트랜잭션 번호보다 동일하거나 이전의 데이터를 조회한다.
- REPEATABLE_READ 수준의 언두로그 읽기
TABLE T
--------------
| no | name |
| 5000 | Lara |
--------------
1. 사용자 B 가 트랜잭션을 시작한다.(T-ID: 10)
2. 사용자 B 가 테이블 T 의 no=5000 인 사람의 이름을 조회한다. 'Lara' 를 받아온다.
3. 사용자 A 가 테이블 T 에 UPDATE 쿼리를 보낸다. (T-ID: 12)
UPDATE t SET name = 'Toto' WHERE no = 5000;
TABLE T (T-ID: 12) <Undo Log> TABLE T (T-ID: 11 또는 그 이전)
-------------- --------------
| no | name | --- | no | name |
| 5000 | Toto | --- | 5000 | Lara |
-------------- --------------
4. 테이블 T 는 내용을 변경하고 변경 전 데이터를 언두 로그로 복사한다.
5. 사용자 A 가 커밋하고 트랜잭션을 종료한다. (T-ID: 12)
6. 사용자 B 가 트랜잭션을 유지하며 테이블 T 의 no=5000 인 사람의 이름을 다시 조회한다.(T-ID: 10)
7. 사용자 B 는 T-ID 가 자신(T-ID: 10)보다 낮은 언두로그에서 'Toto' 를 받아온다.
*실제로는 데이터 단위로 붙지만 설명을 위해 레코드 단위로 그려보았다.
이제 문제없겠네요? - 그럴 리가요.
언두 레코드에는 잠금을 걸 수 없다.
비관적 락을 적용한 트랜잭션이 데이터를 조회하게 되면 SELECT … FOR UPDATE
라는 쿼리를 날리게 된다.
REPEATABLE_READ 로 격리 수준을 관리하고 있더라도, 해당 쿼리가 요청하면 언두로그가 아닌 커밋된 최신 결과를 읽어가게 된다.
가장 처음 예시인 READ COMMITTED 의 Dirty Read 와 동일한 결과가 발생되게 된다.
비관적 락? 그게 DB 락인가요? - 네 :)
Lock 의 전략과 종류
1. 비관적 락 (Pessimistic Locking)
데이터를 읽을 때부터 즉시 락을 걸어 다른 트랜잭션이 접근하지 못하도록 한다.
다른 사용자는 먼저 락은 건 사용자가 변경을 마치고 락을 해제할 때까지 기다려야 한다.
공유 락 (Shared Lock)
- 데이터베이스에서 데이터를 읽을 때 사용. 여러 트랜잭션이 동시에 같은 데이터를 읽을 수 있지만, 공유 락이 걸린 동안에는 데이터를 수정할 수 없다.
배타 락 (Exclusive Lock)
- 데이터를 수정할 때 사용. 배타 락이 걸린 데이터는 다른 트랜잭션이 읽거나 수정할 수 없다.
SELECT … FOR UPDATE
: Lock 이 걸리면 아래와 같이 쿼리가 나온다
2. 낙관적 락 (Optimistic Locking)
데이터를 수정하기 전까지 락을 걸지 않고, 수정 시점에만 충돌을 확인하는 방식이다.
버전 번호 또는 타임스탬프(잘 안 씀)를 활용하여 레코드를 수정할 때 버전 번호가 지속적으로 커질 수 있도록 유효성 검사를 한다. 동일한 버전의 레코드를 조회하여 있을 경우 버전번호+1과 함께 업데이트를 진행한다.
비관적 락이 데이터베이스 레벨에서 접근을 차단하는 방식이라면,
낙관적 락은 커밋시점에서 데이터가 변경되었는지 확인하여 충돌을 처리한다.충돌이 발생하였을 때 비즈니스 로직에 따라 롤백 과정을 수행한다.
비관적 락과 낙관적 락 비교
비관적 락
- 커밋 전까지 데이터에 락을 걸기 때문에 동시성을 완벽하게 제어할 수 있다.
- 구현이 쉽다. (JPA 에서 어노테이션 하나 붙임)
- 여러 레코드가 비관적 락을 걸게 되면 교착 상태가 발생할 수 있다.
- 트랜잭션이 락을 오래 유지하면 성능상 심각한 문제가 발생할 수 있다.
- 롤백에 비용이 많이 드는 경우, 주문 시에 쿠폰 등 여러 기능이 한 트랜잭션에 엮어 있을 경우 비관적락이 적합할 수 있다.
💡교착상태
두 개 이상의 작업이 서로 상대방의 작업이 끝나기를 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태
낙관적 락
- 자원에 락을 걸 필요가 없기 때문에 비관적 락 보다 빠르다.
- 데이터 경쟁이 치열하다면 성능과 사용자 경험의 문제가 발생할 수 있다.
- 커밋 시점에 동시성을 제어하여 제품 재고가 차감되는 경우 낙관적 락을 사용할 수 있다.
💡버전으로 관리하는 낙관적 락을 사용하여 호텔 예약 시스템을 만들 경우
100명이 동시에 잔여 객실을 조회하여 v1 의 데이터를 조회한다.
결국 먼저 선점한 1명만이 v2 로 업데이트를 성공하고 나머지 99명은 실패를 하게 된다.
99 명의 사용자는 이러한 불쾌한 경험을 반복하게 된다.
잊지 말자, 나는 호텔 예약 시스템을 만들고 싶다.
현재 설계 중인 예약은 초당 조회율이 높지 않을 것이기 때문에, 낙관적 락으로 적합하다.
하지만 만약 booking.com 이나 expedia.com , agoda.com 등 외부 여행 예약 웹 사이트와 연동이 된다면 다른 방법을 고려해야 할 수 있다.
Lock 실습
JPA 에서 Lock을 적용하기
- 실제코드와 함께 데이터 락에 대한 전반적인 내용을 보기 좋은 블로그
Spring Data JPA 환경에서 비관적 락(Pessimistic Lock)을 사용한 동시성 문제 해결
Spring, Kotlin 기반 환경에서 동시성 문제를 해결하는 방법 (1)
medium.com
[JPA 에서의 Lock 종류]
- READ와 WRITE
- READ와 WRITE는 일반적인 읽기와 쓰기 작업을 나타내며, 특별한 락 메커니즘을 적용하지 않습니다.
비관적 락 (Pessimistic Lock)SELECT … FOR UPDATE
- PESSIMISTIC_READ
- 공유 락(Shared Lock)을 사용합니다.
- 다른 트랜잭션의 읽기는 허용하지만 쓰기는 막습니다
- PESSIMISTIC_WRITE
- 배타적 락(Exclusive Lock)을 사용합니다
- 다른 트랜잭션의 읽기와 쓰기를 모두 막습니다
- PESSIMISTIC_FORCE_INCREMENT
- 배타적 락을 사용하면서 버전 정보도 함께 사용합니다
- 읽기 작업에도 버전을 업데이트하며, 트랜잭션 종료 시 추가로 버전을 증가시킵니다
- 비관적 락 적용 예제
@Repository
public interface InventoryRepository extends JpaRepository<RoomTypeInventoryEntity, RoomTypeInventoryId> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<RoomTypeInventoryEntity> findByIdWithLock(RoomTypeInventoryId id);
}
낙관적 락 (Optimistic Lock)
- NONE
- 엔티티에 버전 속성이 있으면 자동으로 낙관적 락이 적용됩니다
- 엔티티를 수정할 때만 버전을 체크하고 증가시킵니다
- PTIMISTIC
- 엔티티를 조회만 해도 버전을 체크합니다
- 트랜잭션 종료 시 버전 정보를 검증하여 변경이 있으면 예외를 발생시킵니다
- OPTIMISTIC_FORCE_INCREMENT
- 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킵니다
- 단순 읽기 작업에도 버전이 업데이트됩니다
- 낙관적 락 적용 예제
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Double price;
@Version
private Integer version; // 버전 필드를 통해 낙관적 락을 구현
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public void updateProductPrice(Long productId, Double newPrice) {
try {
// 기존 데이터를 읽어옵니다.
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("Product not found"));
// 가격을 수정합니다.
product.setPrice(newPrice);
// 저장 시 버전 충돌이 발생하면 예외가 발생합니다.
productRepository.save(product);
} catch (ObjectOptimisticLockingFailureException e) {
// 낙관적 락 예외 처리
System.err.println("낙관적 락 충돌이 발생했습니다. 다른 트랜잭션이 먼저 데이터를 수정했습니다.");
throw e;
}
}
}
QueryDSL 에서 Lock을 적용하기
- 어노테이션을 사용할 수 없기 때문에 DSL 에 직접 추가해 주어야 한다.
- 주의사항 : 해당 쿼리의 반환 타입이 Entity 가 아닐 경우 Lock 이 걸리지 않는 문제가 발생한다.
public List<RoomTypeInventoryEntity> findAvailableRoomsWithPessimisticLock(
InventoryQueryRequestDto requestDto) {
return queryFactory
.select(roomTypeInventoryEntity) // Entity 전체를 반환
.from(roomTypeInventoryEntity)
.where(
roomTypeInventoryEntity.id.hotelId.eq(requestDto.getHotelId())
.and(roomTypeInventoryEntity.id.date.between(
requestDto.getStartDate(),
requestDto.getEndDate().minusDays(1))
)
)
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 사용
.fetch();
}
호텔 예약 시스템을 하며 낙관적 락의 '사용자 예약 실패 경험 발생'이라는 문제점을 추가로 겪었다.
낙관적 락 이외에 더 도입할 수 있는 방법은 무엇이 있을까?
한 발자국 더 나아가기
- 모든 예제 총정리 블로그
[E-commerce] 동시성 문제 해결하기 (비관적 락, 네임드 락, 분산 락)
이커머스 서비스에서 발생할 수 있는 동시성 문제들을 정리하고, 다양한 방법으로 동시성 문제를 해결해 보며 겪은 경험들을 공유해 보자 합니다. 동시성 문제가 발생할 수 있는 유즈케이스Case
seungjjun.tistory.com
네임드 락 (Named Lock)
네임드 락은 데이터베이스에서 제공하는 락 메커니즘으로, 특정 이름을 가진 락을 생성하여 동시성을 제어한다.
- MySQL에서 주로 사용되는 메타데이터 락의 일종
- 락의 이름을 지정하여 특정 리소스나 작업에 대한 접근을 제어
- 한 세션이 락을 획득하면, 해당 락이 해제될 때까지 다른 세션은 같은 이름의 락을 획득 불가
- 데이터베이스 레벨에서 동시성을 관리하므로, 애플리케이션 로직과 분리됨
분산 락 (Distributed Lock)
분산 락은 여러 서버나 프로세스가 공유 리소스에 접근할 때 동시성을 제어하는 메커니즘이다.
- 데이터베이스의 락 기능을 활용하여 구현할 수 있지만, 반드시 데이터베이스에 종속되지는 않는다.
- Redis, Zookeeper 등의 분산 시스템을 사용하여 구현
- 여러 서버에서 동시에 같은 데이터에 접근할 때 데이터의 일관성을 유지하는 데 사용
- 데이터베이스 트랜잭션의 범위를 넘어서는 작업에 대해 동시성을 제어할 수 있다.
메시징 큐
메시징 큐는 직접적인 데이터베이스 락은 아니지만, 데이터베이스 작업의 동시성을 관리하는 데 사용될 수 있다.
- 데이터베이스 작업을 큐에 넣어 순차적으로 처리함으로써 동시성 문제를 해결할 수 있다.
- 메시지 큐를 통해 데이터베이스 작업을 비동기적으로 처리하여 데이터베이스의 부하를 분산시킬 수 있다.
- Redis Pub/Sub, RabbitMQ, Kafka 등의 메시징 시스템을 활용하여 구현
'Data' 카테고리의 다른 글
MySQL의 인덱스 란? B-Tree 알고리즘 이해하기 (0) | 2025.03.23 |
---|---|
JPA 프록시와 지연 로딩 (Proxy? LAZY? EAGER?) (1) | 2025.03.09 |
JPA 영속성 컨텍스트와 변경 감지 (Dirty checking) (0) | 2025.01.15 |
- Total
- Today
- Yesterday
- java
- feignclient
- queryDSL
- 인프런
- Database
- TDD
- JPA
- Lazy
- 클린코드
- 기술도서
- datalock
- JPQL
- MYSQL
- 트러블슈팅
- 코딩테스트
- Spring
- 동시성
- 객체지향
- 트랜잭션
- proxy
- MSA
- 프로젝트기획
- http
- 리팩토링
- 더티체킹
- 스파르타
- mock
- 마이크로서비스
- Article
- Solid
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |