1. 문제 : 페이징 성능 이슈 발생
프로젝트 진행 중, GCP에 배포된 Spring Boot 어플리케이션에서 페이징 기능을 성능 테스트한 결과, 충격적인 데이터를 확인하게 되었습니다. 100명의 유저가 1분 동안 요청을 보낸 결과 평균 TPS는 10.0, MTT는 9.082ms로, 매우 낮은 성능을 보였습니다.
부하 테스트 결과 분석
네트워크 속도나 서버의 하드웨어 성능 문제일 가능성도 염두에 두었지만, 실제 원인은 데이터베이스 쿼리의 비효율성으로 보였습니다. 많은 수의 페이지 요청을 처리하면서 서버의 CPU와 메모리 사용량이 급증했고, 데이터베이스의 처리 시간이 특히 문제가 되었습니다.
기존 페이징 방식은 다음과 같은 흐름을 따르고 있습니다.
- 기본 티켓 목록 페이징: 조건 없이 요청할 경우 예매 시작 날짜 기준 내림차순
- 카테고리별 및 날짜 조건 페이징: 카테고리 또는 날짜 조건을 포함한 쿼리로 요청에 맞춰 해당 티켓을 페이징
이 상태가 지속된다면 사용자가 페이지 이동이나 조건을 설정하여 데이터를 조회할 때 심각한 불편을 겪을 가능성이 커서 개선이 필요했습니다. 현재 사용할 수 있는 클라우드 자원이 한정되어 있기 때문에 네트워크나 하드웨어 업그레이드 대신, 쿼리 성능을 최적화하여 문제를 해결하기로 결정했습니다.
2. 커버링 인덱스 도입
성능 개선을 위해 커서 기반의 무한스크롤 방식도 고려해보았지만, OFFSET 기반의 페이징 방식의 장점을 포기할 수 없었기 때문에, 기존의 OFFSET 기반 페이징 방식을 유지하기로 했습니다. 이에 따라 데이터베이스 쿼리 성능을 최적화하기 위해
커버링 인덱스(Covering Index)를 도입하기로 했습니다.
2-1 커버링 인덱스란?
커버링 인덱스는 쿼리를 충족하는데 필요한 모든 데이터를 갖는 인덱스로 SELECT / WHERE/ GROUP BY / ORDER BY 등에 활용하는 모든 컬럼이 인덱스의 구성요소에 포함됩니다. 이렇게 되면 테이블 자체를 읽지 않고 인덱스만으로 데이터를 반환할 수 있기 때문에, 응답 속도를 개선할 수 있습니다.
커버링 인덱스의 장점
- 테이블 접근 최소화: 필요한 데이터를 인덱스에서 바로 반환하므로, 테이블 데이터 접근을 줄인다.
- 빠른 응답 속도: 인덱스를 통해 모든 데이터를 처리하므로, 응답 속도를 개선할 수 있다.
2-2 현재 페이징 방식의 실행계획 살펴보기
커버링 인덱스를 적용하기 전에 기존 쿼리가 어떻게 처리되고 있는지 파악하기 위해 EXPLAIN 명령어를 사용해 실행 계획을 살펴보겠습니다.
기본 페이징 쿼리 실행계획
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
ORDER BY
t1_0.start_date DESC
LIMIT 10, 10

- Full Table Scan: type이 ALL로 나타나며, 인덱스를 사용하지 않고 테이블 전체를 읽는 Full Table Scan이 발생하고 있습니다.
카테고리별 페이징 실행계획
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
AND t1_0.category_id IN (1)
ORDER BY
t1_0.start_date DESC
LIMIT 10,10

type: ref: category_id에 대해 조건이 적용되고 있어 효율적인 접근이 가능
기간 별 페이징 쿼리 실행계획
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
AND t1_0.start_date BETWEEN 2024-05-04 AND 2024-07-06
ORDER BY
t1_0.start_date DESC
LIMIT 10, 10

