👥🚨 좋아요 토글을 빠르게 연타하면 깨지는 이유와 해결 방법 (프론트 + 백엔드 동시성 정리)

2026. 2. 14. 18:19·프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트

문제 상황

좋아요 버튼을 빠르게 여러 번 누르면:

  • update 쿼리는 정상적으로 나감
  • 그런데 화면이 눌렸다가 바로 풀림
  • likeCount가 0에 머무는 것처럼 보임
  • UI가 실제 DB 상태와 어긋남

처음에는 DB 문제처럼 보이지만, 실제로는 프론트 동시 요청 + 토글 로직의 레이스 컨디션이 결합된 문제였다.


1️⃣ 프론트 문제: 동시 요청이 무제한으로 발생

현재 구조는 클릭할 때마다 바로 fetch() 요청을 날리는 형태였다.

likeButton.addEventListener('click', () => {
  httpRequestJson('POST', `/api/articles/${articleId}/like`, null, ...)
});

사용자가 연타하면 이런 일이 발생한다.

  • 요청 A, B, C, D가 거의 동시에 서버로 전송
  • 서버는 각각을 토글 로직으로 처리
  • 응답은 요청 순서대로 오지 않음

예:

  1. 마지막 클릭은 “좋아요”
  2. 그런데 먼저 날아간 “취소” 요청 응답이 더 늦게 도착
  3. UI가 취소 상태로 다시 덮어써짐

결과:

  • 눌렸다가 바로 풀리는 것처럼 보임
  • likeCount가 최신 값이 아닌, 늦게 도착한 응답 값으로 덮어쓰기됨

✅ 프론트 1차 방어: 요청 중 버튼 비활성화

let likeInFlight = false;

likeButton.addEventListener('click', () => {
  if (likeInFlight) return; // 연타 방지

  likeInFlight = true;
  likeButton.disabled = true;

  httpRequestJson(
    'POST',
    `/api/articles/${articleId}/like`,
    null,
    (data) => {
      // UI 업데이트
      likeInFlight = false;
      likeButton.disabled = false;
    },
    () => {
      alert('좋아요 처리 실패');
      likeInFlight = false;
      likeButton.disabled = false;
    }
  );
});

이 한 줄만 추가해도:

  • 중복 요청 방지
  • 응답 순서 꼬임 감소
  • UI 덮어쓰기 현상 크게 완화

2️⃣ 백엔드 문제: exists → insert/delete 구조의 레이스

기존 토글 로직:

  1. existsByUserIdAndArticleId() 조회
  2. 있으면 delete + decrement
  3. 없으면 insert + increment

이 구조는 동시성에 약하다.

왜냐하면:

  • 요청 A: exists = false 확인
  • 요청 B: exists = false 확인 (A 아직 커밋 전)
  • 둘 다 insert 시도
  • 하나는 성공, 하나는 유니크 충돌

토글이 섞이면 더 복잡해진다.

“상태를 먼저 읽고 행동”하는 구조는
동시 요청 상황에서 쉽게 깨진다.


3️⃣ “likeCount가 0에 머무는” 이유

likeCount는 보통 이렇게 가져온다.

long likeCount = blogRepository.findById(articleId)
        .orElseThrow()
        .getLikeCount();

가능한 시나리오:

  • 증가 직후 감소 요청이 연달아 처리됨
  • DB 값이 실제로 0이 됨
  • 또는 UI가 늦게 도착한 응답 값(0)으로 덮어씀

즉:

  • DB가 진짜 0일 수도 있고
  • DB는 1인데 UI가 0으로 덮어쓴 것일 수도 있음

대부분은 응답 순서 꼬임 + 중복 요청 문제다.


4️⃣ 백엔드 개선: “삭제 먼저 시도” 패턴

핵심 원칙:

exists로 상태를 먼저 보지 말고,
DB가 실제로 수행한 결과(row count)로 판단하자.

① 삭제 결과 row count를 받는 메서드

@Modifying
@Query("delete from ArticleLike al where al.user.id = :userId and al.article.id = :articleId")
int deleteOne(Long userId, Long articleId);

삭제되면 1, 없으면 0 반환.

② likeCount 전용 조회 쿼리

엔티티 전체를 다시 로딩할 필요 없다.

@Query("select a.likeCount from Article a where a.id = :id")
long getLikeCount(Long id);

③ 개선된 토글 로직

@Transactional
public LikeToggleResult toggleLike(Long articleId, String userEmail) {

    User user = userRepository.findByEmail(userEmail)
            .orElseThrow(() -> new IllegalArgumentException("not found user"));

    // 1. 먼저 삭제 시도
    int deleted = likeRepository.deleteOne(user.getId(), articleId);

    if (deleted == 1) {
        blogRepository.decrementLikeCount(articleId);
        long likeCount = blogRepository.getLikeCount(articleId);
        return new LikeToggleResult(false, likeCount);
    }

    // 2. 삭제 안 됐으면 삽입 시도
    try {
        Article article = blogRepository.findById(articleId)
                .orElseThrow(() -> new IllegalArgumentException("not found article"));

        likeRepository.saveAndFlush(new ArticleLike(user, article));
        blogRepository.incrementLikeCount(articleId);

        long likeCount = blogRepository.getLikeCount(articleId);
        return new LikeToggleResult(true, likeCount);

    } catch (DataIntegrityViolationException e) {
        // 동시 insert 충돌
        long likeCount = blogRepository.getLikeCount(articleId);
        return new LikeToggleResult(true, likeCount);
    }
}

이 구조의 장점

  • exists 기반 레이스 제거
  • “삭제 성공 여부”는 DB가 보장하는 사실
  • 토글 안정성 상승
  • likeCount 조회 가벼움

최종 정리

좋아요 토글은 단순해 보이지만
동시성 상황에서는 매우 쉽게 깨진다.

프론트 필수

  • 요청 중 버튼 disable
  • 중복 요청 방지

백엔드 권장

  • exists 기반 분기 제거
  • delete row count → insert 패턴
  • likeCount는 전용 쿼리로 조회

'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글

👥🚨 같은 유저가 좋아요를 동시에 연타할 때 동시성 테스트가 깨지는 이유  (0) 2026.02.14
👥🚨 좋아요 동시성 테스트에서 교착상태가 발생한 이유와 해결 방법  (0) 2026.02.14
👥💡 원자적 방법이란? 벌크성 내용이랑 무슨 차이가 있는지?  (0) 2026.02.14
👥💡 조회수 동시성 문제 해결(원자적)  (0) 2026.02.14
👥💡 Spring Data JPA + Querydsl 커스텀 Repository 구조 정리  (0) 2026.02.14
'프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
  • 👥🚨 같은 유저가 좋아요를 동시에 연타할 때 동시성 테스트가 깨지는 이유
  • 👥🚨 좋아요 동시성 테스트에서 교착상태가 발생한 이유와 해결 방법
  • 👥💡 원자적 방법이란? 벌크성 내용이랑 무슨 차이가 있는지?
  • 👥💡 조회수 동시성 문제 해결(원자적)
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (68)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (32)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (32)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
👥🚨 좋아요 토글을 빠르게 연타하면 깨지는 이유와 해결 방법 (프론트 + 백엔드 동시성 정리)
상단으로

티스토리툴바