Skip to content

Commit 96f361e

Browse files
authored
커뮤니티 게시글 조회수 동시성 이슈 해결 및 성능 최적화
커뮤니티 게시글 조회수 동시성 이슈 해결 및 성능 최적화
2 parents bc09ac7 + 3977977 commit 96f361e

8 files changed

Lines changed: 321 additions & 40 deletions

File tree

src/main/java/site/talent_trade/api/domain/member/Member.java

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import site.talent_trade.api.domain.profile.Profile;
2929
import site.talent_trade.api.domain.review.Review;
3030

31+
3132
@Entity
3233
@Getter
3334
@NoArgsConstructor
@@ -85,23 +86,29 @@ public class Member {
8586
/*생성자*/
8687
@Builder
8788
public Member(String email, String password, String name, String nickname, String phone,
88-
LocalDate birth, Gender gender) {
89-
this.email = email;
90-
this.password = password;
91-
this.name = name;
92-
this.nickname = nickname;
93-
this.phone = phone;
94-
this.birth = birth;
95-
this.gender = gender;
96-
97-
this.messageLimit = 0;
98-
LocalDateTime now = LocalDateTime.now();
99-
this.lastLoginAt = now;
100-
this.timestamp = new Timestamp(now);
101-
this.profile = new Profile(this);
89+
LocalDate birth, Gender gender,
90+
Talent myTalent, String myTalentDetail) {
91+
92+
this.email = email;
93+
this.password = password;
94+
this.name = name;
95+
this.nickname = nickname;
96+
this.phone = phone;
97+
this.birth = birth;
98+
this.gender = gender;
99+
this.myTalent = myTalent;
100+
this.myTalentDetail = myTalentDetail;
101+
102+
LocalDateTime now = LocalDateTime.now();
103+
this.messageLimit = 0;
104+
this.lastLoginAt = now;
105+
this.timestamp = new Timestamp(now);
106+
this.profile = new Profile(this);
102107
}
103108

104-
/* 닉네임, 재능, 한 줄 소개 수정 메소드 */
109+
110+
111+
/* 닉네임, 재능, 한 줄 소개 수정 메소드 */
105112
public void updateMember(String nickname, Talent myTalent, String myTalentDetail,
106113
Talent wishTalent,
107114
String myComment) {

src/main/java/site/talent_trade/api/repository/community/PostRepository.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package site.talent_trade.api.repository.community;
22

33

4+
import jakarta.persistence.LockModeType;
45
import org.springframework.data.domain.Sort;
5-
import org.springframework.data.jpa.repository.JpaRepository;
6-
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
7-
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.jpa.repository.*;
87
import org.springframework.data.repository.query.Param;
98
import org.springframework.stereotype.Repository;
109
import site.talent_trade.api.domain.community.Post;
@@ -18,4 +17,14 @@ public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificat
1817
//멤버 아이디로 내가 쓴 게시물 조회하기
1918
List<Post> findByMemberId(Long memberId);
2019

20+
//update 쿼리로 조회수 직접 업데이트 하기
21+
@Modifying
22+
@Query("update Post p set p.hitCount = p.hitCount + 1 where p.id = :postId")
23+
void increaseHit(@Param("postId") Long postId);
24+
25+
//비관적 락: select 시점에 락이 걸림 -> 트랜잭션 종료 전까지 다른 쓰기 접근 금지
26+
@Lock(LockModeType.PESSIMISTIC_WRITE)
27+
@Query("select p from Post p where p.id = :id")
28+
Post findByIdForUpdate(@Param("id") Long postId);
29+
2130
}

src/main/java/site/talent_trade/api/service/community/PostService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ public interface PostService {
3030
//내가 작성한 글 조회
3131
ResponseDTO<List<PostResponseDTO>> findByMemberId(Long memberId);
3232

33+
//상세 조회 -> 비관적락 사용
34+
ResponseDTO<PostDetailDTO> getPostDetail_rock(Long postId, Long memberId);
35+
3336
}

src/main/java/site/talent_trade/api/service/community/PostServiceImpl.java

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.ArrayList;
44
import java.util.List;
55
import java.util.stream.Collectors;
6+
67
import lombok.extern.slf4j.Slf4j;
78
import org.springframework.beans.factory.annotation.Autowired;
89
import org.springframework.data.jpa.domain.Specification;
@@ -152,18 +153,106 @@ public ResponseDTO<List<PostResponseDTO>> getPostList(String talent, String keyw
152153
@Override
153154
public ResponseDTO<PostDetailDTO> getPostDetail(Long postId, Long memberId) {
154155

155-
156-
// 게시글 조회
156+
//update 쿼리로 조회수 직접 업데이트 하기
157+
postRepository.increaseHit(postId);
158+
//
159+
// // 게시글 조회
157160
Post post = postRepository.findById(postId)
158161
.orElseThrow(() -> new CustomException(ExceptionStatus.POST_NOT_FOUND));
159162

163+
164+
if (post == null) {
165+
throw new CustomException(ExceptionStatus.POST_NOT_FOUND);
166+
}
167+
160168
List<Notification> notifications =
161-
notificationRepository.findUncheckedNotificationsByMemberIdAndPostId(memberId, postId);
169+
notificationRepository.findUncheckedNotificationsByMemberIdAndPostId(memberId, postId);
170+
notifications.forEach(Notification::checkNotification);
171+
172+
173+
// 댓글 리스트가 null일 경우 빈 리스트로 처리하여 사이즈를 안전하게 호출
174+
int commentCount = (post.getComments() != null) ? post.getComments().size() : 0;
175+
log.info("Total comments: " + post.getComments().size());
176+
// 댓글 리스트가 null일 경우 빈 리스트로 처리
177+
List<Comment> comments = (post.getComments() != null) ? post.getComments() : new ArrayList<>();
178+
System.out.println("Comments list: " + comments);
179+
180+
if (post.getComments() == null || post.getComments().isEmpty()) {
181+
log.warn("No comments found for post ID: " + post.getId());
182+
} else {
183+
log.info("Comments list: " + post.getComments());
184+
}
185+
186+
// 댓글에 대해 알림 상태 업데이트하고 댓글 목록 가져오기
187+
List<CommentResponseDTO> commentResponseDTOs = post.getComments().stream()
188+
.map(comment -> {
189+
190+
// CommentResponseDTO 생성
191+
CommentResponseDTO dto = CommentResponseDTO.builder()
192+
.commentId(comment.getId())
193+
.nickname(comment.getMember().getNickname())
194+
.content(comment.getContent())
195+
.talent(comment.getMember().getMyTalent().name())
196+
.talentDetail(comment.getMember().getMyTalentDetail())
197+
.createdAt(comment.getTimestamp().getCreatedAt())
198+
.gender(comment.getMember().getGender().name())
199+
.build();
200+
//log.info("Created CommentResponseDTO: " + dto); // Log the DTO
201+
return dto;
202+
})
203+
.collect(Collectors.toList());
204+
205+
// PostResponseDTO 객체 생성
206+
PostResponseDTO postResponseDTO = PostResponseDTO.builder()
207+
.postId(post.getId())
208+
.nickname(post.getMember().getNickname())
209+
.title(post.getTitle())
210+
.content(post.getContent())
211+
.talent(post.getMember().getMyTalent().name())
212+
.talentDetail(post.getMember().getMyTalentDetail())
213+
.createdAt(post.getTimestamp().getCreatedAt())
214+
.hitCount(post.getHitCount())
215+
.commentCount(commentCount) // 댓글 개수 추가
216+
.gender(post.getMember().getGender().name())
217+
.build();
218+
// PostDetailDTO 객체 생성
219+
PostDetailDTO postDetailDTO = PostDetailDTO.builder()
220+
.post(postResponseDTO) // 게시글 정보
221+
.comments(commentResponseDTOs) // 댓글 목록
222+
.build();
223+
224+
// ResponseDTO로 반환
225+
return new ResponseDTO<>(postDetailDTO, HttpStatus.OK);
226+
}
227+
228+
//상세 조회 -> 조회수 하나씩 증가
229+
@Transactional
230+
@Override
231+
public ResponseDTO<PostDetailDTO> getPostDetail_rock(Long postId, Long memberId) {
232+
233+
//update 쿼리로 조회수 직접 업데이트 하기
234+
// postRepository.increaseHit(postId);
235+
//
236+
// // 게시글 조회
237+
// Post post = postRepository.findById(postId)
238+
// .orElseThrow(() -> new CustomException(ExceptionStatus.POST_NOT_FOUND));
239+
240+
// findById 대신 비관적 락이 적용된 findByIdForUpdate 사용
241+
// 이 시점에 다른 트랜잭션은 해당 row를 수정할 수 없게 대기
242+
Post post = postRepository.findByIdForUpdate(postId);
243+
244+
if (post == null) {
245+
throw new CustomException(ExceptionStatus.POST_NOT_FOUND);
246+
}
247+
248+
List<Notification> notifications =
249+
notificationRepository.findUncheckedNotificationsByMemberIdAndPostId(memberId, postId);
162250
notifications.forEach(Notification::checkNotification);
163251

164252

165253
// 조회수 증가: 새로운 Post 객체 생성
166254
post.incrementHitCount();
255+
167256
// 댓글 리스트가 null일 경우 빈 리스트로 처리하여 사이즈를 안전하게 호출
168257
int commentCount = (post.getComments() != null) ? post.getComments().size() : 0;
169258
log.info("Total comments: " + post.getComments().size());

src/test/java/site/talent_trade/api/ApiApplicationTests.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import org.junit.jupiter.api.Test;
44
import org.springframework.boot.test.context.SpringBootTest;
5+
import org.springframework.test.context.ActiveProfiles;
56

67
@SpringBootTest
8+
@ActiveProfiles("test")
79
class ApiApplicationTests {
810

911
@Test
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package site.talent_trade.api.test;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.beans.factory.annotation.Autowired;
5+
import org.springframework.boot.test.context.SpringBootTest;
6+
import org.springframework.test.context.ActiveProfiles;
7+
import site.talent_trade.api.domain.Timestamp;
8+
import site.talent_trade.api.domain.community.Post;
9+
import site.talent_trade.api.domain.member.Gender;
10+
import site.talent_trade.api.domain.member.Member;
11+
import site.talent_trade.api.domain.member.Talent;
12+
import site.talent_trade.api.repository.community.PostRepository;
13+
import site.talent_trade.api.repository.member.MemberRepository;
14+
import site.talent_trade.api.service.community.PostService;
15+
16+
import java.util.concurrent.CountDownLatch;
17+
import java.util.concurrent.ExecutorService;
18+
import java.util.concurrent.Executors;
19+
20+
import org.springframework.util.StopWatch;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
24+
@SpringBootTest
25+
@ActiveProfiles("test")
26+
public class PostHitCountConcurrencyTest {
27+
@Autowired
28+
PostService postService;
29+
30+
@Autowired
31+
PostRepository postRepository;
32+
@Autowired
33+
private MemberRepository memberRepository;
34+
35+
@Test
36+
void 동시에_100명이_조회하면_hitCount는_100이어야한다_조회수_증가_시_update문_사용() throws InterruptedException {
37+
//given
38+
39+
Member member = memberRepository.save(
40+
Member.builder()
41+
.nickname("tester")
42+
.myTalent(Talent.IT)
43+
.myTalentDetail("백엔드")
44+
.gender(Gender.MALE)
45+
.build()
46+
);
47+
48+
Post newPost = Post.builder()
49+
.member(member)
50+
.title("제목")
51+
.content("내용")
52+
.hitCount(0)
53+
.timestamp(new Timestamp())
54+
.build();
55+
56+
Post post = postRepository.save(newPost);
57+
58+
int threadCount = 100;
59+
60+
ExecutorService executorService = Executors.newFixedThreadPool(32);
61+
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
62+
63+
StopWatch stopWatch = new StopWatch();
64+
stopWatch.start();
65+
//when
66+
for (int i = 0; i < threadCount; i++) {
67+
executorService.submit(() -> {
68+
try {
69+
postService.getPostDetail(post.getId(), member.getId());
70+
} catch (Exception e) {
71+
e.printStackTrace();
72+
} finally {
73+
countDownLatch.countDown();
74+
}
75+
});
76+
}
77+
countDownLatch.await();
78+
stopWatch.stop();
79+
80+
//then
81+
82+
Post result = postRepository.findById(post.getId()).orElseThrow();
83+
System.out.println("--- Update 쿼리 방식 결과 ---");
84+
System.out.println("수행 시간: " + stopWatch.getTotalTimeMillis() + "ms");
85+
System.out.println("최종 조회수: " + result.getHitCount());
86+
87+
assertThat(result.getHitCount()).isEqualTo(threadCount);
88+
}
89+
90+
@Test
91+
void 동시에_100명이_조회하면_hitCount는_100이어야한다_조회수_증가_시_비관적락_사용() throws InterruptedException {
92+
//given
93+
94+
Member member = memberRepository.save(
95+
Member.builder()
96+
.nickname("tester")
97+
.myTalent(Talent.IT)
98+
.myTalentDetail("백엔드")
99+
.gender(Gender.MALE)
100+
.build()
101+
);
102+
103+
Post newPost = Post.builder()
104+
.member(member)
105+
.title("제목")
106+
.content("내용")
107+
.hitCount(0)
108+
.timestamp(new Timestamp())
109+
.build();
110+
111+
Post post = postRepository.save(newPost);
112+
113+
int threadCount = 100;
114+
115+
ExecutorService executorService = Executors.newFixedThreadPool(32);
116+
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
117+
118+
StopWatch stopWatch = new StopWatch();
119+
stopWatch.start();
120+
//when
121+
for (int i = 0; i < threadCount; i++) {
122+
executorService.submit(() -> {
123+
try {
124+
postService.getPostDetail(post.getId(), member.getId());
125+
} catch (Exception e) {
126+
e.printStackTrace();
127+
} finally {
128+
countDownLatch.countDown();
129+
}
130+
});
131+
}
132+
countDownLatch.await();
133+
stopWatch.stop(); // 시간 측정 종료
134+
//then
135+
136+
Post result = postRepository.findById(post.getId()).orElseThrow();
137+
138+
System.out.println("--- 비관적 락 방식 결과 ---");
139+
System.out.println("수행 시간: " + stopWatch.getTotalTimeMillis() + "ms");
140+
System.out.println("최종 조회수: " + result.getHitCount());
141+
142+
assertThat(result.getHitCount()).isEqualTo(threadCount);
143+
}
144+
145+
146+
}

0 commit comments

Comments
 (0)