HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🧚
[1기]최종 프로젝트 데브코스
/
📜
[팀13] 사각사각 ✏️
/
🔥
트러블슈팅
/
🔐
낙관적 잠금 관련 에러
🔐

낙관적 잠금 관련 에러

2021-12-14 02:11:25.930 ERROR 27768 --- [http-nio-8002-exec-2] c.p.m.c.e.GlobalExceptionHandler : handleException org.springframework.transaction.TransactionSystemException: Could not commit JPA transaction; nested exception is javax.persistence.RollbackException: Error while committing the transaction at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:571) 2:12 Caused by: javax.persistence.RollbackException: Error while committing the transaction at org.hibernate.internal.ExceptionConverterImpl.convertCommitException(ExceptionConverterImpl.java:81) at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:104) at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562) ... 102 common frames omitted Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "current" is null at org.hibernate.type.IntegerType.next(IntegerType.java:70) at org.hibernate.type.IntegerType.next(IntegerType.java:22) at org.hibernate.engine.internal.Versioning.increment(Versioning.java:92) at org.hibernate.event.internal.DefaultFlushEntityEventListener.getNextVersion(DefaultFlushEntityEventListener.java:428) at org.hibernate.event.internal.DefaultFlushEntityEventListener.scheduleUpdate(DefaultFlushEntityEventListener.java:305) at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:171) at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107) at org.hibernate.event.internal.AbstractFlushingEventListener.flushEntities(AbstractFlushingEventListener.java:229) at org.hibernate.event.internal.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:93) at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:39) at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107) at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1416) at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:507) at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:3299) at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2434) at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:449) at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:183) at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.access$300(JdbcResourceLocalTransactionCoordinatorImpl.java:40) at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:281) at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101) ... 103 common frames omitted
 
문제 원인 : User는 version 필드로 관리됨.
하지만 기존에 있던 User에 대해 version이 null 값이였다.
 
문제 해결 : version 값 null → 0
 

📢
MonthSub
우리 서비스에서는 READ COMMITTED + 낙관적락을 사용할 것이다.
비관적락을 쓸 이유는 아직 없다고 생각하여 추후 낙관적락으로 처리 불가능할 경우 비관적으로 바꿀 예정이다.
 
EX) 브라우저에서 동시에 10번에 결제가 들어온다고 해보자.
낙관적락을 쓰는 이유 : 락을 걸지 않으면 동시에 10번 결제가 들어오면 10번 결제로 처리되지만
User에 포인트는 한번만 깎이게 된다. 악의적으로 사용하면 포인트만 한번에 깎이고 여러개의 시리즈를 볼 수 있는 것이다.
 
낙관적락
최초 한번의 커밋만 인정된다 ( 한번 결제 완료 )
나머지 9번에 대해서는 예외로 잡고 포인트를 깎아주는 플로우이다.
 
동시성 문제에 대한 테스트로는 curl을 이용하여 재사용 할 수 있도록 파일로 저장한다. (스크립트)
 
일단 동시 요청을 보내보자.
curl -X POST http://localhost:8002/payments/series/1 -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiLCJST0xFX0FVVEhPUiJdLCJpc3MiOiJtb250aHN1YiIsImV4cCI6MTYzOTU1Mzc2MSwiaWF0IjoxNjM5NDY3MzYxLCJ1c2VybmFtZSI6InVzZXIifQ._A9fQZ1rYwR7nYU0-SJnC4iQ2gJHl-MEdRCCUZ87AmVnYUHniqFC_oMHtZhvLFCoODVgUuTbr8tXQHVklPSvpw" & curl -X POST http://localhost:8002/payments/series/1 -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiLCJST0xFX0FVVEhPUiJdLCJpc3MiOiJtb250aHN1YiIsImV4cCI6MTYzOTU1Mzc2MSwiaWF0IjoxNjM5NDY3MzYxLCJ1c2VybmFtZSI6InVzZXIifQ._A9fQZ1rYwR7nYU0-SJnC4iQ2gJHl-MEdRCCUZ87AmVnYUHniqFC_oMHtZhvLFCoODVgUuTbr8tXQHVklPSvpw"
 
notion image
 
최초 한번만 실행되고 두번째 요청에 대해서는 에러가 발생한다.
ObjectOptimisticLockingFailureException 이 예외를 잡아서 재실행을 시켜야 한다.
 
