1836 단어
9 분
비관적 락으로 좋아요 동시성 문제 해결하기
2025-08-05
태그 없음

1. 도입 배경#

버디야의 “좋아요”. 특정 피드에 동시에 좋아요를 누르면 좋아요가 하나만 올라가는, 즉 갱신 손실되는 문제가 발생하였다. 이 문제를 분석하고 왜 그런 결과가 나왔는지 알아보려고 한다.

2. 분석 및 해결 방법#

2-1. 동시 상황을 고려하지 않은 기존 로직#

    public LikeResponse toggleLike(StudentInfo studentInfo, Long feedId) {
    
        FeedLike feedLike = FeedLike.builder()
                .feed(feed)
                .student(student)
                .build();
        feed.increaseLikeCount();
        feedLikeRepository.save(feedLike);
        return LikeResponse.from(true, feed.getLikeCount());
    }

    public void increaseLikeCount() {
        this.likeCount++;
    }

좋아요 코드의 일부분을 보면 사용자가 좋아요를 누르면 FeedLike이 생성되고 Feed의 likeCount를 증가시키는 로직이다. 실제로 update시 UPDATE Feed f SET f.likeCount = f.likeCount + 1 WHERE f.id = :feedId 업데이트 쿼리가 발생할 것이다.

테스트 코드로 확인해보자

