ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도움돼요 토글, 비관적 락 대신 DB 원자적 UPDATE를 선택한 이유
    프로젝트, 트러블슈팅 2026. 3. 4. 15:42

    시작하면서

    리뷰에 "도움돼요" 버튼을 구현하면서 동시성 문제를 처음 마주쳤다. 여러 사람이 동시에 같은 리뷰에 도움돼요를 누르면 DataIntegrityViolationException이 발생하거나 helpfulCount가 실제 레코드 수와 달라지는 문제였다. 처음엔 교과서적인 해결책인 비관적 락을 적용했지만, 막상 써보니 도움돼요 같은 기능에는 과하다는 생각이 들었다. 이 글은 왜 비관적 락을 제거하고 DB 원자적 UPDATE로 바꿨는지, 그 과정을 정리한 것이다.


    동시성 문제란?

    처음 구현한 코드는 이렇게 생겼다.

    @Transactional
    public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) {
        Review review = reviewRepository.findById(reviewId)
            .orElseThrow(() -> new EntityNotFoundException(REVIEW_NOT_FOUND));
    
        boolean exists = reviewHelpfulRepository
            .existsByReviewIdAndUserId(reviewId, userId);
    
        if (exists) {
            reviewHelpfulRepository.deleteByReviewIdAndUserId(reviewId, userId);
            review.decrementHelpfulCount();
        } else {
            ReviewHelpful helpful = ReviewHelpful.builder()
                .review(review)
                .user(user)
                .build();
            reviewHelpfulRepository.save(helpful);
            review.incrementHelpfulCount();
        }
    
        return ReviewHelpfulResponse.builder()
            .helpfulCount(review.getHelpfulCount())
            .build();
    }
    

    얼핏 보면 문제없어 보이지만, 두 사람이 거의 동시에 같은 리뷰에 도움돼요를 누르면 이런 일이 생긴다.

    시간 →    사용자 A (Thread 1)              사용자 B (Thread 2)
    ────────────────────────────────────────────────────────────────
    T1      │ findById → helpfulCount = 10    │
            │                                 │
    T2      │ exists? → false                 │ findById → helpfulCount = 10
            │                                 │
    T3      │ save() → INSERT 성공 ✓         │ exists? → false
            │                                 │
    T4      │ incrementHelpfulCount() → 11    │ save() → UNIQUE 제약 위반! ❌
            │                                 │
    T5      │ COMMIT ✓                        │ ROLLBACK
    ────────────────────────────────────────────────────────────────
    

    A가 이미 INSERT 했는데 B도 같은 (review_id, user_id) 조합으로 INSERT를 시도해서 UNIQUE 제약 위반이 발생한다. B 입장에서는 버튼을 눌렀는데 아무 반응이 없는 것처럼 보이는 셈이다.

    이게 바로 Race Condition, 경쟁 조건이다. "여러 스레드가 동시에 같은 데이터에 접근해서 수정할 때 실행 순서에 따라 결과가 달라지는 상황"이다.


    1차 해결: 비관적 락 적용

    비관적 락이란?

    비관적 락은 "어차피 충돌이 발생할 것을 가정하고, 데이터를 읽는 시점에 미리 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식"이다. DB의 SELECT ... FOR UPDATE 구문을 활용한다.

    -- 이 쿼리를 실행하는 순간 해당 행에 배타적 잠금(Exclusive Lock) 획득
    SELECT * FROM reviews WHERE id = 123 FOR UPDATE;
    
    -- 다른 트랜잭션은 이 행에 접근하지 못하고 대기
    -- 첫 번째 트랜잭션이 COMMIT 또는 ROLLBACK하면 락 해제
    

    Spring Data JPA에서는 @Lock 어노테이션으로 간단하게 적용할 수 있다.

    // ReviewRepository.java
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT r FROM Review r WHERE r.id = :reviewId")
    Optional<Review> findByIdWithLock(@Param("reviewId") Long reviewId);
    

    락 타입은 세 가지가 있는데, 도움돼요 토글처럼 읽고 쓰는 작업에는 PESSIMISTIC_WRITE를 쓴다.

    타입 설명

    PESSIMISTIC_WRITE 배타적 쓰기 락. 다른 트랜잭션의 읽기/쓰기 모두 차단
    PESSIMISTIC_READ 공유 읽기 락. 다른 트랜잭션의 읽기는 허용, 쓰기만 차단
    OPTIMISTIC 낙관적 락. version 필드로 충돌 감지

    락 적용 후 동작

    @Transactional
    public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) {
        // 🔒 FOR UPDATE로 락 획득
        Review review = reviewRepository.findByIdWithLock(reviewId)
            .orElseThrow(() -> new EntityNotFoundException(REVIEW_NOT_FOUND));
    
        boolean exists = reviewHelpfulRepository
            .existsByReviewIdAndUserId(reviewId, userId);
    
        if (exists) {
            reviewHelpfulRepository.deleteByReviewIdAndUserId(reviewId, userId);
            review.decrementHelpfulCount();
        } else {
            reviewHelpfulRepository.save(helpful);
            review.incrementHelpfulCount();
        }
        // COMMIT → 🔓 락 해제
    }
    
    시간 →    사용자 A (Thread 1)                      사용자 B (Thread 2)
    ──────────────────────────────────────────────────────────────────────
    T1      │ findByIdWithLock(123)                  │
            │ → SELECT ... FOR UPDATE                │
            │ → 🔒 락 획득!                          │
            │                                        │
    T2      │ exists? → false                        │ findByIdWithLock(123)
            │                                        │ → ⏳ 대기... (락이 걸려있음)
            │                                        │
    T3      │ save() → INSERT 성공                   │ ⏳ 대기...
            │                                        │
    T4      │ COMMIT → 🔓 락 해제                    │
            │                                        │ → 🔒 락 획득!
            │                                        │ exists? → true (A가 저장함)
            │                                        │ delete() → 취소 처리
            │                                        │ COMMIT → 🔓 락 해제
    ──────────────────────────────────────────────────────────────────────
    

    동시성 문제는 해결됐다. 그런데 쓰다 보니 이상한 점이 있었다.


    비관적 락의 문제: 도움돼요에는 너무 과하다

    비관적 락은 "한 번에 한 트랜잭션만" 처리한다는 구조 자체가 트래픽이 몰릴 때 병목이 된다.

    동시 요청이 많아지면?

    사용자 100명이 동시에 같은 리뷰에 도움돼요 클릭
    
    A → 락 획득 (처리 50ms)
    B → 대기 50ms + 처리 50ms = 100ms
    C → 대기 100ms + 처리 50ms = 150ms
    ...
    100번째 → 대기 4950ms + 처리 50ms = 5초!
    

    뒤에 있는 사람일수록 응답 시간이 쌓인다. 인기 리뷰일수록 더 심해진다.

    더 심각한 건 DB 커넥션 풀이다. 커넥션 풀이 10개라면, 락 대기 중인 요청들이 커넥션을 잡고 있어서 11번째 요청부터는 다른 API도 응답이 안 된다. 도움돼요 하나 때문에 서비스 전체가 멈출 수 있다.

    비관적 락이 진짜 필요한 상황은 따로 있다

    비관적 락은 "읽은 값으로 복잡한 조건을 계산한 뒤 저장"해야 하는 경우에 필요하다.

    // 재고 차감 예시
    int stock = product.getStock();  // 10
    
    if (stock >= orderQuantity) {  // 조건 체크
        product.decreaseStock(orderQuantity);  // 차감
    }
    // 이 패턴은 동시에 실행되면 재고가 마이너스 될 수 있음
    // 비관적 락으로 읽는 시점에 잠가야 안전
    

     

      비관적 락 원자적 DB UPDATE
    단순 카운터 증감 (좋아요, 도움돼요) 과함 적합
    조건 체크 후 차감 (재고, 포인트) 적합 어려움
    선착순 발급 (쿠폰, 티켓) 적합 어려움
    복잡한 계산 후 저장 적합 어려움

    도움돼요 카운터 증감은 조건 체크가 필요 없는 단순 증감이다. 비관적 락보다 더 나은 방법이 있다.


    2차 해결: DB 원자적 UPDATE로 교체

    DB의 UPDATE 쿼리는 그 자체로 원자적이다. 읽고 → 계산하고 → 저장하는 세 단계를 DB가 한 번에 처리한다.

    -- 이 쿼리 하나가 원자적으로 실행됨
    -- 동시에 100명이 실행해도 각자 정확하게 +1됨
    UPDATE reviews SET helpful_count = helpful_count + 1 WHERE id = 123;
    

    Spring Data JPA에서는 @Modifying 어노테이션으로 구현한다.

    // ReviewRepository.java
    
    @Modifying(clearAutomatically = true)
    @Query("UPDATE Review r SET r.helpfulCount = r.helpfulCount + 1 WHERE r.id = :reviewId")
    int incrementHelpfulCount(@Param("reviewId") Long reviewId);
    
    @Modifying(clearAutomatically = true)
    @Query("UPDATE Review r SET r.helpfulCount = r.helpfulCount - 1 WHERE r.id = :reviewId AND r.helpfulCount > 0")
    int decrementHelpfulCount(@Param("reviewId") Long reviewId);
    
    @Query("SELECT r.helpfulCount FROM Review r WHERE r.id = :reviewId")
    Optional<Integer> findHelpfulCountById(@Param("reviewId") Long reviewId);
    

    clearAutomatically = true는 UPDATE 후 JPA 1차 캐시를 자동으로 비워줘서, 이후 조회할 때 DB의 최신값을 가져올 수 있게 한다. 반환값을 int로 한 건 실제로 몇 행이 변경됐는지 확인하기 위해서다.

    삭제 쪽도 같은 방식으로 변경한다.

    // ReviewHelpfulRepository.java
    
    // Before: void → 삭제 성공 여부 확인 불가
    void deleteByReviewIdAndUserId(Long reviewId, Long userId);
    
    // After: int → 실제 삭제된 row 수 반환
    @Modifying
    @Query("DELETE FROM ReviewHelpful rh WHERE rh.review.id = :reviewId AND rh.user.id = :userId")
    int deleteByReviewIdAndUserId(@Param("reviewId") Long reviewId, @Param("userId") Long userId);
    

    TOCTOU 문제 해결

    TOCTOU(Time Of Check To Time Of Use)란 "확인한 시점"과 "사용한 시점" 사이에 틈이 생겨 다른 스레드가 끼어드는 문제다. 원자적 UPDATE로 바꾸면서 이 문제도 함께 해결했다. 기존에는 exists 체크와 delete가 별개 쿼리라 사이에 다른 스레드가 끼어들 수 있는 틈이 존재했다.

    // Before: check → act 사이에 틈 존재 (TOCTOU)
    boolean exists = ...existsByReviewIdAndUserId(...);  // 1번 쿼리
    if (exists) {
        ...deleteByReviewIdAndUserId(...);  // 2번 쿼리 → 사이에 틈!
    }
    
    // After: delete 결과로 이전 상태를 판단 (쿼리 1번으로 통합)
    int deleted = ...deleteByReviewIdAndUserId(...);  // 쿼리 1번
    if (deleted > 0) {
        // 실제로 삭제됨 → 원래 눌려있던 상태 → 카운트 감소
    } else {
        // 삭제된 게 없음 → 원래 안 눌려있던 상태 → 추가 후 카운트 증가
    }
    

    삭제 결과로 이전 상태를 판단하면 check → act 사이의 틈이 사라진다.

    최종 코드

    // ReviewCommandService.java
    
    @Transactional
    public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) {
        if (!reviewRepository.existsById(reviewId)) {
            throw new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND);
        }
    
        // delete 결과로 이전 상태 판단 (TOCTOU 제거)
        int deleted = reviewHelpfulRepository.deleteByReviewIdAndUserId(reviewId, userId);
        boolean isHelpful;
    
        if (deleted > 0) {
            // 도움돼요 취소: 실제로 삭제됐으면 카운트 감소
            reviewRepository.decrementHelpfulCount(reviewId);
            isHelpful = false;
        } else {
            // 도움돼요 추가
            User user = getUserOrThrow(userId);
            Review reviewRef = reviewRepository.getReferenceById(reviewId);
            ReviewHelpful helpful = ReviewHelpful.builder()
                .review(reviewRef)
                .user(user)
                .build();
            reviewHelpfulRepository.save(helpful);
            reviewRepository.incrementHelpfulCount(reviewId);
            isHelpful = true;
        }
    
        // DB에서 최신 count 조회 (메모리 계산 x)
        int updatedCount = reviewRepository.findHelpfulCountById(reviewId).orElse(0);
    
        return ReviewHelpfulResponse.builder()
            .reviewId(reviewId)
            .isHelpful(isHelpful)
            .helpfulCount(updatedCount)
            .build();
    }
    

    응답 helpfulCount도 메모리에 있는 오래된 값으로 계산하지 않고 DB를 재조회해서 반환한다. 동시 요청이 있을 때 메모리 값은 이미 오래된 스냅샷이라 오차가 날 수 있기 때문이다.


    비교

      비관적 락 원자적 DB UPDATE
    동시 100명 요청 한 명씩 순서대로 대기 전부 동시에 처리
    응답 속도 뒤로 갈수록 느려짐 항상 일정
    커넥션 점유 락 잡는 동안 계속 점유 UPDATE 순간만 점유
    코드 복잡도 락 관리 필요 단순
    적합한 상황 조건 체크 후 차감 단순 카운터 증감

    마무리

    비관적 락은 동시성 문제를 해결하는 강력한 도구지만, 모든 상황에 맞는 건 아니다. 도움돼요처럼 단순히 카운터를 올리거나 내리는 기능에 비관적 락을 쓰면 동시 요청이 많아질수록 오히려 성능 문제가 생긴다.

    핵심은 "읽은 값으로 조건을 따져야 하는가"다. 재고 차감이나 포인트 결제처럼 잔액을 읽고 조건을 계산해서 차감해야 하면 비관적 락이 필요하다. 반면 도움돼요처럼 그냥 더하거나 빼는 경우라면 DB 원자적 UPDATE가 더 적합하다.

Designed by Tistory.