Spring

반려견 돌봄 서비스 (6) - 멀티스레드 환경 공유 자원 예약 동시성 처리 및 데드락 방지

인성코린이 2025. 5. 10. 17:46

이번에는 예약과 관련된 행위에 대해 동시성 요청이 일어나는 상황에서 데이터 정합성을 지키는 방법을 다뤄보려고 한다.

 

 

동시성 문제란?

동시성 문제는 여러 트랜잭션이 동시에 하나의 데이터에 접근하거나 수정할 때 발생하는 충돌이다.

  • 사용자 A와 B가 동시에 좌석 A1을 예약 시도하는 상황일 때
  • 사용자 A와 B가 동시에 재고 1개 남은 상품을 구매 시도할 때

이 경우 특별한 처리가 없으면 두 트랜잭션 모두 성공할 수 있고, 그 결과 좌석이 중복 예약되거나 재고가 마이너스가 될 수 있다.(보통 스레드는 1초에 몇 억 ~ 몇십 억 개의 요청을 처리하기 때문에 하나의 요청 결과는 씹힐 가능성이 높음. 그럼 데이터 정합성이 일치하지 않게 됨)

 

 

낙관적 락 (Optimistic Lock)

  • 충돌이 자주 발생하지 않을 것이라고 낙관적으로 가정하고 처리
  • 데이터를 업데이트할 때 버전(version) 값을 비교해서 동시에 수정되지 않았음을 확인
  • 충돌이 발생하면 실패하고, 재시도할 수 있도록 설계
  • version 타입은 int, Integer, long, Long, short, Short, java.sql.Timestamp 중 하나여야 한다.
@Entity
public class Reservation {
    @Id
    private Long id;

    private String seat;

    @Version  // 낙관적 락을 위한 필드
    private Long version;
}

 

예를 들어서 이렇게 예약 엔티티에 version 이라는 필드에 @Version 어노테이션을 추가하면 트랜잭션이 읽기를 할 때, version 정보로 DB에서 데이터를 읽어오고 version 정보가 맞으면 트랜잭션 커밋이 발생하고, version이 맞지 않게 되면 예외(OptimisticLockingFailureException)가 발생하게 된다.

 

 장점

  • 락을 걸지 않아서 성능이 좋음.(요청이 적은 경우에 낙관적 락이 비관적 락보다 성능이 좋다고 함)
  • 충돌이 드물 경우에 효율적

단점

  • 충돌 시 예외가 발생하고, 재시도 로직을 따로 구현해야 함.(트랜잭션 생명 주기를 개발자가 직접 컨트롤할 수 없어서 예외 처리 시 트랜잭션을 수동으로 롤백 해줘야 함) -> 요청이 적은 경우 낙관적 락이 비관적 락보다 성능이 좋다고 할 수 있다 했지만, 만약 자원을 정리하는 트랜잭션 롤백 비용이 많이 들 경우에 오히려 낙관적 락이 성능적인 측면에서 더 안 좋을 수도 있다고 한다.

사용하기 좋은 경우

  • 읽기 위주의 트래픽이 많고, 충돌 가능성이 낮을 때 적합
  • 충돌이 드문 환경 + 성능 중요할 때

 

비관적 락 (Pessimistic Lock)

  • 충돌이 자주 발생할 것이라고 가정하고, 아예 먼저 락을 걸어서 다른 트랜잭션의 접근을 막음.(DB의 해당 row에 락을 검)
  • 데이터 조회 시점에 DB 레벨에서 락을 걸기 때문에 동시 수정을 원천 차단
  • 공유 락 (Shared Lock)과 배타 락 (Exclusive Lock)이 존재함.
// 배타 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Reservation r WHERE r.id = :id")
Reservation findByIdForUpdate(@Param("id") Long id);

장점

  • 충돌을 방지하므로 안정적.(충돌이 날 것이라고 가정을 하고 다른 트랜잭션에서의 접근을 막아 버리기 때문에)
  • 재시도 로직 필요 없음.(A 트랜잭션에서 먼저 락을 얻었다면, B 트랜잭션은 A 트랜잭션이 커밋이 되기까지 기다렸다가 수행하기 때문. 하지만, 대기 시간이 길어지게 되면 에러가 터지게 됨. 이 대기 시간은 별도로 설정 가능)

단점

  • 락으로 인해 트랜잭션 병목, 데드락 가능성.(A 트랜잭션에서 id 1 -> 2 순으로 조회하고 B 트랜잭션에서의 결과(id 2를 조회한 B 트랜잭션이 끝나길)를 기다리고, B 트랜잭션은 id 2 -> 1 순으로 조회해서 A 트랜잭션의 결과(id 1을 조회한 A 트랜잭션이 끝나길)를 서로 기다리게 되는 상황)
  • 성능 저하 발생 가능.(해당 row에 대해 읽기, 쓰기를 막기 때문에 락을 획득해서 읽고 있는 트랜잭션이 있다면, 다른 트랜잭션에서는 락을 얻어(select for update) 읽으려는 경우에 락 획득한 트랜잭션이 끝나길 기다려야 함. 다만, 일반 select 조회라면 락 획득 없이 조회 가능)

