
TL;DR: 이번 주차에서는 인덱스, likesCount 비정규화, 캐시, 쓰기 경로의 명시적 무효화를 통해 상품 조회 경로를 더 단순하게 만들고 읽기 성능을 개선하는 방향으로 구조를 정리했다.
이번 주차를 돌아보면서 내 머릿속에 가장 오래 남은 생각은, 읽기 성능 문제는 쿼리 하나만 고친다고 끝나지 않는다는 점이었다.
상품 목록을 브랜드로 필터링하고, 좋아요 순으로 정렬하고, 같은 요청이 반복해서 들어오는 흐름을 보고 있으면, 문제는 단순히 더 빨라야 한다에 머무르지 않았다. 오히려 읽기 경로 자체를 더 단순하게 만들 필요가 있는 것 아닌가 하는 생각이 계속 남았다.
이번 구현 과제는 바로 그 관점에서 인덱스와 캐시를 추가하고, likesCount를 비정규화하고, 쓰기 경로에 무효화 책임을 두면서 조회 구조를 다시 정리해 보는 과정이었다.
처음에는 상품 목록 조회, 브랜드 필터링, 좋아요 순 정렬이라는 요구사항이 단순한 읽기 최적화 문제처럼 보였다. 그런데 실제로 구현을 따라가다 보니, 이 요구사항 하나가 정렬 기준, 인덱스 구성, 캐시 설계, 그리고 쓰기 이후의 후처리 책임까지 함께 건드리고 있었다.
그래서 이번 글에서는 무엇을 구현했고 무엇을 추가했는지보다, 어떤 구조 변경이 읽기 비용을 낮추는 데 실제로 도움이 되었는지를 중심으로 정리해 보려 한다. 특히 인상적이었던 점은, 인덱스와 캐시를 각각 따로 붙인 것이 아니라 조회 경로를 단순하게 만들기 위한 하나의 구조 개선으로 다뤘다는 점이었다.
인덱스로 주요 조회 패턴에 맞는 읽기 경로를 먼저 정리했다
이번 구현 과제에서 ProductModel에는 상품 목록 조회 패턴에 맞춘 복합 인덱스가 들어가 있다. 브랜드 필터와 가격 정렬, 브랜드 필터와 좋아요 수 정렬, 삭제되지 않은 최신 상품 조회에 맞는 인덱스가 각각 준비되어 있다.
처음에는 이것들을 단순히 조회 속도를 높이기 위한 장치라고 생각할 수도 있었다. 하지만 조금 더 천천히 들여다보면, 자주 사용하는 조회 패턴에 맞춰 읽기 비용이 낮은 접근 경로를 미리 정리한 것에 더 가까웠다.
예를 들어 브랜드별 상품 목록을 좋아요 순으로 보여주려면, 먼저 브랜드 조건으로 범위를 줄이고 그 안에서 좋아요 수 기준 정렬까지 자연스럽게 처리할 수 있어야 한다. 이때 조건과 정렬이 따로 놀면 읽기 비용은 쉽게 커지기 때문이다.
그래서 인덱스 컬럼 순서를 조회 패턴과 맞추는 일은 단순한 튜닝이 아니라, 자주 들어오는 요청을 더 싼 경로로 보내기 위한 구조 정리에 가깝다고 느꼈다.
물론 인덱스만으로 모든 문제가 해결되지는 않는다. 인덱스는 이미 저장된 값을 효율적으로 읽는 데는 강하지만, 좋아요 수 정렬처럼 정렬 기준 자체가 별도 계산을 필요로 하면 한계가 있다. product 안에 그 값이 없다면 결국 매번 likes 테이블을 읽고 계산해야 하기 때문이다.
결국 인덱스만으로는 충분하지 않았다. 정렬 기준 자체가 계산형 값이라면, 인덱스를 추가해도 읽기 비용을 충분히 낮추기 어렵다. 이 생각은 자연스럽게 다음 선택으로 이어졌다.
likesCount 비정규화로 좋아요 순 조회를 더 단순한 읽기 경로로 바꿨다
이번 구현 과제에서 product는 likesCount를 직접 들고 있도록 구현되어 있었다. 정규화 관점에서만 보면 약간 어색하게 느껴질 수도 있는데, 좋아요 기록은 likes 테이블에 있는데, 왜 product에도 같은 의미의 숫자를 다시 저장해야 할까 하는 생각이 들기 때문이다.
처음에는 나도 이것을 성능을 위해 약간의 중복을 허용한 선택 이라는 뉘앙스로 생각했었다. 그런데 조금만 더 오래 생각을 해 보니, 이 선택의 핵심은 단순히 중복을 허용했다는 데 있지 않는 것 같았다. 좋아요 순 조회 자체를 계산형 집계에서 읽기 중심 경로로 바꾸는 데 더 가까지 않나..? 라는 생각이 들었다.
좋아요 수가 product 안에 없으면 목록을 보여줄 때마다 조인과 집계가 필요했다. 이런 구조는 데이터 양이 늘어나거나 요청이 몰릴수록 응답 시간이 흔들릴 가능성이 크다는 생각이 들었다. 반대로 likesCount가 product 안에 있으면, 좋아요 순 정렬은 매번 계산하는 일이 아니라 이미 정리된 값을 읽는 일이 되기 때문이다.
위의 내용처럼 구현하게 되면, 그 순간 목록 조회 경로는 훨씬 단순해진다. 좋아요 순 정렬도 집계 결과를 실시간으로 계산하는 대신 저장된 값을 기준으로 처리할 수 있기 때문이다. 이 변화는 읽기 비용을 줄이는 데 직접적으로 연결된다. 좋은 가독성과 낮은 복잡성을 가지는 것을 선호한다
다만 이 선택이 공짜는 아니었다. likesCount를 product에 두는 순간, 그 숫자는 더 이상 자동으로 맞아떨어지지 않는다. 좋아요를 등록하거나 취소할 때마다 값을 정확하게 반영해야 하고, 동시에 여러 요청이 들어오는 상황도 버텨야 한다.
즉, 읽기 경로를 단순하게 만든 대신 쓰기 경로가 더 큰 책임을 떠안게 된다. 실제로 LikeFacade는 좋아요 등록과 취소 이후 likesCount를 직접 증가시키거나 감소시키도록 구현되어 있었고, 같은 데이터를 동시에 수정할 때 생길 수 있는 충돌은 낙관적 락으로 감지하고 재시도하도록 되어 있었다.
이 부분이 특히 인상적이었던 이유는, 좋아요 순 조회를 빠르게 만들기 위해 쓰기 경로의 후처리 책임도 함께 드러냈기 때문이다. 읽기 경로를 단순하게 만든다는 것은 결국 쓰기 경로의 책임을 어디까지 명시적으로 감당할지 정하는 일이기도 하다는 점이 더 분명하게 보였다.
캐시는 반복 조회를 더 짧은 읽기 경로로 옮기는 역할을 했다
캐시는 성능 최적화에서 가장 자주 언급되는 방법 중 하나다. 반복 조회를 줄이고, 데이터베이스 부하를 낮추고, 응답 시간을 줄이는 데 도움이 되기 때문이다. 이번 구현에서도 ProductFacade는 상품 상세와 상품 목록을 조회할 때 먼저 캐시를 확인하고, 없으면 데이터베이스에서 읽어 캐시에 저장하는 구조를 사용하고 있었다. 흔히 말하는 캐시 어사이드 방식이다.
캐시의 역할도 결국 같은 방향을 향한다. 같은 데이터를 반복해서 요청할 때마다 매번 원본 저장소까지 내려가는 대신, 이미 계산되거나 조회된 결과를 더 짧은 경로에서 읽도록 바꾸는 것이다.
다만 캐시는 읽기 성능을 높이는 대신, 오래된 값이 남을 수 있는 관리 비용도 함께 만든다. 그래서 캐시를 붙이는 것만큼 중요한 것은 키 설계, 만료 시간, 무효화 시점을 명시적으로 두는 일이라고 느꼈다.
이번 구현에서 선언형 캐시 대신 ProductCacheStore와 RedisTemplate을 통해 캐시 키, 조회, 저장, 만료 시간이 코드에 직접 드러나도록 한 점도 그런 면에서 좋았다. 캐시가 어떻게 동작하는지, 읽기 경로를 어떻게 단순하게 만들고 있는지, 그리고 어떤 시점에 무효화가 필요한지가 구현 안에서 비교적 분명하게 보였기 때문이다.
상품 상세와 상품 목록의 만료 시간을 다르게 둔 점도 같은 맥락이었다. 상품 상세는 10분, 상품 목록은 3분으로 설정되어 있었다. 이 차이는 단순한 숫자 조정보다, 상세 조회와 목록 조회의 성격 차이를 반영한 선택처럼 느껴졌다.
상품 상세는 하나의 상품에 대한 정보이기 때문에 변화 범위가 비교적 좁다. 반면 상품 목록은 브랜드, 정렬 기준, 페이지 조합이 많고, 특히 좋아요 순 정렬에서는 작은 데이터 변화도 결과 순서 전체에 영향을 줄 수 있다. 그래서 목록 쪽에 더 짧은 만료 시간을 두는 편이 더 보수적인 판단이라고 이해할 수 있었다.
응답 전체를 캐시하지 않고, 상품 본문과 브랜드 조회를 분리한 점도 흥미로웠다
ProductCacheSnapshot에는 brandId는 있지만 brandName은 없다. 즉, 캐시에서 상품 데이터를 꺼낸 뒤에도 ProductFacade는 브랜드 정보를 다시 읽어 ProductInfo를 완성한다.
처음에는 이것이 덜 최적화된 선택처럼 보이기도 했다. 응답에 필요한 값을 한 번에 모두 캐시에 넣으면 더 많은 값을 캐시만으로 해결할 수 있기 때문이다.
하지만 이번 구현은 응답 전체를 통째로 캐시하기보다, 상품 자체의 반복 조회 비용을 먼저 줄이고 브랜드명은 별도 조회에서 조합하는 방식을 택했다. 즉, 상품 본문 캐시와 브랜드 조회를 나눠 가져가는 구조였다.
이 선택은 캐시 범위를 무작정 넓히기보다, 읽기 경로를 단계적으로 단순화해 나간 것으로 읽혔다. 모든 것을 한 번에 해결하려 하기보다, 어떤 데이터를 캐시하고 어떤 정보는 조합할지를 구분하면서 책임 범위를 나눠 둔 셈이다.
읽기 성능 개선만큼 중요했던 것은 무효화 책임을 쓰기 경로에 두는 구조였다
캐시를 붙이는 일은 생각보다 어렵지 않다. 어떤 값을 읽고 저장할지만 정하면 시작할 수 있다. 하지만 캐시를 두는 순간 더 중요해지는 것은, 데이터가 바뀌었을 때 어떤 읽기 결과를 언제 지울 것인지다.
이번 구현에서는 그 책임이 조회 코드가 아니라 쓰기 코드에 놓여 있었다. LikeFacade는 좋아요 등록과 취소 이후 상품 상세 캐시와 상품 목록 캐시를 무효화하고, ProductCommandFacade 역시 상품 생성, 수정, 삭제 이후 관련 캐시를 지우도록 되어 있었다.
이 구조가 좋게 느껴졌던 이유는, 캐시 무효화 책임이 어디에 있는지가 흐려지지 않았기 때문이다. 조회 경로는 읽기에 집중하고, 변경 이후의 정리는 쓰기 경로가 맡는 방식이 더 명확하게 보였다.
특히 목록 캐시를 부분적으로 지우기보다 prefix 기준으로 전체 무효화한다는 점도 눈에 띄었다. 좋아요 하나가 바뀌었을 때 어떤 브랜드 목록, 어떤 페이지, 어떤 정렬 조건이 영향을 받는지를 완벽하게 계산하는 일은 생각보다 복잡하다.
이 방식은 선택적 무효화보다 단순하고 구현 의도도 분명하다. 물론 그만큼 캐시 적중률에는 불리할 수 있지만, 현재 단계에서는 복잡도를 통제하는 쪽에 더 무게를 둔 선택으로 읽혔다.
정리하며
이번 구현을 따라가며 더 분명해진 것은, 인덱스와 캐시, 비정규화가 각각 따로 노는 기술이 아니라 모두 읽기 경로를 단순하게 만들고 비용을 낮추기 위한 구조 개선이라는 점이었다.
인덱스는 주요 조회 패턴의 접근 경로를 정리하고, 비정규화는 좋아요 순 정렬을 계산형 집계에서 읽기 중심 경로로 바꾸고, 캐시는 반복 조회를 더 짧은 경로로 옮긴다. 그리고 무효화와 likesCount 반영은, 그런 구조 변경에 따라 쓰기 경로가 추가로 책임져야 하는 부분이 된다.
그래서 이번 주차의 핵심은 단순히 캐시를 붙이거나 인덱스를 추가한 데 있지 않았다. 상품 조회에서 자주 발생하는 읽기 비용을 어떤 방식으로 덜어낼지 구조적으로 정리한 데 더 가까웠다고 생각한다.
다만 그렇다고 해서 캐시와 인덱스가 모든 문제를 해결해 주는 것은 아니다. 캐시와 인덱스는 분명 중요하지만, 그것만으로 모든 일관성 문제를 해결할 수 있다고 말하기는 어렵다. 오히려 잘못 붙인 캐시, 설명되지 않는 만료 시간, 흐릿한 무효화 책임은 읽기 경로를 더 복잡하게 만들 수도 있다. 인덱스 역시 읽기 비용을 낮추는 데는 도움이 되지만, 그 자체로 쓰기 경로의 책임까지 대신해 주지는 않는다.
그래서 이번 구현에서 좋았던 점은 기술을 더했다는 사실 자체보다, 그 기술을 붙였을 때 생기는 추가 책임도 함께 코드에 드러냈다는 점이었다. likesCount를 비정규화했다면 쓰기 경로에서 count를 반영하고, 캐시를 붙였다면 무효화 책임을 쓰기 경로에 배치하고, 주요 동시성 시나리오는 테스트로 확인하는 식으로 말이다.
물론 아쉬움이 없는 것은 아니다. 목록 캐시 전체 무효화는 안전하지만 범위가 크고, 실제 운영 트래픽이 커지면 캐시 적중률이 기대보다 낮아질 수도 있다. 성능 개선의 설득력을 더 높이려면 실제 실행 계획과 측정 지표도 함께 쌓여야 할 것이다.
그럼에도 이번 주차를 지나며 더 분명해진 것은, 읽기 성능 개선은 결국 조회 경로를 얼마나 단순하게 만들 수 있는지의 문제라는 점이었다. 그래서 이번 주차의 결론도 단순히 캐시를 붙였다거나 인덱스를 추가했다에서 끝나지 않았다. 인덱스와 캐시, likesCount 비정규화, 쓰기 경로의 명시적 무효화를 함께 두면서 상품 조회 경로를 더 단순하게 만들고 읽기 성능을 개선했다는 쪽이 더 정확하다고 생각한다.
그리고 그 과정에서 읽기 최적화는 조회 코드만의 문제가 아니라, 쓰기 이후 어떤 값을 반영하고 어떤 캐시를 지울지까지 포함한 구조 개선이라는 점을 다시 확인할 수 있었다.
'Study for. > My thoughts' 카테고리의 다른 글
| 4년 전 포인트 시스템이 나에게 남긴 것: Slack 장애 알림 주인공이 되지 않는 날이 되기까지 (0) | 2026.03.06 |
|---|---|
| [WIL - TDD] 대리님 ~ 레드 페이즈에서 어서션 빌드업하고 그린 넘어가서 리팩터링 사이클 돌려주세요 ~ 커버리지 리포트 머지 전에 락앤 주시고요 ~ (1) | 2026.02.08 |