Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요.
낙관적 락 동시성 처리 에러 | Notion
들어가기 앞서..
hail-buttercup-c86.notion.site
들어가기 앞서..
동시성 테스트 과정에서 발생한 트러블 슈팅입니다.
쿠폰 이벤트의 동시성 문제를 처리하기 위해 Lock 전략 중 낙관적 락을 먼저 도입해보기로했다. 처리하기 위해 Event 엔티티에 Version 필드를 추가해주었다.
문제 상황
스크린샷을 첨부하여 어떤 문제가 발생하였는지 작성하자.
@Getter
@Entity
@Table(name = "event")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Event {
.
.
.
@Version
@Column(name = "event_version")
private Long version;
}
}
@Override
@Transactional
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 3;
for (int i = 0; i < maxTry; i++) {
try {
Event event = getEventOrThrow(eventId);
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(
member,
seat.getPerformance(),
paidStatus,
event.getAccrued()
);
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
} catch (OptimisticLockException e) {
if (i == maxTry - 1) {
throw e;
}
try {
Thread.sleep(5L);
} catch (InterruptedException ex) {
}
}
}
throw new IllegalArgumentException("unreachable");
}
테스트를 진행해보자.
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
// given
int memberCount = 100;
final long eventId = 6L;
final int perPrice = 10000; // 이벤트 per price
int eventAmount = 100000;
Event event = eventRepository.findById(eventId).orElseThrow();
ExecutorService pool = Executors.newFixedThreadPool(memberCount);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 각 멤버에게 포인트 적립(2,000)
for (long id = 1; id <= memberCount; id++) {
Member m = memberRepository.findById(id).orElseThrow();
m.addPoint(perPrice);
memberRepository.save(m);
}
// when
for (long id = 1; id <= memberCount; id++) {
final long memberId = id;
pool.submit(() -> {
try {
// 스레드 준비 완료 → 출발 신호 기다림
startGate.await();
// 스레드 별 로그인 컨텍스트 세팅
var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); // 필요시 "ROLE_MEMBER"로
var principal = new CustomUserDetails(
memberId,
"user" + memberId + "@test.com",
"pw" + memberId, // 더미 비밀번호
"유저" + memberId, // 더미 닉네임
authorities
);
var authentication =
new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
TestSecurityContextHolder.setAuthentication(authentication);
eventService.applyTicketEvent(eventId);
successCount.incrementAndGet();
} catch (Exception e) {
// 실패 사유 로그
failCount.incrementAndGet();
log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e);
} finally {
doneGate.countDown();
TestSecurityContextHolder.clearContext();
}
});
}
// 모든 작업자 준비 후 동시에 출발
startGate.countDown();
// 모두 끝날 때까지 대기
doneGate.await();
pool.shutdown();
// then
log.info("successCount = " + successCount.get());
log.info("failCount = " + failCount.get());
Event updated = eventRepository.findById(event.getId()).orElseThrow();
assertThat(updated.getAccrued()).isEqualTo(eventAmount);
}
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.profect.tickle.domain.event.entity.Event#6]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:325)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566)
.
.
.


