웹 서비스는 기본적으로 동시에 여러 요청을 처리한다 문제는 여러 사용자가 동시에 같은 데이터를 접근하여 수정할 때 발생한다
예를 들어 재고가 현재 1개만 남아 있는 상품이 존재한다고 했을 때 여려 명의 사용자가 주문을 하게 된다면 어떻게 될까
기본적인 설정에서 동시성 처리를 하지 않는다면
재고가 1개만 남아 있는 상황에서도 동시에 여러 요청이 들어와 재고가 -1이 되는 현상이 발생할 수 있다
이러한 상황을 Race Condition (경쟁 상태) 이라고 한다
Race Condition (경쟁 상태) 은 여러 스레드(또는 트랜잭션)가 동시에 하나의 자원에 접근하면서
의도하지 않은 결과를 만들어내는 현상이다
해당 문제는 일상에서 사용하고 있는 실제 서비스에서도 발생할 수 있다
- 재고량을 초과한 상품 판매
- 하나의 좌석이 여러 명에게 중복 예매되는 상황
- 수강신청 인원을 초과하여 등록되는 상황
이처럼 동시성 제어가 제대로 이루어지지 않으면 데이터 정합성이 깨지게 되고 이는 단순한 버그를 넘어
비즈니스적으로 큰 손실을 초래할 수 있다
Spring에서는 이런 문제를 해결하기 위해 JPA 기반의 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)을 제공한다
이번 글에서는 낙관적 락을 적용하여 동시성 제어를 하는 방법에 대해서 작성하려고 합니다
또한 개인 운영 사이트에서도 중복 로그인 상태에서 다중 기기에서 수정을 했을때 데이터 정합성을 어떻게 지킬수 있을까에서 시작하게 되었다
낙관적 락(Optimistic Lock)
낙관적 락은 충돌이 자주 발생하지 않을 것을 기반하여 실제로 DB에 락을 걸지 않고 버전(Version) 값을 이용하여 충돌을 감지한다
따라서 DB에 실제 락을 걸어 다른 트랜잭션을 막지 않는다
대신 엔티티의 버전(Version) 값을 사용하여 "내가 조회한 데이터가 수정되었는지" 확인하고 업데이트 시점에서 충돌을 감지하는 방식이다
동작 방식(흐름)
낙관적 락은 보통 다음 순서로 동작한다

