
- 개요
- 1. 동시성 문제란?
- 1.1 동시성 문제의 정의
- 1.2 주요 동시성 문제 유형
- 1) Lost Update (갱신 손실)
- 2) Dirty Read (더티 리드)
- 3) Non-Repeatable Read (반복 읽기 불가)
- 4) Phantom Read (팬텀 리드)
- 2. 트랜잭션 격리 수준의 이해
- 2.1 격리 수준별 방지 효과
- 1) READ_UNCOMMITTED (레벨 0)
- 2) READ_COMMITTED (레벨 1)
- 3) REPEATABLE_READ (레벨 2)
- 4) SERIALIZABLE (레벨 3)
- 2.2 Spring에서의 격리 수준 설정
- 3. 낙관적 락과 비관적 락
- 3.1 낙관적 락 (Optimistic Lock)
- 3.2 비관적 락 (Pessimistic Lock)
- 3.3 실제 사용시 고려사항
- 1) 낙관적 락 사용시 주의점
- 2) 비관적 락 사용시 주의점
- 4 결론
개요
우리는 왜 낙관적 락과 비관적 락을 도입해야 할까요? 동시성 제어 방식으로서 두 락을 도입하는 이유는 여러 가지가 있지만,
주된 이유는 데이터의 일관성을 보장하고 동시성 문제를 해결하기 위해서입니다.