위와같이 에러가 뜨는 것을 확인할 수 있었다. 100명의 유저중 요청에 성공한 사람은 2명밖에 없는 것이다.
해결 과정
해결과정 1. 재시도와 실제 작업의 분리
외부 메서드에서 재시도 루프를 돌리고, 실제 로직을 분리해 매 시도마다 새 트랜잭션으로 실행해야한다.
**/* 재시도 루프 */**
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 5;
for (int i = 0; i < maxTry; i++) {
try {
return applyTicketEventOnce(eventId); // 매번 새 트랜잭션
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
try { Thread.sleep(10L); } catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
**/* 실제 로직 */**
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId); // @Version 필드 포함
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
// 가능하면 아래 한 줄로(방법 B) 대체 권장. 엔티티 변경을 고수한다면 여기서 예외 날 수 있음.
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(member, seat.getPerformance(), paidStatus, event.getAccrued());
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
}
재시도 루프와 실제 로직을 분리하여, 실제 로직 메서드에 매 시도마다 새 트랜잭션을 실행하기 위해 @Transactional(propagation = Propagation.REQUIRES_NEW) 어노테이션을 붙여주었다.
해당 어노테이션을 통해 트랜잭션 전파를 해줄 수 있다.
자세한 내용은 아래의 노션을 확인하자
-
- 한 트랜잭션 안에서 루프를 돌면:→ 예외를 잡아도 그 트랜잭션에서는 더 못 함(커밋 불가).
- → 또한 같은 트랜잭션이라 이전 읽기 스냅샷에 갇혀 최신 버전을 못 봄
- 첫 UPDATE가 OptimisticLockException로 실패하면, JPA/Spring은 그 트랜잭션을 보통 rollback-only 로 표시.
- REQUIRES_NEW로 시도마다 새 트랜잭션을 열면:→ 누군가 먼저 올린 버전을 반영하여 재시도 가능.
- 실패한 시도는 온전히 롤백되고, 다음 시도는 새 커넥션/새 세션/최신 스냅샷에서 다시 읽고 시도.왜 매 시도마다 새 트랜잭션으로 실행해야할까? 낙관적 락 재시도 패턴 때문이다.
- 요약: 재시도 = “매번 깨끗한 트랜잭션” 이 본질이다.
결과
위처럼 실제로직과 재시도 루프를 분리하였으나 지연로딩 에러를 마주하였다. 지연로딩 에러를 해결해보자.

문제 상황
해결과정 1: 테스트코드에 @Tansactional을 붙여보자
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.profect.tickle.domain.member.entity.Member.points: could not initialize proxy - no Session
at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:635)
at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:219)

실제로직과 재시도 루프를 분리하니 Point에서 지연로딩 에러가 뜨는 것을 확인할 수 있었다.
지연로딩이란 무엇인지 아래의 노션을 확인하자.
지연 로딩이 발생하는 이유는 세션이 없어서, 즉 트랜잭션이 끝나서 닫혔기때문이라고 생각했다. 그래서 테스트 코드에 @Transactional을 붙여보았다.
@Transactional
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
결과
포인트 지연 로딩 에러는 해결하였으나, BusinessException("이벤트 정보를 찾을 수 없습니다")의 예외가 계속 발생되는 것을 확인할 수 있었다.
com.profect.tickle.global.exception.BusinessException: 이벤트 정보를 찾을 수 없습니다.
at com.profect.tickle.domain.event.service.impl.EventServiceImpl.lambda$getEventOrThrow$1(EventServiceImpl.java:317)
여기서 에러 이유를 더 찾아보니, 스프링의 @Transactional은 단일 스레드라서 멀티 스레드 환경에서의 동시성 테스트에서 @Transactional을 붙여주면 안된다고 한다. 테스트코드에서의 @Transactional을 제거하고 다시 지연 로딩 에러를 해결해보자
아래 더보기는 @Transactional을 써도 되는 줄 알고 다음 문제를 해결하기 위한 여정들.. (안봐도 되는데 아까워서 넣어놨다.)
해결과정 2: @Sql을 분리된 트랜잭션(ISOLATED)로 실행
테스트 메서드에 @Transactional이 붙어 있고, 서비스 쪽은 applyTicketEventOnce(..)가
@Transactional(propagation = REQUIRES_NEW)라서 서로 다른 트랜잭션을 씁니다.
- @Sql(schema/data 스크립트)와 테스트 초반의 eventRepository.findById(6L) 등은 테스트 메서드 트랜잭션(바깥 TX) 안에서 이루어지고 커밋되지 않은 상태로 남아있어요.
- 그 다음 eventService.applyTicketEvent(eventId) → 내부에서 REQUIRES_NEW가 열리면 새 트랜잭션(안쪽 TX) 이 생성됩니다.
- 새 트랜잭션은 커밋된 데이터만 볼 수 있으므로, 바깥 TX 안에서만 존재하는 event(id=6)을 못 봅니다 → findById(6)가 빈값 → BusinessException("이벤트 정보를 찾을 수 없습니다").
H2/스프링 테스트의 기본 격리(READ_COMMITTED)에서 전형적으로 생기는 현상입니다.
@Sql을 분리된 트랜잭션(ISOLATED)로 실행하자.
테스트 클래스/메서드에:
@Sql(
scripts = {"/sql/schema.sql", "/sql/data.sql"},
config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)
)
- @Sql이 테스트 메서드 트랜잭션과 분리되어 먼저 커밋됩니다.
- 테스트 메서드에 @Transactional을 유지하고 싶다면 이 방법을 쓰세요.
3) 데이터 로딩을 애초에 애플리케이션 부트 시 data.sql로
- 스프링 부팅 시점에 커밋된 상태로 올라오므로 동일하게 문제 사라집니다.
- (테스트에서 spring.sql.init.mode=always 등을 켜야 합니다.)
https://velog.io/@chocochip/failed-to-lazily-initialize-a-collection-of-role-에러에러
문제 상황
테스트코드에서의 @Transactional을 다시 제거하니 아까와 똑같이 Point부분에서 지연로딩 에러가 뜨는 것을 확인할 수 있었다. 도대체 왜 뜨는걸까?
문제는 트랜잭션에 있었다.
해결과정 1: REQUIRES_NEW 메서드를 다른 빈으로 분리하자.
에러
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role:
com.profect.tickle.domain.member.entity.Member.points: could not initialize proxy - no Session

