[프로젝트] Pagination 으로 피드 랜덤조회 개선

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만개보다 더욱 많아진다면, 그 때 가서 고려해봐도 좋을 듯 하다.