- Full Table Scan 발생: type이 ALL로 나타나며, 테이블 전체를 읽는 Full Table Scan이 발생합니다.
지금까지 쿼리 실행 계획을 분석한 결과, 기본 페이징과, 기간 별 페이징 방식에서 테이블 전체를 읽는 Full Table Scan이 발생하는 것을 확인할 수 있었습니다. 이와 같은 문제는 결국 응답 속도 저하와 TPS 감소로 이어지며, 특히 페이징 쿼리가 빈번하게 호출될 때 서버 부하를 가중시킬 수 있습니다.
이 문제를 해결하기 위해서 지금부터는 본격적으로 커버링 인덱스를 적용해보도록 하겠습니다.
2-3 커버링 인덱스 도입하기
커버링 인덱스를 적용하기 전 고민해야할 부분이 한 가지가 있습니다. 커버링 인덱스를 적용하기 위해서는 select, where, order by 등등 사용되는 모든 컬럼이 Index 컬럼안에 포함되어야 하는데 select 절의 모든 컬럼까지 포함하게 되면 너무 많은 컬럼이 인덱스에 포함되게 됩니다.
그렇기 때문에 커버링 인덱스로 빠르게 걸러낸 row의 id를 통해 실제 select절의 항목들을 조회하는 형태를 사용해보도록 하겠습니다.
QueryDsl 작성하기
public class TicketCustomRepositoryImpl implements TicketCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public TicketCustomRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Page<TicketPageResponse> findFilteredTickets(LocalDate startDate, LocalDate endDate, Long categoryId, Pageable pageable) {
QTicket qTicket = QTicket.ticket;
Predicate condition = createCondition(qTicket, startDate, endDate, categoryId);
List<Long> ids = fetchTicketIds(qTicket, condition, pageable);
if (ids.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
List<TicketPageResponse> ticketPageResponse = fetchTicketsByIds(qTicket, ids);
JPAQuery<Long> countQuery = createCountQuery(qTicket, condition);
return PageableExecutionUtils.getPage(ticketPageResponse, pageable, countQuery::fetchOne);
}
private Predicate createCondition(QTicket qTicket, LocalDate startDate, LocalDate endDate, Long categoryId) {
BooleanExpression condition = qTicket.isNotNull(); // 초기 조건 설정
QCategory qCategory = QCategory.category;
if (startDate != null && endDate != null) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
condition = condition.and(qTicket.startDate.between(startDateTime, endDateTime));
}
if (categoryId != null) {
List<Long> categoryIds = fetchCategoryIdsByParentId(categoryId, qCategory);
condition = condition.and(qTicket.category.id.in(categoryIds));
}
return condition;
}
//커버링 인덱스를 통해 id를 조회한다.
private List<Long> fetchTicketIds(QTicket qTicket, Predicate condition, Pageable pageable) {
return jpaQueryFactory
.select(qTicket.id)
.from(qTicket)
.where(condition)
.orderBy(qTicket.startDate.desc()) // 페이징을 위한 정렬
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
//커버링 인덱스를 통해 조회한 id로 select 진행
private List<TicketPageResponse> fetchTicketsByIds(QTicket qTicket, List<Long> ids) {
return jpaQueryFactory
.select(new QTicketPageResponse(
qTicket.id,
qTicket.title,
qTicket.content,
qTicket.runningTime,
qTicket.openDate,
qTicket.endDate,
qTicket.startDate
))
.from(qTicket)
.where(qTicket.id.in(ids))
.orderBy(qTicket.startDate.desc())
.fetch();
}
private JPAQuery<Long> createCountQuery(QTicket qTicket, Predicate condition) {
return jpaQueryFactory
.select(qTicket.count())
.from(qTicket)
.where(condition);
}
private List<Long> fetchCategoryIdsByParentId(Long categoryId, QCategory category) {
return jpaQueryFactory
.select(category.id)
.from(category)
.where(category.parent.id.eq(categoryId).or(category.id.eq(categoryId)))
.fetch();
}
}
1. 커버링 인덱스를 통해 해당 조건에 해당하는 티켓의 PK를 조회한다.
2. 1번의 결과를 통해 얻은 PK를 통해 필요한 컬럼 항목들을 조회한다.
위와 같이 커버링 인덱스가 적용된 컬럼을 통해 티켓의 id를 구하고 티켓의 id를 통해 데이터를 조회하는 방식을 적용해보았습니다. 지금부터는 인덱스 생성 후 변경된 방식의 실행계획을 확인하고 성능이 얼마나 향상되었는지 확인해보도록 하겠습니다.
1. 일반 페이징
EXPLAIN
SELECT
t1_0.id
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
ORDER BY
t1_0.start_date DESC
LIMIT
10 OFFSET 0;

- 인덱스 idx_ticket_start_date가 제대로 활용되어 수행되고 있음
2. 기간별 검색 페이징
EXPLAIN
SELECT
t1_0.id
FROM
ticket t1_0
WHERE
t1_0.start_date BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY
t1_0.start_date
LIMIT 10 OFFSET 0;

