Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요.
https://hail-buttercup-c86.notion.site/269a011d2d8380108873fb5bc477ec7f
분산 락 이후의 포인트 누적 동시성 문제 | Notion
들어가기 앞서..
hail-buttercup-c86.notion.site
들어가기 앞서..
📄 동시성 성능 개선 과정에서 발생한 트러블 슈팅입니다.
분명 lock을 통해서 동시성을 보장해주었다고 생각했는데, 이벤트 누적 금액이 정상적으로 반영되지 않는 결과가 있었다. 왜 동시성이 제대로 보장되지 않았을까? 오류를 해결해보자.
문제 상황
필자의 서비스는 이벤트에 응모할 시, 사용자의 포인트를 차감하여 해당 포인트만큼 이벤트에 누적하게 된다.
하지만 10000개의 요청을 동시에 보냈을 때, 사용자의 포인트 1만큼 이벤트에 만 번이 누적되니 10000원이 누적되어야하는데 아래와 같이 이벤트 누적금액이 8,397원만 누적된 것을 확인할 수 있었다.
분명 동시성을 보장했다고 생각했는데 왜 제대로 값이 누적되지 않았을까?
문제를 파악하기 위해 각 스레드가 이벤트에 금액을 누적한 뒤의 누적 금액이 얼마인지 확인해보았다.
public void accumulate(Short perPrice) {
if (!this.status.getId().equals(StatusIds.Event.IN_PROGRESS)) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
this.accrued += perPrice;
System.out.println("스레드 : " + Thread.currentThread().getName() + "accrued = " + accrued);
}
분명 톰캣이 사용하는 스레드가 다른데 accrued는 둘다 11945원으로, 하나의 스레드가 값을 누적하지 못한 것으로 보인다.
해당 문제를 해결해보자.
해결 과정
해결과정1: 누적 로직 개선
엔티티 메서드에서 JPQL 업데이트로 전환
도저히 의문점이 풀리지 않아 강사님께 여쭤봤다. 강사님께서는 바로 답을 주지않으시고, 커밋 시점에 대해서 한 번 생각해보시면 좋을 것 같다고 말씀을 해주셨다.
강사님께 힌트를 얻어 문제를 해결할 수 있었다.
현재 로직은 이벤트 누적을 Event 엔티티의 accumulate(Short perPrice) 메서드로 처리했다. 그러나 엔티티의 필드 변경은 즉시 DB에 반영되지 않고, 트랜잭션 커밋 시점에 flush 되었다.
그래서 여러 스레드가 동시에 accumulate를 호출했을 때, 커밋 전까지 변경된 값이 공유되지 않아 누적값 불일치가 발생하는 것 이였다. 반면, JPQL update 쿼리를 통한 누적은 실행 시점에 DB에 바로 반영되었기 때문에 동시성 충돌에도 정확한 값이 보장되었다.
기존 방식
기존 방식은 Event에서 엔티티메서드로 accumulate를 통해 누적 금액을 올려주었다.
public class Event{
public void accumulate(Short perPrice) {
if (!this.status.getId().equals(StatusIds.Event.IN_PROGRESS)) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
this.accrued += perPrice;
System.out.println("스레드 : " + Thread.currentThread().getName() + "accrued = " + accrued);
}
}
하지만 accumlate는 ‘트랜잭션이 커밋 될 때’ 값이 DB에 반영되기때문에, 메서드 실행 시점에는 값이 즉시 DB에 반영되지 않았다.
개선 방식
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Event e
set e.accrued = e.accrued + :delta
where e.id = :eventId and e.status.id = :inProgress
""")
int incrementAccrued(@Param("eventId") Long eventId,
@Param("delta") int delta,
@Param("inProgress") Long inProgress);
JPQL update 문을 사용하여 실행 시점에 바로 누적을 반영했다.
clearAutomatically, flushAutomatically 옵션으로 영속성 컨텍스트 동기화 문제도 방지했다.
결과
성공적으로 포인트 객체와 누적 값이 반영되었다.
당첨 후, 이벤트 상태가 종료로 잘 변경되고, 이후에 들어오는 이벤트는 모두 잘 막아주는 것을 확인할 수 있었다.
마무리하며…
이번 트러블슈팅을 통해 엔티티 필드 변경은 즉시 DB에 반영되지 않고 트랜잭션 커밋 시점에 flush 된다는 사실을 명확히 이해할 수 있었다. 그동안 단순히 Lock만 적용하면 동시성이 보장된다고 생각했지만, 실제로는 영속성 컨텍스트와 flush 타이밍을 고려하지 않으면 데이터 정합성이 깨질 수 있음을 경험했다.
따라서 동시성 문제를 해결하기 위해서는 락 같은 제어 장치뿐 아니라, DB 반영 시점·쿼리 방식·영속성 컨텍스트 동기화 전략까지 함께 고려해야 한다는 교훈을 얻었다.
이번 경험은 앞으로 동시성 이슈를 다룰 때 더 깊이 있는 시각을 갖게 해주었다.
'Spring > Error' 카테고리의 다른 글
[ 트러블슈팅 ] HikariPool-1 - Connection is not available, request timed out after... (0) | 2025.09.30 |
---|---|
[ 트러블슈팅 ] 낙관적 락 동시성 처리 에러 (0) | 2025.09.19 |
[트러블슈팅]io.jsonwebtoken.ExpiredJwtException: JWT expired 564427 (0) | 2025.03.24 |
[트러블 슈팅] Authentication Principal is not of type CustomUserDetails. Actual type: java.long.String (0) | 2025.03.01 |
[트러블슈팅] application.yml에서 oauth2 설정이 적용되지 않는 이슈 (0) | 2025.02.12 |