Skip to content

Commit ca5bb37

Browse files
committed
[BE] [REFACTOR] 좋아요 동시성 문제 해결
- post_id + user_id 유니크 제약 선언 - advisory lock, insert on conflict do nothing, row count 반환 delete 추가 - likeCount 원자 증가/감소 쿼리 추가 - 실제 insert/delete가 일어난 경우에만 count 변경 - 기존 중복 row 정리, like_count 재계산, 유니크 제약/인덱스 추가 - 추가/취소/insert 경합/delete 경합 테스트 추가
1 parent f5d620a commit ca5bb37

5 files changed

Lines changed: 226 additions & 49 deletions

File tree

‎backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import jakarta.persistence.Id;
88
import jakarta.persistence.JoinColumn;
99
import jakarta.persistence.ManyToOne;
10+
import jakarta.persistence.Table;
11+
import jakarta.persistence.UniqueConstraint;
1012
import java.util.UUID;
1113
import lombok.AllArgsConstructor;
1214
import lombok.Builder;
@@ -17,6 +19,15 @@
1719
import org.sejongisc.backend.user.entity.User;
1820

1921
@Entity
22+
@Table(
23+
name = "post_like",
24+
uniqueConstraints = {
25+
@UniqueConstraint(
26+
name = "uk_post_like_post_user",
27+
columnNames = {"post_id", "user_id"}
28+
)
29+
}
30+
)
2031
@Getter
2132
@Setter
2233
@NoArgsConstructor
Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package org.sejongisc.backend.board.repository;
22

33
import java.util.List;
4-
import org.sejongisc.backend.board.entity.PostLike;
5-
import org.springframework.data.jpa.repository.JpaRepository;
6-
74
import java.util.Optional;
85
import java.util.UUID;
6+
import org.sejongisc.backend.board.entity.PostLike;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Modifying;
9+
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.repository.query.Param;
911
import org.springframework.transaction.annotation.Transactional;
1012

