Discovery 모듈 성능 개선기 — Protobuf 제거, 캐시 도입, 검색 정확도 향상
이런 증상을 겪고 계신가요?
이커머스 홈 화면(Discovery) 모듈의 성능과 유지보수성이 동시에 문제가 되고 있었습니다.
- Service 계층에 Protobuf 객체가 직접 침투해 있어 변경 영향 범위가 넓음
- 상품 목록 조회 시 상품마다 3번의 추가 쿼리 (N+1)
- 홈 화면 큐레이션 블록이 매 요청마다 DB를 조회
- "원피스" 검색 시 "여름원피스" 등 공백 없는 결과가 누락
이 글에서는 이 문제들을 Protobuf 제거, 캐시 전략 도입, 검색 분석기 개선으로 해결한 과정을 정리합니다.
Phase 1. Protobuf → View 객체 전환
문제: Service가 Protobuf를 알아야 해?
Discovery 모듈의 비즈니스 로직 전반에 gRPC Protobuf 객체가 침투해 있었습니다.
// Before: Service 계층에 Protobuf 의존성
data class ListCatalogCommand(
val usageType: HomeCatalogRequest.UsageType, // Protobuf enum
val brandIds: List<String>,
)
data class ListSectionCommand(
val userId: String?,
val request: HomeListSectionsRequest, // Protobuf 객체 통째로
)
이로 인해 외부 프로토콜 변경이 내부 비즈니스 로직에 직접 영향을 주고, Protobuf 객체는 Redis 캐싱 시 직렬화 오버헤드가 크며, 테스트에서 Builder 패턴으로 Mock 데이터를 생성하기 번거로웠습니다.
해결: API 경계에서만 변환
Protobuf는 Web Mapper에서만 사용하고, 내부는 순수 Kotlin View 객체로 통일했습니다.
// After: 순수 Kotlin 모델
data class ListCatalogCommand(
val usageType: String, // "ITEM_SEARCH_FILTER"
val brandIds: List<String>,
)
// View 객체 도입
data class BrandPriceView(
val brandId: String,
val name: String,
val image: ImageView,
val catalogPrices: List<CatalogPriceView>,
)
data class ListBrandPricesResult(
val brandPrices: List<BrandPriceView>,
val calculatedAt: Long,
)
// Web Mapper: API 경계에서만 Protobuf 변환
fun toProtobuf(result: ListBrandPricesResult): HomeListBrandPricesResult =
HomeListBrandPricesResult.newBuilder()
.addAllBrandPrices(result.brandPrices.map(::toDto))
.setCalculatedAt(result.calculatedAt)
.build()
ViewAssembler 분산
기존에 하나의 거대한 Mapper에 모든 변환 로직이 집중되어 있었습니다. 이를 도메인별 Assembler로 분산했습니다.
// Before: 단일 거대 Mapper
HomeViewMapper
├── toView(HomeImage)
├── toView(HomeBrand)
├── toView(HomeCategory)
├── toView(HomeCatalog)
└── ... 수십 개의 메서드
// After: 도메인별 Assembler
ImageViewAssembler → 이미지 변환
BrandViewAssembler → 브랜드 변환
CategoryViewAssembler → 카테고리 변환
CatalogViewAssembler → 카탈로그 변환
ProductViewAssembler → 상품 변환 (Context 패턴)
도메인별 Assembler 분리로 코드량이 줄고, 각 Assembler를 독립적으로 테스트할 수 있게 되었습니다.
Phase 2. Redis 캐시 전략 도입
문제: 상품 20개에 쿼리가 60번?
상품 목록을 조회할 때, 각 상품마다 썸네일, 브랜드, 카테고리를 개별 조회하는 N+1 문제가 있었습니다. 개별 상품 단위로 @Cacheable이 적용되어 있었지만, 캐시 미스 시 N+1이 그대로 발생하고, 캐시 히트여도 Redis 조회 자체가 N번 일어나는 구조였습니다.
// Before: 상품 단위 개별 캐시 (N+1 구조)
@Cacheable(cacheNames = ["TO_PRODUCT_DTO"], key = "#productId")
fun toProductDto(productId: String): ProductDto {
val thumbnail = loadThumbnail(productId) // 캐시 미스 시 DB 조회
val brand = loadBrand(productId) // 캐시 미스 시 DB 조회
val category = loadCategory(productId) // 캐시 미스 시 DB 조회
return ProductDto(thumbnail, brand, category)
}
// 상품 20개 → 캐시 미스 시 60번 DB 조회, 히트 시에도 20번 Redis 조회
해결: Context Resolver 패턴으로 배치 조회
// After: 배치 조회 + 메모리 매핑
class ProductViewContextResolver {
fun resolve(products: List<HomeProduct>): ProductViewContext =
ProductViewContext(
thumbnailsByProductId = loadThumbnails(products).associateBy { it.targetId },
brandsByProductId = loadBrands(products).associateBy { it.id },
categoriesByProductId = loadCategories(products).associateBy { it.id },
)
}
class ProductViewAssembler {
fun assemble(products: List<HomeProduct>, context: ProductViewContext): List<ProductView> =
products.map { product ->
ProductView(
thumbnail = context.thumbnailsByProductId[product.id],
brand = context.brandsByProductId[product.brandId],
// O(1) 메모리 조회
)
}
}
// 상품 20개 → 3번의 배치 조회
참고: 개별 캐시(
@Cacheableper item) 대신 배치 조회를 선택한 이유는 세 가지입니다. (1) cold start 시 N+1이 그대로 발생, (2) TTL 관리 포인트가 N배 증가, (3) 개별 캐시는 각기 다른 시점의 데이터가 반환될 수 있어 일관성이 깨집니다.
큐레이션 블록 캐시 도입
홈 화면의 핵심인 큐레이션 블록 데이터에 Redis 캐시를 적용했습니다.
@Service
class ResolveCurationBlockViewService(
private val loadCurationBlockPort: LoadCurationBlockPort,
private val curationBlockViewAssembler: CurationBlockViewAssembler,
) {
@Cacheable(cacheNames = ["CURATION_BLOCK_VIEW"], key = "#blockId")
fun getBlockView(section: SectionView?, blockId: String): CurationBlockView {
val (block, banners) = loadCurationBlockPort.getBlockWithBanners(blockId)
return curationBlockViewAssembler.assemble(block, section, banners)
}
}
캐시 무효화: 메시지 이벤트 기반
관리자가 블록을 수정하면 메시지 이벤트를 발행하고, 핸들러가 캐시를 선택적으로 무효화합니다.
@Service
class HomeCacheEvictHandler {
fun handle(request: CacheEvictBlockBannerRequest) {
homeCacheEvictSupport.evictBlockView(request.blockId)
homeCacheEvictSupport.evictBlockIdList(request.sectionId)
}
}
@Service
class HomeCacheEvictSupport {
@CacheEvict(cacheNames = ["CURATION_BLOCK_VIEW"], key = "#blockId")
fun evictBlockView(blockId: String) {}
@CacheEvict(cacheNames = ["LIST_BLOCK_IDS"], key = "#sectionId")
fun evictBlockIdList(sectionId: String) {}
}
Admin UI (수정) → 메시지 이벤트 → CacheEvictHandler → Redis 캐시 삭제
→ 다음 요청 시 DB에서 재로드
참고:
@CacheEvict를 직접 호출하지 않고 메시지 큐를 경유하면, Admin 모듈과 홈 화면 모듈의 직접 의존을 제거할 수 있고, 관리자 작업의 응답 시간에도 영향을 주지 않습니다.
Phase 3. OpenSearch 검색 분석기 개선
문제: "원피스"를 검색하면 "여름원피스"가 안 나온다
사용자가 "원피스"를 검색할 때 "썸머 원피스", "여름원피스" 등의 결과가 누락되는 문제가 있었습니다. 특히 공백 유무에 따른 검색 불일치가 심각했습니다.
해결: 다중 필드 + Compact 분석기
mm.fields(
"name^2", // 상품명 (가중치 2)
"name.compact^5", // 상품명 공백제거 (가중치 5, 최우선)
"name.ngram^1", // N-gram 부분 매칭
"subName^2", // 부제목 (새로 추가)
"subName.compact^5", // 부제목 공백제거
"subName.ngram", // 부제목 N-gram
"brandNames^2", // 브랜드명
"brandNames.compact^4", // 브랜드명 공백제거
"brandNames.ngram", // 브랜드명 N-gram
"categoryName^2", // 카테고리명
"categoryName.compact^4",
"categoryName.ngram",
)
가중치 전략:
| 필드 타입 | 가중치 | 용도 |
|---|---|---|
.compact |
4~5 | 공백 제거 후 매칭 (정확도 최우선) |
| 원본 필드 | 2 | 띄어쓰기 포함 정확 매칭 |
.ngram |
1 | 부분 문자열 매칭 (recall 확보) |
검색 연산자:
TextQueryType.CrossFields: 검색어 토큰을 여러 필드에 걸쳐 매칭. 단, analyzer가 서로 다른 필드는 별도 그룹으로 나뉘어 처리됩니다Operator.And: 모든 토큰이 매칭되어야 결과에 포함
예시: "마르디 원피스" 검색 시
- "마르디"는
brandNames에서, "원피스"는name에서 매칭 → 결과 포함 - "마르디"만 매칭되고 "원피스"가 없으면 → 결과 제외
개선 결과
| 영역 | 개선 전 | 개선 후 |
|---|---|---|
| 모듈 결합도 | Protobuf가 Service까지 침투 | API 경계에서만 변환 |
| 상품 목록 조회 | N+1 (상품 수 x 3 쿼리) | 배치 3회 조회 |
| 큐레이션 블록 응답 | 매 요청마다 DB 조회 | Redis 캐시 + 메시지 기반 무효화 |
| 검색 필드 | 5개 필드 | 12개 필드 (부제목, compact 추가) |
| 공백 검색 | 매칭 실패 | compact 분석기로 해결 |
교훈
-
캐싱 대상 객체는 가능하면 먼저 경량화한 뒤 캐시를 적용하는 편이 좋습니다. — Protobuf 같은 무거운 객체를 그대로 캐싱하면 직렬화 오버헤드가 커질 수 있고, View 객체로 전환한 뒤 캐시를 적용하면 다루기 쉬워집니다.
-
Context Resolver 패턴은 목록 조회의 N+1을 구조적으로 방지합니다. — 배치 조회 + Map 매핑을 Context 객체로 캡슐화하면, Assembler에서 O(1) 조회만으로 뷰를 조립할 수 있습니다.
-
캐시 무효화는 모듈 간 직접 결합을 늘리지 않는 방식이 좋습니다. — 메시지 큐 기반 무효화를 사용하면 Admin과 홈 화면 모듈 간 직접 의존 없이 캐시를 관리할 수 있습니다.
-
한국어 검색에서 공백 불일치가 문제라면 별도 분석기를 검토할 가치가 큽니다. — compact 분석기로 공백을 제거한 필드를 높은 가중치로 매칭하면, "원피스"와 "여름원피스"를 함께 잡는 데 도움이 됩니다.