이전 글에서 낙관적 락(Optimistic Lock)을 적용하여 동시성 제어를 하는 방법에 대해서 다뤄봤습니다
[Spring] Spring Boot JPA 낙관적 락(Optimistic Lock)을 활용한 동시성 제어 방법
웹 서비스는 기본적으로 동시에 여러 요청을 처리한다 문제는 여러 사용자가 동시에 같은 데이터를 접근하여 수정할 때 발생한다예를 들어 재고가 현재 1개만 남아 있는 상품이 존재한다고 했
white63ser.tistory.com
이번에는 비관적 락(Pessimistic Lock)을 적용하여 동시성 제어를 하는 방법에 대해서 작성하려고 합니다
공연 티켓 예매를 해본 사람이라면 익숙한 문장이 있습니다
수많은 대기열을 뚫고 들어가서 포도알(좌석)을 보고 눌렀더니 나오는 한 줄의 메시지
이미 선택된 좌석입니다
일명 이선좌 (이게 나왔다면 취소표를 노려보는 게...)
실제로 당하면 속상하지만 이러한 메시지는 단순한 실패 안내가 아닐 수도 있는데요
그 뒤에는 수만 명의 동시 요청을 처리하기 위한 치열한 동시성 제어 로직이 숨어 있을 거예요
"데이터 정합성을 지키기 위해 누군가 먼저 좌석을 선점했다는 증거"라고 볼 수 있습니다 (느리다는 증거)
공연 티켓 예매 시스템을 예시로 서버 내부에서는 어떤 동시성 제어가 이루어지고 있는지
비관적 락(Pessimistic Lock)을 중심으로 풀어보려고 합니다
비관적 락(Pessimistic Lock)
비관적 락은 충돌은 충분히 발생할 수 있다 를 전재로 데이터를 조회하는 시점에 DB 레벨에서 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식
낙관적 락은 충돌을 나중에 감지하는 전략이라면 비관적 락은 충돌을 미리 차단하는 전략이라고 볼 수 있다
동작 방식(흐름)
1. 트랜잭션이 데이터를 조회하는 순간 DB레벨에서 락을 획득한다
2. 다른 트랜잭션은 해당 행에 접근 시 대기 상태가 된다
3. 선점한 트랜잭션은 비즈니스 로직을 수행하여 업데이트를 시도한다
4. 트랜잭션 커밋 완료 시 락을 해제한다
5. 대기 중이던 다른 트랜잭션이 실행된다
SQL 관점에서 이해하기
낙관적 락을 적용하여 실행하게 되면 실제 쿼리는 다음과 비슷하다
SELECT * FROM seat
WHERE id = ? FOR UPDATE;
- 첫 번째 트랜잭션: 행 락을 획득하여 수정이 가능한 상태 (결재,취소)
- 이후 트랜잭션: 같은 행 접근 시 대기 상태 (첫 번째 트랜잭션이 COMMIT/ROLLBACK 할 때까지 블로킹)
장점
1) 데이터 정합성을 강하게 보장
동시에 같은 데이터를 수정하는 상황 자체를 원천적으로 차단하기 때문에 데이터 정합성을 강하게 보장할 수 있다
충돌이 자주 발생할 것을 기반으로 DB 레벨에서 락을 걸어버려 충돌 확률이 높은 영역에서 안전하다
2) 예외 빈도가 낮다
낙관적 락처럼 충돌 시 예외를 던지는 구조가 아닌 대기 후 순차적으로 처리되는 방식으로 사용자 입장에서는 실패 응답이 적을 수 있다
단점
1) 성능 저하 가능성이 존재한다
락 대기로 인하여 응답 시간이 증가할 수 있다
특히 티켓예매처럼 특정 시간에 트래픽이 몰리는 상황에서 사용자가 동시에 접근하는 경우 DB 병목 현상이 발생할 수 있다
2) 데드락(Deadlock) 위험성
두 개 이상의 트랜잭션이 서로가 점유한 락을 기다리면서 영원히 대기 상태에 빠지는 현상이 생길 수 있다
각 트랜잭션이 상대방이 보유한 락을 기다리는 상태가 되면 누구도 작업을 완료하지 못하는 악순환에 빠지게 된다
예매 시스템 동시성 제어 하기
실제 시스템을 기반으로 설명하는 게 아닌 개인적 견해를 포함하여 설명하기 때문에 부정확한 정보가 포함될 수 있습니다
예매 시작과 동시에 수천 명이 같은 좌석을 클릭한다고 가정해 보자
동시에 두 명이 클릭하면 A,B 모두 예약 시도를 할 것이다
동시성 제어가 없다면 어떤 일이 벌어질까?
동일한 좌석을 여러 사용자가 동시에 예약에 성공하는 상황이 발생할 수 있다
이는 곧 중복 예매 문제로 이어지게 된다 시스템 상에서는 모두가 "예매 성공"으로 보일지 몰라도
실제 공연 당일에는 한 좌석에 여러 명이 자기 좌석이라며 티켓을 보여주는 어이없는 상황이 생기게 된다
예매 시스템은 충돌이 드물게 발생하는 환경이 아닌 충돌이 집중적으로 발생하는 환경이다
따라서 충돌을 사후 감지하는 낙관적 락보다 충돌을 사전에 차단하고 순서를 보장하는 비관적 락이
예매 도메인에 특수성에는 더 적절한 전략이 될 수 있을 거라고 생각한다
물론 실제 서비스에서는 단순히 DB 락만 사용하는 게 아닌 트래픽을 관리하기 위해 사전에 대기열 시스템을 도입하고
Redis 기반 선점 로직,분산 락 등 다양한 전략을 사용할 것으로 예상된다
비관적 락(Pessimistic Lock) 구현해보기
비관적 락을 적용하여 테스트 환경을 구성하여 동시성 제어를 검증하기 위해서 티켓 예매 도메인을 기반으로 엔티티를 설계했다
먼저 Seat 엔티티를 생성해 주고 예매 중간 테이블 Reservation 엔티티를 생성했습니다
@Entity
@Table(name = "seat")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Seat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private SeatStatus status;
@Builder
public Seat(SeatStatus status) {
this.status = status;
}
public void reserveSeat() {
if(this.status == SeatStatus.RESERVED) {
throw new IllegalArgumentException("이미 선택중인 좌석입니다.");
}
this.status = SeatStatus.RESERVED;
}
}
간단하게 좌석의 상태를 나타내는 엔티티를 설계했습니다
좌석의 상태를 변경하는 메서드도 선언하여 사용할 수 있도록 구성합니다
public enum SeatStatus {
AVAILABLE,
RESERVED,
}
좌석 상태를 나타내는 enum 타입 SeatStatus입니다
@Entity
@Table(name = "reservation")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@OneToOne(fetch = FetchType.LAZY)
private Seat seat;
@Builder
public Reservation(User user, Seat seat) {
this.user = user;
this.seat = seat;
}
}
다음으로 예매내역을 나타내는 중간 엔티티를 생성합니다
User : Reservation = 1 : N
Seat : Reservation = 1 : 1
관계로 매핑했습니다
public interface SeatRepository extends JpaRepository<Seat,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s from Seat s WHERE s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);
}
동시성 제어를 위해서 좌석을 조회하는 시점에서 DB 레벨에 락을 획득하도록 구성해야 합니다
이를 위해서 SeatRepository에서 다음과 같이 @Lock을 사용하여 적용해 줍니다
@Lock(LockModeType.PESSIMISTIC_WRITE)
이 어노테이션은 Hibernate의 내부적으로 다음과 같은 SQL을 생성한다
SELECT id, status FROM seat
WHERE id = ? FOR UPDATE
해당 좌석 행(row)에 대해 쓰기 락(Exclusive Lock)을 획득하게 된다
SELECT ... FOR UPDATE 가 실행되면 해당 트랜잭션이 종료될 때까지
같은 행(row)에 대한 다른 트랜잭션의 접근은 대기 상태가 된다
동시에 여러 사용자가 같은 좌석을 예매 요청을 보냈더라도 DB가 물리적으로 순서를 보장해 주는 구조이다
@Service
@RequiredArgsConstructor
public class SeatService {
private final SeatRepository seatRepository;
private final UserRepository userRepository;
private final ReservationRepository reservationRepository;
@Transactional
public void reserveSeat(Long userId, Long seatId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));
Seat seat = seatRepository.findByIdForUpdate(seatId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 좌석입니다."));
if(seat.getStatus().equals(SeatStatus.RESERVED)){
throw new IllegalArgumentException("이미 선택된 좌석입니다");
}
Reservation reservation = Reservation.builder()
.seat(seat)
.user(user)
.build();
reservationRepository.save(reservation);
seat.reserveSeat();
}
}
Service 계층은 다음과 같이 동작 흐름을 갖는다
1. 트랜잭션이 시작된다
2. 좌석을 SELECT ... FOR UPDATE로 조회한다
3. 해당 좌석 행에 쓰기 락 (Exclusive Lock)이 걸리게 된다
4. 비즈니스 검증 로직을 수행한다
5. 애매 내역을 저장하고 좌석 상태를 변경한다
6. 트랜잭션이 커밋되면서 락이 해제된다
동시성 제어 테스트
낙관적 락과 동일한 구조로 멀티스레드 환경에서 동시에 여러 유저가 한 좌석에 예매 요청을 보냈을 경우
비관적 락이 정상적으로 DB 레벨에서 락을 통해 충돌을 방지하는지 검증하는 테스트 코드를 작성하여 테스트를 진행했습니다
@SpringBootTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@TestPropertySource(properties = {
"spring.jpa.show-sql=true"
})
class SeatConcurrencyTest {
@Autowired
private SeatService seatService;
@Autowired
private SeatRepository seatRepository;
@Autowired
private UserRepository userRepository;
@Test
void pessimisticLock_test() throws InterruptedException {
Seat seat = seatRepository.save(
Seat.builder().status(SeatStatus.AVAILABLE).build()
);
int threadCount = 50; // 테스트하고 싶은 동시 요청 수
List<User> users = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
users.add(
userRepository.save(
new User("user_" + i)
)
);
}
// 고정된 스레드 풀 생성
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
// 모든 스레드가 종료될 때까지 대기하기 위한 CountDownLatch
CountDownLatch latch = new CountDownLatch(threadCount);
// 성공 횟수 카운트용 (멀티스레드 환경이므로 Atomic 사용)
AtomicInteger successCount = new AtomicInteger();
// 실패 횟수 카운트용
AtomicInteger failCount = new AtomicInteger();
// 전체 요청의 실행 시간을 누적하여 평균 계산용
AtomicLong totalExecutionTime = new AtomicLong();
// 전체 테스트 시작 시간
long startAll = System.nanoTime();
for (int i = 0; i < threadCount; i++) {
final Long userId = users.get(i).getId();
executorService.submit(() -> {
long start = System.nanoTime();
try {
seatService.reserveSeat(userId,seat.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
System.out.println("예외 발생: " + e.getMessage());
} finally {
long end = System.nanoTime();
totalExecutionTime.addAndGet(end - start);
latch.countDown();
}
});
}
latch.await();
long endAll = System.nanoTime();
// 전체 실행 시간(ms)
// 평균 요청 처리 시간(ms)
long totalTimeMs = (endAll - startAll) / 1_000_000;
long avgTimeMs = (totalExecutionTime.get() / threadCount) / 1_000_000;
Seat result = seatRepository.findById(seat.getId()).orElseThrow();
System.out.println("===== 테스트 결과 =====");
System.out.println("성공 횟수: " + successCount.get());
System.out.println("실패 횟수: " + failCount.get());
System.out.println("총 실행 시간(ms): " + totalTimeMs);
System.out.println("평균 요청 시간(ms): " + avgTimeMs);
System.out.println("최종 상태: " + result.getStatus());
}
}
테스트 결과 분석

