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건”이 아니고 웨이브(일정 동시성 + 대기)로 흘러가므로, 나머지는 풀 내부 큐 대기 후 순차 실행하도록 설정
실행 순서
- 모든 스레드가 동일 Feed 행을 조회 → 같은 likeCount 스냅샷을 들고 있음
- 각 스레드가 메모리에서 +1 수행
- 커밋 타이밍이 겹치며 마지막 커밋이 승자가 되어 앞선 갱신을 덮어씀(갱신 손실)
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% 보장할 수 없기 때문에 락을 걸어 대기하더라도 정합성을 보장할 수 있는 비관적 락을 사용하였다. 좋아요 동시 요청 시 갱신 손실로 정합성이 깨지는 문제를 비관적 락을 사용하여 해결하였다. 동시성 문제에 대해 비관적, 낙관적 락이 정답은 있다기보다 서비스 상황을 고려하여 해결하는 것이 알맞는 방법인 것 같다.