사용하기 좋은 경우

  • 재고 감소, 좌석 예약 등 충돌 가능성이 높은 상황에서 유용(공유 자원에 대해 여러 스레드에서의 접근으로 인해 충돌 가능성이 높은 경우)
  • 충돌이 자주 발생하고 정합성이 중요할 때

 

비관적 락의 종류

1) 공유 락 (Shared Lock, S Lock)

  • 다른 트랜잭션도 읽을 수 있지만 수정은 불가
  • 여러 사용자가 동시에 읽을 수는 있지만, 누군가 쓰려고 하면 대기
  • 예: select ... for share

2) 배타 락 (Exclusive Lock, X Lock)

  • 읽기와 쓰기 모두 다른 트랜잭션에서 불가능
  • 오직 하나의 트랜잭션만 접근 가능
  • 예: select ... for update
  • select ... for update가 아닌 일반 select라면 다른 트랜잭션에서도 읽기는 가능하지만 쓰기는 불가능

 

synchronized (Java 레벨 락)

  • 자바에서 제공하는 키워드로, JVM 수준에서 스레드 동기화 처리
  • 한 번에 하나의 스레드만 critical section(임계 영역) 실행
// 메서드 단위에 synchronized 적용
public synchronized void reserveSeat() {
    // 좌석 예약 처리
}

이 경우 @Trascation(spring + jpa 환경)에서는 무용지물임. (JPA 트랜잭션은 DB와 연동되므로, DB 자체의 동시성 제어와는 별개)

자바의 synchronized JVM 레벨의 동기화에만 영향을 주는데, Spring의 @Transactional DB 트랜잭션 커밋 시점까지는 실제 DB 변경이 반영되지 않음. 그러므로, synchronized 블록이 끝나고 트랜잭션 커밋이 되기 전 사이의 에서 다른 스레드가 synchronized 블록 안으로 진입하면, DB 상태는 아직 이전 트랜잭션 반영 전이므로 변경되기 이전의 데이터를 가져오기 때문에 데이터 정합성이 깨질 수 있다.

 

// 락을 획득한 스레드만이 좌석 예약 처리 로직 수행 가능
// (synchronized 안 비즈니스 로직을 @Transcation으로 감싸서 새로운 메서드로)
public void reserveSeat() {
    synchronized (this) {
        // 좌석 예약 처리
        reserveInternal(timeId); // 이 내부에서 트랜잭션 처리
    }
}

@Transactional
public void reserveInternal(Long timeId) {
	// 1. 예약 시간 조회
	// 2. 예약 가능 여부 확인
	// 3. 예약 상태 변경
}

spring + jpa 환경에서 굳이 써야겠다 싶으면 이렇게 synchronized 안에 비즈니스 로직을 처리하는 메서드를 호출하게끔 함.

그럼 비즈니스 로직(해당 좌석 예약 처리, 사용자 포인트 사용 유무 등)을 처리하는 메서드를 @Transcation으로 감싸면 됨. -> 해당 스레드가 synchronized 블록을 벗어날 때까지는 다른 스레드는 진입 불가. 결과적으로, 트랜잭션이 커밋되기 전에는 절대 다른 스레드가 진입할 수 없는 상황이 만들어지기에 데이터 정합성 보장이 됨.

 

 

장점

  • 구현이 간단하고 Java 내에서만 동작

단점

  • 분산 환경에서 적용 불가(서버 1대에만 유효)
  • 성능 병목 가능성

사용하기 좋은 경우

  • 단일 서버/단일 JVM 환경에서 간단한 동기화 필요할 때(ConcurrenthashMap와 자주 같이 사용.)

 

 

이렇게 기본적으로 동시성을 해결하는 여러 방법에 대해 간단한 이론과 방법을 다뤄보았고 현재 내 프로젝트에서 테스트하고 적용한 코드를 말해보자면, 나는 비관적 락의 배타 락(X-Lock)으로 해결하였다.

 

여러 고객이 한 명의 돌봄사의 같은 날짜에 동시에 2000개의 요청이 들어왔을 때를 가정했다.

동시 예약 발생 테스트 코드

 

[l-2-thread-1923] java.lang.IllegalArgumentException: 요청하신 2025-10-01 이 날짜는 이미 예약된 날짜이거나 돌봄사가 삭제한 날짜입니다. (1923번 스레드가 예약을 하려 했지만 다른 스레드의 트랜잭션이 이미 예약을 해서 예외가 터진 모습)

 

이런 식으로 1개의 예약이 성공된 경우를 제외하고, 1999개의 요청이 모두 이렇게 예외가 터졌을 것이다.

 

테스트 환경 h2database

 

이와 같이 예약 엔티티에 하나의 예약만 발생하고 다른 스레드 요청은 모두 에러가 터진 모습을 볼 수 있다.(id 1번은 기존에 원래 있던 샘플 데이터임. 2번이 방금 발생한 예약한 건)

 

확실히 또 확인하고 싶으면 

 