- idx_ticket_start_date 인덱스를 사용해 범위 검색(type = range)이 수행되고 있음.
- 커버링 인덱스(Using index)를 사용중
공통적으로 발생하는 쿼리
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IN (1, 2, 3)
ORDER BY
t1_0.start_date DESC;
위에서 얻은 id를 통해 select를 진행하면서 테이블 풀 스캔을 하던 이전 방식과 달리 pk값을 이용한 범위검색이 진행되는 것을 볼 수 있습니다.

2-4 성능 테스트 진행
nGrinder를 통해 100명의 유저가 1분동안 요청을 보내는 경우를 테스트해 보겠습니다.
일반 페이징 요청

TPS가 10.0에서 -> 43.0 로 증가
Mean Test Time가 9,082ms -> 2,290ms 감소
날짜 범위 검색

TPS가 5.5에서 -> 62.3 로 증가
Mean Test Time가 15,700ms -> 1,541ms 감소
기존 페이징 쿼리의 문제점을 커버링 인덱스를 도입함으로써 TPS(Transactions Per Second)가 크게 증가하고 Mean Test Time이 대폭 감소하여 서버 응답 속도를 개선할 수 있었습니다. 물론 여전히 1초 이상의 응답 시간이 소요된다는 한계는 있지만, 한정된 자원 내에서 최대한 성능을 향상시켰다는 점에서 의의가 있었다고 생각합니다.
앞으로도 이러한 문제를 해결하기 위한 추가적인 최적화 방안을 찾아서 돌아오도록 하겠습니다.
참고
'Backend > 프로젝트' 카테고리의 다른 글
Elasticsearch 활용한 검색 서비스 만들기 1편 (Docker로 Elasticsearch + Kibana 구축) (0) | 2025.03.10 |
---|---|
무중단 배포 적용하기 (0) | 2025.01.01 |
[Spring] Redis 캐시 적용하기 (0) | 2024.10.31 |
nGrinder Docker 설치 및 사용방법 (0) | 2024.10.25 |
[스프링] 토스 간편결제 기능 구현하기 (0) | 2024.09.10 |
1. 문제 : 페이징 성능 이슈 발생
프로젝트 진행 중, GCP에 배포된 Spring Boot 어플리케이션에서 페이징 기능을 성능 테스트한 결과, 충격적인 데이터를 확인하게 되었습니다. 100명의 유저가 1분 동안 요청을 보낸 결과 평균 TPS는 10.0, MTT는 9.082ms로, 매우 낮은 성능을 보였습니다.
부하 테스트 결과 분석
네트워크 속도나 서버의 하드웨어 성능 문제일 가능성도 염두에 두었지만, 실제 원인은 데이터베이스 쿼리의 비효율성으로 보였습니다. 많은 수의 페이지 요청을 처리하면서 서버의 CPU와 메모리 사용량이 급증했고, 데이터베이스의 처리 시간이 특히 문제가 되었습니다.
기존 페이징 방식은 다음과 같은 흐름을 따르고 있습니다.
- 기본 티켓 목록 페이징: 조건 없이 요청할 경우 예매 시작 날짜 기준 내림차순
- 카테고리별 및 날짜 조건 페이징: 카테고리 또는 날짜 조건을 포함한 쿼리로 요청에 맞춰 해당 티켓을 페이징
이 상태가 지속된다면 사용자가 페이지 이동이나 조건을 설정하여 데이터를 조회할 때 심각한 불편을 겪을 가능성이 커서 개선이 필요했습니다. 현재 사용할 수 있는 클라우드 자원이 한정되어 있기 때문에 네트워크나 하드웨어 업그레이드 대신, 쿼리 성능을 최적화하여 문제를 해결하기로 결정했습니다.
2. 커버링 인덱스 도입
성능 개선을 위해 커서 기반의 무한스크롤 방식도 고려해보았지만, OFFSET 기반의 페이징 방식의 장점을 포기할 수 없었기 때문에, 기존의 OFFSET 기반 페이징 방식을 유지하기로 했습니다. 이에 따라 데이터베이스 쿼리 성능을 최적화하기 위해
커버링 인덱스(Covering Index)를 도입하기로 했습니다.
2-1 커버링 인덱스란?
커버링 인덱스는 쿼리를 충족하는데 필요한 모든 데이터를 갖는 인덱스로 SELECT / WHERE/ GROUP BY / ORDER BY 등에 활용하는 모든 컬럼이 인덱스의 구성요소에 포함됩니다. 이렇게 되면 테이블 자체를 읽지 않고 인덱스만으로 데이터를 반환할 수 있기 때문에, 응답 속도를 개선할 수 있습니다.
커버링 인덱스의 장점
- 테이블 접근 최소화: 필요한 데이터를 인덱스에서 바로 반환하므로, 테이블 데이터 접근을 줄인다.
- 빠른 응답 속도: 인덱스를 통해 모든 데이터를 처리하므로, 응답 속도를 개선할 수 있다.
2-2 현재 페이징 방식의 실행계획 살펴보기
커버링 인덱스를 적용하기 전에 기존 쿼리가 어떻게 처리되고 있는지 파악하기 위해 EXPLAIN 명령어를 사용해 실행 계획을 살펴보겠습니다.
기본 페이징 쿼리 실행계획
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
ORDER BY
t1_0.start_date DESC
LIMIT 10, 10

