전략패턴 / 팩토리패턴

김영준·2026년 8월 29일

디자인 패턴

목록 보기
1/1

전략 패턴과 팩토리 패턴 정리

1. 왜 공부하게 됐냐

검색 기능 구현하다가 PostSearchStrategy라는 인터페이스를 이미 쓰고 있었는데, 정작 "이게 왜 전략패턴인지", "if-else로 짜면 왜 안 되는지"는 제대로 설명 못 하는 상태였음. 그래서 개념부터 다시 정리해봄.

2. 전략 패턴이 뭐냐

행위(알고리즘)를 인터페이스로 통일해서, 갈아끼울 수 있게 만드는 패턴임.

구성 요소는 3개임.

  • Strategy (인터페이스) — 규격
  • ConcreteStrategy (구현체 여러 개) — 실제 알고리즘들
  • Context (전략을 사용하는 쪽) — 전략을 직접 만들지 않고, 주입받아서 호출만 함

비유하면 리모컨 규격 같은 거임. 삼성 TV든 LG TV든 "전원/채널/볼륨" 버튼이 똑같은 규격(인터페이스)을 따르면, 어떤 회사 TV를 쓰든 리모컨 다루는 법을 새로 안 배워도 됨. Spring AI에서 ChatModel 인터페이스 하나로 Claude/Gemini/OpenAI를 똑같이 다루는 것도 같은 원리임.

3. if-else로 하면 안 되나? (OCP)

물론 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를 지킴.

4. 실제 프로젝트 코드

내 프로젝트에서 게시글 검색 기능을 GIN trigram 방식이랑 pgvector(시맨틱 검색) 방식 두 가지로 지원하는데, 이걸 전략패턴으로 구현해놓음.

Strategy 인터페이스

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
    );
}

ConcreteStrategy 1 — GIN trigram 기반

@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);
    }
}

ConcreteStrategy 2 — pgvector 기반 (시맨틱 검색)

@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(...);
    }
}

5. 전략패턴의 장점 정리

  1. OCP 준수 — 새 전략 추가해도 기존 코드 안 건드림
  2. 조건문 제거 — if-else/switch 덩어리가 클래스별로 분산됨
  3. 런타임 교체 가능 — (단, @ConditionalOnProperty는 앱 시작 시점 고정이라 재시작해야 바뀜. 진짜 런타임 교체를 하려면 팩토리패턴이 필요함 → 6번에서 이어짐)
  4. 테스트 용이 — 각 전략을 독립적으로 테스트 가능 (GinTrigramSearchStrategyTest, PgVectorSearchStrategyTest처럼 따로 테스트 가능)

6. 팩토리 패턴이 뭐냐

객체를 생성하는 로직을 한 클래스로 모아두는 패턴임. "공장"처럼, 여러 곳에서 각자 조립하지 말고 한 군데서 찍어내라는 거임.

왜 필요하냐 — new가 여기저기 흩어지는 문제

// 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) 안에 섞여있는 거였지, 분기 자체가 나쁜 게 아니었음.

7. 전략패턴 vs 팩토리패턴 — 관계 정리

둘이 답하는 질문 자체가 다름.

  • 전략패턴: 여러 구현을 어떻게 똑같이 다룰까 (규격 통일)
  • 팩토리패턴: 그중 뭘 쓸지 누가 결정할까 (생성 + 선택)

관리자가 검색 방식을 런타임에 전환하는 기능을 넣는다고 가정하면 둘이 같이 필요해짐.

// ① 전략패턴 영역: 인터페이스 + 구현체들
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는 전략을 실행만 함
    }
}

4가지 조합으로 비교

둘 다 없음 — 분기가 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는 "설정값 읽기 → 팩토리한테 만들어달라고 요청 → 받은 전략 실행"만 함. 분기도 없고, 구체 클래스도 모름.

8. 결론

  • 전략패턴 = 여러 구현체를 인터페이스로 규격화 (밖에서 보기엔 똑같이 다룰 수 있게)
  • 팩토리패턴 = 그 규격화된 것들 중 뭘 쓸지 결정해서 만들어주는 역할
  • 둘 다 "없어도 동작은 함". 필수가 아니라 코드를 깔끔하게 유지하기 위한 선택임. 다만 새 케이스가 늘어나거나, 런타임에 전략을 바꿔야 하는 요구사항이 생기는 순간부터 그 가치가 확 드러남.
  • 내 프로젝트는 현재 전략패턴만 쓰고 있고(Spring @ConditionalOnProperty가 팩토리 역할을 대신함, 단 앱 재시작 시에만 결정됨), 나중에 관리자가 런타임에 검색 엔진을 토글하는 기능을 넣으면 그때 진짜 팩토리패턴이 필요해질 거임.
profile
개발의 신이 될거다

0개의 댓글