-
Notifications
You must be signed in to change notification settings - Fork 0
[refactor] 게시글 좋아요 api 서버 처리율 개선 - 레디스 비동기 큐잉 적용 #341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
5ac36e6
480e850
f14a490
1731bcc
20c2b95
74aa272
be10bed
b3b4655
a9b91ca
a34c050
5dfae3b
f4d8041
36d5524
bc6b49e
fcf3e5c
7302cc3
5a9cc95
4154b49
3e7a47c
7390280
567f1e6
c972f51
35f3456
c6401d7
cb00e01
e4891a4
f6b6ba9
f4d3a2a
bb2a260
59f89c2
9011584
a6cdc57
02794c2
17f23d3
c66eb33
94d579a
6ed0014
9aae94f
3505110
ec0f5a1
ac12ae8
485c288
d7012e6
4614116
4fea986
5270bef
e54c50e
bd73f80
11b0833
a2d8f1b
8f48fdd
a74bed6
12ab679
360e8d3
0c0deb0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| // feed-like-load-test.js | ||
| import http from 'k6/http'; | ||
| import { sleep,check } from 'k6'; | ||
| import { Trend, Counter } from 'k6/metrics'; | ||
|
|
||
| const BASE_URL = 'http://localhost:8080'; | ||
| const FEED_ID = 1; // 테스트할 피드 ID | ||
| const USERS_START = 1; // 토큰 발급 시작 userId | ||
| const USERS_COUNT = 5000; // 총 사용자 = VU 수 | ||
| const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 | ||
| const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지) | ||
| const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 요청 동시 시작) | ||
|
|
||
| // ===== 커스텀 메트릭 ===== | ||
| const likeLatency = new Trend('feed_like_latency'); // 참여 API 지연(ms) | ||
| const http5xx = new Counter('feed_like_5xx'); // 5xx 개수 | ||
| const http2xx = new Counter('feed_like_2xx'); // 2xx 개수 | ||
| const http4xx = new Counter('feed_like_4xx'); // 4xx 개수 | ||
|
|
||
| // 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) | ||
| const token_issue_failed = new Counter('token_issue_failed'); | ||
| const fail_POST_ALREADY_LIKED = new Counter('fail_POST_ALREADY_LIKED'); | ||
| const fail_POST_NOT_LIKED_CANNOT_CANCEL = new Counter('fail_POST_NOT_LIKED_CANNOT_CANCEL'); | ||
| const fail_POST_LIKE_COUNT_UNDERFLOW = new Counter('fail_POST_LIKE_COUNT_UNDERFLOW'); | ||
| const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); | ||
|
|
||
| const ERR = { // THIP error code | ||
| POST_ALREADY_LIKED: 185001, | ||
| POST_NOT_LIKED_CANNOT_CANCEL: 185002, | ||
| POST_LIKE_COUNT_UNDERFLOW: 185000 | ||
| }; | ||
|
|
||
| function parseError(res) { | ||
| try { | ||
| const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 | ||
| return { | ||
| code: Number(j.code), // 정수 코드 | ||
| message: j.message || '', | ||
| requestId: j.requestId || '', | ||
| isSuccess: !!j.isSuccess | ||
| }; | ||
| } catch (e) { | ||
| return { code: NaN, message: '', requestId: '', isSuccess: false }; | ||
| } | ||
| } | ||
|
|
||
| // ------------ 시나리오 ------------ | ||
| // 특정 시점에 한 게시물 (인기 작가,인플루언서가 작성한)에 좋아요 요청이 몰리는 상황 가정 | ||
| export let options = { | ||
| scenarios: { | ||
| // 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저 | ||
| feed_like_once: { | ||
| executor: 'per-vu-iterations', | ||
| vus: USERS_COUNT, | ||
| iterations: 1, | ||
| startTime: '0s', // 모든 VU가 거의 동시에 스케줄링 | ||
| gracefulStop: '5s', | ||
| }, | ||
| }, | ||
| thresholds: { | ||
| feed_like_5xx: ['count==0'], // 서버 오류는 0건이어야 함 | ||
| feed_like_latency: ['p(95)<500'], // p95 < 500ms | ||
| }, | ||
| }; | ||
|
|
||
| // 테스트 전 사용자 별 토큰 배치 발급 | ||
| export function setup() { | ||
| const userIds = Array.from({ length: USERS_COUNT }, (_, i) => USERS_START + i); | ||
| const tokens = []; | ||
|
|
||
| for (let i = 0; i < userIds.length; i += TOKEN_BATCH) { | ||
| const slice = userIds.slice(i, i + TOKEN_BATCH); | ||
| const reqs = slice.map((uid) => [ | ||
| 'GET', | ||
| `${BASE_URL}/api/test/token/access?userId=${uid}`, | ||
| null, | ||
| { tags: { phase: 'setup_token_issue', feed: `${FEED_ID}` } }, | ||
| ]); | ||
|
|
||
| const responses = http.batch(reqs); | ||
| for (const r of responses) { | ||
| if (r.status === 200 && r.body) { | ||
| tokens.push(r.body.trim()); | ||
| } | ||
| else { | ||
| tokens.push(''); // 실패한 자리도 인덱스 유지 | ||
| token_issue_failed.add(1); | ||
| } | ||
| } | ||
| sleep(BATCH_PAUSE_S); | ||
| } | ||
| if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT; | ||
|
|
||
| const startAt = Date.now() + START_DELAY_S * 1000; // 동시 시작 시간 | ||
|
|
||
| return { tokens, startAt }; | ||
| } | ||
|
|
||
| // VU : 각자 자기 토큰으로 참여 호출 & 각자 1회만 실행 | ||
| export default function (data) { | ||
| const vuIdx = __VU - 1; | ||
| const token = data.tokens[vuIdx]; | ||
|
|
||
| // 동기 시작: startAt까지 대기 → 모든 VU가 거의 같은 타이밍에 시작 | ||
| const now = Date.now(); | ||
| if (now < data.startAt) { | ||
| sleep((data.startAt - now) / 1000); | ||
| } | ||
|
|
||
| if (!token) { // 토큰 발급 실패 -> 스킵 | ||
| return; | ||
| } | ||
|
|
||
| const headers = { | ||
| Authorization: `Bearer ${token}`, | ||
| 'Content-Type': 'application/json', | ||
| }; | ||
|
|
||
| // 동시에 모든 유저가 인기 게시물에 대해 좋아요 요청 | ||
| const body = JSON.stringify({ type: 'true' }); | ||
| const url = `${BASE_URL}/feeds/${FEED_ID}/likes`; | ||
|
|
||
| const res = http.post(url, body, { headers, tags: { phase: 'like', feed: `${FEED_ID}` } }); | ||
|
|
||
| // === 커스텀 메트릭 기록 === | ||
| likeLatency.add(res.timings.duration); | ||
| if (res.status >= 200 && res.status < 300) http2xx.add(1); | ||
| else if (res.status >= 400 && res.status < 500) { | ||
| http4xx.add(1); | ||
| const err = parseError(res); | ||
| switch (err.code) { | ||
| case ERR.POST_ALREADY_LIKED: | ||
| fail_POST_ALREADY_LIKED.add(1); | ||
| break; | ||
| case ERR.POST_NOT_LIKED_CANNOT_CANCEL: | ||
| fail_POST_NOT_LIKED_CANNOT_CANCEL.add(1); | ||
| break; | ||
| case ERR.POST_LIKE_COUNT_UNDERFLOW: | ||
| fail_POST_LIKE_COUNT_UNDERFLOW.add(1); | ||
| break; | ||
| default: | ||
| fail_OTHER_4XX.add(1); | ||
| } | ||
| } else if (res.status >= 500) { | ||
| http5xx.add(1); | ||
| } | ||
|
|
||
| // === 검증 === | ||
| check(res, { | ||
| 'like responded': (r) => r.status !== 0, | ||
| 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), | ||
| }); | ||
| } | ||
|
|
||
| // 테스트 결과 html 리포트로 저장 | ||
| import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; | ||
| export function handleSummary(data) { | ||
| return { | ||
| "summary.html": htmlReport(data), | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| package konkuk.thip.feed.application.port.out; | ||
|
|
||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import konkuk.thip.common.exception.EntityNotFoundException; | ||
| import konkuk.thip.feed.domain.Feed; | ||
|
|
||
|
|
@@ -12,6 +14,7 @@ public interface FeedCommandPort { | |
| Long save(Feed feed); | ||
| Long update(Feed feed); | ||
| Optional<Feed> findById(Long id); | ||
| List<Long> findByIds(List<Long> ids); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find and examine FeedCommandPort interface
find . -name "FeedCommandPort.java" -type fRepository: THIP-TextHip/THIP-Server Length of output: 142 🏁 Script executed: #!/bin/bash
# Search for FeedCommandPersistenceAdapter
find . -name "FeedCommandPersistenceAdapter.java" -type fRepository: THIP-TextHip/THIP-Server Length of output: 159 🏁 Script executed: #!/bin/bash
# Look for Feed entity definition
find . -path "*/feed/*" -name "Feed.java" -type fRepository: THIP-TextHip/THIP-Server Length of output: 117 🏁 Script executed: #!/bin/bash
# Search for usages of findByIds method
rg "findByIds" --type javaRepository: THIP-TextHip/THIP-Server Length of output: 1691 🏁 Script executed: #!/bin/bash
# Examine FeedCommandPort interface
cat -n ./src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.javaRepository: THIP-TextHip/THIP-Server Length of output: 1179 🏁 Script executed: #!/bin/bash
# Examine FeedCommandPersistenceAdapter implementation
cat -n ./src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.javaRepository: THIP-TextHip/THIP-Server Length of output: 7510 🏁 Script executed: #!/bin/bash
# Check context where findByIds is called in PostHandler
rg -A 5 -B 5 "findByIds" ./src/main/java/konkuk/thip/post/application/service/handler/PostHandler.javaRepository: THIP-TextHip/THIP-Server Length of output: 508 🏁 Script executed: #!/bin/bash
# Check UserCommandPort for comparison (it returns entities)
cat -n ./src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.javaRepository: THIP-TextHip/THIP-Server Length of output: 507
다음 중 하나로 수정하세요:
🤖 Prompt for AI Agents |
||
| default Feed getByIdOrThrow(Long id) { | ||
| return findById(id) | ||
| .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); | ||
|
|
@@ -21,4 +24,5 @@ default Feed getByIdOrThrow(Long id) { | |
| void deleteSavedFeed(Long userId, Long feedId); | ||
| void deleteAllSavedFeedByUserId(Long userId); | ||
| void deleteAllFeedByUserId(Long userId); | ||
| void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package konkuk.thip.post.adapter.out.event; | ||
|
|
||
| import konkuk.thip.post.adapter.out.event.dto.PostLikeChangedEvent; | ||
| import konkuk.thip.post.application.port.out.PostLikeEventCommandPort; | ||
| import konkuk.thip.post.application.port.out.PostLikeQueueCommandPort; | ||
| import konkuk.thip.post.application.port.out.PostLikeRedisCommandPort; | ||
| import konkuk.thip.post.domain.PostType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.ApplicationEventPublisher; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class PostLikeEventSyncAdapter implements PostLikeEventCommandPort { | ||
|
|
||
| private final ApplicationEventPublisher publisher; | ||
|
|
||
| private final PostLikeRedisCommandPort postLikeRedisCommandPort; | ||
| private final PostLikeQueueCommandPort postLikeQueueCommandPort; | ||
|
|
||
| @Override | ||
| public void publishEvent(Long userId, Long postId, boolean isLike, PostType postType, int finalLikeCount) { | ||
| publisher.publishEvent(PostLikeChangedEvent.builder() | ||
| .userId(userId) | ||
| .postId(postId) | ||
| .isLike(isLike) | ||
| .postType(postType) | ||
| .finalLikeCount(finalLikeCount) | ||
| .build()); | ||
| } | ||
|
|
||
| @Async("postLikeAsyncExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| protected void handlePostLikeChangedEvent(PostLikeChangedEvent event) { | ||
| // 1. Redis Set 기록 (좋아요 상태 반영) | ||
| if (event.isLike()) { | ||
| postLikeRedisCommandPort.addLikeRecordToSet(event.userId(), event.postId()); | ||
| } else { | ||
| postLikeRedisCommandPort.removeLikeRecordFromSet(event.userId(), event.postId()); | ||
| } | ||
|
|
||
| // 2. Redis 카운트 갱신 (INCR/DECR) | ||
| postLikeRedisCommandPort.updateLikeCount(event.postType(), event.postId(), | ||
| event.finalLikeCount(), event.isLike()); | ||
|
|
||
| // 3. 비동기 DB 기록 메시지를 큐에 삽입 (Redis List LPUSH) | ||
| try { | ||
| postLikeQueueCommandPort.enqueueFromEvent(event); | ||
| } catch (Exception e) { | ||
| log.error("CRITICAL: Failed to publish Like Record to Queue. Event Data: {}", event, e); | ||
| } | ||
| } | ||
|
Comment on lines
+37
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 부분 실패 시 데이터 정합성 문제가 발생할 수 있습니다. 현재 구현에서 Redis Set 업데이트(1단계) 또는 카운트 갱신(2단계)이 성공한 후 큐 삽입(3단계)이 실패하면, 사용자에게는 좋아요가 반영된 것처럼 보이지만 DB에는 영구 기록되지 않습니다. 최소한의 복구 전략으로:
try {
postLikeQueueCommandPort.enqueueFromEvent(event);
} catch (Exception e) {
log.error("CRITICAL: Failed to publish Like Record to Queue. Event Data: {}", event, e);
+ // 복구를 위해 실패한 이벤트를 별도로 저장하거나 재시도 큐에 추가
+ // failedEventRepository.save(event);
} |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
종료 시 태스크 완료 대기 시간 및 거부 정책을 명시적으로 설정하세요.
현재 구성에 두 가지 잠재적 데이터 유실 위험이 있습니다:
awaitTerminationSeconds미설정:waitForTasksToCompleteOnShutdown만으로는 무한정 대기할 수 있습니다. 애플리케이션 종료 시 작업 완료를 위한 최대 대기 시간을 설정해야 합니다.RejectedExecutionHandler미설정: 큐가 가득 차고 최대 스레드 수에 도달하면 기본AbortPolicy가 작동하여 새 작업이 예외와 함께 거부됩니다. 좋아요 이벤트 유실을 방지하려면CallerRunsPolicy를 사용하는 것을 권장합니다.다음 diff를 적용하세요:
@Bean(name = "postLikeAsyncExecutor") public Executor postLikeAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(30); executor.setQueueCapacity(300); executor.setThreadNamePrefix("like-async-"); executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }🤖 Prompt for AI Agents