Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5ac36e6
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 13, 2025
480e850
[test] 피드 좋아요 상태변경 다중 스레드 테스트 (#322)
hd0rable Oct 18, 2025
f14a490
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
1731bcc
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 25, 2025
20c2b95
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
74aa272
[fix] 게시글 좋아요 상태변경 s-lock에서 x-lock으로 승격할때 데드락 상황을 해결하기위해 서비스로직 순서 변경 …
hd0rable Nov 9, 2025
be10bed
[chore] 안쓰는 테스트코드 스크릾트 삭제 (#338)
hd0rable Nov 24, 2025
b3b4655
[test] 특정 시점에 한 게시물 (인기 작가,인플루언서가 작성한)에 좋아요 요청이 몰리는 시나리오 k6 테스트 스크립트 …
hd0rable Nov 24, 2025
a9b91ca
[refactor] 게시글 공통인터페이스에 getLikeCount() 함수 추가 (#338)
hd0rable Nov 24, 2025
a34c050
[refactor] 레디스 도입시 게시글 도메인이 좋아요 업데이트 검증시에 likeCount 외부에서 주입받도록 수정 (#338)
hd0rable Nov 24, 2025
5dfae3b
[refactor] feed findPostIdsByIds,batchUpdateLikeCounts 추가 (#338)
hd0rable Nov 24, 2025
f4d8041
[feat] FeedJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
36d5524
[feat] FeedCommandPersistenceAdapter.findByIds() 추가 및 batchUpdateLik…
hd0rable Nov 24, 2025
bc6b49e
[feat] PostHandler
hd0rable Nov 24, 2025
fcf3e5c
[feat] 게시글 좋아요 캐싱 PostLikeCountRedisCommand,Query Port 추가 (#338)
hd0rable Nov 24, 2025
7302cc3
[feat] 게시글 좋아요 캐싱 구현체 PostLikeCountRedisAdapter 추가 (#338)
hd0rable Nov 24, 2025
5a9cc95
[feat] RecordCommandPersistenceAdapterAdapter.findByIds() 추가 및 batch…
hd0rable Nov 24, 2025
4154b49
[refactor] RecordCommandPort findPostIdsByIds,batchUpdateLikeCounts 추…
hd0rable Nov 24, 2025
3e7a47c
[feat] RecordJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
7390280
[feat] RedisConfig.redisIntegerTemplate 추가 (#338)
hd0rable Nov 24, 2025
567f1e6
[feat] VoteCommandPersistenceAdapter.findByIds() 추가 및 batchUpdateLik…
hd0rable Nov 24, 2025
c972f51
[refactor] VoteCommandPort.findPostIdsByIds,batchUpdateLikeCounts 추가 …
hd0rable Nov 24, 2025
35f3456
[feat] VoteJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
c6401d7
[refactor] 게시글 좋아요 시, post 최초 조회시에 비관락 해제하고 레디스의 INCR/DECR 명령어를 사용하는 …
hd0rable Nov 24, 2025
cb00e01
[feat]
hd0rable Nov 24, 2025
e4891a4
[chore] 주석 정리 (#332)
hd0rable Nov 28, 2025
f6b6ba9
[fix] 쿼리 실행 전 빈 목록 파라미터 유효성 검사 추가 (#324)
hd0rable Dec 6, 2025
f4d3a2a
[refactor] 실제 존재하는 ID가 없을 때 DB 업데이트 방지 (#324)
hd0rable Dec 6, 2025
bb2a260
[test] 게시글 좋아요 로직 변경에 따른 테스트 코드 수정
hd0rable Dec 6, 2025
59f89c2
[test] 게시글 좋아요 로직 변경에 따른 게시글(도메인) 테스트 코드 수정
hd0rable Dec 6, 2025
9011584
[refactor] db 동기화 스케줄러 test환경에서는 실행 안되도록 수정 (#338)
hd0rable Dec 6, 2025
a6cdc57
[test] book 중복 insert 테스트 깨지는 거 수정 (#338)
hd0rable Dec 6, 2025
02794c2
[test] 캐싱 로직 추가로 레디스 초기화하는 셋업 처리 추가 (#338)
hd0rable Dec 6, 2025
17f23d3
[chore] import문 정리 (#338)
hd0rable Dec 6, 2025
38bfd73
Merge branch 'develop' into test/#322-k6-feed-like-redis-INCR
hd0rable Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions loadtest/feed/feed-like-load-test.js
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),
};
}
11 changes: 11 additions & 0 deletions src/main/java/konkuk/thip/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
Expand All @@ -29,6 +30,16 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connec
return redisTemplate;
}

@Bean
public RedisTemplate<String, Integer> redisIntegerTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
return redisTemplate;
}


@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package konkuk.thip.feed.adapter.out.persistence;

import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Map;
import konkuk.thip.book.adapter.out.jpa.BookJpaEntity;
import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository;
import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository;
Expand All @@ -16,6 +20,8 @@
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.List;
Expand All @@ -27,6 +33,8 @@
@RequiredArgsConstructor
public class FeedCommandPersistenceAdapter implements FeedCommandPort {

private final JdbcTemplate jdbcTemplate;

private final FeedJpaRepository feedJpaRepository;
private final UserJpaRepository userJpaRepository;
private final BookJpaRepository bookJpaRepository;
Expand All @@ -44,6 +52,13 @@ public Optional<Feed> findById(Long id) {
.map(feedMapper::toDomainEntity);
}

@Override
public List<Long> findByIds(List<Long> ids) {
if (ids == null || ids.isEmpty())
return new ArrayList<>();
return feedJpaRepository.findByPostIds(ids);
}


@Override
public Long save(Feed feed) {
Expand Down Expand Up @@ -114,6 +129,26 @@ public void deleteAllFeedByUserId(Long userId) {
feedJpaRepository.softDeleteAllByUserId(userId);
}

@Override
public void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount) {
String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?";
List<Map.Entry<Long, Integer>> entries = new ArrayList<>(idToLikeCount.entrySet());

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Map.Entry<Long, Integer> entry = entries.get(i);
ps.setInt(1, entry.getValue()); // Redis에서 가져온 좋아요 수 (증분 값)
ps.setLong(2, entry.getKey()); //게시글 ID
}

@Override
public int getBatchSize() {
return entries.size();
}
});
}

@Override
public void delete(Feed feed) {
FeedJpaEntity feedJpaEntity = feedJpaRepository.findByPostId(feed.getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ public interface FeedJpaRepository extends JpaRepository<FeedJpaEntity, Long>, F
@Query("UPDATE FeedJpaEntity f SET f.status = 'INACTIVE' WHERE f.userJpaEntity.userId = :userId")
void softDeleteAllByUserId(@Param("userId") Long userId);

@Query("SELECT f.postId FROM FeedJpaEntity f WHERE f.postId IN :postIds")
List<Long> findByPostIds(@Param("postIds") List<Long> postIds);
}
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;

Expand All @@ -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);
default Feed getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
Expand All @@ -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);
}
4 changes: 2 additions & 2 deletions src/main/java/konkuk/thip/feed/domain/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ public void decreaseCommentCount() {
}

@Override
public void updateLikeCount(PostCountService postCountService, boolean isLike) {
likeCount = postCountService.updatePostLikeCount(isLike, likeCount);
public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) {
likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount);
}

private void checkCommentCountNotUnderflow() {
Expand Down
Loading