1️⃣ 좋아요는 사실 두 문제다
A. “내가 이 글을 좋아요 했는가?” (상태)
- 테이블: article_like (user_id, article_id)
- (user_id, article_id) 유니크 제약
- 핵심: 중복 좋아요 방지
B. “이 글의 총 좋아요 수는 몇 개인가?” (집계)
- 방법 1: select count(*) 매번 계산
- 방법 2: Article.likeCount 컬럼에 캐싱
나는 성능을 위해 likeCount를 컬럼으로 캐싱했다.
대신, 이렇게 되면 반드시:
likeCount 증감이 동시성에서 안전해야 한다.
2️⃣ 토글(toggle)은 동시성에서 더 어렵다
토글은 다음 로직을 가진다.
- 좋아요 없으면 → 생성 +1
- 좋아요 있으면 → 삭제 -1
문제는:
- 같은 유저가 연타
- 여러 요청이 동시에 들어옴
- 응답 순서가 꼬임
- UI가 늦게 도착한 응답으로 덮어쓰기
그래서 결론은 명확하다.
백엔드 설계 + 프론트 연타 방지
둘 다 해야 완성된다.
3️⃣ ArticleLike: 유니크 제약으로 1차 방어
@Entity
@Table(
name = "article_like",
uniqueConstraints = @UniqueConstraint(
name = "uk_article_like_user_article",
columnNames = {"user_id", "article_id"}
)
)
public class ArticleLike {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id", nullable = false)
private Article article;
public ArticleLike(User user, Article article) {
this.user = user;
this.article = article;
}
}
이 설계의 의미
- 같은 유저가 같은 글에 좋아요 2개 생성 불가
- 동시 insert가 와도 DB가 유니크 제약으로 방어
- 애플리케이션이 아니라 DB가 최종 방어선
4️⃣ 삭제는 “한 방 쿼리”로 처리
기존의 문제 있는 패턴:
exists() → delete()
이 구조는 동시성에서 취약하다.
개선된 방식:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("delete from ArticleLike al where al.user.id = :userId and al.article.id = :articleId")
int deleteOne(Long userId, Long articleId);
장점
- 삭제 성공 → 1
- 삭제할 게 없음 → 0
- 상태를 delete 결과값으로 판단 가능
- 선조회 제거 → 레이스 감소
5️⃣ likeCount 증감은 “원자적 UPDATE”
@Modifying(clearAutomatically = true)
@Query("update Article a set a.likeCount = a.likeCount + 1 where a.id = :id")
int incrementLikeCount(Long id);
@Modifying(clearAutomatically = true)
@Query("update Article a set a.likeCount = a.likeCount - 1 where a.id = :id and a.likeCount > 0")
int decrementLikeCount(Long id);
@Query("select a.likeCount from Article a where a.id = :id")
long getLikeCount(Long id);
왜 안전한가?
- likeCount = likeCount + 1은 DB 레벨 원자 연산
- 동시 업데이트에서도 덮어쓰기 발생 안 함
- 감소 시 likeCount > 0 조건으로 음수 방지
6️⃣ LikeService: “삭제 먼저 → 아니면 insert”
@Transactional
public LikeToggleResult toggleLike(Long articleId, String userEmail) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new IllegalArgumentException("not found user"));
Long userId = user.getId();
// 1) 먼저 삭제 시도
int deleted = likeRepository.deleteOne(userId, articleId);
if (deleted == 1) {
blogRepository.decrementLikeCount(articleId);
long likeCount = blogRepository.getLikeCount(articleId);
return new LikeToggleResult(false, likeCount);
}
// 2) 삭제 안 됐으면 insert 시도
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);
}
}
7️⃣ 왜 이 구조가 동시성에 강한가?
① exists 선조회 제거
- 상태를 먼저 읽고 판단하지 않음
- delete 결과로 상태 판단
- 레이스 조건 감소
② insert는 DB 유니크가 보호
- 동시에 두 번 insert → 하나는 충돌
- 충돌은 catch로 처리
- 서비스는 안정적으로 “좋아요 상태” 반환
③ likeCount는 원자적 증가/감소
- 덮어쓰기 없음
- 누적 손실 없음
- row 수와 count 정합성 유지 가능
8️⃣ 프론트도 반드시 필요
백엔드가 완벽해도
프론트가 연타를 허용하면 UI는 여전히 튄다.
필수:
if (likeInFlight) return;
likeButton.disabled = true;
실서비스에서는
대부분 프론트에서 연타를 막는다.
9️⃣ 최종 정리
좋아요 기능은 단순해 보이지만 실제로는:
- 상태 관리 (user-article 관계)
- 집계 관리 (likeCount 캐시)
- 동시성 제어
- UI 응답 순서 문제
가 모두 얽혀 있다.
내가 적용한 설계의 핵심은:
상태는 유니크 제약으로 보호
집계는 원자적 UPDATE로 처리
토글은 delete 결과 기반 판단
insert는 DB 충돌로 안전 확보
그리고
백엔드 + 프론트 둘 다 설계해야
연타와 동시성에서도 안정적이다.
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 1차 설계 (0) | 2026.02.14 |
|---|---|
| 👥🚨 좋아요 상태 변경 후 본인 게시글 삭제 오류 해결 (FK 제약 & DB CASCADE) (0) | 2026.02.14 |
| 👥🚨 같은 유저가 좋아요를 동시에 연타할 때 동시성 테스트가 깨지는 이유 (0) | 2026.02.14 |
| 👥🚨 좋아요 동시성 테스트에서 교착상태가 발생한 이유와 해결 방법 (0) | 2026.02.14 |
| 👥🚨 좋아요 토글을 빠르게 연타하면 깨지는 이유와 해결 방법 (프론트 + 백엔드 동시성 정리) (0) | 2026.02.14 |