#개발 #큐시즘 #백엔드 #기술블로그 #대외학회 #기업프로젝트 #트러블슈팅 #퍼사드 #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