본문 바로가기
Loopers

WIL - 5주차 회고

by Soono991 2025. 8. 17.

이번 주는 서비스 개발을 하면서 가장 흔히 부딪히는 문제 중 하나인 조회 성능을 주제로 삼았다.

인덱스와 캐시를 어떻게 적용하면 성능을 개선할 수 있을지 직접 실험해보고 고민을 정리해봤다.

조회 성능이 느리다는 건 무슨 의미일까?

그동안 “조회가 느리다”라는 말을 너무 쉽게 받아들였던 것 같다.

단순히 응답 시간이 길다는 얘기라고만 생각했는데, 이번에 깊게 들여다보니 여러 원인이 있었다.

  • 데이터베이스가 풀스캔을 하고 있는 경우
  • 디스크 I/O가 과도하게 발생하는 경우
  • DB는 빨리 조회했는데 애플리케이션 로직(N+1 문제 등) 때문에 느려지는 경우
  • 네트워크 왕복 비용이 누적되는 경우

즉, “느리다”라는 말 뒤에는 꽤 다양한 원인이 숨어있다는 걸 알게 됐다.

그래서 문제를 풀 때는 먼저 애플리케이션 레벨에서 불필요한 성능 저하가 없는지 확인하고, 그다음에 DB와 캐시를 고민하는 게 순서라는 것도 깨달았다.

DB 최적화 – 인덱스와 반정규화

사실 인덱스에 대해서도 나는 단순히

검색 조건에 쓰는 컬럼에는 인덱스 걸자

정렬 조건에 쓰는 컬럼에도 인덱스 걸자

이 정도로만 생각했었다.

 

그런데 실제로 성능 테스트를 해보니, 인덱스를 어떻게 설계하느냐에 따라 수치 차이가 어마어마했다.

커버링 인덱스까지 적용해봤을 때의 응답 시간과 TPS 차이를 보면서, 인덱스를 단순히 “거는 것”에서 끝낼 게 아니라 조회 패턴을 먼저 고민하고 설계해야 한다는 걸 직접 체감했다.

 

반정규화 역시 마찬가지였다. 쓰기 시점에는 인덱스 갱신이나 동기화라는 추가 비용이 있겠지만, 조회 성능은 확실히 빨라졌다.

결국 읽기 최적화와 쓰기 부담 사이에서 트레이드오프를 어디서 가져갈지가 핵심이라는 걸 알게 됐다.

애플리케이션 최적화 – 캐시

캐시는 예상했던 것보다 훨씬 성능 차이가 심했다.

캐시를 적용하기 전과 후의 응답 시간을 비교해보면 ms 단위로 줄어드는 게 눈에 보였다.

 

다만 캐싱을 어디에 적용할지에 대해서는 다음과 고민도 많았다.

// ProductFacade.getProduct에 적용
@Component
class ProductFacade(
	// ...
) {

    private val log = LoggerFactory.getLogger(ProductFacade::class.java)

    @Transactional(readOnly = true)
    fun getProduct(id: Long): ProductInfo.ProductDetail {
        val product = productService.findProductBy(id) ?: throw CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. $id")
        val brand = brandService.findBrandBy(product.brandId) ?: throw CoreException(
            ErrorType.NOT_FOUND,
            "브랜드를 찾을 수 없습니다. ${product.brandId}",
        )
        val productLikeCount = productLikeService.getProductLikeCount(product.id)
        return ProductInfo.ProductDetail.from(product, brand, productLikeCount)
    }
}

// 각 서비스에서 적용 (ProductService.findProductBy, BrandService.findBrandBy)
@Service
class ProductService(
	// ...
) {

    private val log = LoggerFactory.getLogger(ProductService::class.java)

    @Transactional(readOnly = true)
    fun findProductBy(id: Long): ProductEntity? {
        return productRepository.findById(id)
    }
}


@Service
class BrandService(
	// ...
) {

    private val log = LoggerFactory.getLogger(BrandService::class.java)

    @Transactional(readOnly = true)
    fun findBrandBy(brandId: Long): BrandEntity? {
        return brandRepository.findById(brandId)
    }
}
  • ProductFacade 단에서 한 번에 캐싱하면 성능은 극대화되지만, 데이터 변경 시 무효화/갱신 로직이 복잡해진다.
  • 반대로 서비스 단위로 쪼개서 캐싱하면 관리가 훨씬 단순해지지만, 조회 성능은 조금 손해를 본다.
Case TPS p(90) p(95)
ProductFacade 약 975 약 10ms 약 24ms
각 서비스 약 963 약 33ms 약 87ms

결국 나는 관리 편의성을 선택해서 서비스 단위 캐싱을 적용했지만, 성능과 운영 사이에서 균형을 어떻게 잡을지가 중요한 포인트라는 걸 알게 됐다.

캐시 스템피드에 대한 고민

캐시를 적용하면서 또 하나 마주친 문제가 캐시 스템피드였다. 캐시 스템피드는 TTL이 만료되었을 때 캐시 갱신을 하기 위해 여러 요청이 한꺼번에 DB로 몰리는 현상을 말한다.

  • 여러 키가 동시에 만료되는 경우 → TTL에 Jitter를 줘서 분산
  • 단일 키가 만료되는 경우 → Early Refresh, Singleflight 같은 기법 필요

특히 단일 키 만료 시점의 스템피드는 단순히 TTL을 조정한다고 해결되지 않았다. 그래서 결국 메시지나 이벤트 기반으로 캐시를 갱신하는 방법을 써야겠다는 생각이 들었다.

이번 주를 돌아보며

사실 지금까지는 “조회 성능 개선”을 진지하게 고민한 적이 거의 없었다. 그냥 인덱스는 걸면 빨라진다, 캐시는 쓰면 좋아진다 정도로만 생각했다.

이번에 직접 수치를 보면서 체감한 건,

  • 성능은 결국 설계의 문제다
  • 그리고 항상 트레이드오프가 존재한다는 것

읽기 최적화를 하면 쓰기 비용이 늘고, 캐싱 위치에 따라 관리 비용이 달라진다.

답이 하나로 정해져 있는 게 아니라, 서비스의 성격과 상황에 맞게 선택해야 한다는 걸 다시 느꼈다.

다음에 더 해보고 싶은 것

  • 캐시 로직을 AOP나 데코레이터 패턴으로 분리해서 코드 가독성 높이기
  • Redis 캐시 스템피드 방지 기법 실험 (PER, Early Refresh 등)
  • Cache Key 관리 전략 체계화 (네이밍, 버전, TTL 정책)
  • 메시지/이벤트 기반 캐시 갱신 구조 설계

'Loopers' 카테고리의 다른 글

WIL - 7주차 회고  (1) 2025.08.29
WIL - 6주차 회고  (1) 2025.08.24
WIL - 4주차 회고  (5) 2025.08.10
WIL - 3주차 회고  (2) 2025.08.03
WIL - 2주차 회고  (2) 2025.07.25

댓글