- saveOrUpdate 구현 시 동시성 이슈2025년 06월 02일 01시 12분 19초에 업로드 된 글입니다.작성자: do_hyuk728x90반응형
기존 코드
@Tarnsactional public Long saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { Member member = memberCommand.findByEmail(email); Optional<CodePostBookmark> existingBookmark = codePostBookmarkRepository.findById(requestDto.codePostId(), member.getId()); return existingBookmark .map(CodePostBookmark::update) .orElseGet(() -> codePostBookmarkRepository.save(requestDto.toEntity(member)).getId()); }
배경
- 북마크 조회 했을 때 없으면 저장하고, 있으면 상태를 업데이트 함
문제
- api를 빠른 속도로 여러번 호출할 경우 같은 컬럼이 여러번 저장되는 문제 발생(동시성 문제)
❓ 1차 해결방법
- code_post_bookmark 테이블에 (code_post_id, member_id) 복합 UNIQUE 제약 조건 생성
- 이미 저장된 컬럼이 저장될 때 UNIQUE 제약 조건에 반하여 에러 호출
- 호출된 에러를 catch 후 북마크 조회 후 상태 업데이트 진행
@Transactional public Long saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { Member member = memberCommand.findByEmail(email); try { return codePostBookmarkRepository.save(requestDto.toEntity(member)).getId(); } catch (DataIntegrityViolationException e) { return codePostBookmarkRepository.findById(requestDto.codePostId(), member.getId()) .get().update(); } }
❗ 발생한 문제
- 동시 요청에서 save 시 UNIQUE 제약 조건 위반 발생
- 이를 catch로 잡고 update로 우회했으나
- 다음과 같은 에러가 터짐:
org.hibernate.AssertionFailure: null id in entity org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
🔍 원인 분석
🚨 @Transactional 트랜잭션 내부에서 예외 발생 시
- 트랜잭션은 rollback-only 상태가 됨
- 이후 예외를 catch로 처리하고 정상 리턴해도 commit 불가능
- 결국 UnexpectedRollbackException 발생
✅ 2차 해결 방법
방법 특징 DB 제약 + 예외 처리 + 분리 트랜잭션 !가장 안정적, 지금 사용할 방식! Optimistic Lock 업데이트 중심 로직에 적합 FOR UPDATE Locking 직관적이지만 확장성 낮음 Upsert (INSERT IGNORE) SQL 기반, 초고성능 1. @Transactional(noRollbackFor = ...) (비추천)
@Transactional(noRollbackFor = DataIntegrityViolationException.class)
- 예외 발생해도 rollback 되지 않도록 설정
- 하지만 의도하지 않은 예외까지 롤백되지 않을 수 있어 권장되지 않음
2. 트랜잭션 분리 (실무 추천 방식)
예외가 발생할 가능성이 있는 save() 로직과 update() 로직을 별도 트랜잭션으로 분리:
public Long saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { Member member = memberCommand.findByEmail(email); CodePostBookmark entity = requestDto.toEntity(member); try { return bookmarkHelper.trySave(entity); } catch (DataIntegrityViolationException e) { return bookmarkHelper.fallbackToUpdate(requestDto.codePostId(), member.getId()); } }
@RequiredArgsConstructor @Service public class BookmarkHelper { private final CodePostBookmarkRepository codePostBookmarkRepository; @Transactional public Long trySave(CodePostBookmarkSaveRequestDto requestDto, Member member) { return codePostBookmarkRepository.save(requestDto.toEntity(member)).getId(); } @Transactional public Long fallbackToUpdate(Long codePostId, Long memberId) { return codePostBookmarkRepository.findById(codePostId, memberId) .map(CodePostBookmark::update) .orElseThrow(() -> new IllegalStateException("Bookmark should exist but was not found")); } }
🏁 결론
- JPA에서 saveOrUpdate 같은 작업은 동시성 제약을 고려하지 않으면 UNIQUE 에러 + rollback-only 트랜잭션 문제로 이어질 수 있음
- 이를 해결하기 위해선 예외가 발생 가능한 구간을 별도 트랜잭션으로 분리하는 것이 가장 깔끔하고 실무에서도 자주 사용된다고 한다.
추가 변경 사항 (2025.06.15)
Exception이 발생할 경우 JVM은 예외 스택을 생성하고 트레이싱 하기 때문에 동시성 문제에 사용하기에는 성능 이슈가 크다고 판단되었다. 따라서 현재 사용하고 있는 DB가 MySQL이기 때문에 지원되는 기능 중 Upsert 기능을 사용하기로 하였다.
Upsert 기능은 INSERT와 UPDATE를 하나의 쿼리로 해결하는 기능으로 중복 키 에러가 발생할 경우 UPDATE 쿼리를 실행한다.
대신 Upsert 같은 경우 반환값이 void 또는 int/Integer 이고 여기서 int/Integer 반환값은 수정된 컬럼 개수이기 때문에 Entity Id를 반환하기 위해서는 조회 쿼리가 필요하다.
Upsert 적용
public Long saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { return codePostBookmarkCommand.saveOrUpdate(requestDto, email).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_BOOKMARK) ).getId(); }
@Transactional public Optional<CodePostBookmark> saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { codePostBookmarkRepository.upsert(email, requestDto.codePostId()); return codePostBookmarkRepository.findById(email, requestDto.codePostId()); }
@Modifying @Query( value = " INSERT INTO code_post_bookmark (email, code_post_id, is_deleted, create_date, update_date) " + " VALUES (:email, :codePostId, false, NOW(), NOW()) " + " ON DUPLICATE KEY UPDATE is_deleted = NOT is_deleted, update_date = NOW() ", nativeQuery = true ) void upsert(@Param("email") String email, @Param("codePostId") Long codePostId);
728x90반응형'백엔드' 카테고리의 다른 글
Filter와 Interceptor의 차이점을 말해주세요. (1) 2025.06.12 Spring MVC의 실행 흐름에 대해 설명해주세요. (0) 2025.06.04 초당 수천 건의 결제를 처리하는 API 만들기 (0) 2025.05.28 단순 CRUD를 넘어 진짜 백엔드로 가는 길 (0) 2025.05.27 어떤 이유로 코루틴을 사용한 작업 처리가 기존 스레드 방식보다 가벼운지 설명해주세요. (0) 2025.05.26 댓글