티스토리 뷰
☄️ RefreshToken 수정 시 동시성 예외 (ObjectOptimisticLockingFailureException) 발생 해결하기
리미32 2025. 6. 19. 17:00
예전부터 주구장창 이 에러가 discord back-monitoring 채널에 올라왔다. gamegoo 백앤드 서버는 back-git-pr, back-deploy, back-monitoring을 통해 Github, CICD, AWS를 연동해서 서버 상태를 확인한다. 에러가 발생할 경우 back-monitoring에 다음과 같이 에러 메세지가 올라온다.
예외 메시지를 보자마자 @Version 안 썼는데 왜... 동시성 오류가 났을까라는 생각이 들었다.
문제 원인 파악
문제의 중심은 로그인 시 발급되는 RefreshToken 저장 로직이었다.
/**
* 리프레시 토큰 생성
*
* @param member 로그인한 회원
* @param refreshToken 리프레시토큰 정보
*/
@Transactional
public void addRefreshToken(Member member, String refreshToken) {
// 이전에 있던 refreshToken 전부 지우기
refreshTokenRepository.findByMember(member).ifPresent(refreshTokenRepository::delete);
// refresh token DB에 저장하기
refreshTokenRepository.save(RefreshToken.create(refreshToken, member));
}
매우 단순한 로직이다.
- 이전 토큰이 있으면 삭제하고,
- 새 토큰을 저장한다.
그런데 동시성 예외가 터졌다.
게다가 내 RefreshToken 엔티티에는 @Version 필드가 아예 없다.
발생 이유
Hibernate는 delete 후 영향받은 row 수가 0이면 예외를 발생시킨다
즉, 기존 토큰이 있으면 삭제 후 새로 저장하는 방식이었다. 이 구조는 다음과 같은 경쟁 조건(Race Condition) 이 발생할 수 있다:
- 트랜잭션 A와 B가 거의 동시에 같은 회원에 대한 addRefreshToken()을 호출
- 둘 다 findByMember()로 동일한 토큰을 조회
- A가 먼저 삭제 → B가 삭제 시도 시 이미 DB에서 제거된 상태
- Hibernate는 삭제할 row가 없어 동시성 예외를 발생시킴
즉, 낙관적 락 없이도 동시성 오류가 발생할 수 있는 구조였던 것이다.
refresh 토큰이 있을 경우에만 삭제하도록 deleteRefreshToken() 메서드는 따로 만들어뒀지만 실제 호출은 하지 않고, addRefreshToken() 안에서 직접 삭제하고 다시 추가하는 로직이 들어가 있었다.
이거 내가 작성한 코드 맞아..? 네..
ifPresent는 그 순간만 확인하고, JPA의 delete()는 즉시 DB에 반영되지 않기 때문에, delete와 save를 하나의 트랜잭션에서 처리하더라도, 동시에 여러 요청이 들어오면 각 트랜잭션이 같은 엔티티를 삭제하려고 하면서 충돌이 날 수 있다.
코드
해결 전
@Transactional
public void addRefreshToken(Member member, String refreshToken) {
// 이전에 있던 refreshToken 전부 지우기
refreshTokenRepository.findByMember(member).ifPresent(refreshTokenRepository::delete);
// refresh token DB에 저장하기
refreshTokenRepository.save(RefreshToken.create(refreshToken, member));
}
해결 후
@Transactional
public void updateRefreshToken(Member member, String refreshToken) {
refreshTokenRepository.findByMember(member)
.ifPresentOrElse(
existingToken -> {
// 기존 토큰 업데이트
existingToken.updateToken(refreshToken);
refreshTokenRepository.save(existingToken); // 변경 감지를 명시적으로 반영
},
() -> {
// 새 토큰 저장
RefreshToken newToken = RefreshToken.create(refreshToken, member);
refreshTokenRepository.save(newToken);
}
);
}
'개발 프로젝트 정리 > Gamegoo 롤 매칭 서비스' 카테고리의 다른 글
☄️socket 서버가 계속 죽는 이유?! (1) | 2025.09.08 |
---|---|
☄️ matching-found-sender 누락 원인 분석과 UUID 검증 로직 추가 (1) | 2025.06.18 |
☄️ GameStyle이 null로 들어올 때 발생한 매칭 오류 해결기 (2) | 2025.06.14 |
- Total
- Today
- Yesterday
- googleapis
- Linux
- 자바스크립트
- 백준
- 교환학생
- 개발
- 깃 예제
- Process
- 혼공
- 혼공학습단
- 프로젝트
- JS
- 혼공단
- 스페인
- 스페인 교환학생
- 공룡책
- AWS
- 혼공단 9기
- C++
- 운영체제
- SQL
- 프로그래머스
- 개발일지
- Signal
- JavaScript
- 리눅스
- nodejs
- 혼공단 SQL
- 해커톤
- MySQL
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |