텍스트 편집기에서 문단을 화면 폭에 맞게 줄바꿈하는 코드를 짠다고 하자. 가장 단순하게는 단어가 오른쪽 경계를 넘으면 그 앞에서 줄을 끊으면 된다. 그런데 요구가 붙는다 — 어떤 문서는 단어 하나하나의 폭을 재서 한 줄에 최대한 욱여넣고 싶고(그리디), 어떤 문서는 문단 전체를 놓고 줄들의 여백 편차가 가장 작아지도록 끊고 싶다(TeX식). 표를 그릴 때는 아예 줄을 안 끊는다. 줄바꿈이라는 하나의 일에 서로 다른 여러 방법이 달라붙기 시작한다.

이때 흔히 벌어지는 일은 Composition이라는 클래스 안에 breakLines 메서드를 두고, 그 안에 if (mode == GREEDY) ... else if (mode == TEX) ... else if (mode == ARRAY) 를 늘려 가는 것이다. 방법이 늘 때마다 이 메서드가 부풀고, 세 알고리즘의 지역 변수가 한 메서드에 뒤섞이며, 새 방법을 추가하려면 잘 돌아가던 이 덩어리를 다시 열어 고쳐야 한다. 전략(Strategy) 패턴은 이 부푼 분기를 알고리즘 하나당 객체 하나로 뽑아내, Composition이 줄바꿈 방법을 밖에서 갈아 끼우게 한다.

무엇이 문제인가 — 동기

같은 증상을 결제로 옮겨 보면 더 익숙하다. 주문 금액에 대해 할인을 계산하는데, 정률 할인·정액 할인·등급별 할인·쿠폰 할인이 있고 프로모션 기간마다 새 방식이 추가된다. calculateDiscount 하나에 switch(type)를 두면 다음 세 가지가 동시에 아프다.

첫째, 메서드가 계속 부푼다. 방법이 N개면 분기가 N개고, 각 분기의 로직이 길어질수록 한 메서드가 수백 줄이 된다. 정률 할인을 디버깅하러 들어갔는데 정액·등급·쿠폰 코드까지 눈에 밟힌다.

둘째, 변경이 한 파일로 집중된다. 새 할인 방식을 추가하는 일과 기존 방식을 고치는 일이 전부 이 한 메서드를 건드린다. 서로 무관한 변경들이 같은 자리에서 충돌하고, 잘 돌던 분기를 실수로 깨뜨릴 위험이 매번 따라온다. 개방-폐쇄 원칙이 정확히 여기서 무너진다 — 확장(새 방법)을 위해 수정(기존 메서드 재편집)이 강요된다.

셋째, 알고리즘을 갈아 끼울 수 없다. 분기 조건이 코드에 박혀 있으니, 런타임에 “이 주문은 등급 할인으로” 하고 방법만 바꾸려면 다시 조건 분기를 타야 한다. 알고리즘이 클라이언트 코드에 하드코딩되어 있어서, 방법 자체를 값처럼 주고받지 못한다.

판단 기준: “하는 일은 하나인데 하는 방법이 여럿이고, 그 방법이 앞으로도 늘어난다”가 보이면 전략을 의심한다. 함정: 방법이 지금 두 개고 앞으로 늘 일이 없다면 if/else 하나가 정답이다 — 늘어나지 않을 분기까지 객체로 뽑는 건 대가만 치르는 짓이다.

패턴의 구조 — 누가 무엇을 하나

전략은 세 배역으로 이뤄진다. 등장인물은 단순하지만 책임의 분배가 핵심이다.

Strategy(전략 인터페이스). “이 종류의 일을 한다”는 계약 하나만 선언한다. DiscountPolicy라면 long discount(Order order) 하나. 이 인터페이스가 모든 구체 알고리즘이 공유하는 공통의 얼굴이고, 클라이언트는 이 얼굴만 안다.

ConcreteStrategy(구체 전략들). 인터페이스를 각자의 방식으로 구현한 알고리즘 객체들. RateDiscount, AmountDiscount, GradeDiscount… 각자 자기 계산법 하나만 안다. 서로의 존재를 모르고, 자기 안에 자기 로직만 담는다. 방법이 하나 늘면 이 자리에 클래스가 하나 는다 — 기존 클래스는 건드리지 않는다.

