👥💡 좋아요(Like) 기능 설계: 상태, 집계, 동시성까지 고려한 구현 정리

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

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
'프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
  • ⏰ 선착순 강의 대규모 트래픽 처리 – Redis 1차 설계
  • 👥🚨 좋아요 상태 변경 후 본인 게시글 삭제 오류 해결 (FK 제약 & DB CASCADE)
  • 👥🚨 같은 유저가 좋아요를 동시에 연타할 때 동시성 테스트가 깨지는 이유
  • 👥🚨 좋아요 동시성 테스트에서 교착상태가 발생한 이유와 해결 방법
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (68)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (32)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (32)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
👥💡 좋아요(Like) 기능 설계: 상태, 집계, 동시성까지 고려한 구현 정리
상단으로

티스토리툴바