successCount.get()을 2라고 예상 했을 경우, 2를 예상했지만 1이 맞다고 알려주고 있다.

 

 

그럼 이제 어떻게 비즈니스 로직이 구성이 되어있는지 

customerReservationService.save(request);에서 일부의 코드를 보자면,

 

customerReservationService.save() 초반부

돌봄사인 sitter와 고객을 발생시키는 customer 조회를 우선시 조회하고 있다.(항상 이 락을 획득하여 조회하는 순서는 모든 트랜잭션에서 일관적으로 맞춰줘야 데드락이 발생하지 않음. -> 락 획득 순서를 일관적으로 맞추지 않아 데드락이 발생하여 해결함.(A 트랜잭션에서 어떤 메서드의 비즈니스 로직에서는 sitter -> customer 순으로 조회하고, B 트랜잭션에서 A와는 다른 메서드의 비즈니스 로직에서는 customer -> sitter 순으로 조회해서 순환 대기 상태에 빠져 데드락이 발생했었음. 이 부분을 락 획득 순서를 일관적이게 맞춰줘서 해결함.)

 

memberRepository.findByIdAndFalseWithLock()

배타 락(X-Lock)으로 해당 회원 row에 대해 다른 트랜잭션에서 읽기, 쓰기 막게 함.(이유는 돌봄사가 돌봄사의 정보를 수정하는 도중에 예약이 발생하는 경우(돌봄사의 정보인 주소지가 돌봄 주소로 되어 있기 때문에), 관리자가 돌봄사 혹은 고객을 회원탈퇴 시키는 요청과 예약이 동시에 요청되는 경우가 발생할 수 있기 때문에 예약 메서드에서 회원 조회 시에도 락을 걸어버렸음)

 

careAvailableDateRepository.findBySitterIdAndId()

그리고 가장 중요한 고객이 동시에 접근하게 되는 공유 자원돌봄사가 등록한 돌봄 가능 날짜이다. 이 날짜에도 배타 락(X-Lock)을 걸었다.

배타 락을 걸게되면 A 트랜잭션에서 이 해당 날짜 row에 먼저 락을 획득했으면, 다른 트랜잭션에서는 여기서 모두 다 대기하고 있다가 먼저 락을 획득한 A 트랜잭션이 커밋을 치거나 롤백을 칠 때까지 대기하다가 A 트랜잭션이 락을 반납하면 다른 트랜잭션이 락을 얻어 마저 로직을 수행하게 됨. 만약, 이전 A 트랜잭션이 예약 성공을 하고 커밋을 했다면 이후에 트랜잭션들은 모두 해당 돌봄 날짜는 예약이 된 상태의 row를 조회하기 때문에 예약이 되지 않고 예외가 터질 것임.

customerReservationService.save() 후반부

sitterReservation()에서 예약 처리를 해주고 있는데 이 로직을 수행하는 과정에서

 

careAvailableDate.reservation()

이 에러가 테스트 코드에서 터지고 있던 것이다. 흐름을 짧고 간결하게 정리하자면,

 

1. 여러 요청이 들어오게 됐을 때 먼저 sittercustomer에 대해 락을 획득하여 조회(select ... for update)를 한다.

-> 이 이유는 돌봄 주소지가 돌봄사의 정보의 주소로 되어 있기 때문에 돌봄사가 정보를 수정하는 동작고객이 예약을 하는 동작동시에 요청하는 경우가 있을 수 있고, 관리자가 관리자 권한으로 돌봄사 혹은 고객을 회원탈퇴 시키는 요청도 동시에 있을 수 있기 때문이다.(이때, 다른 트랜잭션에서 select... for update가 아닌 일반 select 조회는 가능하고 쓰기는 불가능)

2. 이 이후에 공유 자원돌봄사가 등록한 돌봄 가능 날짜락을 얻어 조회(select ... for update)한 후 성공적으로 로직이 수행되면 커밋을 하게 된다. 이후에 접근하는 트랜잭션들은 이전 트랜잭션이 DB에 커밋한 이후에 접근하여 select 하기 때문에 이미 예약된 상태의 날짜를 조회하게 되어 모두 에러가 터지게 되는 것이다.

 

코드가 너무 길어서 대표적인 하나의 공유 자원에 대해 동시에 접근하여 요청이 발생하는 경우를 하나의 예시로 간단히 소개를 해보았다.

현재 이런 방법으로 모든 상황의 시나리오를 생각해서 데이터 정합성을 보장할 수 있게 되었다.

(ex: 해당 돌봄 예약을 관리자, 고객, 돌봄사가 동시에 취소하는 경우, '2025-05-10'의 돌봄 날짜를 돌봄사가 삭제하는 요청과 이 해당 날짜에 고객이 동시에 요청하는 경우 등과 같이 다양한 상황)

 

비관적 락(배타 락) 방식으로 해결할 때에는 데드락이 발생하지 않게 트랜잭션이 서로 다른 비즈니스 로직을 처리할 때 순환 대기 상황에 빠지지 않는지 여러 상황 시나리오를 그려보면서 테스트해서 해결해 봐야 한다!