50개의 스레드를 1개의 좌석에 예매 요청을 동시에 보낸 결과 1건만 성공하고 49건은 실패하는 결과를 보였다
오직 하나의 트랜잭션만 성공하고 나머지 트랜잭션은 비즈니스 검증 단계에서 실패한 것이다

로그를 확인해 보면 내부 동작을 확인할 수 있는데 간단하게 하나의 트랜잭션에서 먼저 락을 획득한 후 좌석 상태 변경을 시도하게 된다
- 좌석을 SELECT ... FOR UPDATE 로 조회 → DB가 해당 row에 락을 건다
- 해당 좌석 상태는 AVAILBLE 상태인지 확인 후 일치하다면 비즈니스 검증 통과
- 애매 내역 엔티티 객체를 생성 후 저장
- 마지막으로 해당 좌석의 상태를 변경 후 트랜잭션을 커밋하고 락이 해제된다
그렇다면 그 이후에 실행되는 트랜잭션은 어떻게 동작하게 될까
아래 로그를 보면

이 시점에서는 이미 첫 번째 트랜잭션이 락을 잡고 있는 상태이기 때문에 DB 레벨에서 대기 상태에 들어가게 된다
첫 번째 트랜잭션이 커밋이 완료가 되고 락이 해제된 뒤에 다른 트랜잭션이 실행되는데 이때 비즈니스 검증 로직을 보면
if(this.status == SeatStatus.RESERVED) {
throw new IllegalArgumentException("이미 선택중인 좌석입니다.");
}
좌석 상태가 RESERVER 상태 일 경우 예외를 발생하도록 설계하여 결과적으로 예외가 발생하게 된다

최종적으로 하나의 좌석은 오직 한 명의 유저만 예매에 성공하였으며
비관적 락을 통해 데이터 정합성을 안전하게 유지할 수 있음을 확인할 수 있었다
예매 도메인처럼 충돌이 빈번하게 발생하는 환경에서는 비관적 락이 더 안정적인 전략이 될 수 있다고 생각합니다
물론 실제 서비스에서는 대기열 시스템,캐시,분산 락 등 여러 전략을 조합해서 사용하겠지만요
'Back-end > Spring' 카테고리의 다른 글
| [Spring] @Transactional 내부 동작 원리 (0) | 2026.03.10 |
|---|---|
| [Spring] Spring Boot 운영 환경 모니터링 시스템 구축 방법 (Prometheus + Grafana + Docker) (0) | 2026.02.23 |
| [Spring] Spring Boot JPA 낙관적 락(Optimistic Lock)을 활용한 동시성 제어 방법 (0) | 2026.02.13 |
| [Spring] Spring Boot(Log) Slf4j, Logback 로깅 레벨 관리하는 방법 (0) | 2026.02.12 |
| [Spring] Spring Security JWT 로그인 구현 필수 개념 (Session, JWT, Access / Refresh Token) (0) | 2026.01.16 |