Context(맥락). 전략을 사용하는 쪽이다. OrderDiscountPolicy를 필드로 들고 있다가, 할인을 계산할 때 자신이 어떤 방식인지 따지지 않고 그냥 policy.discount(this)를 호출한다. Context는 어떤 구체 전략을 쥐고 있는지 모른다 — 인터페이스 뒤에 가려져 있기 때문이다. 대개 전략은 생성자나 세터로 주입받는다.

협력의 결정적 지점은 데이터를 어떻게 넘기느냐다. 전략이 계산하려면 Context의 데이터(주문 금액, 회원 등급)가 필요하다. 두 갈래가 있다. Context가 필요한 값들을 인자로 전략 메서드에 넘기거나(discount(amount, grade)), Context 자신을 통째로 넘겨(discount(order)) 전략이 필요한 걸 꺼내 쓰게 하거나. 전자는 전략이 Context를 몰라 깔끔하지만 안 쓰는 전략에도 모든 인자를 강요한다. 후자는 유연하지만 전략이 Context의 내부에 결합된다. 이 선택이 전략 패턴에서 가장 자주 삐끗하는 자리다.

판단 기준: Context와 전략이 주고받을 데이터가 적고 고정적이면 인자로 넘기고, 전략마다 필요한 데이터가 제각각이면 Context를 통째로 넘긴 뒤 전략이 골라 쓰게 한다. 함정: Context를 통째로 넘기면서 전략이 context.getA().getB()로 내부를 파고들면, 분기는 없앴지만 결합을 새로 심은 것이다 — 넘기는 것은 최소한으로.

코드로 — 패턴이 자라나는 과정

부푼 switch에서 출발해 전략 인터페이스를 뽑고, 주입으로 갈아 끼울 수 있게 만드는 과정을 단계로 따라간다.

