Part 1. 문제 상황
현재 Products(상품) 테이블에는 180만개의 데이터가 적재되어있다. 180만개였던 이유는 무신사의 경우 1만개 브랜드가 입점해있었고 '바로'에서는 그의 5분의 1 수준인 2000개 브랜드 기준으로 브랜드당 3개의 카테고리, 카테고리 당 300개의 상품을 둔다고 가정한 수치이다. 기능 개발을 마치고 API들을 테스트해보던 중 인기상품조회 API의 응답시간이 느리다는 것을 파악하게 되어 개선을 시작했다
현재 기능에 대해 좀 더 자세히 설명하자면 상품을 좋아요 개수 순서대로 보여주는 인기상품조회 기능이다. Cursor를 활용한 무한스크롤 방식으로 구현하였고, CurosrId로는 likeCount(좋아요 수), productId(상품 ID)를 복합적으로 사용하고 있다.
일단 먼저 개선 전 부하테스트를 nGrinder로 진행했다
💻 테스트 환경
WAS(SpringBoot) 1대 : AWS t4g.xlarge(4 vCPU, 16GIB Memory)
MySQL 1대 : AWS t4g.micro(2 vCPU, 1GIB Memory)
부하테스트 툴 : nGrinder
APM 툴 : Pinpoint
Metric 수집 및 시각화 툴 : Prometheus + Grafana
🕹️ 테스트 방식
- vUsers : 10명
- Duration : 1m
- Sleep Time : 1000ms
☠️ 부하테스트 결과

- TPS : 1
- p99 latency : 10s
- Average response time : 9.2s
TPS, 응답시간 모두 처참했다.. DB가 병목이었을 것이었기에 Grafana를 통해 MySQL까지 모니터링하였다

문제가 전반적으로 많아보인다.. 하나하나씩 해결해보자. 일단 가장 처음으로는 Slow Query 부분을 살펴보면,