@Test
    void 동시에_좋아요를_누르면_갱신손실이_발생한다() throws InterruptedException {
        // given
        int userCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        CountDownLatch latch = new CountDownLatch(userCount);

        // when
        for (Student liker : likerStudents) {
            executorService.submit(() -> {
                try {
                    StudentInfo studentInfo = new StudentInfo(liker.getId(), liker.getRole(), true);
                    feedLikeService.toggleLike(studentInfo, savedFeed.getId());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        long actualLikeRows = feedLikeRepository.countByFeed(savedFeed);
        Feed finalFeed = feedRepository.findById(savedFeed.getId()).orElseThrow();
        int finalLikeCount = finalFeed.getLikeCount();

        System.out.println("==============================================");
        System.out.println("실제 생성된 FeedLike 레코드 수: " + actualLikeRows);
        System.out.println("Feed 엔티티에 기록된 최종 likeCount: " + finalLikeCount);
        System.out.println("==============================================");

        assertThat(finalLikeCount).isLessThan(userCount);
    }

실행 조건

  • newFixedThreadPool(20) → 동시에 최대 20개 요청 실행.
    • 총 100명 제출 → 20명 × 5라운드로 처리(앞 20개 실행, 끝나면 다음 20개…)
  • 실서비스는 스레드풀 한도로 “요청 100건 = 동시 100건”이 아니고 웨이브(일정 동시성 + 대기)로 흘러가므로, 나머지는 풀 내부 큐 대기 후 순차 실행하도록 설정

실행 순서

  1. 모든 스레드가 동일 Feed 행을 조회 → 같은 likeCount 스냅샷을 들고 있음
  2. 각 스레드가 메모리에서 +1 수행
  3. 커밋 타이밍이 겹치며 마지막 커밋이 승자가 되어 앞선 갱신을 덮어씀(갱신 손실)
100개의 요청이 15개밖에 저장되지 않는다..

갱신 손실 발생 이유?

  • 20개 쓰레드가 같은 시점을 시준으로 likeCount + 1을 하고 커밋 타이밍만 다르게 DB에 업데이트 날림
  • 결국 마지막에 커밋된 값이 앞선 값들을 덮어씀
  • 라운드마다 이런 덮어쓰기 경쟁이 반복되면서 최종 likeCount가 userCount(100)에 훨씬 못 미치게 된다

2-2. 낙관적 락: @Version + 재시도(백오프)#

@Entity
@Table(name = "feed")
public class Feed {
    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;

    @Column(name = "like_count", nullable = false)
    private int likeCount;
    ...
}
public LikeResponse toggleLikeOptimistic(StudentInfo studentInfo, Long feedId) {
    final int maxRetries = 3;

    for (int attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            return txTemplate.execute(status -> {
                Feed feed = feedRepository.findById(feedId)
                    .orElseThrow(...);
                Student student = findStudentService.findByStudentId(studentInfo.id());
      
                //... 생략
      
                FeedLike like = FeedLike.builder().feed(feed).student(student).build();
                feed.increaseLikeCount();         // @Version 갱신 트리거
                feedLikeRepository.save(like);
                return LikeResponse.from(true, feed.getLikeCount());
            });
        } catch (OptimisticLockingFailureException e) {
            if (attempt == maxRetries) throw new FeedException(FeedExceptionType.OPTIMISTIC_LOCK_FAILED);
            long backoff = 50L * (long) Math.pow(2, attempt - 1); // 50,100,200ms
            try { Thread.sleep(backoff); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
        }
    }
    throw new FeedException(FeedExceptionType.OPTIMISTIC_LOCK_FAILED);
}
  • @Version은 flush/commit 시점에 WHERE id=? AND version=? 조건으로 충돌을 감지한다.
  • 충돌 시 OptimisticLockingFailureException 발생 → 새 트랜잭션으로 재시도해야 한다.
  • 그래서 메서드 전체에 @Transactional을 거는 대신, TransactionTemplate로 “시도=트랜잭션 1개” 가 되도록 구성했다.
  • 재시도는 지수 백오프 방식을 사용하여 재충돌 확률을 낮추도록 하였다.

그래도 같은 행에 많은 동시 요청 시 충돌이 연속으로 발생해 정합성 100% 보장이 어렵다는 한계가 있다..

같은 실행 조건과 실행 순서로 테스트 했을 때 100건

100개의 동시 요청중 이전 동시 상황을 고려할 때보다는 많은 요청을 해결했지만 정합성을 100% 보장해주지는 못한다. 2번 재시도를 해도 안되는 경우가 있기 때문이다.

장점

  • 락을 걸지 않으므로 일렬로 줄세워서 락을 획득하는 비관적 락보다는 빠르다

단점

  • 충돌이 많지 않은 상황을 가정한 것이라 많은 충돌이 발생했을 때는 오히려 재시도가 많이 발생하기 때문에 정합성이 깨지고 시간이 많이 걸린다

2-3. 비관적 락: SELECT … FOR UPDATE (행 잠금)#

  • 같은 Feed를 건드리는 트랜잭션을 순차적으로 진행한다.
  • @Lock(PESSIMISTIC_WRITE)을 활용하여 FOR UPDATE 동작과 같도록 구현
public interface FeedRepository extends JpaRepository<Feed, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select f from Feed f where f.id = :id")
    Optional<Feed> findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public LikeResponse toggleLikePessimistic(StudentInfo info, Long feedId) {
    Feed feed = feedRepository.findByIdForUpdate(feedId).orElseThrow(...);
    Student student = findStudentService.findByStudentId(info.id());

    // ...생략

    FeedLike like = FeedLike.builder().feed(feed).student(student).build();
    feed.increaseLikeCount();
    feedLikeRepository.save(like);
    return LikeResponse.from(true, feed.getLikeCount());
}
  • @Lock(PESSIMISTIC_WRITE) → 내부적으로 SELECT … FOR UPDATE. 동일 행을 수정하려는 다른 트랜잭션은 락 대기한다.

  • 읽기(일반 SELECT) 는 대부분 허용되지만, 보이는 값은 격리 수준에 따라 다를 수 있다. (MySQL InnoDB REPEATABLE READ → 같은 트랜잭션 내에서는 최초 스냅샷 유지)

100건의 동시 요청을 모두 통과한다.

장점

  • 정합성 100%(측정치 100 / 100)

단점

  • 직렬화 비용으로 지연 증가(335ms), 경합 심하면 타임아웃 / 데드락 발생 위험

3. 어떤 방법을 선택해야할까?#

세 방식 트레이드 오프 정리#

방식정합성평균 지연(측정)병목/운영 난이도권장 상황
락 없음❌ (갱신 손실)42ms낮음
낙관적 락△ (일부 누락)218ms중간(재시도·백오프 튜닝)충돌이 많이 없는 경우
비관적 락✅ (완전)335ms중간(락 대기·타임아웃)충돌 많은 경우

좋아요처럼 같은 행에 동시 쓰기가 몰리는 도메인엔 비관적 락이 구조적으로 유리하다. 낙관적 락은 같은 행 충돌이 “가끔”인 도메인에 적합한 것 같다

실험 결과 재정리

락 없음: 42ms / 13/100 → 속도는 빠르지만 카운터가 틀어져 실서비스 불가.

낙관적 락: 218ms / 85/100 → 재시도로 일부 복구되지만 경쟁이 심한 구간에서는 실패 누적.

비관적 락: 335ms / 100/100 → 정합성 완벽, 대기/직렬화 비용이 있으나 UX상 “좋아요 수가 정확”한 게 더 중요.

4. 결론 및 성과#

좋아요는 눈에 보이는 숫자가 즉시 맞아야 하고, 인기 피드 같은 경우 특정 피드로 트래픽이 몰리는 경우가 많다고 판단했다. 따라서 낙관적 락 방식을 사용해도 정합성을 100% 보장할 수 없기 때문에 락을 걸어 대기하더라도 정합성을 보장할 수 있는 비관적 락을 사용하였다. 좋아요 동시 요청 시 갱신 손실로 정합성이 깨지는 문제를 비관적 락을 사용하여 해결하였다. 동시성 문제에 대해 비관적, 낙관적 락이 정답은 있다기보다 서비스 상황을 고려하여 해결하는 것이 알맞는 방법인 것 같다.

비관적 락으로 좋아요 동시성 문제 해결하기
저자
Joonyoung Hwang
게시일
2025-08-05