Refactoring Step 부푼 switch — 할인 방법이 한 메서드에 뭉쳐 있다
1/4
public class Order {    private long amount;    private Grade grade;    private String discountType;   // "RATE" | "AMOUNT" | "GRADE"    public long calculateDiscount() {        switch (discountType) {            case "RATE":                return (long) (amount * 0.1);          // 10% 정률            case "AMOUNT":                return Math.min(amount, 3000);          // 3000원 정액            case "GRADE":                if (grade == Grade.GOLD)  return (long) (amount * 0.2);                if (grade == Grade.SILVER) return (long) (amount * 0.1);                return 0;            default:                return 0;        }    }}// 방법이 하나 늘 때마다 case가 하나 는다. 세 알고리즘의 로직이 한 메서드에// 뒤엉키고, 새 방식 추가가 이 잘 돌던 메서드를 매번 다시 열게 만든다.

첫 스텝의 switch가 사라진 것이 아니라 세 클래스로 분산됐다는 점이 중요하다. 전략은 조건 분기를 마법처럼 없애지 않는다. 분기가 하던 “여럿 중 하나를 고르는” 판단을, 코드의 if에서 객체를 주입하는 순간으로 옮겼을 뿐이다. 그 대신 각 알고리즘이 자기 자리를 얻어 홀로 테스트되고 홀로 교체된다.

판단 기준: 리팩터링 후 “새 방법 추가 = 새 클래스 하나, 기존 코드 0줄 수정”이 성립하면 전략이 제대로 앉은 것이다. 함정: 전략을 뽑았는데 여전히 클라이언트가 if (type) new RateDiscount() else ...로 고르고 있다면, 분기를 없앤 게 아니라 자리만 옮긴 것이다 — 그 선택 자체를 팩토리나 맵으로 밀어내야 분기가 진짜 사라진다.

결과 — 무엇을 얻고 무엇을 내주나

전략이 사는 값은 교체 가능성과 개방-폐쇄다. 알고리즘이 인터페이스 뒤로 숨었으니, 클라이언트는 방법을 값처럼 주고받는다. 런타임에 changePolicy로 갈아 끼우고, 새 방법은 클래스 하나 추가로 끝난다. 기존 코드를 다시 열지 않으므로 잘 돌던 분기를 깨뜨릴 위험이 사라진다. 각 알고리즘이 독립된 클래스라 단위 테스트도 알고리즘별로 갈라지고, switch 안에 갇혀 있던 로직이 이름을 얻는다(GradeDiscount가 곧 문서다).

대가는 세 가지다. 첫째, 클래스 수가 는다. 분기 세 개가 클래스 세 개가 됐다. 방법이 잘게 여럿이면 파일이 우수수 늘고, 전체 흐름을 한눈에 보려면 여러 파일을 오가야 한다 — switch 한 곳에서 세 갈래를 다 읽던 편함은 사라진다. 둘째, Context와 전략 사이 데이터 전달 비용이 새로 생긴다. 전략이 계산에 필요한 값을 Context에서 받아야 하는데, 이 인터페이스를 잘못 잡으면(안 쓰는 전략에도 모든 인자를 강요하거나, Context 내부를 전략이 파고들거나) 결합이 되살아난다. 셋째, 클라이언트가 전략들을 알아야 한다. 어떤 전략을 주입할지 누군가는 골라야 하므로, 선택의 책임이 사라지지 않고 클라이언트나 팩토리로 옮겨 간다. 전략이 서로 다르다는 걸 알아야 옳게 고를 수 있다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 방법이 자주 바뀌거나 추가되는 축이라면, 늘어난 클래스 수의 대가보다 “기존 코드를 안 열어도 되는” 이득이 크다. 반대로 방법이 굳어 있고 추가될 일이 없다면, 흩어진 클래스들은 간접성만 더하고 이득은 쓰이지 않는다.

판단 기준: 얻는 것(교체·확장·독립 테스트)과 내주는 것(클래스 증가·데이터 전달·선택 책임 이동)을 세어 순이득이 양수일 때만 전략을 앉힌다. 함정: “if는 나쁘다”는 미신으로 분기를 무조건 전략으로 바꾸면, 두 갈래짜리 조건 하나 때문에 인터페이스 하나·클래스 둘·주입 배선까지 떠안는다 — 대가만 크고 유연성은 놀고 있다.

언제 쓰고, 언제 과용인가

전략이 정확히 맞는 자리는 이렇다. 관련된 알고리즘 **군(群)**이 있고 그중 하나를 상황에 따라 골라 써야 할 때(정렬 비교자, 압축 포맷, 결제 수단, 요금 계산). 한 클래스에 여러 행동이 조건 분기로 들어 있고 그 분기가 자꾸 자라날 때. 알고리즘이 쓰는 자료 구조를 클라이언트에게 숨기고 싶을 때. 자바의 Comparator가 교과서적 사례다 — Collections.sort(list, comparator)에서 comparator가 바로 갈아 끼우는 정렬 전략이고, 정렬 로직 자체는 하나도 안 바뀐다.

과용의 냄새는 반대편에서 난다. 방법이 앞으로도 하나거나 둘이고 늘 일이 없는데 “확장성”을 위해 미리 인터페이스를 판다면, 그건 오지 않을 변경에 거는 보험이다. 전략 클래스가 상태 없이 메서드 하나뿐이고 Context 데이터를 인자로만 받는다면, 자바에서는 굳이 클래스로 만들 것 없이 람다나 메서드 참조로 족한 경우가 많다(전략의 경량 버전). 전략을 주입하는 코드가 다시 거대한 switch가 되어 버렸다면, 분기를 없앤 게 아니라 한 칸 옆으로 밀었을 뿐이다.

여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. [상태]와 전략은 구조가 쌍둥이처럼 닮았다 — 둘 다 행동을 객체로 뽑아 Context가 위임한다. 갈라지는 것은 의도다. 전략의 구체 전략들은 서로를 모르고, 클라이언트가 밖에서 하나를 골라 준다(“이 정렬은 빠른정렬로”). 상태의 상태 객체들은 서로를 알고, 자기들끼리 다음 상태로 넘긴다(“결제 완료가 되면 배송 준비로”) — 전이가 내부에 있다. 방법을 밖에서 고르면 전략, 상태가 스스로 다음을 정하면 상태다. 템플릿메서드(Template Method)는 같은 문제의 상속 버전이다 — 전략이 알고리즘 전체를 객체로 갈아 끼운다면, 템플릿 메서드는 알고리즘 골격은 상위 클래스에 고정하고 일부 단계만 서브클래스가 채운다. 전략은 합성(런타임 교체)으로, 템플릿 메서드는 상속(컴파일타임 고정)으로 같은 유연성을 다르게 산다.

판단 기준: 알고리즘 전체를 통째로 바꾸고 런타임 교체가 필요하면 전략(합성), 알고리즘의 일부 훅만 다르고 골격은 공유하면 템플릿 메서드(상속)를 고른다. 함정: 상태 전이가 필요한 자리에 전략을 쓰면, 다음 상태를 정하는 로직이 클라이언트로 새어 나와 상태 기계가 코드 곳곳에 흩어진다 — “누가 다음을 정하는가”로 둘을 가른다.

다음으로 템플릿메서드