이처럼 Slow Query가 발생했던 것을 확인할 수 있다. 기존에 Slow Query 측정 기준을 1s로 설정을 해두었고, 카운트가 집계되는 것으로 보아 1s가 넘는 쿼리들이 발생하고 있다는 뜻이다. 그럼 이제부터 어떤 쿼리가 문제였는지 찾아보자
Part 2. 원인 탐색
1. Slow Query 로그 체크
Slow Query가 발생했기 때문에 Slow Query 로그부터 살펴보았고, 내용은 다음과 같았다
SET timestamp=1757040717;
select p1_0.id,p1_0.created_at,p1_0.description,p1_0.likes_count,p1_0.modified_at,p1_0.product_name,p1_0.price,p1_0.quantity,p1_0.store_id,p1_0.thumbnail_url
from products p1_0
where (null is null or exists(select 1 from product_categories pc1_0 where pc1_0.product_id=p1_0.id and pc1_0.category_id=null)) and (null is null or p1_0.likes_count<null or (p1_0.likes_count=null and p1_0.id<null)) order by p1_0.likes_count desc,p1_0.id desc limit 22;
# Time: 2025-09-05T02:52:07.475843Z
# User@Host: baro[baro] @ [xx.xxx.xxx.xx] Id: 873
# Query_time: 10.005079 Lock_time: 0.000002 Rows_sent: 22 Rows_examined: 1800022
요약하면, 현재 22개의 행을 찾기 위해 180만개의 행을 모두 찾는 Full Table Scan이 발생하고 있었다. 어떤 쿼리가 문제였는지 확인했기 때문에 이 쿼리가 내부적으로 어떻게 동작하고 있는지 파악하기 위해 MySQl의 실행 계획을 분석해보자
2. EXPLAIN ANALYZE를 통한 실행 계획 분석
-> Limit: 22 row(s) (cost=190264 rows=22) (actual time=2878..2878 rows=22 loops=1)
-> Sort row IDs: p1_0.likes_count DESC, p1_0.id DESC, limit input to 22 row(s) per chunk (cost=190264 rows=1.78e+6) (actual time=2878..2878 rows=22 loops=1)
-> Table scan on p1_0 (cost=190264 rows=1.78e+6) (actual time=0.0313..2512 rows=1.8e+6 loops=1)
전체 실행 계획은 다음과 같다. 복잡해보이니 하나씩 확인해보자
-> Limit: 22 row(s) (cost=190264 rows=22) (actual time=2878..2878 rows=22 loops=1)
- 동작: 최종 결과를 22개 행으로 제한
- 예상 비용: 190,264 (매우 높음)
- 실제 실행시간: 2878 - 2878 = 약 0ms - 거의 안걸림
- 결과: 22개 행 반환
-> Sort row IDs: p1_0.likes_count DESC, p1_0.id DESC, limit input to 22 row(s) per chunk (cost=190264 rows=1.78e+6) (actual time=2878..2878 rows=22 loops=1)
- 동작: likes_count와 id를 내림차순으로 정렬하면서 상위 22개만 유지
- 처리 대상: 1,780,000개 행 (1.78e+6)
- 실제 실행시간: 2878 - 2878 = 약 0ms - 거의 안걸림
- 최적화: "limit input to 22 row(s) per chunk"로 메모리 효율적 정렬 수행
-> Table scan on p1_0 (cost=190264 rows=1.78e+6) (actual time=0.0313..2512 rows=1.8e+6 loops=1)
- 동작: products 테이블 전체 스캔 (Full Table Scan)
- 실제 실행시간: 2,512 - 0.03 = 약 2500ms 소요
- 처리량: 1,800,000개 행 모두 읽음
즉 Full Table Scan 이 문제다!
결국 180만개를 모두 읽는 Full Table Scan이 문제라고 판단해 먼제 이를 발생시키지 않기 위해 Index를 적용시키기로 결정하였다. 그 전에 현재 쿼리가 어떤 형태로 발생하는지 자세히 살펴보자
Part 3. Query 살펴보기
먼저 쿼리를 자세히 살펴보자
SELECT
p.id,
p.created_at,
p.description,
p.likes_count,
p.modified_at,
p.product_name,
p.price,
p.quantity,
p.store_id,
p.thumbnail_url
FROM products p
WHERE
-- 카테고리 필터링: categoryId가 제공되면 해당 카테고리 상품만, 아니면 모든 상품
(:categoryId IS NULL
OR EXISTS (
SELECT 1
FROM product_categories pc
WHERE pc.product_id = p.id
AND pc.category_id = :categoryId
))
-- 커서 기반 페이징: 이전 페이지의 마지막 항목 이후부터 조회
AND (:cursorLikes IS NULL
OR p.likes_count < :cursorLikes
OR (p.likes_count = :cursorLikes AND p.id < :cursorId))
ORDER BY
p.likes_count DESC, -- 좋아요 수 내림차순 (인기순)
p.id DESC -- 같은 좋아요 수일 때 최신 순
LIMIT :pageSize; -- Pageable에서 제공되는 페이지 크기
위의 Query가 아래의 JPQL에 의해서 발생하게 된다
@Query("""
select p from Product p
where (:categoryId is null or exists (
select 1 from ProductCategory pc
where pc.product = p and pc.category.id = :categoryId ))
and (:cursorLikes is null
or p.likesCount < :cursorLikes
or (p.likesCount = :cursorLikes and p.id < :cursorId))
order by p.likesCount desc, p.id desc
""")
fun findPopularProductsByCursor(
@Param("categoryId") categoryId: Long?,
@Param("cursorLikes") cursorLikes: Int?,
@Param("cursorId") cursorId: Long?,
pageable: Pageable,
): Slice<Product>
정리해보면,
1. 조건부 카테고리 필터링(WHERE 절)
- categoryId가 NULL이면 모든 상품 조회
- 값이 있으면 해당 카테고리의 상품만 조회
2. 커서 기반 페이징(WHERE 절)
- 전통적인 OFFSET 방식 대신 커서 기반 방식 사용
- cursorLikes와 cursorId를 기준으로 다음 페이지 결정
- 대용량 데이터에서 일관된 성능과 중복 방지 보장
3. 복합 정렬(ORDER BY 절)
- 1차: likes_count DESC (인기순)
- 2차: id DESC (최신순 - 동일한 좋아요 수일 때)
Part 4. 1차 개선 (복합 인덱스)
일단 WHERE 절과 ORDER BY 절에서 공통적으로 사용되고 있는 likesCount와 productId 컬럼에 복합인덱스를 생성하였다. 이렇게 인덱스를 설계 한다면 MySQL 옵티마이저는 다음과 같은 최적화를 수행할 수 있다. 먼저 인덱스 스캔만으로 정렬된 결과를 얻을 수 있어 별도의 filesort 작업이 불필요하다. 즉 복합인덱스 (likesCount, productId)가 생성됐을 때, 인덱스 자체가 이미 이 순서로 정렬되어 있기 때문에 ORDER BY likes_count DESC, id DESC 조건을 만족하는 데이터를 순차적으로 읽기만 하면 된다.
또한 커서 기반 페이징의 WHERE 조건이 인덱스의 선두 컬럼들과 일치하여 효율적인 범위 스캔(Range Scan)이 가능하다. 만약 인덱스 구조와 조건이 일치하지 않는다면, MySQL은 인덱스를 활용하지 못하고 전체 테이블 스캔이나 비효율적인 인덱스 스캔을 수행하게 된다.
👊🏻 목표 성능
- Concurrent Users(동시 사용자 수) = 500명
- Average Response Time(평균 응답 시간) = 200ms 이하
- TPS(Think Time 1000ms 기준) = 500 TPS
기존 vUsers를 50명으로 진행했을 때 응답시간이 매우 길어져 time-out으로 인해 측정할 수가 없었고, 10명으로 최대한 줄여서 테스트를 진행했다
🕹️ 테스트 방식
- vUsers : 10명
- Duration : 1m
- Sleep Time : 1000ms
1. 개선 결과(Vusers : 10명)


