2024. 8. 8. 17:04ㆍ프로젝트
Flickr(플리커) 개인 프로젝트 개발 중에,
k6을 이용해 부하 테스트를 해보고 성능 개선 경험을 적어보려고 한다.
k6란?
- 오픈 소스 성능 테스트 툴로, 쉽고 간편하게 스크립트를 작성하고, 테스트 해볼 수 있음
- 성능이 뛰어나고 테스트가 간편해 채택하였다.
MAC 에서 k6 설치
brew install k6
위 명령어로 새로운 script.js 스크립트 파일을 생성해, 테스트를 수행할 수 있다.
플리커 홈 화면
홈에서 새로고침을 할 때마다 전체 피드중에서 랜덤하게 15개를 골라 출력한다.
즉 랜덤한 피드(게시글) 15개를 가져오는 로직이 이 서비스의 핵심 기능 중 하나라고 볼 수 있다.
상황 : user 데이터 1000개, feed(게시글) 데이터 10만개라고 가정하고, 부하 테스트를 진행해보자.
- 사진과 같이 Feed 테이블에 정확히 10만 112개의 데이터가 있다.
기존 코드
feed를 전부 가져와서 랜덤하게 15개를 골라서 출력한다.
@RestController
@RequestMapping("/api/feeds")
@RequiredArgsConstructor
public class FeedController {
private final FeedService feedService;
@GetMapping("/random")
public List<SocialPost> listRandomFeeds() {
return feedService.getRandomFeeds();
}
}
@Service
@RequiredArgsConstructor
public class FeedService {
private final FeedJpaRepository feedJpaRepository;
private final UserRepository userRepository;
public List<SocialPost> getRandomFeeds() {
// 전체 id를 가져온다.
List<Integer> feedIds = feedJpaRepository.getAllIds();
Collections.shuffle(feedIds, new Random());
List<Integer> temp = new ArrayList<>();
// temp : 15개의 랜덤 feed_id 가 저장됨
for (int i = 0; i < 15; i++) {
temp.add(feedIds.get(i));
}
List<SocialFeed> feeds = feedJpaRepository.findWithIds(temp);
List<SocialPost> result = new ArrayList<>();
for (SocialFeed feed : feeds) {
result.add(
new SocialPost(feed, feedRepository.countLikes(feed.getFeedId()))
);
}
return result;
}
}
public interface FeedJpaRepository extends JpaRepository<SocialFeed, Integer> {
@Query("SELECT f FROM SocialFeed f JOIN FETCH f.user WHERE f.feedId IN :feedIds")
List<SocialFeed> findWithIds(@Param("feedIds") List<Integer> feedIds);
}
코드 설명
feed 테이블 전체에서 id 값들을 모두 불러온다. (전체를 Select 하는 건 메모리에게 너무 부담이 될까봐 id 를 불러온다.)
id 리스트에서 15개를 랜덤하게 고르고, feedRepository 에서 15개 id 각각에 해당하는 feed 정보를 가져온다.
- List<SocialFeed> feeds = feedJpaRepository.findWithIds(temp);
- user fetch-join 을 해서 가져온다. 그래야 SocialPost 로 변환할 때, N+1 문제가 발생하지 않는다.
즉 전체 피드 id를 조회해 List 로 가져오고, 그 중에서 15개를 랜덤으로 추출하여 SocialPost 로 변환하여 반환한다.
성능테스트
test_old.js 파일
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
export let errorRate = new Rate('errors');
export let options = {
stages: [
{ duration: '30s', target: 50 }, // ramp-up to 50 users over 30 seconds
{ duration: '1m', target: 50 }, // stay at 50 users for 1 minute
{ duration: '30s', target: 0 }, // ramp-down to 0 users over 30 seconds
]
};
export default function () {
let res = http.get('http://localhost:8080/api/feeds/random');
let result = check(res, {
'is status 200': (r) => r.status === 200,
'is duration < 500ms': (r) => r.timings.duration < 500,
});
errorRate.add(!result);
sleep(1);
}
- Ramp-up 단계 : 첫 30초 동안 가상 사용자가 0명에서 50명으로 증가한다.
- 유지 단계 : 다음 1분 동안 50명의 가상 사용자가 유지된다.
- Ramp-down 단계 : 마지막 30초 동안 가상 사용자가 50명에서 0명으로 감소한다.
왜 위의 3단계가 필요할까?
시스템의 초기 부하 대응 능력 평가 : Ramp-up 단계는 시스템이 점진적으로 증가하는 부하에 어떻게 대응하는지 평가할 수 있다.
갑작스런 트래픽 폭주가 아니라, 사용자가 서서히 증가할 때 시스템이 안정적으로 작동하는지 확인 가능
유지 단계 : 시스템이 일정 수준의 고부하 상태에서 지속적으로 어떻게 작동하는지를 평가한다. 실제 운영 환경에서는 일시적인 트래픽 폭주보다는 일정 시간 높은 부하가 유지되는 상황이 자주 발생한다.
Ramp-down : 부하가 감소할 때 시스템이 어떻게 안정적으로 작동하는지 평가한다. 시스템의 자원 회복력과 안정성을 테스트한다.
테스트 결과
거의 모든 요청의 응답 시간이 500ms를 초과해, is duration < 500ms 조건을 충족하지 못한 요청 수 : 92.99% 로 나온다.
성공 기준인, 실행 시간 0.5ms 이하가 단 7% 임을 알 수 있다. (feed 데이터 10만개 기준)
checks : 53.5% 는 정의된 검사 조건이 성공했는지를 나타낸다.
- 응답 상태 코드가 200 인지, 응답 시간이 500ms 이하인지 2개
- 모든 요청이 200을 반환했지만, 응답 시간 < 500ms 인 요청 성공이 7%밖에 안되서, 전체 checks: 53.5% 라고 나온다.
http_req_duration
- 평균 응답 시간 3.74 초
- 최소 응답 시간 : 86.59ms
- 최대 응답 시간 : 12.39초
응답 시간이 매우 길어, 성능 최적화가 필요해 보인다.
피드를 조회하는데 4초 가까이 걸린다면 아무도 서비스를 사용하지 않을 것 같다.
http_reqs
- total 971 (8.02/s)
- 처리량(Throughput)은 대략 8.02/s 이다.
수정된 코드
페이지네이션 : 페이지 한 부분을 고르고 그 페이지에서 랜덤하게 15개를 출력한다.
앞서 피드 테이블에서 전체 데이터를 가져오는 것이 아니라, Page로 나눠 그 중에서 한 개를 선택한다.
public List<SocialPost> getRandomFeedsByPaging() {
int totalFeeds = (int) feedJpaRepository.count();
int pageSize = 100;
int totalPages = (totalFeeds + pageSize - 1) / pageSize;
// 랜덤 페이지 선택
Random random = new Random();
int randomPageNumber = random.nextInt(totalPages);
if (randomPageNumber == totalPages - 1) {
/** 만약 마지막 페이지 데이터 수가 50 미만이라면, 바로 앞의 페이지에 더해준다. */
}
Pageable pageable = PageRequest.of(randomPageNumber, pageSize);
Page<SocialFeed> feedPage = feedRepository.findAllWithUser(pageable);
List<SocialFeed> feeds = new ArrayList<>(feedPage.getContent());
Collections.shuffle(feeds);
// 15개의 랜덤 피드 선택
List<SocialFeed> randomFeeds = new ArrayList<>();
for (int i = 0; i < Math.min(15, feeds.size()); i++) {
randomFeeds.add(feeds.get(i));
}
List<SocialPost> result = new ArrayList<>();
for (SocialFeed feed : randomFeeds) {
SocialPost socialPost = new SocialPost(feed, feedRepository.countLikes(feed.getFeedId()));
result.add(socialPost);
}
return result;
}
코드 설명
- int pageSize = 100;
- 한 페이지에 100개의 피드를 가져온다. (페이지 당 100개)
- int totalPages = (totalFeeds + pageSize - 1) / pageSize;
- 전체 피드 수를 페이지 크기로 나눠 총 페이지 수를 계산한다. (페이지 수를 올림하여 정확한 페이지 수)
- Random random = new Random(); int randomPageNumber = random.nextInt(totalPages);
- totalPages 범위 내에서 랜덤한 페이지 번호를 선택한다.
- Pageable pageable = PageRequest.of(randomPageNumber, pageSize); Page<SocialFeed> feedPage = feedRepository.findAll(pageable);
- Pageable 객체를 생성하며, 선택된 랜덤 페이지 번호와 페이지 크기를 기반으로 특정 페이지를 요청
- List<SocialFeed> feeds = feedPage.getContent(); Collections.shuffle(feeds);
- 지정된 페이지 번호와 크기에 해당하는 피드 데이터를 가져온다.
- 그 후 Collections.shuffle()을 통해 15개의 피드 데이터를 가져온다.
테스트 결과
페이지네이션을 적용하여 랜덤하게 15개의 피드를 가져오는 API를 테스트해보자.
스크립트는 100% 동일하다.
http_req_duration (응답 시간)
- 평균 297.48 ms로, 크게 감소하였다.
- 3.74 sec -> 297.47 ms 약 13배 상승
http_reqs : 3499, 29.14/s
- 8.02 -> 29.14
- 초당 처리량은 약 3.63 배 상승
결론
10만 개의 피드(게시글) 데이터를 바탕으로 성능 테스트를 진행한 결과, 페이지네이션 적용 후 Random Feed를 가져오는 API 의 성능을 크게 향상시켰다.
- API 수행 속도는 3.74 sec ⇒ 297.47 ms. 약 13배 향상
- 초당 처리량은 8.02 -> 29.14 로, 약 3.7 배 향상
페이지네이션을 적용한 후, DB에서 데이터를 더 효율적으로 가져올 수 있게 된다.
테이블 전체를 스캔하는 대신 특정 범위의 데이터만을 조회할 수 있게 되어, DB쿼리 처리 시간을 크게 줄이고 I/O 부하를 낮추어 더 많은 요청을 처리할 수 있게 한다.
페이지네이션을 통해 대량의 데이터를 효율적으로 처리가 가능했고, 성능 최적화에 중요한 역할을 한다는 것을 알게 되었다.
의문점
Pageable 객체를 사용해 페이지네이션을 적용했는데 PageRequest.of() 는 내부적으로 OFFSET, LIMIT 구문을 사용해 쿼리를 날린다.
Pageable pageable = PageRequest.of(randomPageNumber, pageSize);
Page<SocialFeed> feedPage = feedRepository.findAllWithUser(pageable);
OFFSET은 특정 행을 건너뛰는 것이고, LIMIT은 쿼리 결과에서 반환할 최대 행 수를 제한한다.
OFFSET 기반 페이지네이션은 DB에서 많은 양의 데이터를 처리할 때 유용하지만, OFFSET이 지나치게 큰 경우에는 성능 저하가 발생할 수 있다. 데이터가 많아질 경우 커서 기반 등 다른 방식도 고려해보는 것도 좋을 듯 하다.
이 프로젝트에서 커서 기반 페이지네이션으로 랜덤피드를 가져오게 된다면 특정 부분에 너무 몰린다고 생각해서 Offset 기반으로 구현했다.
만약 데이터가 10만개보다 더욱 많아진다면, 그 때 가서 고려해봐도 좋을 듯 하다.
'프로젝트' 카테고리의 다른 글
[프로젝트] 캐시를 통한 좋아요, 팔로우 로직 최적화 (1) | 2024.08.10 |
---|---|
[프로젝트] 레디스 Pub/Sub, RabbitMQ 도입으로 구조 개선 (0) | 2024.06.18 |
[프로젝트] SSE 로 실시간 알림 기능 구현하기 (0) | 2024.06.13 |
[쿠폰] 서버 모니터링 및 부하테스트 (0) | 2024.05.12 |
레디스 쿼리로 서버 RPS 개선 (0) | 2024.04.29 |