옛날과는 다르게 요즘은 모두 디지털화 되어있기 때문에 어떤 일이든 다 웹을 통해서 진행을 하게 됩니다. 그에 따라 수많은 사람들이
한 서비스에 몰릴때도 많은데요, 예를 들어, 인터파크 같은 대형 서비스의 티켓팅 서비스에서 동일한 좌석을 여러 사용자가 동시에
예매하려 할 때, 또는 특정 시간대의 예매 폭주로 인해 실제 좌석 수보다 더 많은 예약이 발생하는 등 데이터의 정합성이
깨질 수 있는 상황이 빈번하게 발생합니다.
이러한 동시성 문제를 해결하기 위한 전략으로 낙관적 락과 비관적 락이 사용되며, 각각의 상황과 요구사항에 맞는 적절한
락 전략을 선택하는 것이 중요합니다.
1. 동시성 문제란?
1.1 동시성 문제의 정의
동시성 문제는 여러 트랜잭션이 동시에 같은 데이터에 접근할 때 발생하는 데이터 불일치 현상을 말합니다. 이는 데이터의 일관성(Consistency)과 무결성(Integrity)을 해치는 심각한 문제를 초래할 수 있습니다.
1.2 주요 동시성 문제 유형
1) Lost Update (갱신 손실)
여러 트랜잭션이 동시에 같은 데이터를 수정할 때 나중에 수정한 내용이 이전 수정 내용을 덮어쓰는 현상
실제 시나리오: 콘서트 예매 시스템의 좌석 수정
@Entity
public class Concert {
@Id
private Long id;
private int availableSeats; // 남은 좌석 수
}
// 예매 로직
public void bookTicket() {
Concert concert = concertRepository.findById(1L); // 현재 남은 좌석: 100석
if (concert.getAvailableSeats() > 0) {
concert.setAvailableSeats(concert.getAvailableSeats() - 1);
concertRepository.save(concert);
}
}
문제 상황 설명
사용자 A, B가 동시에 예매 시도
A, B 모두 남은 좌석을 100석으로 확인
A가 99석으로 업데이트
B도 99석으로 업데이트
결과적으로 두 명이 예매했는데 좌석은 1석만 감소
2) Dirty Read (더티 리드)
한 트랜잭션에서 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상
실제 시나리오: 포인트 충전 시스템
// 트랜잭션 A
@Transactional
public void chargePoint() {
User user = userRepository.findById(1L); // 포인트: 1000
user.setPoint(1500); // DB에 반영은 되었지만, 아직 커밋은 안 된 상태
// 결제 API 실패
throw new RuntimeException("결제 실패"); // 롤백 발생
}
// 트랜잭션 B (격리 수준이 READ_UNCOMMITTED인 경우)
@Transactional
public void processPointEvent() {
User user = userRepository.findById(1L);
int point = user.getPoint(); // 1500 읽음 (커밋되지 않은 데이터)
// 1500포인트라고 생각하고 이벤트 처리
if (point >= 1500) { // true로 판단
// 실제로는 1000포인트인데 잘못된 처리가 수행됨
giveEventReward(); // 보상 지급
}
}
초기 상태: 사용자 포인트 1000점
[트랜잭션 A: 포인트 충전]
1. 포인트 1000 -> 1500으로 업데이트
2. 아직 커밋하지 않은 상태
3. 결제 API 호출 실패
4. 롤백 수행
[트랜잭션 B: 포인트 조회]
- A가 커밋하지 않은 1500점을 읽음 (더티 리드 발생)
- 이 값으로 후속 처리를 진행
3) Non-Repeatable Read (반복 읽기 불가)
한 트랜잭션에서 같은 데이터를 여러 번 조회할 때, 다른 트랜잭션의 커밋으로 인해 값이 변경되어 다른 결과가 조회되는 현상
실제 시나리오: 좌석 예약 시스템
@Service
@RequiredArgsConstructor
public class ConcertService {
private final SeatRepository seatRepository;
@Transactional
public void processReservation(String seatNumber) {
// 첫 번째 좌석 상태 확인
Seat seat = seatRepository.findBySeatNumber(seatNumber);
String initialStatus = seat.getStatus();
// 복잡한 예약 로직 수행
processSeatReservation(seat); // 시간이 좀 걸리는 작업
// 최종 좌석 상태 다시 확인
seat = seatRepository.findBySeatNumber(seatNumber);
String finalStatus = seat.getStatus();
// 상태가 변경되었는지 확인
if (!initialStatus.equals(finalStatus)) {
throw new ConcurrentModificationException("좌석 상태가 변경되었습니다.");
}
}
private void processSeatReservation(Seat seat) {
try {
// 예약 처리 로직 수행 중...
Thread.sleep(1000); // 실제로는 복잡한 비즈니스 로직
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
[문제 상황 시나리오]
초기 상태: A1 좌석 status = "AVAILABLE"
[트랜잭션 A: 예약 프로세스]
1. A1 좌석 첫 번째 조회: status = "AVAILABLE"
2. 예약 로직 처리 중...
[트랜잭션 B: 다른 예약]
1. A1 좌석 예약 수행
2. status를 "RESERVED"로 변경
3. 커밋 완료
[트랜잭션 A 계속]
3. A1 좌석 두 번째 조회: status = "RESERVED"
4. 상태 불일치 발생
결과: 같은 트랜잭션 내에서 조회한 데이터가 다름
4) Phantom Read (팬텀 리드)
한 트랜잭션에서 조건으로 데이터를 조회할 때, 다른 트랜잭션에 의해 해당 조건에 부합하는 데이터가 추가/삭제되어
결과 집합이 달라지는 현상
실제 시나리오: 콘서트 좌석 조회 시스템
@Service
@RequiredArgsConstructor
public class ConcertService {
private final SeatRepository seatRepository;
@Transactional
public void processZoneReservation(String zoneCode) {
// 첫 번째 구역 좌석 목록 조회
List<Seat> seats = seatRepository.findByZoneCode(zoneCode);
int initialCount = seats.size(); // 처음 조회한 좌석 수
// 예약 가능 좌석 수 계산 및 검증
validateZoneCapacity(initialCount);
// 복잡한 예약 로직 처리 중...
processZoneBooking(zoneCode);
// 최종 좌석 수 다시 확인
List<Seat> updatedSeats = seatRepository.findByZoneCode(zoneCode);
int finalCount = updatedSeats.size();
if (initialCount != finalCount) {
throw new ConcurrentModificationException(
"좌석 구성이 변경되었습니다. 다시 시도해주세요."
);
}
}
private void processZoneBooking(String zoneCode) {
try {
// 구역 예약 처리 중...
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
List<Seat> findByZoneCode(String zoneCode);
}
[문제 상황 시나리오]
초기 상태: A구역 좌석 목록 [A1, A2, A3] (총 3석)
[트랜잭션 A: 구역 예약 처리]
1. A구역 좌석 목록 첫 번째 조회: 3석 확인
2. 3인 단체 예약 가능 여부 확인
3. 예약 로직 처리 중...
[트랜잭션 B: 구역 좌석 추가]
1. A구역에 임시좌석 A4 추가
2. 커밋 완료
[트랜잭션 A 계속]
4. A구역 좌석 목록 다시 조회: 4석 확인 [A1, A2, A3, A4]
5. 좌석 수 불일치 발생
결과: 같은 조건으로 조회했으나 결과 집합이 변경됨
- 처음에는 3석으로 예약 진행했는데 중간에 좌석이 추가되어
- 예약 처리 로직이 의도한 대로 동작하지 않을 수 있음
주요 차이점:
- Non-Repeatable Read: 기존 데이터의 수정으로 인한 문제
- Phantom Read: 데이터의 추가/삭제로 인한 문제
2. 트랜잭션 격리 수준의 이해
데이터베이스는 동시성 문제를 해결하기 위해 다양한 격리 수준을 제공하고 있는데요,
각 격리 수준별로 어떤 문제들이 방지되는지 알아보겠습니다.
2.1 격리 수준별 방지 효과
1) READ_UNCOMMITTED (레벨 0)
가장 낮은 격리 수준으로, 커밋되지 않은 데이터도 읽을 수 있습니다.
- 방지되는 문제: 없음
- 발생 가능한 문제: Dirty Read, Non-Repeatable Read, Phantom Read
- 장점: 동시 처리 성능이 가장 좋음
- 단점: 데이터 정합성을 전혀 보장할 수 없음
2) READ_COMMITTED (레벨 1)
대부분의 데이터베이스가 기본적으로 사용하는 격리 수준입니다.
- 방지되는 문제: Dirty Read
- 발생 가능한 문제: Non-Repeatable Read, Phantom Read
- 특징: 커밋된 데이터만 읽을 수 있음
3) REPEATABLE_READ (레벨 2)
동일 트랜잭션 내에서 동일한 결과를 보장합니다.
- 방지되는 문제: Dirty Read, Non-Repeatable Read
- 발생 가능한 문제: Phantom Read
- 특징: InnoDB의 경우 MVCC(Multi Version Concurrency Control)를 통해
Phantom Read도 일부 방지
4) SERIALIZABLE (레벨 3)
가장 높은 격리 수준으로, 완벽한 데이터 일관성을 제공합니다.
- 방지되는 문제: 모든 동시성 문제 방지
- 단점: 동시 처리 성능이 매우 떨어짐
2.2 Spring에서의 격리 수준 설정
@Transactional(isolation = Isolation.READ_COMMITTED)
public void reserveSeat(String seatNumber) {
// 예약 로직
}
3. 낙관적 락과 비관적 락
이제 본격적으로 락(Lock)에 대해 알아보겠습니다. 락은 크게 낙관적 락과 비관적 락으로 나뉘는데, 각각의 특징과
동작 방식이 매우 다릅니다.
3.1 낙관적 락 (Optimistic Lock)
낙관적 락은 이름 그대로 데이터 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방식입니다.
@Entity
public class Seat {
@Id
private Long id;
@Version // 낙관적 락을 위한 버전 정보
private Long version;
private String seatNumber;
private String status;
}
라인 별 동작 설명
@Version 애노테이션으로 버전 관리할 필드 지정
1. 엔티티가 수정될 때마다 버전이 자동으로 증가
2. 수정 시 현재 버전과 DB의 버전이 일치하는지 확인
3. 버전이 다르면 ObjectOptimisticLockingFailureException 발생
실제 동작 예시
[트랜잭션 A]
1. Seat 조회: version = 1
2. 상태 변경 시도 중...
[트랜잭션 B]
1. 같은 Seat 조회: version = 1
2. 상태 변경 및 커밋: version = 2
[트랜잭션 A]
3. 상태 변경 시도 실패 (version 불일치)
장점
- 실제 데이터베이스 락을 사용하지 않아 성능이 좋음
- 데드락이 발생하지 않음
- 비교적 구현이 단순함
- 트랜잭션을 짧게 가져갈 수 있음
단점
- 충돌이 발생하면 롤백 후 재시도가 필요함
- 잦은 충돌 시 계속되는 재시도로 인한 성능 저하
- 사용자에게 재시도 요청을 해야 할 수 있음
사용하면 좋은 상황
- 데이터 충돌이 적은 경우
- 읽기 작업이 많고 쓰기 작업이 적은 경우
- 실시간성이 중요한 경우
3.2 비관적 락 (Pessimistic Lock)
비관적 락은 데이터 충돌이 발생할 것이라고 비관적으로 가정하고 아예 처음부터 락을 거는 방식입니다.
@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdWithPessimisticLock(@Param("id") Long id);
}
@Service
@Transactional
public class SeatService {
public void reserveSeat(Long seatId) {
// 비관적 락으로 좌석 조회
Seat seat = seatRepository.findByIdWithPessimisticLock(seatId)
.orElseThrow(() -> new SeatNotFoundException());
// 이 시점에서 다른 트랜잭션은 이 좌석에 접근 불가
seat.reserve();
}
}
장점
- 데이터 정합성을 확실하게 보장
- 충돌이 빈번한 경우 낙관적 락보다 성능이 좋을 수 있음
- 업데이트 성공이 보장됨
단점
- 데이터베이스 락으로 인한 성능 저하
- 데드락이 발생할 가능성이 있음
- 락 획득을 위한 대기 시간이 필요
- 트랜잭션을 길게 가져갈 수 있음
사용하면 좋은 상황
- 데이터 충돌이 자주 발생하는 경우
- 정합성이 매우 중요한 경우
3.3 실제 사용시 고려사항
1) 낙관적 락 사용시 주의점
- 재시도 로직 구현의 필요성
@Service
public class OptimisticLockService {
private static final int MAX_RETRIES = 3;
@Transactional
public void updateWithRetry() {
int retryCount = 0;
while (retryCount < MAX_RETRIES) {
try {
// 업데이트 로직
return;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
if (retryCount == MAX_RETRIES) {
throw new ConcurrentModificationException("재시도 횟수 초과");
}
// 잠시 대기 후 재시도
Thread.sleep(100);
}
}
}
}
2) 비관적 락 사용시 주의점
- 데드락 방지를 위한 락 획득 순서 고려
- 타임아웃 설정의 필요성
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")})
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdWithPessimisticLock(@Param("id") Long id);
4 결론
동시성 제어 방식의 선택은 서비스의 특성과 요구사항을 고려해야 합니다. 충돌이 빈번하고 데이터 정합성이 매우 중요한 경우
(예: 재고 관리, 예약 시스템)에는 비관적 락을, 충돌이 적고 실시간성이 중요한 경우(예: 게시글 수정, 일반적인 데이터 수정)에는
낙관적 락을 사용하는 것이 좋습니다.
특히 대규모 동시 접속이 예상되는 서비스에서는 두 방식을 적절히 혼합하여 사용하는 것도 고려해볼 수 있습니다.
예를 들어, 첫 단계에서는 낙관적 락을 시도하고, 실패가 빈번하다면 비관적 락으로 전환하는 방식을 사용할 수 있습니다.
'DB' 카테고리의 다른 글
콘서트 예약 시스템 인덱스 성능 최적화 분석 보고서 (4) | 2024.11.14 |
---|---|
인덱스 설계와 쿼리 튜닝 (14) | 2024.11.12 |
Redis VS DB 성능비교 (3) | 2024.11.08 |
깨굴딱지의 코드연못입니다
올챙이가 개구리로 거듭나듯, 끊임없는 노력으로 진화하는 개발자의 길을 걷습니다. 🐸