검색 기능 구현하다가 PostSearchStrategy라는 인터페이스를 이미 쓰고 있었는데, 정작 "이게 왜 전략패턴인지", "if-else로 짜면 왜 안 되는지"는 제대로 설명 못 하는 상태였음. 그래서 개념부터 다시 정리해봄.
행위(알고리즘)를 인터페이스로 통일해서, 갈아끼울 수 있게 만드는 패턴임.
구성 요소는 3개임.
비유하면 리모컨 규격 같은 거임. 삼성 TV든 LG TV든 "전원/채널/볼륨" 버튼이 똑같은 규격(인터페이스)을 따르면, 어떤 회사 TV를 쓰든 리모컨 다루는 법을 새로 안 배워도 됨. Spring AI에서 ChatModel 인터페이스 하나로 Claude/Gemini/OpenAI를 똑같이 다루는 것도 같은 원리임.
물론 if-else로도 똑같은 결과를 낼 수 있음. 차이는 새로운 케이스가 추가될 때 드러남.
// if-else 버전
SearchCursorResponse search(String type, ...) {
if (type.equals("gin")) {
// GIN 검색 로직
} else if (type.equals("pgvector")) {
// pgvector 검색 로직
}
// 새 검색 방식(예: ES) 추가되면 여기다 또 분기 추가해야 함
}
새 검색 방식(예: Elasticsearch)이 추가될 때마다 이 메서드 자체를 열어서 수정해야 함. 이게 바로 OCP(Open-Closed Principle, 개방-폐쇄 원칙) 를 어기는 거임 — "확장에는 열려있고, 변경에는 닫혀있어야 한다"는 원칙인데, if-else는 확장할 때마다 기존 코드를 계속 변경하게 됨.
전략패턴으로 짜면 새 전략 클래스만 추가하면 되고, 기존 코드는 안 건드림. 그래서 OCP를 지킴.
내 프로젝트에서 게시글 검색 기능을 GIN trigram 방식이랑 pgvector(시맨틱 검색) 방식 두 가지로 지원하는데, 이걸 전략패턴으로 구현해놓음.
public interface PostSearchStrategy {
/**
* @param canViewPrivate THERAPIST/ADMIN이면 true. PRIVATE 검색 결과를 일반 형태로 응답에 노출.
* USER이면 false. PRIVATE도 결과에 포함되지만 SearchResultAssembler가
* contentPreview를 마스킹하고 accessLocked=true 표시.
*/
SearchCursorResponse search(
PostSearchCondition condition,
BigDecimal lastScore,
Long lastId,
int size,
boolean canViewPrivate
);
}
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.search.strategy", havingValue = "gin", matchIfMissing = true)
public class GinTrigramSearchStrategy implements PostSearchStrategy {
private final TherapyPostRepository therapyPostRepository;
private final SearchResultAssembler assembler;
@PersistenceContext
private EntityManager entityManager;
@Override
public SearchCursorResponse search(
PostSearchCondition condition,
BigDecimal lastScore,
Long lastId,
int size,
boolean canViewPrivate
) {
entityManager.createNativeQuery("SET LOCAL pg_trgm.word_similarity_threshold = 0.1")
.executeUpdate();
String rawKeyword = condition.getKeyword().trim().toLowerCase();
String escapedKeyword = condition.getEscapedKeyword().trim().toLowerCase();
String area = condition.getTherapyArea() != null ? condition.getTherapyArea().name() : null;
String type = condition.getPostType() != null ? condition.getPostType().name() : null;
int limit = size + 1;
boolean firstPage = (lastScore == null && lastId == null);
List<Object[]> rows = firstPage
? therapyPostRepository.searchIdsByRelevanceFirstPage(
rawKeyword, escapedKeyword, area, type, limit)
: therapyPostRepository.searchIdsByRelevanceNextPage(
rawKeyword, escapedKeyword, area, type, lastScore, lastId, limit);
return assembler.assemble(rows, size, canViewPrivate);
}
}
@Component
@ConditionalOnProperty(name = "app.search.strategy", havingValue = "pgvector")
public class PgVectorSearchStrategy implements PostSearchStrategy {
private static final BigDecimal FALLBACK_MIN_SCORE = new BigDecimal("0.2");
private static final int FALLBACK_THRESHOLD = 3;
private final TherapyPostRepository therapyPostRepository;
private final EmbeddingService embeddingService;
private final SearchResultAssembler assembler;
@Value("${app.search.vector.min-score}")
private BigDecimal minScore;
@Override
public SearchCursorResponse search(
PostSearchCondition condition,
BigDecimal lastScore,
Long lastId,
int size,
boolean canViewPrivate
) {
float[] queryEmbedding = embeddingService.embed(condition.getKeyword().trim());
String embeddingStr = EmbeddingService.toVectorString(queryEmbedding);
// ... 검색 로직, 결과 부족하면 min-score 완화해서 폴백 재검색
return assembler.assemble(rows, size, canViewPrivate);
}
}
여기서 포인트는 @ConditionalOnProperty임. app.search.strategy 설정값에 따라 Spring이 앱 시작 시점에 둘 중 하나만 빈으로 등록함. 그래서 PostSearchStrategy 타입 빈이 항상 하나만 존재하고, 이걸 사용하는 쪽(PostService)은 어떤 구현체가 들어올지 몰라도 그냥 인터페이스만 보고 호출하면 됨.
class PostService {
private final PostSearchStrategy strategy; // 어떤 구현체인지 모름, DI로 주입만 받음
SearchCursorResponse search(...) {
return strategy.search(...);
}
}
@ConditionalOnProperty는 앱 시작 시점 고정이라 재시작해야 바뀜. 진짜 런타임 교체를 하려면 팩토리패턴이 필요함 → 6번에서 이어짐)GinTrigramSearchStrategyTest, PgVectorSearchStrategyTest처럼 따로 테스트 가능)객체를 생성하는 로직을 한 클래스로 모아두는 패턴임. "공장"처럼, 여러 곳에서 각자 조립하지 말고 한 군데서 찍어내라는 거임.
// OrderService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);
// RefundService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);
// SubscriptionService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);
CardPayment 생성자에 파라미터(region)가 하나 추가되면, 저 new CardPayment(...)가 있는 곳을 전부 찾아서 고쳐야 함.
class PaymentFactory {
PaymentMethod create() {
return new CardPayment(apiKey, timeout, retryCount, region);
}
}
// A, B, C 전부
PaymentMethod pm = paymentFactory.create();
이제 생성자가 바뀌어도 PaymentFactory 한 곳만 고치면 끝임.
팩토리가 진짜 힘을 발휘하는 건 "여러 구현체 중 뭘 만들지 결정"해야 할 때임.
class PaymentFactory {
PaymentMethod create(String type) {
return switch (type) {
case "card" -> new CardPayment();
case "cash" -> new CashPayment();
default -> throw new IllegalArgumentException();
};
}
}
여기 안에 분기(switch)가 있는데, 이건 괜찮음. 팩토리의 역할 자체가 "타입에 따라 다른 객체를 만드는 것"이라서, 이 분기는 팩토리가 짊어져야 할 책임임. 문제였던 건 이 분기가 비즈니스 로직(Context) 안에 섞여있는 거였지, 분기 자체가 나쁜 게 아니었음.
둘이 답하는 질문 자체가 다름.
관리자가 검색 방식을 런타임에 전환하는 기능을 넣는다고 가정하면 둘이 같이 필요해짐.
// ① 전략패턴 영역: 인터페이스 + 구현체들
interface PostSearchStrategy {
SearchCursorResponse search(...);
}
class GinTrigramSearchStrategy implements PostSearchStrategy { ... }
class PgVectorSearchStrategy implements PostSearchStrategy { ... }
// ② 팩토리패턴 영역: "뭘 만들지 결정"하는 로직
class SearchStrategyFactory {
private final GinTrigramSearchStrategy gin;
private final PgVectorSearchStrategy pgvector;
PostSearchStrategy create(String type) {
return switch (type) {
case "gin" -> gin;
case "pgvector" -> pgvector;
};
}
}
// ③ Context: 사용하는 쪽
class PostService {
private final SearchStrategyFactory factory;
private final AdminConfigRepository configRepo;
SearchCursorResponse search(...) {
String type = configRepo.getCurrentSearchType(); // 관리자가 저장해둔 값
PostSearchStrategy strategy = factory.create(type); // 팩토리가 결정+생성
return strategy.search(...); // Context는 전략을 실행만 함
}
}
둘 다 없음 — 분기가 Context 안에 다 있음
class PostService {
SearchCursorResponse search(...) {
String type = configRepo.getCurrentSearchType();
if (type.equals("gin")) {
// GIN 검색 로직 통째로
} else {
// pgvector 검색 로직 통째로
}
}
}
전략패턴만 있음 — 인터페이스는 있지만, 고르는 로직은 Context가 직접 함
class PostService {
private final GinTrigramSearchStrategy gin;
private final PgVectorSearchStrategy pgvector;
SearchCursorResponse search(...) {
String type = configRepo.getCurrentSearchType();
PostSearchStrategy strategy = type.equals("gin") ? gin : pgvector; // 분기 여전히 Context 안
return strategy.search(...);
}
}
팩토리패턴만 있음 — 인터페이스가 없어서 억지스러워짐
class GinTrigramSearchStrategy { ... } // interface 구현 안 함
class PgVectorSearchStrategy { ... } // 서로 무관한 별개 클래스
class SearchFactory {
Object create(String type) { // 공통 타입이 없어서 반환 타입이 애매해짐
return type.equals("gin") ? new GinTrigramSearchStrategy(...) : new PgVectorSearchStrategy(...);
}
}
// PostService는 반환받은 걸 형변환해서 각각 다르게 호출해야 함 → 사실상 못 씀
둘 다 있음 — 위에서 본 형태, 가장 깔끔함
Context는 "설정값 읽기 → 팩토리한테 만들어달라고 요청 → 받은 전략 실행"만 함. 분기도 없고, 구체 클래스도 모름.
@ConditionalOnProperty가 팩토리 역할을 대신함, 단 앱 재시작 시에만 결정됨), 나중에 관리자가 런타임에 검색 엔진을 토글하는 기능을 넣으면 그때 진짜 팩토리패턴이 필요해질 거임.