문제 상황
좋아요 버튼을 빠르게 여러 번 누르면:
- update 쿼리는 정상적으로 나감
- 그런데 화면이 눌렸다가 바로 풀림
- likeCount가 0에 머무는 것처럼 보임
- UI가 실제 DB 상태와 어긋남
처음에는 DB 문제처럼 보이지만, 실제로는 프론트 동시 요청 + 토글 로직의 레이스 컨디션이 결합된 문제였다.
1️⃣ 프론트 문제: 동시 요청이 무제한으로 발생
현재 구조는 클릭할 때마다 바로 fetch() 요청을 날리는 형태였다.
likeButton.addEventListener('click', () => {
httpRequestJson('POST', `/api/articles/${articleId}/like`, null, ...)
});
사용자가 연타하면 이런 일이 발생한다.
- 요청 A, B, C, D가 거의 동시에 서버로 전송
- 서버는 각각을 토글 로직으로 처리
- 응답은 요청 순서대로 오지 않음
예:
- 마지막 클릭은 “좋아요”
- 그런데 먼저 날아간 “취소” 요청 응답이 더 늦게 도착
- 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 구조의 레이스
기존 토글 로직:
- existsByUserIdAndArticleId() 조회
- 있으면 delete + decrement
- 없으면 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 |