
현재 운영중인 이커머스 서비스에서 일 평균 300만건 이상 호출되는 개인화 추천 API 평균 응답시간이 약 90ms 수준으로 유지되고 있다는 점이 눈에 들어왔습니다. 서비스 전체에서 높은 트래픽을 담당하는 API임을 고려하면, 이 정도의 응답시간도 전체 사용자 경험에 직접적인 영향을 주기에 충분했기 때문에 성능 개선이 필요한 상황이었습니다.
이 API는 단일 엔드포인트에서 모든 추천 비즈니스를 한꺼번에 처리하는 구조였습니다.
사용자 추천, 사용자가 최근 본 상품 추천, 최근 구매 상품, 유사 브랜드, 유사 카테고리 등 서로 다른 성격의 추천 로직이 한곳에 몰려 있었고, 그로 인해 다음과 같은 문제점이 있었습니다.
- 어떤 추천 비즈니스가 응답시간을 잡아먹는지 파악이 어려운 모니터링 구조
- 필요하지 않은 경우에도 항상 함께 실행되는 불필요한 연산
- 여러 비즈니스가 하나의 요청, 응답 객체에 섞여 있어 캐싱 전략 적용이 어려운 구조
- 여러 비즈니스가 뒤엉켜 코드 정책을 명확히 파악하기 어려움
결국 단일 엔드포인트 구조의 한계가 API 성능과 운영 복잡도를 키우고 있었고, 이를 근본적으로 해결할 필요가 있었습니다.
이에 따라 추천 비즈니스 단위로 분리하고, 캐싱 아키텍처를 전면 재설계하는 리팩토링을 진행하게 되었습니다.
목표는 단순한 성능 개선뿐만 아니라 성능.운영.확장성 모두를 개선하는것 입니다.
리팩토링 배경
기존 구조 (ItemRecommendationsApiController)
- 단일 엔드포인트: GET /v1/recommendations/personal
- 이 한 API에서 모든 추천 케이스를 처리하고 있었음
- (유사상품, 개인화, 인기상품, 백업 로직 등 전부 한 군데 집결)
문제점
1. 단일 엔드포인트의 한계
- /v1/recommendations/personal 하나에 카테고리 추천, 브랜드 추천, 개인화 추천, 인기 추천 등
- 다양한 비즈니스 로직이 전부 붙어 있는 구조
- 책임이 불분명하고, 특정 비즈니스의 성능 이슈만 따로 보기 어려움
2. 캐싱 불가 구조
- 하나의 API 응답 안에 여러 비즈니스 결과가 섞여 있어 “부분 DTO만 캐싱” 같은 전략을 적용하기 어려움
- 결과적으로 캐싱의 단위가 너무 크고 애매해서 사실상 활용이 어려웠음
3. 불필요한 리소스 낭비
- 백업 로직이 필요 없는 케이스에서도 단일 API 특성상 무거운 쿼리까지 같이 수행
- 특정 시나리오에서는 불필요한 SQL과 연산이 항상 따라오는 구조
4. 개인화 복잡성 증가
- 사용자마다 결과가 달라지는 개인화 로직이 다수 포함되어 응답 구조와 내부 로직이 점점 복잡해짐
- 어떤 요청에서 어떤 추천이 얼마나 느린지”를 케이스별로 분리해서 보기가 거의 불가능
서비스 임팩트 (AS-IS 상태)
- 일 호출량: 약 3,000,000건
- 기존 성능 이슈
- 평균 응답시간: 86~91ms (평균값 기준 89.4ms)
- 최대 응답시간: 91,788ms
DB 부하
- 1건당 평균 SQL: 4.78건
- 1건당 평균 SQL 패치 수: 82.27개
복잡한 비즈니스를 단일 API로 처리하면서, 성능·운영·모니터링·확장성 모두에 병목이 있는 상태였습니다.
리팩토링 결과 – 새로운 추천 API 아키텍처
새로운 구조 (ItemRecommendApiController)
- 비즈니스별 엔드포인트 분리로, API 하나당 책임을 구체적으로 정의
비즈니스별 엔드포인트 분리
엔드포인트 설명 일 호출량 평균 응답시간
| /v1/recommend/similar/category | 카테고리 유사 상품 | 755,687건 | 65ms |
| /v1/recommend/similar/brand | 브랜드 유사 상품 | 433,948건 | 71ms |
| /v1/recommend/personal/purchase | 함께 구매한 상품 | 270,735건 | 29ms |
| /v1/recommend/personal/user | 개인행동기반 추천 | 172,811건 | 42ms |
| /v1/recommend/personal/wish | 관심상품 추천 | 98,765건 | 65ms |
| /v1/recommend/best/same/brand | 동일브랜드 인기상품 | 162,758건 | 34ms |
| /v1/recommend/best/category | 카테고리별 인기상품 | 69,191건 | 79ms |
| /v1/recommend/personal/view | 함께 본 상품 | 70,985건 | 64ms |
| /v1/recommend/best/brand | 인기 브랜드 상품 | 3,878건 | 33ms |
- 각 엔드포인트는 하나의 추천 타입만 담당
- 덕분에“어떤 추천 타입이 얼마나 느린지” , “어떤 추천이 실제로 많이 호출되는지”, “어떤 곳에 캐싱·튜닝을 우선 적용해야 하는지”를 명확하게 관찰할 수 있게 됨
성능 개선 효과
AS-IS vs TO-BE 성능 비교
구분 리팩토링 전 (AS-IS) 리팩토링 후 (TO-BE) 개선 효과
| 1건당 평균 응답시간 | 89.4ms | 54.17ms | –35.23ms (약 66% 개선) |
| 1건당 평균 SQL 건수 | 4.78건 | 2.81건 | –1.97건 (약 41% 감소) |
| 1건당 평균 SQL 패치수 | 82.27개 | 33.42개 | –48.85개 (약 59% 감소) |
개선 효과 요약
- 응답시간 66% 단축 (89.4ms → 54.17ms)
- DB 호출 건수 41% 감소 (SQL 건 4.78 → 2.81건)
- SQL 패치 59% 감소 (82.27 → 33.42개)
트래픽 분산 효과
- 단일 API → 9개 API로 분산
- 장애 시 영향 범위 축소, 모니터링 단위도 기능별로 쪼개짐
API를 나누면서, 각 비즈니스의 성격이 수치로 드러나기 시작했습니다.
고성능 API
- /v1/recommend/personal/purchase (함께 구매한 상품): 29ms
- /v1/recommend/best/same/brand (동일브랜드 인기상품): 34ms
표준 성능 API
- /v1/recommend/personal/user (개인행동기반 추천): 42ms
- /v1/recommend/similar/category (카테고리 유사 상품): 65ms
최적화 타겟
- /v1/recommend/best/category (카테고리별 인기상품): 79ms
- 상대적으로 높은 응답시간으로, 추가 튜닝 대상으로 명확히 식별
이전에는 “그냥 추천 API가 좀 느리다” 정도였다면, 지금은 “어떤 추천 타입이 병목인지 구체적으로 말할 수 있는 상태”가 됐습니다.
코드 복잡도 대폭 감소
- Before
- 하나의 복합 메서드에 약 500라인 가까운 코드
- 조건 분기와 fallback, 예외 케이스 등이 뒤엉킨 구조
- After
- 비즈니스별 개별 메서드로 분리
- 각 메서드는 평균 3~5라인 수준의 단순한 위임 로직
- 공통 흐름은 템플릿 메서드로 묶고, 각 추천 타입마다
- 필요한 부분만 BiFunction으로 주입
5-2. 공통 처리 로직 추상화
private DalgonaResponse<DisplayItemRecommendationsResponse> handleRecommendationRequest(
ApiAwsPersonalRecommendDto queryDto,
FoUserInfo userInfo,
RECOMMENATION_TYPE type,
BiFunction<ApiAwsPersonalRecommendDto, FoUserInfo, ItemConverter> recommendFunction
)
- 공통 처리 담당
- 요청 DTO 전처리
- 사용자 정보 (FoUserInfo) 처리
- 추천 타입(RECOMMENATION_TYPE)에 따른 공통 분기
- 공통 예외 처리 및 응답 변환(DalgonaResponse<DisplayItemRecommendationsResponse>)
- 각 추천 엔드포인트에서는 recommendFunction만 넘겨주면 되도록 설계 → 신규 추천 타입 추가 시, 핵심 로직만 구현하고 공통 흐름은 그대로 재사용 가능
백업 로직 최적화
- Before
- “혹시 몰라서” 항상 백업용 추천 로직까지 같이 준비
- 실제로는 필요 없는 케이스에도 무거운 쿼리가 도는 구조
- After
- “정말 필요한 경우에만” 백업 추천 실행
- 5 단위 반올림 최적화를 적용하여
- 특정 조건에 도달하기 전까지는 백업 로직 호출을 건너뛰고
- 의미 있는 시점에서만 백업 추천을 실행하도록 조정 → 불필요한 호출을 줄여 DB 부하와 응답시간을 함께 줄이는 효과
모니터링 개선
- 기능별 트래픽 현황을 엔드포인트 단위로 실시간 파악 가능
- 각 추천 타입별 성능 병목 지점을 명확하게 식별
- 장애 발생 시,
- “추천 API 전체 문제”가 아니라
- “특정 추천 타입 API 문제”로 영역을 좁혀서 빠르게 대응 가능
6-2. 캐싱 전략 개선 (상세는 아래 ‘캐싱키 전략’ 섹션)
- 비즈니스별로 서로 다른 캐싱 전략 적용 가능
- 호출 패턴(트래픽, 실시간성 요구)에 따라
- 캐시 TTL을 차별화해서 운영할 수 있는 구조로 개선
6-3. 개발 생산성 향상
- 새로운 추천 알고리즘 / 추천 타입을 추가할 때,
- 기존 거대한 로직을 건드리지 않고
- 엔드포인트 + 서비스 + DTO + 캐시키만 추가하면 되도록 구조화
- 비즈니스 단위로 독립적인 테스트 / 배포가 가능해짐 → 특정 추천 타입만 롤백하거나, 별도로 모니터링하는 것도 용이
리팩토링 정리
- 성능 최적화는 “코드 튜닝”만으로는 부족하다
- 응답시간 66% 단축(89.4ms → 54.17ms)은 단순히 쿼리나 for문을 최적화해서 나온 결과가 아니라, 엔드포인트 설계·아키텍처 레벨에서 구조를 다시 잡은 결과였습니다.
- 모니터링은 “나누는 순간”부터 제대로 된다
- 단일 API에서는 “어디가 문제인지” 알기 힘들지만, 비즈니스별로 나누자 호출량/에러율/평균/최대 응답시간사용 패턴과 성능 특성이 그대로 드러나기 시작했습니다.
- 점진적 개선이 모이면 큰 숫자로 돌아온다
- SQL 건수 41% 감소
- SQL 패치 수 59% 감소
- 작은 최적화들을 쌓아가면서, 불필요한 로직 제거 + 효율적인 쿼리 실행 구조가 완성했습니다.
- 비즈니스 가치로 연결되는 성능 개선
- 하루 260만 건 트래픽에서의 성능 개선은 사용자 경험 (체감 속도·로딩 안정성) 향상과 인프라 비용 절감 두 가지를 동시에 가져온다. 단순한 숫자 개선이 아니라, “서비스 전체 비용 구조와 UX”에 대한 영향을 직접 체감할 수 있는 작업이었습니다.
추가 작업 계획
1. 추가 최적화
- /v1/recommend/best/category API 성능을 현 79ms → 50ms 수준까지 낮추는 것을 목표로 추가 튜닝 예정
2. 캐싱 전략 고도화
- 비즈니스별/파라미터별 호출 패턴에 맞춘 맞춤형 캐시 정책 적용
- 실시간성이 필요 없는 영역에 더 공격적인 TTL 적용 검토
캐싱키 전략 설계 & 구현
단순히 @Cacheable만 붙인 게 아니라, BaseDto + 비즈니스별 키 설계 + 시간 기반 무효화까지 포함한 계층형 캐싱 구조
DTO 상속 구조
BaseRecommendationsQueryDto (기본 캐시 설정)
├── BrandRecommendationsQueryDto
├── CategoryRecommendationsQueryDto
├── AggregatedItemRecommendationsQueryDto
└── PersonalPromotionQueryDto
- 상위 DTO에서 공통 캐싱 로직과 기본 설정을 제공
- 각 하위 DTO에서 비즈니스별 캐시 키 커스터마이징
BaseRecommendationsQueryDto – 공통 기능
1. 캐시 활성화 기본값
- isCache() = true
2. TTL 설정
- CacheProperties.EXPIRE_SECONDS_5MINS → 기본 캐시 만료 시간 5분
3. 사용자 정보 자동 설정
- custId mbrNo dispMallNo등 개인화 / 몰 구분에 필요한 공통 필드 자동 세팅
4. 시간 기반 캐시 키
- currentDaySuffix() 메서드를 통해 날짜 기반 suffix를 공통 사용
성능 및 효과 요약
- DB 쿼리 감소 효과
- 캐시 Hit Rate: 약 70~80% (추정)
- 반복 조회 시 쿼리 호출이 대폭 감소
- 캐시된 데이터 사용 시, 응답은 거의 몇 ms 단위로 처리
- 서비스 확장성 향상
- 새로운 추천 API 추가 시 BaseRecommendationsQueryDto 상속만으로 캐싱 기능 자동 적용
- 공통 캐싱 규칙을 유지하면서 비즈니스 특수성을 DTO별로 오버라이드
- 메모리 효율성 관리
- 5분 TTL로 캐시 생명주기를 짧게 잡아 불필요한 장기 캐시를 방지
- 일자 기반 키 갱신으로 오래된 캐시를 자연스럽게 정리
'n년차 개발자' 카테고리의 다른 글
| 크롤링 트래픽을 어디까지 허용하고 어떻게 통제해야 할까? (0) | 2025.12.15 |
|---|---|
| Spring MVC와 Spring WebFlux, 무엇을 선택해야할까 (0) | 2025.12.02 |
| 서버 자원 수집 및 시각화 (0) | 2025.04.28 |
| Spring Boot Logstash 연동 설정 및 작동 방식 (1) | 2025.04.16 |
| 모니터링 시스템 구축 (0) | 2025.04.07 |