- Full Table Scan: type이 ALL로 나타나며, 인덱스를 사용하지 않고 테이블 전체를 읽는 Full Table Scan이 발생하고 있습니다.
카테고리별 페이징 실행계획
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
AND t1_0.category_id IN (1)
ORDER BY
t1_0.start_date DESC
LIMIT 10,10

type: ref: category_id에 대해 조건이 적용되고 있어 효율적인 접근이 가능
기간 별 페이징 쿼리 실행계획
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
AND t1_0.start_date BETWEEN 2024-05-04 AND 2024-07-06
ORDER BY
t1_0.start_date DESC
LIMIT 10, 10

- Full Table Scan 발생: type이 ALL로 나타나며, 테이블 전체를 읽는 Full Table Scan이 발생합니다.
지금까지 쿼리 실행 계획을 분석한 결과, 기본 페이징과, 기간 별 페이징 방식에서 테이블 전체를 읽는 Full Table Scan이 발생하는 것을 확인할 수 있었습니다. 이와 같은 문제는 결국 응답 속도 저하와 TPS 감소로 이어지며, 특히 페이징 쿼리가 빈번하게 호출될 때 서버 부하를 가중시킬 수 있습니다.
이 문제를 해결하기 위해서 지금부터는 본격적으로 커버링 인덱스를 적용해보도록 하겠습니다.
2-3 커버링 인덱스 도입하기
커버링 인덱스를 적용하기 전 고민해야할 부분이 한 가지가 있습니다. 커버링 인덱스를 적용하기 위해서는 select, where, order by 등등 사용되는 모든 컬럼이 Index 컬럼안에 포함되어야 하는데 select 절의 모든 컬럼까지 포함하게 되면 너무 많은 컬럼이 인덱스에 포함되게 됩니다.
그렇기 때문에 커버링 인덱스로 빠르게 걸러낸 row의 id를 통해 실제 select절의 항목들을 조회하는 형태를 사용해보도록 하겠습니다.
QueryDsl 작성하기
public class TicketCustomRepositoryImpl implements TicketCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public TicketCustomRepositoryImpl(final JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Page<TicketPageResponse> findFilteredTickets(LocalDate startDate, LocalDate endDate, Long categoryId, Pageable pageable) {
QTicket qTicket = QTicket.ticket;
Predicate condition = createCondition(qTicket, startDate, endDate, categoryId);
List<Long> ids = fetchTicketIds(qTicket, condition, pageable);
if (ids.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
List<TicketPageResponse> ticketPageResponse = fetchTicketsByIds(qTicket, ids);
JPAQuery<Long> countQuery = createCountQuery(qTicket, condition);
return PageableExecutionUtils.getPage(ticketPageResponse, pageable, countQuery::fetchOne);
}
private Predicate createCondition(QTicket qTicket, LocalDate startDate, LocalDate endDate, Long categoryId) {
BooleanExpression condition = qTicket.isNotNull(); // 초기 조건 설정
QCategory qCategory = QCategory.category;
if (startDate != null && endDate != null) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
condition = condition.and(qTicket.startDate.between(startDateTime, endDateTime));
}
if (categoryId != null) {
List<Long> categoryIds = fetchCategoryIdsByParentId(categoryId, qCategory);
condition = condition.and(qTicket.category.id.in(categoryIds));
}
return condition;
}
//커버링 인덱스를 통해 id를 조회한다.
private List<Long> fetchTicketIds(QTicket qTicket, Predicate condition, Pageable pageable) {
return jpaQueryFactory
.select(qTicket.id)
.from(qTicket)
.where(condition)
.orderBy(qTicket.startDate.desc()) // 페이징을 위한 정렬
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
//커버링 인덱스를 통해 조회한 id로 select 진행
private List<TicketPageResponse> fetchTicketsByIds(QTicket qTicket, List<Long> ids) {
return jpaQueryFactory
.select(new QTicketPageResponse(
qTicket.id,
qTicket.title,
qTicket.content,
qTicket.runningTime,
qTicket.openDate,
qTicket.endDate,
qTicket.startDate
))
.from(qTicket)
.where(qTicket.id.in(ids))
.orderBy(qTicket.startDate.desc())
.fetch();
}
private JPAQuery<Long> createCountQuery(QTicket qTicket, Predicate condition) {
return jpaQueryFactory
.select(qTicket.count())
.from(qTicket)
.where(condition);
}
private List<Long> fetchCategoryIdsByParentId(Long categoryId, QCategory category) {
return jpaQueryFactory
.select(category.id)
.from(category)
.where(category.parent.id.eq(categoryId).or(category.id.eq(categoryId)))
.fetch();
}
}
1. 커버링 인덱스를 통해 해당 조건에 해당하는 티켓의 PK를 조회한다.
2. 1번의 결과를 통해 얻은 PK를 통해 필요한 컬럼 항목들을 조회한다.
위와 같이 커버링 인덱스가 적용된 컬럼을 통해 티켓의 id를 구하고 티켓의 id를 통해 데이터를 조회하는 방식을 적용해보았습니다. 지금부터는 인덱스 생성 후 변경된 방식의 실행계획을 확인하고 성능이 얼마나 향상되었는지 확인해보도록 하겠습니다.
1. 일반 페이징
EXPLAIN
SELECT
t1_0.id
FROM
ticket t1_0
WHERE
t1_0.id IS NOT NULL
ORDER BY
t1_0.start_date DESC
LIMIT
10 OFFSET 0;

- 인덱스 idx_ticket_start_date가 제대로 활용되어 수행되고 있음
2. 기간별 검색 페이징
EXPLAIN
SELECT
t1_0.id
FROM
ticket t1_0
WHERE
t1_0.start_date BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY
t1_0.start_date
LIMIT 10 OFFSET 0;

- idx_ticket_start_date 인덱스를 사용해 범위 검색(type = range)이 수행되고 있음.
- 커버링 인덱스(Using index)를 사용중
공통적으로 발생하는 쿼리
EXPLAIN
SELECT
t1_0.id,
t1_0.title,
t1_0.content,
t1_0.running_time,
t1_0.open_date,
t1_0.end_date,
t1_0.start_date
FROM
ticket t1_0
WHERE
t1_0.id IN (1, 2, 3)
ORDER BY
t1_0.start_date DESC;
위에서 얻은 id를 통해 select를 진행하면서 테이블 풀 스캔을 하던 이전 방식과 달리 pk값을 이용한 범위검색이 진행되는 것을 볼 수 있습니다.

2-4 성능 테스트 진행
nGrinder를 통해 100명의 유저가 1분동안 요청을 보내는 경우를 테스트해 보겠습니다.
일반 페이징 요청

TPS가 10.0에서 -> 43.0 로 증가
Mean Test Time가 9,082ms -> 2,290ms 감소
날짜 범위 검색

TPS가 5.5에서 -> 62.3 로 증가
Mean Test Time가 15,700ms -> 1,541ms 감소
기존 페이징 쿼리의 문제점을 커버링 인덱스를 도입함으로써 TPS(Transactions Per Second)가 크게 증가하고 Mean Test Time이 대폭 감소하여 서버 응답 속도를 개선할 수 있었습니다. 물론 여전히 1초 이상의 응답 시간이 소요된다는 한계는 있지만, 한정된 자원 내에서 최대한 성능을 향상시켰다는 점에서 의의가 있었다고 생각합니다.
앞으로도 이러한 문제를 해결하기 위한 추가적인 최적화 방안을 찾아서 돌아오도록 하겠습니다.
참고
'Backend > 프로젝트' 카테고리의 다른 글
Elasticsearch 활용한 검색 서비스 만들기 1편 (Docker로 Elasticsearch + Kibana 구축) (0) | 2025.03.10 |
---|---|
무중단 배포 적용하기 (0) | 2025.01.01 |
[Spring] Redis 캐시 적용하기 (0) | 2024.10.31 |
nGrinder Docker 설치 및 사용방법 (0) | 2024.10.25 |
[스프링] 토스 간편결제 기능 구현하기 (0) | 2024.09.10 |