서비스 코드
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 5;
for (int i = 0; i < maxTry; i++) {
try {
//같은 빈 내부에서 직접 호출하는 중
return applyTicketEventOnce(eventId);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
try { Thread.sleep(10L); } catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId);
Member member = getMemberOrThrow();
// 여기서 Point 생성(+ member 연관 세팅)
Point point = member.deductPoint(event.getPerPrice(), eventTarget); // 여기서 Point 생성(+ member 연관 세팅)
pointRepository.save(point);
event.accumulate(event.getPerPrice());
...
}
현재 필자의 코드는 위와같다.
applyTicketEvent는 재시도 로직을 가지고 있는 메서드이고, applyTicketEventOnce는 applyTicketEvent에서 호출되는 실제 로직을 가지고 있는 메서드이다.
applyTicketEvent은 단순 반복 루프를 가지고 있는 메서드이기때문에 트랜잭션을 붙여주지않았고, applyTicketEventOnce에는 트랜잭션을 붙이고, 트랜잭션 전파도 설정해주었다.
어디서 문제가 터졌을까?
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
try {
//같은 빈 내부에서 직접 호출하는 중
return applyTicketEventOnce(eventId);
.
.
.
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
.
.
// 여기서 Point 생성(+ member 연관 세팅)
Point point = member.deductPoint(event.getPerPrice(), eventTarget); // 여기서 Point 생성(+ member 연관 세팅)
pointRepository.save(point);
...
}
분리까지는 잘한 것 같으나, 현재 applyTicketEvent가 같은 클래스 안의 applyTicketEventOnce를 직접 호출하고있다.
스프링의 @Transactional은 프록시를 경유할 때만 적용되기때문에, 같은 빈 내부에서 직접 호출을 한다면 프록시를 안 타므로 REQUIRES_NEW 트랜잭션이 시작되지 않는다.
때문에 스레드 테스트에서 각 스레드가 서비스를 호출할 때, 트랜잭션이 실제로 없어, 지연 로딩(LAZY)이 필요한 순간이여서 하이버네이트가 세션을 열려고 하지만 세션이 없어 LazyInitializationException이 발생했다.
<aside> 📄
스택트레이스의 **Point.$$_hibernate_write_member**는 Point.member 연관을 세팅할 때 하이버네이트가 양방향 일관성 점검(또는 프록시 초기화)을 시도하다가, 세션이 없어 실패했다는 신호이다.
</aside>
결과
@Service
@RequiredArgsConstructor
public class EventServiceImpl implements EventService {
//분리한 서비스 로직을 빈으로 주입
private final EventApplyExecutor executor;
.
.
.
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 5;
for (int i = 0; i < maxTry; i++) {
try {
// 프록시를 경유
return executor.applyOnce(eventId);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
try { Thread.sleep(10L); } catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
}
@Service
public class EventApplyExecutor {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId); // @Version 필드 포함
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
// 가능하면 아래 한 줄로(방법 B) 대체 권장. 엔티티 변경을 고수한다면 여기서 예외 날 수 있음.
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(member, seat.getPerformance(), paidStatus, event.getAccrued());
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
}}
다른 빈으로 분리함으로 써, applyTicketEventOnce 호출이 반드시 AOP 프록시를 통과하게 되어 @Transactional(REQUIRES_NEW)가 동작하게 하였다.
이제 각 스레드에서 독립된 트랜잭션이 열려, Member를 로드하고, Point를 만들어 Member 연관 세팅 시 필요한 LAZY 프록시 초기화가 세션 안에서 안전하게 수행된다.
하지만 이후 낙관적 락이 정상적으로 동작을 하고있는 것은 확인했지만, 이후에 OptimisticLonkingFailureExceptiondl 뜨며 오류가 나는 것을 확인했다.
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.profect.tickle.domain.event.entity.Event#6]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:325)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
2025-08-26 15:36:27 - successCount = 35
2025-08-26 15:36:27 - failCount = 65