개선 전 대비 성능 비교 (Before vs After)
| Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
| TPS (Transactions/sec) | 1 | 76 | 7,500% ↑ |
| p99 latency | 10000 ms | 66.8 ms | 99.3% ↓ |
| Average response time | 9200 ms | 49 ms | 99.4% ↓ |
- TPS: 초당 처리 가능한 트랜잭션 수가 1 → 76으로 약 7,500% 증가
- p99 latency: 응답 지연 상위 1% 구간이 10,000ms → 66.8ms로 약 99.3% 단축
- Average response time: 9,200ms → 49ms로 약 99.5% 단축
인덱스만으로 성능이 상당히 개선되었고 신난 마음으로 목표한 TPS 도달을 위해 바로 vUsers를 500명으로 높이고 부하테스트를 한번 더 진행하였다
🕹️ 테스트 방식
- vUsers : 500명
- Duration : 1m
- Sleep Time : 1000ms
2. 개선 결과(Vusers : 500명)

- TPS : 115
- p99 latency : 1.58s
- Average response time : 635ms
기존보다 TPS가 상당히 개선되긴 하였지만 아직 목표 TPS인 500까지 가려면 너무 멀었다. 일단 Application 서버 상태를 보자
Application Server

MySQL


InnoDB BP Hit Ratio를 보면 99.93%로 거의 모든 요청이 메모리에서 처리되고 있는 것을 확인할 수 있다. 즉 지금 MySQL은 매우 효율적으로 응답을 처리하고 있다는 것이다. 그리고 이후 여러 지표를 살펴보다 Application 서버의 HikariCP 통계를 볼 때 최대 181개까지 connection을 기다리고 있는 것을 확인할 수 있었다
Part 5. 2차 개선 (DB Connection Pool Size 튜닝)
그럼 HikariCP를 늘리면 해결이 될까?
일단 늘리기 전의 생각으로는 쓰레드가 과도하게 많아지는 경우, CPU가 Context Switching을 하는데 작업 비율이 늘어나게 되어 성능이 저조해질 수 있다는 것은 이론적으로 알고 있었다. 기존에 HikariCP를 최소 8, 최대 16으로 설정했었는데, 이와 관련해서 자료를 찾던 중 HikariCP 공식문서에서 다음과 같은 수식을 찾을 수 있었다
https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
connections = ((core_count * 2) + effective_spindle_count)
현재 MySQL이 설치되어있는 EC2의 스펙은 t4g.micro로 2개의 코어를 가지고 있다. 그렇다면 위 공식에 의거하여 Connection 수를 4로 잡는다면 성능이 더 빨라질까?
🕹️ 테스트 방식
- vUsers : 500명
- Duration : 1m
- Sleep Time : 1000ms
- HikariCP Size : 2, 4, 6, 8, 10, 12, 14, 16(8가지 각각 5번씩)

HikariCP Size 별로 총 40번의 테스트를 진행한 결과를 표로 정리해보았다
1. TPS

2. Application 서버 CPU와 DB CPU 평균 사용률

3. Application 서버 Heap 메모리 사용량

표에서 보이겠지만 DB Connection Pool이 16일 때 테스트를 멈췄다. 애초에 4가 최적의 수치라 예상했기 때문에 8정도까지 실험을 진행하려 했었으나 계속 올라가는 TPS 때문에 계속 테스트를 진행했고 14,16을 거치며 유의미한 변화가 없었기에 그만두었다. 일단 여기서 드는 의문은 왜 Pool Size가 4일 때 가장 수치가 높지 않았을까이다.
왜 Pool Size를 (Core*2)개 이상으로 해도 TPS가 계속 올라갔을까?
이에 대한 실마리는 Pinpoint를 통해 찾을 수 있었다

