전략패턴을 공부하면서
고전 디자인 패턴에 대해 공부하기 시작했다.
솔직히 말하면 굳이 해야 하나 싶었다.
쭉 훑어 봤을때는
그냥 추상 클래스 하나 두고 DIP나 OCP 적용하라는 이야기처럼 보이는 것도 있었고,
언어 레벨에서 이미 당연하게 지원해주는 내용도 있었으며, 이걸 내가 직접 구현할 일이 있을까? 싶은 패턴도 많았다.
그래도 기본은 알고 있어야 할 것 같아서
제일 만만해 보이는 전략 패턴부터 공부해봤다.
이미 구글링이나 유튜브를 통해 접한 내용이 있어서
아래 같은 코드를 보면 아 이런 상황에서는 전략 패턴을 적용할 수 있겠구나 정도의 감각은 있었다.
1class OrderService {2 public void process(Order order) {3 if (order.getType() == WHOLESALE) {4 System.out.println("도매 주문 처리 로직");5 } else if (order.getType() == RETAIL) {6 System.out.println("소매 주문 처리 로직");7 } else if (order.getType() == ONLINE) {8 System.out.println("온라인 주문 처리 로직");9 }10 }11}12그래서 처음에는 전략 패턴을 단순한 분기 제거용 테크닉 정도로만 이해하고 있었다.
하지만 GoF의 정의를 다시 읽어보면서 생각이 조금 바뀌었다.
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
나는 이 정의의 핵심이 다음 3가지라고 생각한다.
-
Family of algorithms 같은 목적을 달성하는 여러 방법들이다. 위 예시에서는 주문을 처리한다는 목적을 달성하기 위한 도매, 소매, 온라인이라는 다른 방법들을 의미한다.
-
Encapsulate & Interchangeable 각 알고리즘을 독립적인 객체로 만들고 교체 가능하게 한다. 인터페이스로 추상화하면
OrderService는 구체적인 전략을 몰라도 된다. 런타임에 전략을 바꿀 수도 있다. -
Vary independently 전략과 클라이언트가 서로 다른 이유로 변경된다. 도매 정책이 바뀌어도
OrderService는 수정할 필요가 없고,OrderService의 로깅 방식이 바뀌어도 각 전략은 수정할 필요가 없다.
if else 코드는 도매 로직 하나만 바뀌어도 OrderService 전체를 수정해야 한다. 전략 패턴을 적용하면 WholesaleStrategy 만 수정하면 된다.
전략을 추가했는데 왜 주문 서비스를 열어야 해?
우리는 코드를 읽거나 짤 때 무의식적으로 어떤 기능이 어디에 있을 것이다라는 막연한 기대를 한다.
OrderService.java 파일을 열 때 개발자가 기대하는 건 주문이 처리되는 핵심 흐름이지, 어떤 전략 구현체들이 존재하고 그걸 맵에 넣는 지루한 초기화 작업이 아니다.
이러한 개발자의 기대와 실제 코드의 역할을 일치시키는 것이 바로 객체지향에서 말하는 책임의 분리다.
그래서 가장 먼저 하는 일은 이 분기의 책임을 밖으로 덜어내는 것이다. 흔히 말하는 팩토리를 만드는 것이다.
처음엔 단순히 if문을 옮기는 것부터 시작한다.
1public class OrderStrategyFactory {2 public static OrderStrategy getStrategy(OrderType type) {3 if (type == OrderType.WHOLESALE) {4 return new WholesaleStrategy();5 } else if (type == OrderType.RETAIL) {6 return new RetailStrategy();7 } else if (type == OrderType.ONLINE) {8 return new OnlineStrategy();9 }10 throw new IllegalArgumentException("Unknown order type");11 }12}13최신 자바를 쓴다면 switch 표현식으로 조금 더 깔끔하게 만들 수도 있다.
1public class OrderStrategyFactory {2 public static OrderStrategy getStrategy(OrderType type) {3 return switch (type) {4 case WHOLESALE -> new WholesaleStrategy();5 case RETAIL -> new RetailStrategy();6 case ONLINE -> new OnlineStrategy();7 };8 }9}10이제 OrderService는 아주 깨끗해진다.
1public class OrderService {2 public void process(Order order) {3 OrderStrategy strategy = OrderStrategyFactory.getStrategy(order.getType());4 strategy.process(order);5 }6}7전략패턴을 쓰면 마법처럼 분기가 사라지길 기대하지만 OrderService에서 팩토리로 분기로직이 밀려났을뿐이다.
분기문 대신 Map 쓰기
여기서 한 단계 더 나아가면 재미있는 현상이 벌어진다. 팩토리 안의 switch문조차 자료구조로 바꿔버리는 것이다.
물론 switch도 나쁘지 않다. 특히 자바에서는 sealed 키워드를 사용하면 컴파일 타임에 모든 케이스를 체크해주는 등 이점이 많다. 하지만 전략이 런타임에 동적으로 늘어나야 하면 코드로 박혀있는 switch로는 한계가 있다.
이럴 때 사용하는 것이 바로 Map이다.
1public class OrderStrategyFactory {2 private static final Map<OrderType, OrderStrategy> strategies = new HashMap<>();34 static {5 strategies.put(OrderType.WHOLESALE, new WholesaleStrategy());6 strategies.put(OrderType.RETAIL, new RetailStrategy());7 strategies.put(OrderType.ONLINE, new OnlineStrategy());8 }910 public static OrderStrategy getStrategy(OrderType type) {11 return strategies.get(type);12 }13}14이 코드를 처음 보면 분기가 완전히 사라졌다고 느끼기 쉽다.
하지만 이건 일종의 착시다. 전략 패턴의 본질은 제어 흐름을 데이터 구조로 바꾼 것에 가깝다.
if나 switch는 프로그램이 실행되면서 코드의 경로를 따라 분기를 태운다. 반면 Map은 미리 준비된 조회 테이블에서 정답을 찾아 꺼낸다.
알고리즘을 선택하는 문제를 조건문 실행에서 데이터 조회로 치환한 것이다. 눈앞에서 if 키워드가 안 보이니까 사라졌다고 착각할 뿐, 라우팅이라는 요구사항은 맵이라는 자료구조 뒤에 숨어서 여전히 작동하고 있다.
이 라우팅 책임을 아예 코드 밖으로 밀어버리기도 한다. 스프링 같은 프레임워크를 써서 빈을 주입받거나, 아예 DB에 매핑 정보를 넣어두고 애플리케이션이 그걸 읽게 만든다. 이렇게 하면 개발자는 코드를 단 한 줄도 수정하지 않고 시스템의 동작을 바꿀 수 있다. 다만 착각하면 안되는게 이것도 분리가 사라진건 아니다. 단지 개발자의 손에서 떠났을뿐이다.
근데 파일이 늘어나기 시작한다
격리와 치환에는 성공했다. 그런데 이 격리를 위해 지불한 대가가 만만치 않다.
1📂 strategy/2 ├── OrderStrategy.java3 ├── WholesaleStrategy.java4 ├── RetailStrategy.java5 ├── OnlineStrategy.java6 ├── OrderStrategyFactory.java7 └── ...8단순히 메서드 안의 분기 몇 줄을 밖으로 빼내려고 파일 5~6개를 더 만들었다. 의존성은 깔끔해졌지만, 관리해야 할 클래스가 늘어나는 구조적 비용이 발생한 것이다. 과연 클래스라는 이 무거운 틀이 항상 최선일까? 의존성은 격리하면서도, 파일 개수와 코드 양을 줄일 수 있는 방법은 없을까?