심지어 실행할 때 마다 값이 달라지는 것을 확인했다. 이는 낙관적 락이 제대로 적용되지 않음을 뜻한다.
해결과정 2: 재시도 정책을 강화해보자.
아무래도 낙관적 락은 정상적으로 동작하나, 여러 스레드가 업데이트하다 보니, 버전이 뒤처진 트랜잭션들이 커밋 단계에서 튕기는 것 같았다.

재시도 정책을 강화해보자.
1)지수 백오프 + 지터
기존
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 5;
for (int i = 0; i < maxTry; i++) {
try {
return executor.applyTicketEventOnce(eventId);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
try { Thread.sleep(10L); } catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
변경
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 20;
for (int i = 0; i < maxTry; i++) {
try {
return executor.applyTicketEventOnce(eventId);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
// 재시도 대기: 지수 백오프 + 지터
try {
long base = 5L; // 기본 단위
long backoff = (long) (base * Math.pow(2, i)); // 지수적으로 증가
long jitter = ThreadLocalRandom.current().nextLong(0, 5); // 약간 랜덤 섞기
Thread.sleep(Math.min(200L, backoff + jitter));// 상한 200ms
} catch (InterruptedException ignored) {}
}
}
2)진행중만 응모 허용
여러 스레드가 포인트를 누적시키다보니, 목표치 달성 직후에도 계속 응모하면 버전 충돌 및 상태 불일치가 늘어나므로 applyTicketEventOnce 초반에 진행 상태를 검사하도록 하였다.
@Service
@RequiredArgsConstructor
public class EventApplyExecutor {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId);
Member member = getMemberOrThrow();
// 실제 로직이 진행되기 전에 진행 상태 검사
if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
...
}
}
완료 이후 요청은 초반에 예외를 보내므로 충돌면적이 감소한다.
결과
테스트를 성공적으로 수행하였다!


마무리하며…
이번 경험을 통해 동시성 제어는 단순히 @Version을 붙이는 것만으로 해결되지 않는다는 사실을 깨달았다.
락 전략 선택, 트랜잭션 경계 설정, 재시도 정책, 테스트 환경까지 모든 요소가 맞물려야 안정적인 처리가 가능하다는 것을 직접 확인했다.
이를 통해 낙관적 락·비관적 락의 특성과 한계를 몸소 이해했고, 앞으로는 트래픽 패턴에 맞는 유연한 동시성 전략을 설계할 수 있는 시야를 넓혔다.