위 사진을 보면 getConnection() 부분에서 많은 시간이 소요되고 있었고 심지어 그게 2번 발생한다는 것을 확인할 수 있었다. Connection을 받은 이후에 실제 쿼리가 수행되는데 걸린 소요시간은 각각 3ms, 1ms로 매우 짧았고, 이는 InnoDB BP Hit Ratio가 99.93%였기에 메모리만 사용되어 인메모리 DB인 Redis와 같은 성능을 낼 수 있었던 것이다. 즉 DB 자체는 병목이 아니었고 정리하면 아래와 같다
getConnection() - 731ms (첫 번째) ← 병목!
├─ prepareStatement() - 1ms
└─ executeQuery() - 3ms ← 실제 쿼리는 빠름
getConnection() - 424ms (두 번째) ← 병목!
├─ prepareStatement() - 0ms
└─ executeQuery() - 1ms ← 실제 쿼리는 빠름
결국 DB는 InnoDB BP에서 hit되는 가벼운 작업만 수행되고 있었고 따라서 Conncetion Pool 사이즈를 높였을 때도 큰 부하없이 실행되었던 것이다. 실제로 위의 2번째 차트를 보면 DB의 CPU 코어(2 vCPU)가 Application 서버(4 vCPU)에 비해 2배 낮음에도 CPU 사용률은 2배 혹은 그 이상 적게 사용하고 있었다.
왜 getConnection()은 2번 일어나고 있었을까?
Pinpoint를 보며 getConnection()이 왜 2번 발생했는지 궁금했을 것이다. 먼저 기존에 작성했던 코드를 보자
package com.dh.baro.product.application
@Service
class ProductFacade(
private val storeService: StoreService,
private val productService: ProductService,
) {
fun getPopularProducts(
categoryId: Long?,
cursorLikes: Int?,
cursorId: Long?,
size: Int,
): ProductSliceBundle {
val productSlice = productQueryService.getPopularProducts(categoryId, cursorLikes, cursorId, size)
val stores = storeService.getStoresByIds(productSlice.content.map { it.storeId }.toSet())
return ProductSliceBundle(productSlice, stores)
}
}
이처럼 @Transactional을 붙이지 않고 있었고 그 이유는, MSA 분리를 고려한 설계 때문이었다. 따라서 Application Layer에 여러 도메인의 정보를 불러와 조합하는 Facade 패턴을 사용하고 있었고, 추후 MSA로 분리된다면 storeService가 아니라 API 호출 형식으로 변경될 것이다. 그 상황이 온다면 @Transactional이 외부 API 호출까지 포함하게 되어 불필요하게 DB Connection을 점유하게 되기 때문에 제거를 했다. 또한 현재의 구조에서도 Connection 요청이 여러 번 발생하는 것이 문제가 된다는 점을 인지하지 못한 채, 단순히 Connection을 짧게 가져가면 좋겠다는 생각으로 이렇게 설계했다. 하지만 현재는 모놀리식 구조로 storeService를 가져와 사용하고 있었고 따라서 @Transactional로 묶어주는게 맞다는 판단이 들었다. 지금까지의 계층 구조를 정리해보면 다음과 같다
Application Layer:
ProductController
↓
ProductFacade ← @Transactional(readOnly = true)(개선)
↓
Domain Layer:
ProductQueryService / StoreService ← @Transactional(readOnly = true)(기존)
↓
Spring/JPA Layer:
TransactionAspectSupport.invokeWithinTransaction()
↓
HikariDataSource.getConnection() ← 병목 (731ms, 424ms)
↓
JDBC Layer:
ConnectionImpl.prepareStatement()
↓
ClientPreparedStatement.executeQuery() ← 실제 쿼리 실행 (3ms, 1ms)
Part 6. 3차 개선 (@Transactional 범위 확장)
@Transactional(readOnly = true) 범위를 확장시키기고 바로 테스트를 진행했다

테스트 결과 HikariCP Size 16 기준으로 TPS가 257이 나오는 것을 확인할 수 있었다. 개선 전 TPS 248보다 3.6% 개선되었다. Pinpoint 확인 결과 아래와 같이 connection 요청을 한번만 하고 있다