처음 방법 1) try catch로 잡기
문제원인 : 에러가 잡히지 않음
notion image
 
문제 해결 : 트랜잭션이 끝난 후 Try Catch를 해야 한다.
따라서 throws ObjectOptimisticLockingFailureException 로 예외를 던지고
트랜잭션이 끝날 때 catch로 잡아야 한다.
Pay라는 메서드를 만들고 Pay 안에서 createPayment를 리턴한다.

해결 코드 ( X)

코드
public PaymentPost.Response pay( Long id, Long userId ) { try { return this.createPayment(id, userId); } catch (ObjectOptimisticLockingFailureException e) { log.error("재시도"); return this.createPayment(id, userId); } } @Transactional public Response createPayment( Long seriesId, Long userId ) throws ObjectOptimisticLockingFailureException { Series series = this.seriesProvider.getById(seriesId); List<ArticleUploadDate> uploadDateList = this.seriesProvider.getArticleUploadDate(seriesId); User user = this.userProvider.findById(userId); user.decreasePoint(series.getPrice()); return this.paymentConverter.paymentResponse(series, uploadDateList, user.getPoint()); }
 
문제원인 : catch로 오류를 잡을 수는 있지만 포인트 변경이 되지 않는다. → 트랜잭션 문제
클래스에 @Transactional (readOnly = true) 가 걸려있었다.
pay(readonly = true) → createPayment 불러옴
pay 메서드가 readOnly = ture로 작동하여 createPayment 작동하지 않는 것이였음.
 
해결 방법 : transactionTemplate 과 Retry 사용
해결 코드
@Retryable(maxAttempts = 3, value = ObjectOptimisticLockingFailureException.class) public PaymentPost.Response pay( Long id, Long userId ) { try { return this.transactionTemplate.execute(status -> this.createPayment(id, userId)); } catch (ObjectOptimisticLockingFailureException e) { log.info("충돌 감지 재시도: {}", e.getMessage()); throw new ObjectOptimisticLockingFailureException("충돌", Throwable.class); } } @Transactional public Response createPayment( Long seriesId, Long userId ) throws ObjectOptimisticLockingFailureException { Series series = this.seriesProvider.getById(seriesId); List<ArticleUploadDate> uploadDateList = this.seriesProvider.getArticleUploadDate(seriesId); User user = this.userProvider.findById(userId); user.decreasePoint(series.getPrice()); this.paymentRepository .findByUserIdAndSeriesId(userId, seriesId) .map(pay -> {throw new PaymentDuplicated("이미 결제되었습니다.");}); this.paymentRepository.save(this.paymentConverter.toEntity(series, user)); return this.paymentConverter.toPaymentPost(series, uploadDateList, user.getPoint()); }
 
maxAttempts = 3 3번까지는 동시요청을 허용한다. 횟수는 정해주기 나름이므로 정책이다.
 
notion image
 
notion image
Spring 트랜잭션 처리
보통 스프링에서 트랜잭션 처리를 할 때 @Transactional 어노테이션을 이용하여 스프링에서 제공하는 선언적 트랜잭션을 이용했다. 그런데 메서드 단위가 아니라 메서드 내의 특정 구간만 트랜잭션 처리를 해야하는 경우 어떻게 할 수 있을까? 그리고 우리가 사용하던 @Transactional 어노테이션을 이용해서 스프링에서는 어떤식으로 트랜잭션 기능을 처리할 수 있었을까?
Spring 트랜잭션 처리
https://hirlawldo.tistory.com/124
Spring 트랜잭션 처리
How to retry JPA transactions after an OptimisticLockException - Vlad Mihalcea
Introduction This is the third part of the optimistic locking series, and I will discuss how we can implement the automatic retry mechanism when dealing with JPA repositories. You can find the introductory part here and the MongoDB implementation here.
How to retry JPA transactions after an OptimisticLockException - Vlad Mihalcea
https://vladmihalcea.com/optimistic-locking-retry-with-jpa/
How to retry JPA transactions after an OptimisticLockException - Vlad Mihalcea
www.baeldung.com
https://www.baeldung.com/spring-retry
 
스크립트 참고 파일
curl.sh
0.2KB