1113
public interface PostLikeRepository extends JpaRepository<PostLike, UUID> {
@@ -16,6 +18,46 @@ public interface PostLikeRepository extends JpaRepository<PostLike, UUID> {
1618

1719
List<PostLike> findAllByPostPostId(UUID postId);
1820

21+
@Query(
22+
value = """
23+
select pg_advisory_xact_lock(
24+
hashtext(cast(:postId as text)),
25+
hashtext(cast(:userId as text))
26+
)
27+
""",
28+
nativeQuery = true
29+
)
30+
Object acquirePostUserLikeToggleLock(
31+
@Param("postId") UUID postId,
32+
@Param("userId") UUID userId
33+
);
34+
35+
@Modifying(flushAutomatically = true, clearAutomatically = true)
36+
@Query(
37+
value = """
38+
insert into post_like (post_like_id, post_id, user_id, created_date, updated_date)
39+
values (:postLikeId, :postId, :userId, now(), now())
40+
on conflict (post_id, user_id) do nothing
41+
""",
42+
nativeQuery = true
43+
)
44+
int insertIgnore(
45+
@Param("postLikeId") UUID postLikeId,
46+
@Param("postId") UUID postId,
47+
@Param("userId") UUID userId
48+
);
49+
50+
@Modifying(flushAutomatically = true, clearAutomatically = true)
51+
@Query("""
52+
delete from PostLike pl
53+
where pl.post.postId = :postId
54+
and pl.user.userId = :userId
55+
""")
56+
int deleteByPostIdAndUserId(
57+
@Param("postId") UUID postId,
58+
@Param("userId") UUID userId
59+
);
60+
1961
@Transactional
2062
void deleteAllByPostPostId(UUID postId);
2163
}

‎backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -177,29 +177,34 @@ public void toggleLike(UUID postId, UUID userId) {
177177
User user = userRepository.findById(userId)
178178
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
179179

180-
// 이미 좋아요를 눌렀는지 확인
180+
// 토글은 같은 사용자-게시글 조합 안에서 순서가 의미를 가지므로 해당 조합만 직렬화합니다.
181+
postLikeRepository.acquirePostUserLikeToggleLock(postId, userId);
182+
181183
Optional<PostLike> existingLike = postLikeRepository.findByPostPostIdAndUserUserId(postId, userId);
182184

183185
if (existingLike.isPresent()) {
184-
// 좋아요가 이미 있으면 -> 삭제 (좋아요 취소)
185-
postLikeRepository.delete(existingLike.get());
186-
post.setLikeCount(post.getLikeCount() - 1); // Post 엔티티 카운트 감소
187-
} else {
188-
// 좋아요가 없으면 -> 생성 (좋아요)
189-
PostLike newLike = PostLike.builder()
190-
.post(post)
191-
.user(user)
192-
.build();
193-
postLikeRepository.save(newLike);
194-
post.setLikeCount(post.getLikeCount() + 1); // Post 엔티티 카운트 증가
195-
eventPublisher.publishEvent(new ActivityEvent(
196-
user.getUserId(),
197-
user.getName(),
198-
ActivityType.BOARD_LIKE,
199-
"[" + post.getTitle() + "]에 좋아요를 눌렀습니다.",
200-
post.getPostId(),
201-
post.getBoard().getBoardName()));
186+
int deletedRows = postLikeRepository.deleteByPostIdAndUserId(postId, userId);
187+
if (deletedRows > 0) {
188+
// 실제 좋아요 row가 삭제된 경우에만 캐시 카운트를 원자적으로 감소시킵니다.
189+
postRepository.decrementLikeCount(postId);
190+
}
191+
return;
202192
}
193+
194+
int insertedRows = postLikeRepository.insertIgnore(UUID.randomUUID(), postId, userId);
195+
if (insertedRows == 0) {
196+
return;
197+
}
198+
199+
// 실제 좋아요 row가 추가된 경우에만 캐시 카운트를 원자적으로 증가시킵니다.
200+
postRepository.incrementLikeCount(postId);
201+
eventPublisher.publishEvent(new ActivityEvent(
202+
user.getUserId(),
203+
user.getName(),
204+
ActivityType.BOARD_LIKE,
205+
"[" + post.getTitle() + "]에 좋아요를 눌렀습니다.",
206+
post.getPostId(),
207+
post.getBoard().getBoardName()));
203208
}
204209

205210
// 북마크 등록/삭제
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
-- Make board post likes concurrency-safe.
2+
-- 1) Collapse legacy duplicate likes per (post_id, user_id).
3+
-- 2) Reconcile cached post.like_count from the canonical post_like table.
4+
-- 3) Add a DB-level uniqueness guard so duplicates cannot be reintroduced.
5+
6+
delete from post_like
7+
where ctid in (
8+
select like_ctid
9+
from (
10+
select
11+
ctid as like_ctid,
12+
row_number() over (
13+
partition by post_id, user_id
14+
order by created_date asc nulls last, post_like_id asc
15+
) as row_number
16+
from post_like
17+
where post_id is not null
18+
and user_id is not null
19+
) ranked_likes
20+
where ranked_likes.row_number > 1
21+
);
22+
23+
update post p
24+
set like_count = coalesce(like_totals.like_count, 0)
25+
from (
26+
select p_inner.post_id, count(pl.post_like_id)::integer as like_count
27+
from post p_inner
28+
left join post_like pl on pl.post_id = p_inner.post_id
29+
group by p_inner.post_id
30+
) like_totals
31+
where p.post_id = like_totals.post_id;
32+
33+
alter table post
34+
alter column like_count set default 0,
35+
alter column like_count set not null;
36+
37+
do $$
38+
begin
39+
if not exists (
40+
select 1
41+
from pg_constraint
42+
where conname = 'uk_post_like_post_user'
43+
and conrelid = 'post_like'::regclass
44+
) then
45+
alter table post_like
46+
add constraint uk_post_like_post_user unique (post_id, user_id);
47+
end if;
48+
end $$;
49+
50+
create index if not exists idx_post_like_post_id on post_like(post_id);
51+
create index if not exists idx_post_like_user_id on post_like(user_id);

0 commit comments

Comments
 (0)