하나의 쓰레드에서는 하나의 Connection만 사용하는 것이 Connection을 얻고 반납하는 오버헤드를 줄일 수 있어 효과적일 것이다. 다만 HikariDataSource의 getConnection() 메서드에 대해 알아보던 중 @Transactional이 정확히 뭘 하는지에 대해 궁금해졌고, 현재처럼 조회만 하는 상황에서도 꼭 필요한 것인지에 대한 의문이 들었다
그럼 @Transactional은 DB Connection을 잡고 있기 위해 쓰는 것일까
먼저 정의를 생각해보면, @Transactional은 spring에서 메서드의 원자성을 보장하며 JDBC의 연결을 관리하기 위해 정의된 annotation interface이다. 하지만 현재 상황의 경우에는 조회만 이루어지기 때문에 원자성은 필요가없다. 그럼 @Transactional를 쓰는게 아니라 Connection 잡아주는 무언가만 사용할 수는 없을까라는 생각이 들었다. 하지만 일단 이에 대해 자세히 알기 위해서 @Transactional이 어떻게 동작하는지 알 필요가 있다. 이에 대한 내용은 다음 포스팅에서 더 다뤄 보겠다
Part 7. 4차 개선 (N+1, test-query)
@Transactional에 대해 조사하면서 추가적으로 발생시키는 쿼리에 대해 알아보기 위해 MySQL general log를 살펴보았다.