1. 트랜잭션이 데이터를 조회하면서 version 값도 함께 읽는다
2. 비즈니스 로직을 수행한 뒤 업데이트를 시도한다
3. 업데이트할 때 WHERE id = ? AND version ? 조건이 붙는다
4. 이미 다른 트랜잭션이 먼저 수정하여 version이 변경되었다면 업데이트 대상 행(row)이 0건이 된다
5. JPA는 이를 충돌로 판단하여 OptimisticLockException 을 발생시킨다.
"내가 읽은 시점 이후로 누군가가 수정했는지”를 version으로 판별한다
SQL 관점에서 이해하기
낙관적 락을 적용하면 실제로는 이런 형태의 업데이트가 수행된다
UPDATE borad
SET title = ?, version = version + 1
WHERE id = ? AND version = ?
- version이 일치하면 업데이트 성공 + version 증가 (업데이트 완료)
- version이 다르면 업데이트 실패(0 rows) → 충돌 감지 (예외 발생)
장점
1) DB 레벨 락을 걸지 않아 → 성능상 유리할 수 있다
트랜잭션이 길어져도 다른 요청을 "대기" 시키지 않고 진행되기 때문에 충돌이 드문 환경이라면 처리량(throughput)이 좋아질 수 있다
2) 일반적인 CRUD에 적합
예를 들어 사용자 프로필 수정,게시글 수정처럼 "동시에 같은 데이터를 수정할 확률이 낮은 기능"에 적합하다
중복 로그인 후 이미 수정한 내용을 수정하려고 하는 상황에 사용될 수 있다
3) 구현이 단순하다
엔티티에 @Version 필드만 추가해도 기본적인 충돌 감지가 가능하기 때문이다 (재시도 전략 포함)
단점
1) 충돌이 많아지면 예외가 빈번하게 발생한다
충돌이 발생하면 예외로 알려주는 방식이다 따라서 예매/좌석/재고 같이 동시에 몰리는 기능에서는
- OptimisticLockException 빈발
- 예외 처리 비용 증가
- 사용자 입장에서는 실패 응답이 많아짐
같은 문제가 발생할 수 있다
2) 재시도(Retry) 로직을 직접 설계해야 한다
낙관적 락 충돌은 "버그" 가 아닌 "정상적인 동작"일 수 있다
그렇기 때문에 충돌 시 어떻게 할지가 매주 중요하다고 볼 수 있다
바로 실패 처리를 할 건지, 일정 횟수 재시도 후 실패로 처리할 건지, 예외 처리 메시지는 어떻게 설계할 건지
서비스 요구사항에 맞춰 결정해야 한다
낙관적 락(Optimistic Lock) 구현
낙관적 락을 적용한 동시성 제어를 검증하기 위해서 사용자 포인트 차감 시나리오를 기반으로 엔티티를 설계했다
테스트 환경 구성을 위해 먼저 User 엔티티를 생성하고 사용자와 포인트 정보를 1:1 관계로 매핑한 UserPoint 엔티티를 설계했다
@Entity
@Table(name = "user")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Builder
public User(String name) {
this.name = name;
}
}
UserPoint 엔티티에는 @Version 어노테이션을 적용한 칼럼을 추가하여
Hibernate가 UPDATE 시점에 버전 조건을 포함하여 이를 통해 동시성 충돌을 사후 감지하도록 구성하였다
@Entity
@Table(name = "user_point")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserPoint {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id",nullable = false,unique = true)
private User user;
@Column(name = "point", nullable = false)
private Long point;
@Version
private Long version;
@Builder
public UserPoint(User user, Long point) {
this.user = user;
this.point = point;
}
public void charge(Long amount){
this.point += amount;
}
public void deduct(Long amount){
if(this.point < amount) {
throw new IllegalArgumentException("포인트가 부족합니다");
}
this.point -= amount;
}
}
다음으로 비즈니스 로직인 포인트 차감 로직은 서비스 계층에서 처리하도록 구성하였다
Service Method에 @Transactional을 적용하여 트랜잭션 경계를 설정해 줬다
@Service
@RequiredArgsConstructor
public class PointService {
private final UserPointRepository userPointRepository;
@Transactional
public void deductPoint(Long userId, Long amount) {
UserPoint userPoint = userPointRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("UserPoint not found"));
userPoint.deduct(amount);
}
}
@Transactional은 낙관적 락이 정상적으로 동작하기 위한 필수 조건이라고 볼 수 있는데 동작 흐름을 보면
1. findById() 호출
→ SELECT 실행 (엔티티가 영속성 컨텍스트에 저장됨)
2. userPoint.deduct(amout) 호출
→ 객체의 point 값만 변경됨 (아직 UPDATE X)
3. 트랜잭션 커밋 시점
→ Hibernate의 Dirty Checking 수행
→ UPDATE SQL 생성 (WHERE 절에 version 조건 포함)
→ 버전 비교 후 충돌 감지
실제 낙관적 락 충돌 감지는 트랜잭션 종료 시점에 발생하기 때문에 해당 로직에는 반드시 트랜잭션이 적용되어야 한다
동시성 제어 테스트
멀티스레드 환경에서 동시에 포인트 차감 요청이 발생한 경우
낙관적 락이 정상적으로 충돌을 감지하고 데이터 정합성을 보장하는지 검증하는 테스트 코드를 작성하여 테스트를 진행했습니다
@SpringBootTest
// 테스트 메서드 자체에 트랜잭션이 걸리면 멀티스레드 환경이 제대로 동작하지 않으므로
// 테스트 클래스에서는 트랜잭션을 사용하지 않도록 설정
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@TestPropertySource(properties = {
"spring.jpa.show-sql=true"
})
@DisplayName("낙관적 락 동시성 테스트")
class PointConcurrencyTest {
@Autowired
private PointService pointService;
@Autowired
private UserRepository userRepository;
@Autowired
private UserPointRepository userPointRepository;
@Test
void optimisticLock_concurrency_with_metrics() throws InterruptedException {
// given
User user = userRepository.save(
User.builder()
.name("테스트유저")
.build()
);
userPointRepository.save(
UserPoint.builder()
.user(user)
.point(1000L)
.build()
);
// 동시에 실행할 스레드 수
int threadCount = 20;
// 고정된 스레드 풀 생성
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();
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
long start = System.nanoTime();
try {
// 낙관적 락 충돌이 발생하면 예외가 발생함
pointService.deductPoint(user.getId(), 100L);
successCount.incrementAndGet();
} catch (Exception e) {
// OptimisticLockException 또는 기타 예외 발생 시 실패 카운트 증가
failCount.incrementAndGet();
} finally {
long end = System.nanoTime();
totalExecutionTime.addAndGet(end - start);
latch.countDown();
}
});
}
// 모든 스레드가 종료될 때까지 대기
latch.await();
long endAll = System.nanoTime();
// then
// 최종 포인트 조회
UserPoint result = userPointRepository.findByUserId(user.getId())
.orElseThrow();
// 전체 실행 시간(ms)
// 평균 요청 처리 시간(ms)
long totalTimeMs = (endAll - startAll) / 1_000_000;
long avgTimeMs = (totalExecutionTime.get() / threadCount) / 1_000_000;
System.out.println("===== 테스트 결과 =====");
System.out.println("성공 횟수: " + successCount.get());
System.out.println("실패 횟수: " + failCount.get());
System.out.println("최종 포인트: " + result.getPoint());
System.out.println("총 실행 시간(ms): " + totalTimeMs);
System.out.println("평균 요청 시간(ms): " + avgTimeMs);
assertEquals(successCount.get() * 100, 1000 - result.getPoint());
// 포인트는 절대 음수가 되면 안 됨
assertTrue(result.getPoint() >= 0);
}
}
테스트 결과 분석

20개의 스레드를 통해 동시 요청을 발생시킨 결과 3건의 요청만 성공하고 17건은 실패하였다
이는 동일한 버전 값을 기반으로 동시에 UPDATE가 수행되면서 일부 요청은 버전 불일치로 인한 충돌이 발생했기 때문이다
초기 포인트는 1000 포인트였으며 100씩 차감되는 로직이었는데 3회 성공 시 최종 포인트는 700 포인트가 되어야 한다
실제 테스트 결과 최종 포인트는 700 포인트로 확인되며 이는 (성공 횟수 * 차감 금액)과 정확히 일치한다
이른 통해 Lost Update가 발생하지 않고 낙관적 락이 정상적으로 동작했음을 확인할 수 있었다

Hibernate가 생성한 UPDATE SQL을 확인해 보면 WHERE 절에 (and version = ?) 조건이 포함되어 있는 것을 확인할 수 있는데
JPA의 @Version 어노테이션에 의해 자동으로 추가되는 조건으로 현재 트랜잭션이 조회한 버전과 DB에 저장된 버전이 일치할 때만 UPDATE가 수행되는 구조이다
그렇기 때문에 다른 트랜잭션이 먼저 데이트를 수정하여 버전 값이 증가한 경우 WHERE 조건이 만족하지 않아
UPDATE는 0 row 영향을 주게 되면서 이 시점에서 OptimisticLockException이 발생한다
중복 로그인 환경의 동시성 충돌 시나리오
그렇다면 가상의 시나리오에서 동시성 제어를 어떻게 하는지 설명하려고 합니다
초기 상태
포인트: 1000
version: 0
동일 사용자가 데스크탑과 모바일에서 동시에 해당 정보를 조회한다
- 데스크탑 → version = 0 조회
- 모바일 → version = 0 조회
두 기기 모두 동일한 스냅샷을 소유하고 있다
데스크탑에서 먼저 수정
데스크탑에서 포인트를 200 차감한다고 가정해 보자
트랜잭션 커밋 시 Hibernate는 다음과 같이 SQL을 실행한다
update user_point
set point=800,
version=1
where id=1
and version=0;
저장된 버전이 0 조건의 버전도 0으로 일치하므로 UPDATE 성공
포인트: 800
version: 1
모바일에서 최신화 없이 수정 시도 (이전값)
모바일은 여전히 이전 상태값을 가지고 있다
포인트: 1000
version: 0
모바일에서 300 차감을 시도하면 Hibernate는 다음과 같은 쿼리를 생성한다
update user_point
set point=700,
version=1
where id=1
and version=0;
하지만 최신 DB의 versiond은 이미 수정된 1이다
WHERE 조건 불일치로 인해 0 row update가 수행되며 OptimisticLockException 발생하는 구조이다
모바일은 잘못된 계산을 한 것이 아닌 이전 상태값을 기반으로 수정하려고 했기 때문에 차단된 것이다
낙관적 락을 적용 안 한 상태
모바일의 UPDATE가 수행된다면 이런 식으로 수행될 것이다
update user_point
set point=700
where id=1;
결과는 데스크탑에서 먼저 반영된 800 포인트 상태는 모바일의 UPDATE에 의해 덮어쓰이게 된다
모바일은 여전히 1000 포인트라는 초기 스냅샷을 기반으로 300을 차감을 시도하기 때문에 최종적으로 700이라는 잘못된 값이 저장된다
이는 서로 다른 트랜잭션이 동일한 초기 데이터를 기반으로 수정한 뒤 마지막에 실행된 UPDATE가 이전 변경 사항을 덮어쓰는
Lost Update 문제에 해당한다 이러한 구조는 데이터 정합성을 보장할 수 없으며 특히 금전적 데이터나 포인트 시스템과 같이
무결성이 중요한 영역에서는 심각한 비즈니스 문제로 이어질 수 있다
따라서 동시성 제어가 적용되지 않은 상태에서는 다중 기기 로그인,네트워크 재시도,분산 환경에서는
데이터 유실이 언제든지 발생할 수 있으며 이를 방지하기 위해 버전 기반의 낙관적 락을 적용할 수 있다
'Back-end > Spring' 카테고리의 다른 글
| [Spring] Spring Boot(Log) Slf4j, Logback 로깅 레벨 관리하는 방법 (0) | 2026.02.12 |
|---|---|
| [Spring] Spring Security JWT 로그인 구현 필수 개념 (Session, JWT, Access / Refresh Token) (0) | 2026.01.16 |
| [Spring] Spring Security 기본 구조와 인증 처리 흐름 (Filter Chain) (0) | 2026.01.09 |
| [Spring] Spring Boot + MySQL 연동 & JPA 활용 심화 (2) (0) | 2026.01.03 |
| [Spring] Spring Boot + MySQL 연동 & JPA 활용 심화 (1) (0) | 2025.12.30 |