타입으로만 처리해야하나?
지난 글에서 전략 패턴을 통해 if-else 지옥을 탈출하고 의존성을 격리하는 과정을 다뤘다.
분기는 사라졌고(사실 위임되었고), 코드는 깔끔해졌다.
하지만 그 대가로 파일이 주렁주렁 늘어났다.
WholesaleStrategy.java, RetailStrategy.java, OnlineStrategy.java...
고작 메서드 하나 떼어내자고 클래스라는 무거운 틀을 매번 만드는 게 과연 효율적일까?
목표는 책임 분리를 통해 라우팅을 위임하는 것이지, 클래스 파일 개수 늘리기가 아니다. 이 구조적 비용을 줄일 수 있는 더 가벼운 도구들이 있다.
람다
클래스 생성이 부담스러운 진짜 이유는 메서드 하나를 위해 클래스를 만들어야 하기 때문이다. 자바 8부터는 람다를 통해 행위 그 자체를 값 다룰 수 있게 됐다.
1public class OrderStrategyFactory {2 // 전략 = Order를 받아서 소비하는 함수(Consumer)3 private static final Map<OrderType, Consumer<Order>> strategies = new HashMap<>();45 static {6 strategies.put(OrderType.WHOLESALE, order -> System.out.println("도매 처리"));7 strategies.put(OrderType.RETAIL, order -> System.out.println("소매 처리"));8 // 필요하다면 메서드 참조로 깔끔하게 뺄 수도 있다.9 strategies.put(OrderType.ONLINE, this::processOnline);10 }1112 public static void process(Order order) {13 Consumer<Order> strategy = strategies.get(order.getType());14 strategy.accept(order);15 }16}17이것도 전략 패턴인가?
누군가는 "이건 클래스를 상속받는 구조가 아니니 전략 패턴이 아니다"라고 할지도 모른다. 하지만 전략 패턴의 정의를 다시 생각해보자.
"Define a family of algorithms, encapsulate each one, and make them interchangeable." (알고리즘군을 정의하고, 각각을 캡슐화하여 교체 가능하게 만든다.)
여기서 Consumer<Order>는 알고리즘의 인터페이스 역할을 하고, 람다식은 개별 구현체가 된다.
Map을 통해 런타임에 교체하고 선택하니, 본질은 완벽하게 전략 패턴이다. 오히려 불필요한 클래스 선언을 제거하고 로직 그 자체에 집중할 수 있게 해준다.
Enum
분리된 전략 객체들을 따로 관리하기 귀찮다면, 아예 타입 정의 안에 로직을 박아버릴 수 있다.
추상 메서드를 정의하고 각 상수에서 익명 클래스로 구현(new ...)하는 방식을 썼지만, 코드가 지저분했다.
지금은 함수형 인터페이스를 필드로 선언하고, 생성자에서 람다를 받는 방식이 훨씬 간단하다.
1public enum OrderType {2 // 람다로 전략(행위)을 주입받는다.3 WHOLESALE(order -> System.out.println("도매 로직: " + order.getId())),4 RETAIL(order -> System.out.println("소매 로직: " + order.getId()));56 private final Consumer<Order> strategy;78 OrderType(Consumer<Order> strategy) {9 this.strategy = strategy;10 }1112 public void process(Order order) {13 strategy.accept(order);14 }15}16이렇게 하면 호출부(Client)도 극단적으로 단순해진다. 팩토리 클래스조차 필요 없다.
1public void process(Order order) {2 // 타입 자체가 전략을 품고 있다.3 order.getType().process(order);4}5가장 큰 장점은 응집도 이다. 새로운 주문 타입이 추가되는 순간, 개발자는 이 Enum에 상수를 추가해야 한다. 그 과정에서 생성자가 람다를 요구하므로, 로직 구현이 강제된다. "타입은 추가했는데 로직 구현하는 걸 깜빡했어요" 같은 실수가 원천 차단된다. 타입과 로직이 한 파일 안에서 1:1로 강력하게 결합되어 있기 때문이다.
다만, 위 두 방식은 람다 본문이 복잡해지기 시작하면 Enum 파일 하나가 쓰레기통이 된다. 응집도가 높다는건 반대로 결합도가 높을 가능성이 있다. 로직이 3~5줄 내외로 아주 가벼울 때만 유효한 전략이다.
좀 더 복잡한 경우라면?
위 코드를 구현하는거 자체는 너무너무 아름답고 쉬워보인다.. 파라미터가 하나뿐이니까. 하지만 도메인 복잡도가 올라간다면 슬슬 클래스로 분리하는게 낫다는 생각도 들기 시작할것이다.
내가 작업했던 거래소 시세 조회(MarketData) 모듈의 코드를 예로 들어보자.
단순히 값을 리턴하는 게 아니라, 인메모리 캐시(MarketDataCache), REST 클라이언트(RestMarketClient), 로깅을 위한 시작 시간(startTime) 등 온갖 의존성이 필요했다.
처음엔 람다가 보기 좋을것이다 라는 생각으로 시작했다
1this.strategies = Map.of(23 Freshness.FAST, (query, cache, restClient, startTime) -> { // 파라미터가 벌써 4개다.4 // 캐시 조회5 var cached = cache.get(query.symbol());6 if (cached != null) {7 boolean stale = cache.isStale(query.symbol(), query.stalenessBudgetMs());8 return MarketDataResult.success(cached.data(), cached.receivedAt(), Source.STORE, stale, System.currentTimeMillis() - startTime);9 }10 11 // 캐시 없으면? REST 호출12 var response = restClient.fetchTicker(query.symbol());13 if (response.success()) {14 cache.put(query.symbol(), response.data().price());15 return MarketDataResult.success(response.data().price(), response.data().timestamp(), Source.REST, false, System.currentTimeMillis() - startTime);16 }17 18 return MarketDataResult.failure("API error: " + response.statusCode(), System.currentTimeMillis() - startTime);19 },2021 Freshness.FRESH, (query, cache, restClient, startTime) -> {22 // 스트림 대기 로직... (위와 비슷하게 긴 코드가 반복됨)23 // ...24 }25);26뭔가 심상치 않았다. 전략패턴을 썼는데 if-else 만 없을뿐이지 읽기는 더 어려워 보였다.
람다 내부에 로직을 억지로 우겨넣다 보니, 비즈니스 로직의 흐름이 람다 라는 문법 뒤에 숨어버렸다. 가독성은 나락으로 떨어졌고, 재사용도 불가능해졌다.
객체로 파라미터 묶어버리기
일단 급한 불을 끄기 위해, 흩어진 파라미터들을 하나의 객체로 묶었다. 흔히 말하는 구조체 형태의 데이터 오브젝트이다.
1public record FetchContext(2 MarketDataCache cache,3 RestMarketClient restClient,4 long startTime5) {}6이제 람다 시그니처는 조금 깔끔해진다. (query, ctx) -> ...
하지만 여전히 람다 본문의 복잡도는 해결되지 않았다. 그리고 더 큰 문제가 기다리고 있었다.
상태(State)가 생기면 람다는 끝이다
새로운 요구사항이 생겼다.
"신선도 우선(FRESH) 조회 시, 데이터가 안 오면 최대 n번까지 재시도(Retry) 해야했다."
이 하나의 요구사항이 람다 기반의 전략 패턴을 무너뜨렸다. 재시도를 하려면 현재 몇 번 시도했는지를 기억해야 한다. 즉, 상태(State)가 필요하다.
람다는 기본적으로 Stateless를 가정할 때 가장 아름답다.
람다 내부에서 retryCount 변수를 쓰려면 AtomicInteger 같은 꼼수를 쓰거나, 람다 바깥으로 변수를 빼야 한다. 멀티스레드 환경에서 동시성 문제가 터지기 딱 좋은 구조다.
결국 나는 다시 클래스를 꺼내 들었다.
1public class RetryableFreshnessFetcher implements FetchStrategy {2 3 private int retryCount = 0; // 상태(State)가 필요하면 클래스가 답이다.4 private final int maxRetries;56 public RetryableFreshnessFetcher(int maxRetries) {7 this.maxRetries = maxRetries;8 }910 @Override11 public MarketDataResult fetch(MarketDataQuery query, MarketDataCache cache, RestMarketClient restClient) {12 // ... 생략 ...13 // 타임아웃 발생 시14 retryCount++;15 if (retryCount > maxRetries) {16 return MarketDataResult.failure("Max retries exceeded", ...);17 }18 // ...19 }20}21이제야 마음이 편안해진다. 객체 지향의 본질인 '상태와 행위의 캡슐화'가 제대로 작동하기 시작한 것이다.
전략과 컨텍스트 에서의 저울질
여기서 한 가지 더 깊은 고민이 생긴다. 바로 "전략 객체에게 어디까지 알려줄 것인가?"이다.
위의 fetch 메서드를 다시 보자.
1public interface FetchStrategy {2 MarketDataResult fetch(3 MarketDataQuery query, 4 MarketDataCache cache, // <--- ??5 RestMarketClient restClient // <--- ??6 );7}8전략 객체가 일을 하기 위해 MarketDataCache와 RestMarketClient라는 인프라 객체를 통째로 받고 있다.
이게 무슨 의미일까?
전략 객체가 Context의 내부 사정을 너무 속속들이 알고 있다는 뜻이다.
만약 RestMarketClient가 GrpcMarketClient로 바뀌면? 모든 전략 코드를 다 뜯어고쳐야 한다.
그렇다고 파라미터를 안 주면? 전략 객체는 아무것도 할 수 없는 바보가 된다.
이 메시지 설계야말로 전략 패턴 적용 시 가장 어려운 부분이다. 데이터를 너무 안 주면? 전략객체는 사실상 메서드가 아닌 유틸리티 함수를 지원한다. 데이터를 너무 퍼주면? 이걸 굳이 다른 클래스로 분리해야 하나? 싶은 생각이 든다. 분명 격리를 위해 클래스를 쪼갰는데 정작 파라미터로 더 단단히 결합하게 된다.
나만의 결론
결국 모든건 복잡도 문제라고 했다. 응집도와 결합도 사이에서 디자인 패턴은 고정된 정답이 아니라 무게 중심을 어디에 둘 것인가의 문제다. 또 언어 차원에서 지원하는 기능에 따라 각 언어에서 구현하는 방법도 다를것이다.
예컨데 자바 17버전 이상이라면 sealed 라는 키워드와 switch의 Pattern Matching을 활용해 전략 구현을 컴파일 타임에 체크 할 수도 있을것이다. 내가 다른 자바나 JS 쪽 숙련도 만큼 다른 언어를 다루지는 못하지만 분명 더 좋은 방법들이 많을거라고 생각한다. 그러나 언어가 어떻든간에 "파일이 너무 많아서 복잡해요"라는 불평과 "이 코드 덩어리는 도저히 못 읽겠어요"라는 불평 사이.
그 사이에서 코드의 배치를 저울질하는 것, 그게 개발자의 실력이라고 생각한다.