요약하면 store 조회 이후 store와 연관된 user들을 조회하는 쿼리가 N+1 형태로 나타나고 있었다. 하지만 아래 코드에서처럼 fetchType을 Lazy을 미리 설정해 두었던 터라 N+1이 발생하는 이유를 찾기가 힘들었다. 하지만 결과적으로 찾은 이유는 Kotlin에 아직 미숙한 실수였다.
@Entity
@Table(name = "stores")
class Store(
@Id
@Column(name = "id")
val id: Long,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
val owner: User,
Kotlin의 경우 기본적으로 모든 class를 final로 선언하기 때문에 Hibernate가 Lazy Loading을 위한 프록시 객체를 생성할 수 없었던 것이다. 이로 인해 @OneToMany나 @ManyToOne 관계에서 LAZY 설정이 무시되고 모든 연관 엔티티가 EAGER 방식으로 조회되어 N+1 문제가 발생했다. 따라서 이를 해결하기위해 다음과 같이 allOpen 플러그인을 추가하여 JPA 관련 어노테이션이 적용된 클래스들을 자동으로 open 상태로 만들어, Hibernate가 프록시 객체를 생성할 수 있도록 하였다
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
위의 플러그인을 통해 N+1 문제를 해결하였고, 다음으로는 테스트를 위한 SELECT 1 쿼리가 계속 발생하는 것이 눈에 걸렸다. 이 쿼리의 경우 다음과 같이 설정해두었던 설정이었다.
spring:
datasource:
hikari:
connection-test-query: SELECT 1 ← 제거!!
하지만 해당 쿼리를 사용하지 않더라도 HikariCP는 JDBC4 호환 드라이버에서 제공하는 Connection.isValid() 메서드를 자동으로 사용하여 연결 상태를 검증한다. 이를 통해 별도의 SQL 쿼리 실행 없이 네트워크 레벨에서 연결 상태 확인하고, 드라이버별 최적화를 자동 적용한다. 실제로 hikari.pool의 poolBase 코드를 보면 isUseJdbc4Validation를 통해 검증하고 있는 것을 확인할 수 있다.
abstract class PoolBase {
private final boolean isUseJdbc4Validation;
boolean isConnectionDead(Connection connection) {
try {
this.setNetworkTimeout(connection, this.validationTimeout);
boolean statement;
try {
int validationSeconds = (int)Math.max(1000L, this.validationTimeout) / 1000;
if (!this.isUseJdbc4Validation) {
Statement statement = connection.createStatement();
개선 결과

- TPS : 660
- p99 latency : 518ms
- Average response time : 294ms
목표 TPS는 이미 상회하는 수치를 통해 성능이 상당히 개선된 모습을 확인할 수 있었다. 그리고 다시 MySQL general log를 살펴본 결과 5개의 쿼리만 발생하는 것을 확인할 수있었다.

다만 DB를 모니터링하였을 때, Buffer Pool Usage가 한계에 도달했다는 것을 알 수 있었다. 이는 현재 성능 테스트에서만 문제가 없을 뿐 실제로는 다른 여러 페이지가 조회될 것이기 때문에 이에 대한 조치가 필요했다. 다만 현재 이미 목표 성능에 도달했고 아직 급한 일들이 많이 남았기 때문에 추후에 더 개선이 필요해보인다면 튜닝을 진행해보도록 하겠다

Part 8. 최종 결과
최종적으로는 CursorID가 있는 경우에 대해 테스트를 진행했다. 테스트는 아래와 같이 직접 무한 스크롤을 해보며 사용되는 CursorId 7가지를 찾아서 등록해두었고, 해당 Id를 랜덤하게 하나만 선택해서 조회하도록하는 nGrinder 스크립트를 작성하였다. cursorLikes는 5000개로 고정하였다
@RunWith(GrinderRunner)
class PopularProductsTest {
public static GTest tPopularProducts = new GTest(1, "get popular products")
public static HTTPRequest httpReq = new HTTPRequest()
public static NVPair[] headers = [ new NVPair("Content-Type","application/json") ] as NVPair[]
static String BASE = "http://xx.xxx.xxx.xxx:8080"
// cursorId 값들을 배열로 정의
static def cursorIds = [1719248, 1590822, 1526003, 1426760, 1298440, 1192504, 1074706]
static Random random = new Random()
@BeforeClass
static void beforeProcess() {
HTTPPluginControl.getConnectionDefaults().setTimeout(60000)
HTTPPluginControl.getConnectionDefaults().setFollowRedirects(true)
HTTPPluginControl.getConnectionDefaults().setUseCookies(true)
tPopularProducts.record(httpReq)
}
@Test
void getPopularProducts() {
try {
// 랜덤으로 cursorId 선택
def randomCursorId = cursorIds[random.nextInt(cursorIds.size())]
// URL 파라미터 구성
def url = "${BASE}/products/popular?cursorId=${randomCursorId}&cursorLikes=5000"
def res = httpReq.GET(url, headers)
grinder.statistics.forLastTest.setSuccess(res.statusCode == 200)
if (res.statusCode != 200) {
grinder.logger.error("unexpected status: {}, body={}", res.statusCode, res.getText())
} else {
grinder.logger.info("Request successful with cursorId: {}", randomCursorId)
}
} catch (Throwable t) {
grinder.logger.error("request failed", t)
grinder.statistics.forLastTest.setSuccess(false)
}
grinder.sleep(1000)
}
결과는 다음과 같았다

- TPS : 1604
- p99 latency : 134ms
- Average response time : 68ms
커서가 존재하는 두번째 페이지부터는 성능이 향상된다
이전 테스트까지는 Cursor가 없는 첫 페이지 조회만을 대상으로 했기 때문에, 현재 결과는 훨씬 더 향상 된 TPS를 보여주고 있다. Cursor가 없는 첫 페이지의 경우 WHERE 조건 절에서 필터링이 적용되지 않아 조회 대상 데이터가 많이지고, 이로 인해 성능이 저하되었다. 따라서 Cursor가 있는 두번째 페이지부터는 성능이 개선되기 때문에, 사용자 접근 빈도가 높을 것으로 예상되는 첫 페이지만 Redis를 통해 사전 캐싱해두면 전체적인 성능을 크게 향상시킬 수 있을 것이다.
커서가 없는 첫 페이지는 캐싱하여 서비스 품질을 높인다?
다만 캐싱을 사용할지는 좀 더 고민해봐야 할 것 같다. 아무래도 인기 상품 조회 기능이라서 순서가 바뀔 가능성이 크고 따라서 데이터 불일치가 발생할 수 있다. 하지만 지금까지 성능 개선하는 과정들은 어느 기능에서나 활용가능한 문제 해결 방법이고, 이를 토대로 다음 기능 구현에서는 이러한 테스트 없이 어느정도 성능 좋은 기능을 만들 수 있을 것 같다. 지금보다 더 개선한다고 가정했을 때는 어떤식으로 설계를 바꿔볼지 고민해보면 재밌을 것 같다.
추가 개선 부분은 추후 포스팅에 남길게요..
'프로젝트' 카테고리의 다른 글
| [바로] 일괄 주문 기능 개선 Vol.2 (Ft. Kafka, Transactional Outbox) (0) | 2025.09.15 |
|---|---|
| [바로] 스와이프 기능 개선 (Ft. MongoDB, Tomcat Thread 튜닝) (0) | 2025.09.11 |
| [바로] Redis의 Lua Script는 Atomic 하지 않다..? (0) | 2025.09.04 |
| [바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency, Lua Script) (0) | 2025.08.27 |
| [바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |