grep

Engineering

전시 동적필터 리팩토링

Cinamon여기어때

2026년 1월 9일

원문에서 보기 ↗

안녕하세요. 전시개발팀 시나몬입니다. 이번 글에서는 여기어때 서비스에서 사용 중인 동적 필터의 리팩토링 과정과 그 결과로 얻은 구조적 개선점에 대해 소개해 보려고 합니다.

1. 동적필터란?

동적 필터란 사용자가 선택한 조건에 따라 이용 가능한 제휴점 수가 실시간으로 반영되어 노출되는 필터를 의미합니다. 사용자가 필터를 선택할 때마다 해당 조건을 만족하는 제휴점 수를 기준으로 필터 상태가 동적으로 변경되며 이를 통해 보다 직관적인 탐색 경험을 제공합니다.

특히 동적 필터는 다중 카테고리를 동시에 선택 할 수 있어, 사용자가 여러 카테고리에 걸친 필터 정보를 한 번에 확인할 수 있는 사용자 편의 기능입니다.

예를 들어 모텔과 호텔을 동시에 선택한 경우 두 카테고리에 모두 적용 가능한 필터만 노출되도록 구성되어 있습니다.

또한 사용자가 선택한 필터 조건에 해당하는 제휴점이 존재하지 않으면 해당 필터 칩은 비활성화(disabled) 상태로 노출됩니다. 이를 통해 사용자는 선택해도 결과가 없는 필터를 미리 인지할 수 있고 불필요한 탐색 과정을 줄일 수 있습니다. 결과적으로 동적 필터는 사용자의 탐색 시간을 감소시키고 보다 효율적인 검색 경험을 제공하는 역할을 합니다.

2. AS-IS: 단순하지만 확장이 어려운 구조

변경 전 구조 / 조건문을 제거하기위해 파생된 중복 클래스

기존 동적 필터 구조에서 가장 큰 문제는 페이지마다 필터의 노출/미노출 정책을 결정해야 했다는 점이었습니다.

페이지 타입에 따라 특정 필터를 숨기거나 노출해야 하는 요구사항이 생길 때마다 이를 처리하는 방식은 점점 일관성을 잃어갔습니다.

이러한 방식은 단기적으로는 요구사항을 빠르게 반영할 수 있었지만

장기적으로는 구조적인 부담을 빠르게 증가시켰습니다.

특히 다음과 같은 문제가 반복적으로 발생했습니다.

결과적으로 필터 정책은 점점 늘어나는데 이를 수용하는 구조는 확장보다는 복제에 가까운 방향으로 발전하고 있었습니다.

이 시점에서 문제는 명확해졌습니다.

필터 생성 로직 자체는 거의 동일한데,

페이지 타입에 따른 노출 정책 때문에 생성 파이프라인이 계속 갈라지고 있다.

이 문제를 해결하지 않으면 페이지 타입이 하나 추가될 때마다 비슷한 Mapper와 조건 분기가 계속 늘어날 수밖에 없다고 판단했습니다.

3. 리팩토링을 통해 가져가고자 했던 방향

리팩토링을 결정하면서 가장 중요하게 생각한 방향은 다음 세 가지였습니다.

3.1 정책과 생성 흐름을 분리한다

필터를 어떻게 조립할 것인가와 어떤 필터를 노출할 것인가는 성격이 전혀 다른 책임이라고 판단했습니다.

따라서 노출 정책은 전략으로 분리하고

생성 흐름은 하나의 공통 파이프라인으로 고정하는 방향을 선택했습니다.

3.2 페이지 타입별 정책을 구조적으로 드러낸다

기존 구조에서는 페이지 타입에 대한 정책이 코드 전반에 암묵적으로 흩어져 있었기 때문에 특정 페이지의 정책을 한눈에 파악하기 어려웠습니다.

개선된 구조에서는 페이지 타입별 정책을 enum 단위로 명시적으로 드러내는 것을 목표로 했습니다.

이를 통해:

3.3 확장은 ‘복사’가 아니라 ‘조합’으로 해결한다

이전 구조에서는 정책 차이가 커질수록 Mapper를 복사하거나 새로운 구현체를 추가하는 방식으로 확장할 수밖에 없었습니다.

리팩토링 이후에는:

즉 새로운 요구사항이 생기더라도 기존 코드를 수정/복사하지않고, 정책만 추가하는 구조를 목표로 했습니다.

4. TO-BE: 정책과 생성 흐름을 분리한 구조

리팩토링 이후 구조의 핵심은

페이지 타입별 필터 노출 정책은 전략에서 결정하고, 필터 생성은 항상 동일한 흐름으로 처리한다는 점입니다.

이를 위해 필터 노출 정책을 QuickFilterStrategy로 분리하고,

필터 생성 과정은 Builder와 Mapper를 활용해 하나의 공통된 파이프라인으로 통합하였습니다.

변경 후

4.1 QuickFilterStrategy: 정책을 enum으로 고정하기

페이지 타입별 정책을 QuickFilterStrategy enum으로 정의했습니다.

