빌더 패턴(Builder Pattern) 적용 회고: 희망편과 절망편
1. 빌더 패턴은 만능일까?
복잡한 객체를 만들 때 자주 언급되는 빌더 패턴.
하지만 이 패턴이 언제나 좋은 설계를 보장하지는 않는다는걸 프로젝트에 세번정도 빌더 패턴을 적용해보다가 깨달았다.
실제로 내가 프로젝트에서 빌더 패턴, 특히 디렉터(Director)를 도입하면서 겪은 희망편과 절망편을 회고해보려고 한다.
- 디렉터를 쓰는 게 좋았던 경우
- 디렉터의 사용이 오히려 복잡도만 올렸던 경우
2. 패턴 요약 및 배경
빌더 패턴 구조 요약
- Builder: 제품의 각 부분 생성 인터페이스
- ConcreteBuilder: 구체적으로 제품 생성 방법 구현
- Director: Builder를 조합해 객체 생성 과정을 관리
- Product: 최종 결과물
적용 배경
- 공통되고 복잡한 생성 과정이 여러 곳에 중복될 때
- 동일한 생성 절차로 다른 표현이 필요할 때
3. 빌더 패턴 희망편: Envers Revision Director
배경
프로젝트에서 여러 도메인 객체(예: User, Role, Bot 등)의 변경 이력을 Hibernate Envers로 관리하고 있었다
이 이력을 다양한 서비스에서 반복적으로 조회해야 했는데 다음과 같은
- 엔티티마다 거의 비슷한 쿼리/조회 로직이 서비스마다 반복됨
- 필터, 조건, 페이징 등은 비슷한데 도메인 클래스만 다름
- 공통화해서 중복을 줄이고 싶다 → 빌더 패턴 + 디렉터 적용
발전 과정
(1) 초기에 각 도메인 서비스별로 직접 이력 조회 메서드를 구현
// (실패 전)public ResultList<RevisionResponse> getUserHistory(RevisionCriteria criteria) {
// User에 맞춘 쿼리와 파싱 로직
}
public ResultList<RevisionResponse> getRoleHistory(RevisionCriteria criteria) {
// Role에 맞춘 쿼리와 파싱 로직
}
- 문제: 코드 중복, 도메인 늘수록 유지보수 지옥
(2) 제너릭 + 디렉터 클래스 도입: 공통화
Hibernate Envers로 여러 도메인 엔티티의 이력을 조회하는 공통 로직을 디렉터 클래스에 구현했다.
각 서비스는 단순히 파라미터만 주면 됨
@Override
public <T> ResultList<AuditReaderResult<T>> getRevisionHistory(
Class<?> clazz,
RevisionCriteria criteria,
Map<String, Object> filters,
Object[] targets,
String targetName
) {
AuditQuery query = buildQuery(clazz, criteria, filters, targets, targetName);
AuditQuery countQuery = buildCountQuery(clazz, criteria, filters, targets, targetName);
List<?> results = query.getResultList();
Object count = countQuery.getSingleResult();
return new ResultList<>((Long) count, auditReaderResultOf(results));
}
private AuditQuery buildQuery(Class<?> clazz, RevisionCriteria criteria, Map<String, Object> filters) {
AuditQuery query = createQuery(clazz);
query = filter(query, filters);
query = timeRange(query, criteria);
query = setPaging(query, criteria);
return query;
}
private AuditQuery buildCountQuery(Class<?> clazz, RevisionCriteria criteria, Map<String, Object> filters) {
AuditQuery countQuery = createCountQuery(clazz);
countQuery = filter(countQuery, filters);
countQuery = timeRange(countQuery, criteria);
return countQuery;
}
활용은 다음과같이 하면 되었다
@Override
public ResultList<RevisionResponse> getHistory(RevisionCriteria criteria) {
Map<String, Object> filter = new HashMap<>();
if (criteria.getKeyword() != null && !criteria.getKeyword().trim().isEmpty()) {
filter.put("name", criteria.getKeyword());
}
ResultList<AuditReaderResult<Role>> resultList = revisionRepository.getRevisionHistory(Role.class, criteria, filter);
Collection<AuditReaderResult<Role>> list = resultList.getElements();
List<RevisionResponse> result = list.stream()
.map(this::mapToDTO)
.collect(Collectors.toList());
return new ResultList<>(resultList.getTotalCount(), result);
}
이 방식을 통해 다양한 도메인 객체에 대한 이력 조회 로직을 일관되게 처리할 수 있었고, 코드 중복을 크게 줄였다.
신규 도메인 추가도 간단해져서 저렇게 바꾸니 일이 줄어서 이력 페이지를 개발하는 다른 팀원이 호평했다.
(3) 구조의 장점
- 공통된 생성 절차는 디렉터가 전담 (buildQuery, buildCountQuery 등)
- 필요에 따라 파라미터만 넘기면 도메인별 이력 조회 가능
- 중복 최소화, 유지보수 용이, 신규 도메인 추가도 간단
4. 빌더 패턴 절망편: 범용 쿼리 빌더 & 메서드 체이닝 위 디렉터
OpenSearch, Querydsl 등은 이미 메서드 체이닝을 지원하지만
모든 쿼리를 한 방에 범용적으로’ 만들고 싶어, 빌더/디렉터 도입을 시도해보았다
그 결과 OpenSearch 쿼리 빌더에서는 다양한 조건에 대응하기 위해 디렉터 클래스가 점점 복잡해지는 상황이 발생했다.
(1) OpenSearch 쿼리 빌더에 적용한 사례
오픈서치 쿼리에도 비슷하게 빌더 패턴을 적용해 보려다가 정말 피를 보게 되었다
public SearchSourceBuilder buildSearchQuery(SearchCriteria criteria) {
BoolQueryBuilder queryBuilder = new BoolQueryBuilder();
// 필수 조건들 처리
if (criteria.getMustConditions() != null) {
criteria.getMustConditions().forEach((field, value) -> {
if (value != null) {
queryBuilder.must(QueryBuilders.termQuery(field, value));
}
});
}
이것만 있을땐 행복했는데... 점점 쿼리에서 쓰는 메서드가 많아지면서 절망편이 되어갔다
public SearchSourceBuilder buildSearchQuery(SearchCriteria criteria) {
BoolQueryBuilder queryBuilder = new BoolQueryBuilder();
// 필수 조건들 처리
if (criteria.getMustConditions() != null) {
criteria.getMustConditions().forEach((field, value) -> {
if (value != null) {
queryBuilder.must(QueryBuilders.termQuery(field, value));
}
});
}
// 일반 조건들 처리
if (criteria.getConditions() != null) {
criteria.getConditions().forEach((field, value) -> {
if (value != null) {
if (value instanceof Boolean) {
queryBuilder.must(QueryBuilders.termQuery(field, value));
} else if (value instanceof String) {
String strValue = (String) value;
if (StringUtils.hasText(strValue) && !"ALL".equals(strValue)) {
if (criteria.getWildcardFields() != null &&
criteria.getWildcardFields().contains(field)) {
queryBuilder.must(QueryBuilders.wildcardQuery(field, "*" + strValue + "*"));
} else {
queryBuilder.must(QueryBuilders.matchQuery(field, strValue));
}
}
} else if (value instanceof List) {
queryBuilder.must(QueryBuilders.termsQuery(field, (List<?>) value));
}
}
});
}
// range 쿼리 처리
if (criteria.getRangeConditions() != null) {
criteria.getRangeConditions().forEach((field, condition) -> {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery(field);
if (condition.getGte() != null) {
rangeQuery.gte(condition.getGte());
}
if (condition.getLte() != null) {
rangeQuery.lte(condition.getLte());
}
if (condition.getGt() != null) {
rangeQuery.gt(condition.getGt());
}
if (condition.getLt() != null) {
rangeQuery.lt(condition.getLt());
}
queryBuilder.must(rangeQuery);
});
}
// 날짜 범위 처리
if (criteria.getFrom() != null && criteria.getTo() != null) {
queryBuilder.must(QueryBuilders.rangeQuery("timestamp")
.gte(criteria.getFrom())
.lte(criteria.getTo()));
}
// ... 더 많은 조건 처리 코드
// SearchSourceBuilder 설정
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder)
.trackTotalHits(true)
.size(criteria.getSize() > 0 ? criteria.getSize() : 10000);
if (criteria.getSortField() != null) {
searchSourceBuilder.sort(criteria.getSortField(),
criteria.getSortOrder() != null ? criteria.getSortOrder() : SortOrder.DESC);
}
if (criteria.getPage() >= 0) {
searchSourceBuilder.from(criteria.getPage() * criteria.getSize());
}
return searchSourceBuilder;
}
이 접근 방식은 점점 복잡해져서 결국 범용적인 쿼리 생성 대신 특정 케이스별로 개별 쿼리를 작성하는 방식으로 전환했다.
빌더패턴을 적용해본 쿼리 생성자로 간략히 보는 문제점… 점점 덕지덕지 붙기 시작함
@Getter
@Setter
@Builder
public static class SearchCriteria {
private Map<String, Object> mustConditions;
private Map<String, Object> shouldExactMatchFields;
private Map<String, RangeQueryCondition> rangeConditions;
private String from;
private String to;
private String tenantCode;
private Map<String, Object> conditions;
private Set<String> wildcardFields;
private String searchTerm;
private List<String> searchFields;
private String sortField;
private SortOrder sortOrder;
private int size;
private int page;
private boolean trackTotalHits;
}
@Getter
@Setter
@Builder
public static class RangeQueryCondition {
private Object gte;
private Object lte;
private Object gt;
private Object lt;
메서드 체이닝이 이미 들어있는 상태에서 디렉터 클래스를 만드는 것은 오버엔지니어링인 것 같고 복잡도만 증가해서 별로인 것 같다
그래서 특정 사용 케이스마다 메서드만 따로 만드는 방식으로 돌아갔다
public SearchSourceBuilder buildLogQuery(String tenantCode, Date from, Date to) {
return new SearchSourceBuilder()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("baseInfo.tenantCode", tenantCode))
.must(QueryBuilders.rangeQuery("timestamp").from(from).to(to)))
.sort("timestamp", SortOrder.DESC);
}
문제 분석
- 문제: 조건이 늘어날수록 디렉터가 점점 더 거대/복잡해짐
- 유지보수 난이도 급상승
- 조건별로 “분기 + 조립”이 반복되고, 공통화의 의미가 사라짐
- 결국 각 케이스마다 분기만 늘어날 뿐, 진짜 범용화도 힘듦
(2) Querydsl 래퍼/빌더 계층에 적용한 사례
public class ArsQueryBuilder {
private final JPAQuery<ArsConnect> query;
public ArsQueryBuilder(JPAQueryFactory queryFactory) {
this.query = queryFactory.selectFrom(arsConnect);
}
public ArsQueryBuilder withBot(Bot bot) {
this.query.where(arsConnect.bot.eq(bot));
return this;
}
public ArsQueryBuilder containsName(String name) {
if (name != null && !name.trim().isEmpty()) {
this.query.where(arsConnect.name.containsIgnoreCase(name));
}
return this;
}
public ArsQueryBuilder containsCode(String code) {
if (code != null && !code.trim().isEmpty()) {
this.query.where(arsConnect.code.containsIgnoreCase(code));
}
return this;
}
public ArsQueryBuilder containsNameOrCode(String keyword) {
if (keyword != null && !keyword.trim().isEmpty()) {
this.query.where(arsConnect.code.containsIgnoreCase(keyword).or(arsConnect.name.containsIgnoreCase(keyword)));
}
return this;
}
public JPAQuery<ArsConnect> build() {
return query;
}
}
이것도 중간에 의미없이 또 ArsConnect만을 위한 코드가 들어가는 거 같아서 나중에 다 빼고 query.method chaning 을 사용했다.
// 직접 JPAQuery 사용
JPAQuery<ArsConnect> query = queryFactory.selectFrom(arsConnect)
.where(arsConnect.bot.eq(bot))
.where(StringUtils.hasText(keyword) ?
arsConnect.code.containsIgnoreCase(keyword).or(arsConnect.name.containsIgnoreCase(keyword)) :
null);
문제 분석
- 문제: 이미 Querydsl의 JPAQuery 자체가 체이닝 API를 지원
- 편의성은 약간 있지만, 오히려 직접 체이닝하는 것보다 복잡
- 엔티티별 빌더 만들다 결국 JPAQuery 직접 쓰는 쪽으로 복귀
절망편의 교훈
- 메서드 체이닝이 이미 구현된 경우 디렉터/추가 래퍼는 오버엔지니어링
- 추상화 계층을 늘린다고 항상 재사용성이 늘지는 않는다
- 조건 분기/케이스가 많아지면 범용 디렉터는 금방 유지보수 지옥이 됨
- 특정 쿼리 패턴은 별도 “전용 메서드/유틸”로 분리하는 게 더 낫다
5. 결론: 언제 디렉터/빌더를 써야 하나?
디렉터가 빛을 발하는 경우
- 공통 생성 절차가 명확하고, 실제 변화는 일부 파라미터(도메인 타입 등) 뿐일 때
- 예: Envers revision 공통화
디렉터가 오히려 독이 되는 경우
- 조건/케이스가 자주 변하고, 생성 과정이 너무 다양해질 때
- 이미 메서드 체이닝이 완비된 빌더 스타일 API 위에 또 디렉터를 얹는 경우
- 예: 범용 쿼리 빌더, Querydsl 래퍼
대안
- 공통 쿼리/생성 로직이 필요한 경우 “전용 유틸/정적 메서드”나 간단한 팩토리/빌더 메서드로 분리
- 자주 쓰이는 조합만 별도 함수로 만들어 중복을 줄이고, 나머지는 체이닝 스타일을 직접 사용
6. 마무리
디자인 패턴은 상황을 잘 보고 미래에 어떻게 변할지까지 모두 고려하여 합리적일때만 적용해야한다는 결론을 얻었다
- 패턴 적용 전, 문제의 본질을 다시 보자.
- 진짜로 재사용/공통화가 필요한 구조에만 도입하자.
- 이미 충분히 직관적인 인터페이스가 있으면, 추가 추상화는 오히려 독이 된다.
'TIL(Develop)' 카테고리의 다른 글
오버라이딩과 오버로딩 차이 (0) | 2024.05.23 |
---|---|
JVM(Java Virtual Machine) (0) | 2024.05.17 |
멀티스레드와 스레드 풀 / 스레드와 프로세스의 차이 (0) | 2024.05.17 |
Java의 메모리 관리와 가비지 컬렉션 (0) | 2024.05.16 |
Java 접근제어자 (0) | 2024.05.15 |