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"

최초 한번만 실행되고 두번째 요청에 대해서는 에러가 발생한다.
ObjectOptimisticLockingFailureException
이 예외를 잡아서 재실행을 시켜야 한다. 처음 방법 1) try catch로 잡기
문제원인 : 에러가 잡히지 않음

문제 해결 : 트랜잭션이 끝난 후 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번까지는 동시요청을 허용한다. 횟수는 정해주기 나름이므로 정책이다.

스크립트 참고 파일