@RequiredArgsConstructor
public enum QuickFilterStrategy {
    검색페이지(
        motelFilter -> /* 생략 */,
        nonMotelFilter -> /* 생략 */,
        guestHouseFilter -> /* 생략 */,
        true
    ),
    카테고리홈(/* 일부 필터 제외 */),
    주변(/* 일부 할인 제외 */),
    지역홈(/* 일부 할인 제외 */);
    private final Predicate<SearchDynamicFilter.FilterDetail> motelDiscountFilter;
    private final Predicate<SearchDynamicFilter.FilterDetail> nonMotelDiscountFilter;
    private final Predicate<SearchDynamicFilter.FilterDetail> guestHouseDiscountFilter;
    private final boolean includeCategory;

각 전략은 다음 정보를 명확히 가지고 있습니다.

이를 통해 페이지 타입별 정책의 집합이 enum 하나로 고정 되었고

정책 분기가 코드 곳곳에 흩어지는 문제를 방지할 수 있었습니다.

4.2 전략은 판단만 하고, 생성은 Builder에 위임하기

QuickFilterStrategy는 필터를 직접 생성하지 않습니다. 대신, 어떤 조건을 적용할지 결정한 뒤 그 결과를 QuickFilterBuilder에 전달합니다.

class QuickFilterStrategy {
   
   /* 생략 ..*/

  public List<Anchor>createAnchorsFor(
       Set<Category> selectedCategories,
       BaseListParam param,
       SearchDynamicFilter searchDynamicFilter
   ) {
   
     var builder = QuickFilterBuilder.create();
   
   if (includeCategory) {
           builder.withCategories(filter.getCategories());
       }
   
       Predicate<SearchDynamicFilter.FilterDetail> discountFilter =
           getDiscountFilterForCategories(selectedCategories);
   
   return builder
         .withPossibleFilter(filter, param.getCheckInOut().getStay())
         .withDiscountFilter(param, filter, discountFilter)
         .build();
   }
}

이 구조에서 전략의 다음과 같습니다.

반면 [Anchor 생성, Content 조립, 결과 리스트 구성] 은 모두 Builder와 Mapper의 책임으로 남겨두었습니다.

4.3 카테고리 조합에 따른 정책 분기 캡슐화

카테고리 조합에 따른 할인 정책 역시 전략 내부로 캡슐화되어 있습니다.

private Predicate<SearchDynamicFilter.FilterDetail>
getDiscountFilterForCategories(Set<Category> selectedCategories) {

if (selectedCategories.size() ==1
        && selectedCategories.contains(Category.GUEST_HOUSE)) {
      return /* 생략 ..*/
  }
if (selectedCategories.contains(Category.MOTEL)
        && selectedCategories.size() >1) {
    return /* 생략 ..*/;
  }
if (selectedCategories.contains(Category.MOTEL)) {
    return /* 생략 ..*/
  }
  
    return nonMotelDiscountFilter;
}

이로 인해:

대신 전략 내부의 Predicate 조합만으로 정책을 표현할 수 있습니다.

5. AS-IS vs TO-BE

5.1 이전 구조의 장점과 한계

기존 동적 필터 구조는 카테고리 분기 중심의 단순한 설계를 가지고 있었습니다. 전체 / 단일 / 다중 카테고리 여부에 따라 Creator가 분기되며 필터 생성 흐름 또한 비교적 직관적으로 이해할 수 있는 장점이 있었습니다.

또한 구조가 단순한 만큼 초기 구현 비용이 낮고 카테고리 중심의 요구사항을 빠르게 반영할 수 있다는 장점도 있었습니다.

하지만 페이지 타입에 대한 고려가 구조적으로 녹아 있지 않아 페이지 타입별 정책이 추가되기 시작하면서 다음과 같은 한계가 드러났습니다.

결과적으로 구조는 단순했지만,

확장에 취약하고 변경에 민감한 구조로 발전할 수밖에 없었습니다.

5.2 개선된 구조의 장점과 트레이드오프

리팩토링 이후 구조는 페이지 타입과 카테고리 분기를 명확히 분리하고

필터 생성 과정을 하나의 공통된 흐름으로 통합한 구조입니다.

이를 통해 페이지 타입이나 카테고리별 요구사항이 추가되더라도

생성 흐름 자체는 변경되지 않으며 정책 정의만으로 확장이 가능해졌습니다.

특히 정책 변경이 발생하더라도 Service나 Builder에 조건문을 추가할 필요 없이 전략(enum) 내부에서 국소적으로 관리할 수 있다는 점이 가장 큰 장점입니다.

다만 이전 구조에 비해 전략과 Builder, Mapper의 역할이 명확히 나뉘면서

구조 자체는 상대적으로 복잡해졌고 설계를 이해하기 위한 초기 진입 비용은 다소 증가했습니다.

5. 마무리

이번 리팩토링은 단순히 코드를 정리하는 작업이라기보다는 변경이 잦은 영역과 안정적으로 유지되어야 하는 영역을 구분하는 과정에 가까웠습니다.

정책은 전략으로 생성은 공통 흐름으로 분리함으로써 앞으로 필터 정책이 확장되더라도 구조적인 복잡도가 급격히 증가하지 않도록 기반을 마련하고자 했습니다.

비슷한 문제를 겪고 계신 분들께 작게나마 참고가 되었으면 합니다.