#개발 #큐시즘 #백엔드 #기술블로그 #대외학회 #기업프로젝트 #트러블슈팅 #퍼사드 #Spring #스프링 **통계 도메인에서 필터 해석과 Repository 호출이 7개 서비스에 중복되던 문제를, `StatisticsQueryFacade` 하나로 일원화해서 해결했다.** --- ## 🧩 Problem 통계 기능을 구현하다 보니 서비스 클래스가 7개로 늘어났다. - `StatisticsRadarScoreService` - `StatisticsClassificationScoreService` - `StatisticsSelfVsCompetitorScoreService` - `StatisticsPainPointService` - `StatisticsWordcloudService` - `StatisticsHighlightCommentService` - `StatisticsByPackageScoreService` 문제는 이 서비스들이 모두 **동일한 패턴**을 반복하고 있었다는 것이다. 1. 요청 파라미터(`productCategory`, `disabilityType`, `view`)를 받는다 2. `"ALL"`이나 `null` 같은 문자열을 실제 쿼리 조건으로 해석한다 3. `StatisticsScoreQueryRepository`나 `StatisticsCommentQueryRepository`를 직접 주입받아 호출한다 4. `EvaluationScore.scoredValues()`, `TargetCategory.MAIN` 같은 고정 상수를 매번 직접 넣어 넘긴다 서비스가 하나일 때는 괜찮다. 그런데 7개가 되면? **필터 해석 로직이 바뀌거나, Repository 메서드 시그니처가 바뀌거나, 상수 하나가 교체될 때 7곳을 전부 찾아가야 한다.** --- ## 🔍 Root Cause 문제의 핵심은 **"필터 해석"과 "Repository 호출 조합"이 서비스 레이어에 섞여 있었다는 것**이다. 각 서비스는 비즈니스 로직(레이더 차트를 어떻게 만드는가, 워드클라우드 키워드를 어떻게 집계하는가)에만 집중해야 하는데, 실제로는 이런 코드가 모든 서비스에 반복됐다. ```java // AS-IS: 서비스마다 이 패턴이 중복됨 String resolvedCategory = "ALL".equals(productCategory) ? null : productCategory; String resolvedDisability = "ALL".equals(disabilityType) ? null : disabilityType; List<StatisticsScoreAggregateRow> rows = statisticsScoreQueryRepository.findScoreRows( resolvedCategory, resolvedDisability, EvaluationScore.scoredValues(), // 항상 동일한 상수 List.of(TargetCategory.MAIN) // 항상 동일한 상수 ); ``` ## 🛠 Solution ### Facade 패턴이란? Facade는 "건물 정면(앞면)"을 뜻하는 프랑스어에서 온 디자인 패턴이다. 복잡한 내부 서브시스템을 숨기고, **호출자에게 단순화된 인터페이스만 노출**하는 것이 핵심이다. > 호출자는 "레이더 점수 행을 가져와"라고만 말한다. > Facade 안에서 어떤 Repository를, 어떤 파라미터 조합으로 호출하는지는 호출자가 알 필요 없다. GoF 패턴 분류로는 **구조 패턴(Structural Pattern)** 에 속한다. 인터페이스를 단순화한다는 의미에서 Adapter와 종종 혼동되는데, Adapter는 "호환되지 않는 인터페이스를 맞춰주는" 것이고, Facade는 "복잡한 것을 단순하게 감싸는" 것이다. --- ### 설계 결정: 3개 컴포넌트로 분리 ``` StatisticsQueryFacade ← 서비스들이 의존하는 단일 진입점 ├── StatisticsFilterResolver ← 문자열 파라미터 → 도메인 조건 해석 ├── StatisticsScoreQueryRepository └── StatisticsCommentQueryRepository ``` | 컴포넌트 | 역할 | | -------------------------- | ---------------------------------------------------------------- | | `StatisticsFilterResolver` | `"ALL"` → `null`, `view` 문자열 → `StatisticsView` enum, 페이지 기본값 결정 | | `StatisticsQueryFacade` | 각 조회 유형에 맞는 Repository 메서드 선택 + 고정 상수 조합 + 정책 문서화 | | 각 `Service` | 요청 파라미터 해석(`RequestResolver`) + Facade 호출 + 응답 조립(`Factory`) | --- ### 실제 코드 **`StatisticsFilterResolver` — 문자열을 도메인 조건으로 해석** ```java @Component public class StatisticsFilterResolver { public StatisticsFilterCriteria resolveFilter(StatisticsFilterRequest request) { if (request == null) { return new StatisticsFilterCriteria(null, null, false, StatisticsView.PREVIEW); } return new StatisticsFilterCriteria( request.productCategory() == null || request.productCategory().isBlank() ? null : request.productCategory().trim(), StatisticsDisabilityFilter.toDisabilityTypeOrNull(request.disabilityType()), Boolean.TRUE.equals(request.includeCompetitor()), StatisticsView.from(request.view()) ); } public StatisticsPageCriteria resolvePage(StatisticsPageRequest request, StatisticsView view) { int defaultSize = view == StatisticsView.ALL ? DEFAULT_ALL_SIZE : DEFAULT_PREVIEW_SIZE; // ...유효성 검사 및 기본값 적용 return new StatisticsPageCriteria(page, size); } } ``` `"ALL"`이나 빈 문자열을 null로 바꾸는 것, `view` 파라미터를 enum으로 변환하는 것, 페이지 기본값을 view 모드에 따라 다르게 적용하는 것이 모두 이 클래스 한 곳에 모였다. --- **`StatisticsQueryFacade` — Repository 호출 단일 진입점** ```java @Component @RequiredArgsConstructor public class StatisticsQueryFacade { // 항상 MAIN 기준인 경우 — 정책을 상수로 명시 private static final List<TargetCategory> PRODUCT_WORDCLOUD_TARGET_CATEGORIES = List.of(TargetCategory.MAIN); private static final List<TargetCategory> TARGET_WORDCLOUD_TARGET_CATEGORIES = List.of(TargetCategory.MAIN); private final StatisticsFilterResolver statisticsFilterResolver; private final StatisticsScoreQueryRepository statisticsScoreQueryRepository; private final StatisticsCommentQueryRepository statisticsCommentQueryRepository; public List<StatisticsScoreAggregateRow> findScoreRows(StatisticsFilterRequest filterRequest) { StatisticsFilterCriteria filter = statisticsFilterResolver.resolveFilter(filterRequest); return statisticsScoreQueryRepository.findScoreRows( filter.productCategory(), filter.disabilityType(), EvaluationScore.scoredValues(), // 고정 상수: 항상 점수 있는 응답만 filter.resolveTargetCategories() ); } public List<StatisticsPainPointSubTaskAggregateRow> findPainPointTopSubTaskRows( StatisticsFilterRequest filterRequest, int limit ) { StatisticsFilterCriteria filter = statisticsFilterResolver.resolveFilter(filterRequest); return statisticsScoreQueryRepository.findPainPointTopSubTaskRows( filter.productCategory(), filter.disabilityType(), EvaluationScore.scoredValues(), TargetCategory.MAIN, EvaluationScore.ZERO, EvaluationScore.ONE, EvaluationScore.TWO, PageRequest.of(0, limit) ); } /** * MAIN 평가내용만 사용한다. */ public List<StatisticsWordcloudKeywordAggregateRow> findTargetWordcloudKeywordRows( StatisticsFilterRequest filterRequest, int limit ) { StatisticsFilterCriteria filter = statisticsFilterResolver.resolveFilter(filterRequest); return statisticsCommentQueryRepository.findWordcloudKeywordRows( filter.productCategory(), filter.disabilityType(), TARGET_WORDCLOUD_TARGET_CATEGORIES, // 정책이 상수명으로 자기 문서화됨 PageRequest.of(0, limit) ); } // ... 이하 동일 패턴으로 10여 개 메서드 } ``` --- **`StatisticsPainPointService` — Facade 도입 이후 서비스가 얼마나 깔끔해졌는지** ```java @Service @RequiredArgsConstructor public class StatisticsPainPointService { private final StatisticsQueryFacade statisticsQueryFacade; // Repository 2개 대신 Facade 1개만 주입 private final StatisticsPainPointFactory statisticsPainPointFactory; private final StatisticsPainPointRequestResolver requestResolver; public StatisticsPainPointTopResponse getTargetPainPointsTop( String disabilityType, String productCategory, String view ) { // 1. 요청 파라미터 해석 StatisticsPainPointTargetRequest request = requestResolver.resolveTarget(disabilityType, productCategory, view); // 2. Facade를 통해 데이터 조회 (Repository가 어떻게 생겼는지 몰라도 됨) List<StatisticsPainPointItem> painPoints = buildPainPoints( request.productCategory(), request.disabilityType() == null ? null : request.disabilityType().getDescription(), request.isAllView() ? ALL_LIMIT : TARGET_PREVIEW_LIMIT ); // 3. 응답 조립 return StatisticsPainPointTopResponse.of( disabilityLabel, productLabel, request.viewValue(), chart, painPoints ); } private List<StatisticsPainPointItem> buildPainPoints(...) { StatisticsFilterRequest filter = new StatisticsFilterRequest(productCategory, disabilityType, false, null); List<StatisticsPainPointSubTaskAggregateRow> subTaskRows = statisticsQueryFacade.findPainPointTopSubTaskRows(filter, limit); List<Long> subTaskIds = subTaskRows.stream() .map(StatisticsPainPointSubTaskAggregateRow::getSubTaskId) .filter(Objects::nonNull) .toList(); List<StatisticsPainPointElementAggregateRow> elementRows = statisticsQueryFacade.findPainPointElementRows(filter, subTaskIds); return statisticsPainPointFactory.toPainPointItems(subTaskRows, elementRows); } } ``` 서비스가 Repository의 존재를 모른다. `EvaluationScore.scoredValues()`도, `TargetCategory.MAIN`도 서비스 코드에 없다. 서비스는 "무엇을 조회할지"만 말하고, "어떻게 조회할지"는 Facade가 책임지도록 했다. --- ### 전후 비교 | Before | After | | | -------------------------------- | ------------ | ------------------------------ | | Repository 의존 위치 | 7개 서비스 각각 | `StatisticsQueryFacade` 1개 | | 필터 해석 위치 | 각 서비스 내 | `StatisticsFilterResolver` | | `EvaluationScore.scoredValues()` | 매 호출마다 직접 전달 | Facade 내부에서만 사용 | | `TargetCategory.MAIN` 정책 | 코드 곳곳에 산재 | Facade 상수로 집중, Javadoc으로 이유 명시 | | Repository 시그니처 변경 시 영향 범위 | 7개 서비스 | Facade 1곳 | --- ## 📚 What I Learned **1. Facade는 "복잡함을 숨기는" 것이 아니라 "책임의 경계를 긋는" 것이다.** "Repository를 직접 호출하지 못하게 막는다"가 목표가 아니다. `EvaluationScore.scoredValues()`라는 상수를 누가 알아야 하는가? 서비스는 몰라도 된다. Facade가 알면 된다. 이 경계를 설정하는 것 자체가 설계다. **2. Facade를 도입하면 서비스 테스트가 쉬워진다.** 각 서비스 테스트에서 `StatisticsScoreQueryRepository`와 `StatisticsCommentQueryRepository` 두 개를 모킹하던 것을 `StatisticsQueryFacade` 하나만 모킹하면 된다. 테스트 코드 작성 시 진짜 어떤 Repository 메서드를 어떤 파라미터로 호출하는지 신경쓰지 않아도 된다. **3. "중복"의 진짜 문제는 코드 양이 아니라 변경 시 일관성이다.** `"ALL"` → null 처리 로직이 7개 서비스에 비슷하게 흩어져 있을 때, 비즈니스 요구사항이 바뀌어 `"ALL"`이 아니라 `"TOTAL"`로 바뀌면 7곳을 찾아야 한다. 하나를 고쳤는데 다른 하나를 놓치는 순간 버그다. Facade를 사용하면 변경의 영향 범위를 1곳으로 줄일 수 있다. --- ## 🔗 Related - https://dami97.tistory.com/40