전략 패턴
strategy pattern
· 마지막 업데이트: 2026-10-17 · 44 min read
전략 패턴(Strategy Pattern)은 같은 일을 수행하는 여러 알고리즘을 공통 계약 아래에 두고, 그 일을 사용하는 코드와 구체적인 수행 방식을 분리하는 행위 패턴이다.
사용하는쪽은 보통 '무엇을 할지'만 알고, '어떻게 할지'는 모르는 계약 뒤에 숨은 구현체가 결정한다.
개인적인 메모:현실세계로 따지면 원청은 계약을 주고, 그 구현은 하청에서 다 하는 것과 같다. 좋은 하청의 기본 원칙은 원청내부 사정을 모르는 것이다. 좋은 프로그래머는 좋은 하청 작업자와 비슷하다.
주문이 할인 규칙을 직접 계산하는 대신 IDiscountPolicy에 맡기거나 진단 출력 코드가 문자열 변환을 별도 포매터에 맡기는 구조가 여기에 해당한다.
본체는 맡길 일과 결과의 약속을 알고, 그 일을 어떤 방식으로 수행할지는 연결된 구현이 정한다는 것은 어렵지만 요새 언어들은 대부분 이를 쉽게 표현할 수 있는 도구를 갖추고 있다. 보통 인터페이스,함수 타입, 델리게이트, 트레이트 같은 것들이다.
주로 쓰는 곳은 할인 계산, 정렬 기준, 압축 방식처럼 목적은 같고 수행 방식만 달라지는 곳에 쓴다.
처음부터 클래스 계층을 만들기는 대체로 안티 패턴이 되기 쉬워서, 현업에서는 먼저 기획서를 작성하고, 어느 부분을 바꿔 끼우려는지와 그 자리에 들어오는 구현들이 무엇을 똑같이 약속해야 하는지부터 정하는 편이 이해하기 쉽다.
GoF의 Design Patterns는 서로 관련된 알고리즘을 각각 캡슐화해 교체 가능하게 만드는 패턴으로 전략을 정리했으며, Policy도 같은 패턴의 다른 이름으로 제시한다.1
왜 필요한가?
처음부터 여러 클래스를 만들 이유는 없다. 알고리즘이 두 개 정도라면 조건문 하나로도 충분하다. 그러나 프로그램이 커지면서 같은 조건문이 여러 곳에 반복되고, 알고리즘을 수정할 때마다 그 조건문들을 함께 수정해야 한다면 이야기가 달라진다.
가령 컴파일러를 만든다고 생각해 보자.
개인적인 메모:pgy는 내 언어의 확장자다.
컴파일러는 소스코드에서 오류를 발견하면 사용자에게 진단 메시지를 출력한다.
main.pgy:12: E001: 이름을 찾을 수 없습니다그런데 개발 도구나 다른 프로그램이 이 진단을 받아 처리하려면 JSON 형식이 편리할 수 있다.
사람은 오류 문장을 읽으면 되지만, 프로그램은 파일명과 행 번호, 오류 코드를 각각 꺼내 써야 한다. 문장을 다시 잘라 해석하는 대신 JSON의 이름 붙은 필드로 전달하면 그 값을 구분하기 쉽다.
요즘 AI가 유행이니까 개발자는 AI에게 넘겨주기 좋은 json 형식으로 데이터를 준다고 하자.
처음에는 출력 형식에 따라 조건문으로 처리하면 된다.
{
"file": "main.pgy",
"line": 12,
"code": "E001",
"message": "이름을 찾을 수 없습니다"
}이처럼 동일한 데이터를 정해진 표현 형식에 맞게 변환하는 역할을 포매터(Formatter)라고 한다.
여기서 데이터란 무엇인가? 오류의 위치, 코드, 메시지다. 포매터는 이 데이터를 사람이 읽기 좋은 문장으로 만들거나, 프로그램이 처리할 수 있는 JSON 문자열로 변환한다.
포매터가 만든 것은 아직 문자열이다. 그 문자열을 화면에 보여줄지 파일에 쓸지는 출력기가 맡으므로, 표현 형식과 출력 장소를 따로 바꿀 수 있다.
Report(diagnostic, format, output)
├─ human → 사람이 읽을 문자열 생성
└─ json → JSON 문자열 생성
↓
output에 기록하지만 출력 형식이 늘어난다고 생각해 보자. 사람이 읽는 형식에는 색상이나 줄바꿈 규칙이 추가되고, JSON에는 새로운 필드와 스키마 버전이 필요해질 수 있다. 같은 출력 형식 선택이 파일 기록이나 배치 실행에도 반복된다면, 형식 하나를 수정할 때 여러 출력 경로를 함께 살펴봐야 한다. 원래 모든 일들이 그렇다 처음엔 작은 일도 구현하다보면 문제점이 드러나니까.
원래 Report()가 하려던 일은 진단을 출력하는 것이었다. 그런데 이제는 각 출력 형식의 내부 규칙까지 관리해야 한다.
각 출력 형식에 공통으로 Format(diagnostic)이라는 계약을 제공하고, 사람이 읽는 문자열과 JSON을 생성하는 알고리즘을 각각의 구현체에 맡기는 것이다.
여기서 계약은 호출하는 쪽이 믿고 사용할 약속이다. 어떤 포매터든 Diagnostic을 받아 문자열을 돌려주도록 인터페이스를 정하면, 출력기는 구현마다 다른 호출법을 배울 필요가 없다.
이제 진단을 출력하는 본체는 어떤 포매터가 연결되었는지만 알고, 각 형식의 내부 구현을 알 필요는 없다.
새로운 출력 형식이 필요하다면 공통 계약을 따르는 구현체를 추가하고, 선택하는 부분에 연결하면 된다. 본체의 진단 출력 흐름은 그대로 유지할 수 있다.
조건문을 없애기 위한 패턴이 아니라, 서로 다른 이유로 변경되는 알고리즘을 본체에서 분리하고 교체할 수 있도록 만드는 패턴이다.
어디에 쓰는가
같은 역할을 수행할 대안들이 있고 그 구현을 따로 설명하거나 교체할 필요가 있을 때 검토하면 된다.
아래 사례에서도 표시 문구 몇 개만 다르다면 대응표 하나로 끝낼 수 있으니, 기능의 이름보다 실제로 달라지는 부분이 과연 어디인지를 보는게 낫다.
사용할 곳 | 바꿔 끼우는 부분 |
|---|---|
진단과 보고서 출력 | 사람이 읽는 문장, JSON 등 출력 형식 |
상품 목록 정렬 | 가격순, 최신순, 특정 상품 우선순위 |
가격 계산 | 정률 할인, 정액 할인 등 계산 규칙 |
데이터 압축 | 공통 입력과 결과 계약을 만족하는 압축 방식 |
재시도 정책의 일부 | 시도 횟수에 따른 대기 시간 계산 |
- 사용할 곳
진단과 보고서 출력
- 바꿔 끼우는 부분
사람이 읽는 문장, JSON 등 출력 형식
- 사용할 곳
상품 목록 정렬
- 바꿔 끼우는 부분
가격순, 최신순, 특정 상품 우선순위
- 사용할 곳
가격 계산
- 바꿔 끼우는 부분
정률 할인, 정액 할인 등 계산 규칙
- 사용할 곳
데이터 압축
- 바꿔 끼우는 부분
공통 입력과 결과 계약을 만족하는 압축 방식
- 사용할 곳
재시도 정책의 일부
- 바꿔 끼우는 부분
시도 횟수에 따른 대기 시간 계산
이외에도 게임의 데미지 계산 공식에서도 자주 쓰이지만 게임의 계산 공식은 게임마다 전혀 다른 방식으로 계산되는 경우가 많아서, 그냥 게임 데미지 계산 공식에 응용할 수 있다라고 생각하면된다.
구성 요소와 호출 흐름
진단 출력 예제를 패턴의 용어에 대응시키면 다음과 같다.
역할 | 예제의 이름 | 맡는 일 |
|---|---|---|
Context |
| 포맷을 요청하고 결과를 출력한다 |
Strategy |
| 진단을 문자열로 바꾸는 공통 계약을 정한다 |
Concrete Strategy |
| 형식별 변환을 구현한다 |
Client | 프로그램 시작 코드, 요청 처리 코드 | 사용할 형식을 정하고 본체와 전략을 조립한다 |
- 역할
Context
- 예제의 이름
DiagnosticReporter- 맡는 일
포맷을 요청하고 결과를 출력한다
- 역할
Strategy
- 예제의 이름
IDiagnosticFormatter- 맡는 일
진단을 문자열로 바꾸는 공통 계약을 정한다
- 역할
Concrete Strategy
- 예제의 이름
HumanDiagnosticFormatter,JsonDiagnosticFormatter- 맡는 일
형식별 변환을 구현한다
- 역할
Client
- 예제의 이름
프로그램 시작 코드, 요청 처리 코드
- 맡는 일
사용할 형식을 정하고 본체와 전략을 조립한다
표의 Context는 전략을 사용하는 쪽이고 Concrete Strategy는 그 일을 실제로 수행하는 구현이다. Client는 사용할 구현을 골라 둘을 연결하는 코드로, 여기서는 프로그램을 시작하는 부분이 그 역할을 맡는다.
형식을 고르는 코드가 길어지면 팩토리로 묶을 수 있다. 여기서 팩토리는 human이나 json이라는 이름을 받아 해당 포매터를 만드는 부분으로, 부록 A에 그 예제가 있다.
시작 코드 또는 요청 처리 코드
-> 형식 이름을 검사하고 포매터를 선택
-> DiagnosticReporter에 포매터를 전달
DiagnosticReporter.Report(diagnostic, output)
-> IDiagnosticFormatter.Format(diagnostic)
-> HumanDiagnosticFormatter 또는 JsonDiagnosticFormatter
-> 반환된 문자열을 output에 기록여기서는 선택을 본체 밖에 두어 DiagnosticReporter가 형식 이름을 몰라도 된다. 전략을 본체 안에서 선택하는 구조도 가능하며, 이 예제처럼 선택 규칙과 실행 규칙을 독립적으로 바꾸려면 조립하는 쪽이 선택을 맡는 편이 편하다.
C#으로 진단 출력 전략 만들기
먼저 한 줄짜리 정상 진단을 넘긴다고 두고, 포매터를 바꿔 끼우는 구조만 만든다. 이 절의 C# 블록 세 개를 순서대로 붙이면 하나의 프로그램이 된다. 입력 검증을 추가한 버전은 부록 A에 있다.
진단 하나를 문자열로 바꾸겠다는 계약을 하자.
여기서 필요한 것은 무엇인가.
바로 데이터와 그 변환을 맡길 자리다.
using System;
using System.IO;
using System.Text.Json;
public sealed record Diagnostic(string File, int Line, string Code, string Message);
public interface IDiagnosticFormatter
{
string Format(Diagnostic diagnostic);
}사람용 문장과 JSON은 그 자리에 들어올 두 구현이다. 받는 진단은 같고, 문자열을 만드는 규칙만 각자 갖는다.
public sealed class HumanDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) =>
$"{diagnostic.File}:{diagnostic.Line}: {diagnostic.Code}: {diagnostic.Message}";
}
public sealed class JsonDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) =>
JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
}출력기는 전달받은 포매터를 호출하고 그 결과를 writer에 쓴다.
어떤 형식을 쓸지는 new DiagnosticReporter(...)로 조립할 때 정하므로, 아래 두 호출 사이에서 출력기의 메서드는 바뀌지 않는다.
출력기 안에서 new HumanDiagnosticFormatter()를 호출해 버리면 출력기가 사람용 형식을 직접 고른 셈이다. 사용할 포매터를 밖에서 넘겨주면 JSON으로 바꿀 때도 출력기 코드는 그대로 둘 수 있는데, 이렇게 필요한 대상을 외부에서 전달하는 것이 의존성 주입이다.
public sealed class DiagnosticReporter
{
private readonly IDiagnosticFormatter formatter;
public DiagnosticReporter(IDiagnosticFormatter formatter) =>
this.formatter = formatter;
public void Report(Diagnostic diagnostic, TextWriter output) =>
output.WriteLine(formatter.Format(diagnostic));
}
public static class Program
{
public static void Main()
{
var diagnostic = new Diagnostic(
"main.pgy", 12, "E001", "이름을 찾을 수 없습니다");
var human = new DiagnosticReporter(new HumanDiagnosticFormatter());
var json = new DiagnosticReporter(new JsonDiagnosticFormatter());
human.Report(diagnostic, Console.Out);
json.Report(diagnostic, Console.Out);
}
}전략을 언제 선택할 것인가
이 예제에서는 생성자로 전달한 전략을 readonly 필드에 보관한다.
포맷이 프로세스 실행 동안 고정되는 CLI라면 시작할 때 한 번 선택하면 되고, 요청마다 형식이 다른 서버라면 요청을 처리할 때 해당 전략으로 본체를 조립하면 된다.
흔히 자주 착각하는게 DI 컨테이너로만 하는게 의존성 주입이라 생각하지만, DI 컨테이너 없이 new로 전달해도 의존성 주입이다.2
GoF 원문에는 C++ 템플릿 인자로 전략을 고정하는 구현도 나온다. 배포나 빌드마다 알고리즘을 정한다면 이런 정적 선택으로 전략을 구성할 수 있고, 실행 도중의 교체는 별도 요구가 있을 때 제공하면 된다.1
선택 시점 | 맞는 상황 | 확인할 점 |
|---|---|---|
빌드 또는 컴파일 시점 | 배포별로 알고리즘을 고정한다 | 다른 전략을 쓰려면 다시 빌드해야 하는가 |
시작 시점 | CLI 옵션이나 프로세스 설정이 고정된다 | 설정 오류를 시작 단계에서 발견하는가 |
요청 또는 작업 시작 시점 | 요청별 형식, 고객별 정책이 다르다 | 요청 사이에 선택과 상태가 섞이지 않는가 |
실행 도중 | 편집기에서 도구나 처리 모드를 바꾼다 | 진행 중인 작업은 이전 전략을 쓰는가, 새 전략을 쓰는가 |
- 선택 시점
빌드 또는 컴파일 시점
- 맞는 상황
배포별로 알고리즘을 고정한다
- 확인할 점
다른 전략을 쓰려면 다시 빌드해야 하는가
- 선택 시점
시작 시점
- 맞는 상황
CLI 옵션이나 프로세스 설정이 고정된다
- 확인할 점
설정 오류를 시작 단계에서 발견하는가
- 선택 시점
요청 또는 작업 시작 시점
- 맞는 상황
요청별 형식, 고객별 정책이 다르다
- 확인할 점
요청 사이에 선택과 상태가 섞이지 않는가
- 선택 시점
실행 도중
- 맞는 상황
편집기에서 도구나 처리 모드를 바꾼다
- 확인할 점
진행 중인 작업은 이전 전략을 쓰는가, 새 전략을 쓰는가
실행 중 교체할 필요가 없다면 생성자로 받은 전략을 그대로 사용하면 된다. 편집기처럼 교체가 필요한 경우에는 작업 중 전략을 바꿔도 되는 시점을 정해야 한다. readonly는 참조의 재할당을 막을 뿐, 전략 객체의 내부 상태까지 고정하지는 않는다.
함수형 스타일과 LINQ로 바꿔보기
계약이 한 번의 계산으로 끝나고 전략마다 독립적인 수명 관리가 없다면 함수 인자가 더 읽기 쉬울 수 있다.
C#에서는 delegate나 Func<...>를 사용할 수 있고, Microsoft 문서도 호출자가 전달한 메서드를 호출 코드가 구체 구현을 모른 채 실행하는 방식을 설명한다.3
함수 하나를 전략으로 넘기는 최소 구현
이 정도 변환에는 포매터 객체 대신 함수만 넘겨도 된다.
다음은 앞의 클래스 예제와 별도로 실행하는 프로그램으로, Report가 받는 것은 Func<Diagnostic, string>이고 호출하는 쪽이 Human이나 Json을 고른다.
Func<Diagnostic, string>은 진단을 받아 문자열을 반환하는 함수를 담는 타입이다. 앞에서는 객체의 Format 메서드를 호출했지만, 여기서는 그 역할을 하는 함수 하나를 받아 호출한다.
using System;
using System.Text.Json;
var diagnostic = new Diagnostic("main.pgy", 12, "E001", "이름을 찾을 수 없습니다");
string Human(Diagnostic item) =>
$"{item.File}:{item.Line}: {item.Code}: {item.Message}";
string Json(Diagnostic item) => JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = item.File,
line = item.Line,
code = item.Code,
message = item.Message
});
void Report(Diagnostic item, Func<Diagnostic, string> format) =>
Console.WriteLine(format(item));
Report(diagnostic, Human);
Report(diagnostic, Json);
public sealed record Diagnostic(string File, int Line, string Code, string Message);객체 예제의 formatter.Format(diagnostic)가 여기서는 format(diagnostic)가 된 셈이다. 인스턴스별 설정이나 자원을 보관할 일이 없다면 이쪽이 짧다. 여러 진단을 한꺼번에 변환하는 예제는 부록 B에 있다.
개인적인 메모: 이게 왜 되는가? 의문이 있을 것이다. C#에서는 호환되는 메서드를 대리자(Delegate) 값으로 전달할 수 있기 때문이다. 프로그래밍에서는 보통 우선순위를 나누고 어떤 것은 불변하고, 어떤것은 변하게 한다. 전략패턴의 본질은 같은 종류의 입력을 받아 같은 역할의 결과를 내는 여러 알고리즘을 바꿔 끼는 것이 본질이다. 객체지향에서는 인터페이스로 구현하지만, C#에서는 메서드 자체를 변수로 넘길 수 있다. 이를 대리자(Delegate)라고 한다.
LINQ로 정렬 규칙만 바꾸기
정렬도 목록을 받는 쪽이 가격순인지 역순인지까지 알아야 할 필요는 없다. 정상적인 상품 목록을 두고, 순서를 만드는 함수를 넘기는 부분만 보면 다음과 같다. 이 코드 역시 별도 프로그램이다.
포매터 예제에서 바꾼 것은 진단을 문자열로 만드는 규칙이었다. 이번에는 상품의 순서를 정하는 규칙을 바꾸며, 목록을 받아 결과를 돌려주는 흐름은 공통으로 둔다.
using System;
using System.Collections.Generic;
using System.Linq;
var products = new[]
{
new Product(3, 2000m),
new Product(2, 1000m),
new Product(1, 1000m)
};
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> cheapestFirst =
items => items.OrderBy(item => item.PriceWon).ThenBy(item => item.Id);
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> expensiveFirst =
items => items.OrderByDescending(item => item.PriceWon).ThenBy(item => item.Id);
void Print(Func<IEnumerable<Product>, IOrderedEnumerable<Product>> order) =>
Console.WriteLine(string.Join(",", order(products).Select(item => item.Id)));
Print(cheapestFirst); // 1,2,3
Print(expensiveFirst); // 3,1,2
Console.WriteLine(string.Join(",", products.Select(item => item.Id))); // 3,2,1
public sealed record Product(int Id, decimal PriceWon);두 함수는 같은 원소들을 서로 다른 순서로 돌려주고, 호출부는 받은 함수에 따라 정렬한다. 가격이 같을 때 ID 순서를 덧붙이려면 ThenBy로 이어야 하며, 새 OrderBy를 호출하면 주 정렬 기준을 다시 세운다. 부록 B에서 이 예제를 확장한다.4
TypeScript에서는 비교 함수를 넘긴다
TypeScript도 같은 식으로 비교 규칙을 넘길 수 있다. 여기서는 가격과 ID가 올바른 안전한 정수라고 가정하고, 원본 배열을 복사한 뒤 정렬한다. 입력 검사를 포함한 버전은 부록 B에 있다.
이 비교 함수는 상품 두 개를 받아 어느 쪽을 앞에 둘지 정한다. 음수면 첫 상품을 앞에, 양수면 뒤에 두고 0이면 같은 순위로 취급하므로, 정렬 코드는 상품의 가격 정책을 직접 알 필요가 없다.
type Product = Readonly<{ id: number; priceWon: number }>;
type ProductComparison = (left: Product, right: Product) => number;
// 비교 함수는 입력을 수정하지 않고 음수, 0, 양수로 순서를 표현한다.
const byPriceAscending: ProductComparison = (left, right) =>
left.priceWon - right.priceWon || left.id - right.id;
const byPriceDescending: ProductComparison = (left, right) =>
right.priceWon - left.priceWon || left.id - right.id;
function sortProducts(
products: readonly Product[],
compare: ProductComparison,
): Product[] {
// sort는 배열을 변경하므로 배열을 복사한 뒤 정렬한다.
// 상품 객체 자체를 깊게 복사하는 것은 아니다.
return [...products].sort(compare);
}
const products: readonly Product[] = [
{ id: 3, priceWon: 2000 },
{ id: 2, priceWon: 1000 },
{ id: 1, priceWon: 1000 },
];
const cheapFirst = sortProducts(products, byPriceAscending);
const expensiveFirst = sortProducts(products, byPriceDescending);
console.log(cheapFirst.map((product) => product.id)); // [1, 2, 3]
console.log(expensiveFirst.map((product) => product.id)); // [3, 1, 2]
console.log(products.map((product) => product.id)); // [3, 2, 1]가격이 같을 때는 ID가 작은 쪽을 앞에 두므로 두 전략의 동률 규칙도 같다. 배열은 복사하지만 안의 상품 객체까지 깊게 복사하지는 않으며, 비교 함수가 입력을 바꾸지 않고 일관된 순서를 만들어야 한다는 계약은 최소 예제에서도 남는다.5
테스트와 구현 나누기
포매터가 맞는 문자열을 만드는지와 프로그램이 맞는 포매터를 골랐는지는 사실 다르다고 할 수 있다.
개인적인 메모:내가 아무리 운동을 열심히 한다고해서, 그게 프로그래밍에 맞는 노력인지 알 수 없는 것처럼 말이다.
JSON 구현만 잘 테스트해도 "json"을 사람용 포매터에 잘못 연결했다면 실제 출력은 틀리므로, 구현 테스트와 선택 테스트를 함께 두는게 중요하다.
검증 대상 | 확인할 예 |
|---|---|
입력 계약 | 0 이하 행 번호, 빈 메시지, 파일 이름의 CR/LF를 거부한다 |
공통 계약 | 비어 있지 않은 한 줄 문자열을 반환하고 같은 입력에 같은 결과를 낸다 |
형식별 의미 | 사람용 위치 표기가 맞고 JSON의 필드와 원래 메시지가 보존된다 |
선택 경계 |
|
본체의 출력 | 문자열 뒤에 줄 구분자를 하나 쓰고 호출자 소유 writer를 닫지 않는다 |
실패 전파 | 계약 위반을 거부하고 출력 장치의 실패를 숨기지 않는다 |
- 검증 대상
입력 계약
- 확인할 예
0 이하 행 번호, 빈 메시지, 파일 이름의 CR/LF를 거부한다
- 검증 대상
공통 계약
- 확인할 예
비어 있지 않은 한 줄 문자열을 반환하고 같은 입력에 같은 결과를 낸다
- 검증 대상
형식별 의미
- 확인할 예
사람용 위치 표기가 맞고 JSON의 필드와 원래 메시지가 보존된다
- 검증 대상
선택 경계
- 확인할 예
human,json을 맞게 연결하고 알 수 없는 이름을 거부한다
- 검증 대상
본체의 출력
- 확인할 예
문자열 뒤에 줄 구분자를 하나 쓰고 호출자 소유 writer를 닫지 않는다
- 검증 대상
실패 전파
- 확인할 예
계약 위반을 거부하고 출력 장치의 실패를 숨기지 않는다
부록 C는 구현과 선택을 나눠 검사하는 실행 테스트다.
다른 패턴과 헷갈리는 사례
비슷하게 객체나 함수를 전달하더라도 무엇을 표현하려고 분리했는지가 다르다.
개인적인 메모:프로그래밍에서 경력이 늘어난다는 건 디테일이 늘어난다는 것이다. 원래 초보일때는 C상황에 Z패턴만 썼다면, 경력이 늘수록 C상황에 대응할 수 있는 가짓수가 늘어나고, C상황이 사실은 A+B로 조합되어 더 세분화할 수 있다거나 하는 걸 알게되는 것이다.
포매터를 다른 출력 방식으로 교체하는 것, 기존 포매터를 감싸 머리말을 붙이는 것, 출력을 나중에 실행할 요청으로 저장하는 것은 코드 일부가 닮은 것 같지만 아주 약간씩 다르다. 그 미묘함을 어떻게 설명해야하는지는 모르겠다.
헷갈리는 대상 | 예를 들어 | 전략과 구별할 지점 |
|---|---|---|
State 패턴 | 현재 주문 상태 객체에 취소·발송 처리를 위임한다 | 단순 상태 값이 아니라 상태별 행동과 전이를 표현한다 |
Template Method | 가져오기 작업의 특정 단계를 재정의한다 | 부모의 실행 뼈대와 안정된 상속용 hook을 사용한다 |
Command | 출력 요청을 큐에 넣고 나중에 실행한다 | 알고리즘의 대안보다 실행할 요청 자체를 보관한다 |
Adapter | 외부 라이브러리의 API를 내부 포매터 계약에 맞춘다 | 호출 규약과 데이터 표현을 호환시킨다 |
Decorator | 기존 포매터를 감싸 머리말을 붙인다 | 기존 동작을 유지하면서 기능을 덧붙인다 |
DI와 컴포지션 | 본체에 포매터를 전달하고 그 포매터에 위임한다 | 의존성을 조립하는 방법이며 알고리즘 교체라는 의도와 범위가 다르다 |
함수 합성·파이프라인 | 읽기·파싱·검증·저장을 차례로 수행한다 | 대안 하나를 고르는 대신 여러 단계를 모두 거친다 |
설정·대응표 | 계산식은 같고 요율이나 이름만 다르다 | 값의 차이만으로 전략 클래스를 늘릴 필요는 없다 |
- 헷갈리는 대상
State 패턴
- 예를 들어
현재 주문 상태 객체에 취소·발송 처리를 위임한다
- 전략과 구별할 지점
단순 상태 값이 아니라 상태별 행동과 전이를 표현한다
- 헷갈리는 대상
Template Method
- 예를 들어
가져오기 작업의 특정 단계를 재정의한다
- 전략과 구별할 지점
부모의 실행 뼈대와 안정된 상속용 hook을 사용한다
- 헷갈리는 대상
Command
- 예를 들어
출력 요청을 큐에 넣고 나중에 실행한다
- 전략과 구별할 지점
알고리즘의 대안보다 실행할 요청 자체를 보관한다
- 헷갈리는 대상
Adapter
- 예를 들어
외부 라이브러리의 API를 내부 포매터 계약에 맞춘다
- 전략과 구별할 지점
호출 규약과 데이터 표현을 호환시킨다
- 헷갈리는 대상
Decorator
- 예를 들어
기존 포매터를 감싸 머리말을 붙인다
- 전략과 구별할 지점
기존 동작을 유지하면서 기능을 덧붙인다
- 헷갈리는 대상
DI와 컴포지션
- 예를 들어
본체에 포매터를 전달하고 그 포매터에 위임한다
- 전략과 구별할 지점
의존성을 조립하는 방법이며 알고리즘 교체라는 의도와 범위가 다르다
- 헷갈리는 대상
함수 합성·파이프라인
- 예를 들어
읽기·파싱·검증·저장을 차례로 수행한다
- 전략과 구별할 지점
대안 하나를 고르는 대신 여러 단계를 모두 거친다
- 헷갈리는 대상
설정·대응표
- 예를 들어
계산식은 같고 요율이나 이름만 다르다
- 전략과 구별할 지점
값의 차이만으로 전략 클래스를 늘릴 필요는 없다
일반적인 상태와 State 패턴은 다르다
프로그래밍에서 상태(state)는 이후의 동작에 영향을 주는 현재 정보로, 로그인 실패 횟수나 주문의 Paid 값처럼 변수나 필드에 보관할 수 있다. State 패턴은 이 넓은 상태 개념 가운데 상태별 행동을 분리해 현재 상태의 구현에 위임하는 설계를 가리킨다.
용어 | 가리키는 것 |
|---|---|
상태 값 | 현재 실패 횟수, 주문 상태, 편집 중인 내용 등 보존한 정보 |
상태 머신 | 상태와 사건에 따른 전이·행동을 표현하는 모델 |
State 패턴 | 상태별 행동을 별도 객체 등에 분리하고 본체가 현재 상태의 구현에 위임하는 설계 |
- 용어
상태 값
- 가리키는 것
현재 실패 횟수, 주문 상태, 편집 중인 내용 등 보존한 정보
- 용어
상태 머신
- 가리키는 것
상태와 사건에 따른 전이·행동을 표현하는 모델
- 용어
State 패턴
- 가리키는 것
상태별 행동을 별도 객체 등에 분리하고 본체가 현재 상태의 구현에 위임하는 설계
상태 머신은 enum + switch나 전이표로 구현할 수 있고, 본체가 상태별 구현에 일을 맡기도록 나누면 GoF의 State 패턴으로 설명할 수 있다.6 .(상태랑 상태패턴은 다른데, 사실 혼용해서들 쓴다)
예를 들어 주문이 결제 대기 -> 결제 완료 -> 배송 중으로 진행하는 것만으로는 상태 모델을 설명한 것이다. 취소와 발송 등 여러 연산에 상태별 분기가 흩어지고, 그 행동을 PendingState, PaidState, ShippedState로 묶어 현재 상태 객체에 위임할 때 State 패턴을 검토할 이유가 생긴다. 전이 규칙만 간단히 검사하면 되는 코드라면 전이 함수나 표로도 충분할 수 있다.
"밖에서 바꾸면 Strategy, 안에서 바꾸면 State"라고 외우면 어지간한 문제가 해결되는 것은 맞지만, 외부 이벤트로 상태가 바뀌는 경우나 본체가 전략을 자동 선택하는 경우에서 다시 헷갈리기에 누가 바꾸는지만 볼 게 아니라, 같은 역할을 수행할 알고리즘의 대안들을 표현하는지 객체의 상태와 전이를 표현하는지를 봐야 하는 이유다.
함께 쓰더라도 역할은 구별한다
Template Method에서는 부모 메서드가 this의 재정의된 hook을 호출하고, Strategy에서는 본체가 협력 객체나 함수를 호출한다. 가져오기 작업의 순서는 안정된 상속용 hook으로 열어두면서 파싱 알고리즘은 전략으로 전달할 수도 있으니, 두 패턴은 함께 쓸 수 있다. 물론 실행순서의 이유로 상속하는 경우도 있지만 반드시 그럴 이유는 없다.
선택한 포매터를 데코레이터로 감쌌다면 그 결과도 같은 출력 계약을 지켜야 한다. JSON 문서 앞에 머리말 문자열을 붙이면 반환 타입은 여전히 string이어도 JSON 파싱은 깨지므로, 기능을 덧붙이는 것과 기존 소비자가 계속 읽을 수 있는지는 따로 확인해야 한다. 공통 계약을 지키는 데코레이터라면 그 객체가 전략 역할도 함께 맡을 수 있고, 외부 API를 내부 계약에 맞춘 어댑터 역시 여러 전략 중 하나로 선택될 수 있다.
CSV 파서와 JSON 파서 중 하나를 고르는 것은 전략 선택으로 설명할 수 있지만, 읽기·파싱·검증·저장을 모두 거치는 흐름에는 단계별 입력·출력과 실패·중단 규칙이 필요하다. 단계마다 타입이 달라도 되며, 같은 인터페이스로 억지로 맞출 필요는 없다.
생성자로 포매터를 전달하는 것은 DI이고, 다른 객체를 조립해 변환을 맡기는 것은 컴포지션이다. 이 예제를 Strategy로 설명하는 지점은 그렇게 전달한 구현들이 같은 역할의 대안이 되고, 공통 계약을 지키면서 교체될 수 있다는 데 있다.
정책을 여러 개 적용해야 한다면
전략은 같은 역할의 대안 하나를 고르는 데 잘 맞는다. 할인 규칙 여러 개를 모두 적용하려면, 그 규칙을 조합하는 쪽에서 적용 순서와 중복 허용 여부도 정해야 한다.
가령 정가가 10,000원이고 10% 할인과 1,000원 할인을 둘 다 적용한다고 가정하자. 정률 할인 후 정액 할인은 8,000원이고 정액 할인 후 정률 할인은 8,100원이다. 두 구현이 IDiscountPolicy를 따르더라도 어느 계산을 택할지는 할인 도메인의 규칙으로 결정해야 한다.
이 경우에는 할인 기준 금액, 적용 순서, 중복 가능 여부, 반올림 시점을 도메인 규칙으로 정해야 한다. 그 규칙을 수행하는 하나의 합성 전략을 만들거나 별도의 할인 파이프라인으로 표현할 수는 있는데, KoreaGoldMemberHolidayDiscountStrategy처럼 가능한 조합마다 클래스를 만드는 것으로 넘어가면 다시 변경 축들이 한 이름 안에 엉킨다.
반대로 모든 나라의 계산식이 같고 세율 값만 다르다면 같은 전략에 세율 데이터를 전달하거나 표로 관리하는 것으로 충분할 수 있다. 값의 차이와 알고리즘의 차이를 구별해야 클래스 수를 불필요하게 늘리지 않는다.
요구사항에서 패턴을 고르는 법
보통 요구사항에서 클래스 이름을 먼저 정하려고 하면 같은 코드가 Strategy 같기도 하고 State 같기도 해서 헷갈릴 것이다.
요구사항에서 무엇을 바꾸려는지 한 문장으로 적어 보면 구별이 쉬워지는데, "전송 방식을 바꾼다"와 "전송 요청을 저장해 뒀다가 실행한다"는 처음부터 다른 변경이다.
예시
예시로 알림 기능을 만든다고 하자.
"알림 기능을 만든다"라는 거시적 설명 안에 디테일에 따라서 나누는게 핵심이다.
추가된 요구 | 판단 |
|---|---|
같은 전송 요청을 이메일이나 다른 채널로 보낸다 | 공통 전송 계약을 정할 수 있다면 Strategy를 검토한다 |
채널은 그대로 두고 전송 앞뒤에 소요 시간 측정을 붙인다 | 기존 전송을 감싸는 Decorator를 검토한다 |
외부 SDK의 전송 함수와 응답 형식이 내부 계약과 다르다 | 그 차이를 변환하는 Adapter를 둔다 |
지금 보내지 않고 요청을 보관해 예약 시각에 보낸다 | 요청 데이터와 실행기를 분리하거나 Command로 표현한다 |
전송 대기·처리 중·완료 상태를 표시한다 | 우선 상태 값이면 된다. 표시만으로 State 패턴을 만들지 않는다 |
상태마다 취소·재개 등 여러 동작의 구현과 보관 데이터가 크게 다르다 | 상태별 책임을 묶는 State 패턴이 도움이 되는지 검토한다 |
- 추가된 요구
같은 전송 요청을 이메일이나 다른 채널로 보낸다
- 판단
공통 전송 계약을 정할 수 있다면 Strategy를 검토한다
- 추가된 요구
채널은 그대로 두고 전송 앞뒤에 소요 시간 측정을 붙인다
- 판단
기존 전송을 감싸는 Decorator를 검토한다
- 추가된 요구
외부 SDK의 전송 함수와 응답 형식이 내부 계약과 다르다
- 판단
그 차이를 변환하는 Adapter를 둔다
- 추가된 요구
지금 보내지 않고 요청을 보관해 예약 시각에 보낸다
- 판단
요청 데이터와 실행기를 분리하거나 Command로 표현한다
- 추가된 요구
전송 대기·처리 중·완료 상태를 표시한다
- 판단
우선 상태 값이면 된다. 표시만으로 State 패턴을 만들지 않는다
- 추가된 요구
상태마다 취소·재개 등 여러 동작의 구현과 보관 데이터가 크게 다르다
- 판단
상태별 책임을 묶는 State 패턴이 도움이 되는지 검토한다
이 요구들이 함께 있다면 실행기가 예약된 요청을 꺼내 채널 전략을 선택하고, 그 전략이 외부 SDK 어댑터를 사용하는 구조가 될 수 있다.
패턴 없이 두어도 되는 경우
상태별 표시 문구는 대응표로, 요율의 차이는 설정값으로, 한 곳에서만 쓰는 작은 분기는 조건문으로 남길 수 있다는 걸 감안하자, 상태 전이도 검사 함수 하나로 읽히고 충분히 테스트할 수 있다면 상태별 클래스를 만들 필요는 없다.
사용자가 알고리즘을 주입하는 라이브러리라면 구현체가 하나여도 계약을 둘 이유가 있다. 그런 교체 요구 없이 DefaultStrategy만 만들면 선언과 조립 코드만 늘어날 수 있으니, 실제로 바꿔야 할 부분부터 보면 된다.
GoF에서 이어진 맥락
Design Patterns의 출판사 서지는 출판일을 1994년 10월 31일로, 저작권 표기를 1995년으로 적는다.7 이 날짜는 이미 반복해서 쓰이던 설계를 책에서 이름과 협력 구조로 정리한 출판 시점을 가리킨다.
책의 전략 예제는 텍스트를 어디서 줄바꿈할지 결정하는 알고리즘을 문서 배치 객체에서 분리한다. 한 줄씩 처리하는 방식과 문단 전체를 고려하는 방식 등을 교체하면서 배치 객체를 유지하는 예인데, 오늘날 할인 정책이나 정렬 비교 규칙으로 설명하는 것과 같은 분리 의도다.1
2009년 회고 인터뷰에서 Ralph Johnson은 언어가 같은 문제를 해결하는 다른 기능을 제공하면 일부 패턴이 필요하지 않을 수 있고, GoF의 패턴이 모든 언어를 위한 것은 아니라고 설명했다.8 C#에서 인터페이스와 클래스로 표현한 교체 지점도 TypeScript나 Python에서는 함수 인자로 표현할 수 있으니, 지금 쓰는 언어에서 그 역할과 계약이 잘 드러나는 형태를 고르면 된다.
부록 A. 계약과 선택 코드를 추가한 C# 구현
본문의 객체형 예제에 입력 검증과 출력 계약, 형식 선택을 붙인 구현이다. 본문과 타입 이름이 같으므로 두 버전을 섞지 않고, 부록 A~C의 C# 블록을 순서대로 모아 하나의 프로그램으로 실행한다.
비교할 분기 구현
다음은 형식을 고르는 일과 형식별 문자열을 만드는 일을 한 메서드에서 처리한 코드다.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
public sealed class BranchingDiagnosticReporter
{
public void Report(Diagnostic diagnostic, string format, TextWriter output)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(format);
ArgumentNullException.ThrowIfNull(output);
string rendered;
switch (format)
{
case "human":
var line = diagnostic.Line.ToString(CultureInfo.InvariantCulture);
var message = diagnostic.Message.Replace("\r", "\\r").Replace("\n", "\\n");
rendered = $"{diagnostic.File}:{line}: {diagnostic.Code}: {message}";
break;
case "json":
rendered = JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
break;
default:
throw new ArgumentException("지원하지 않는 출력 형식입니다.", nameof(format));
}
output.WriteLine(rendered);
}
}입력과 출력 계약
string Format(Diagnostic diagnostic)라는 시그니처를 맞췄더라도, 한 구현은 출력할 문자열을 돌려주고 다른 구현은 파일을 생성한 뒤 경로를 돌려준다면 본체가 기대한 행동은 달라진다. 교체하려는 구현들은 반환 타입뿐 아니라 그 값의 의미와 부작용의 범위도 맞아야 한다.
예제는 .NET 8과 C# 12, nullable 분석 활성화를 기준으로 하며 외부 패키지는 사용하지 않는다.9
포매터는 진단 하나를 받아 비어 있지 않은 문자열을 반환하고, 같은 입력과 고정된 설정에는 같은 결과를 돌려준다. 직접 출력하거나 입력을 바꾸지 않으며, 반환값에 실제 CR 또는 LF 문자를 넣지 않는다. 메시지 안의 CR/LF는 사람이 읽는 형식에서는 \r, \n으로 표시하고, JSON에서는 직렬화기가 이스케이프한다.
Diagnostic은 파일 이름, 양의 행 번호, 진단 코드, 메시지를 보관하며, 파일 이름·코드·메시지 모두 null, 빈 문자열, 공백으로만 이루어진 값을 거부한다. 파일 이름과 코드는 여기에 CR/LF 금지 조건을 더하고, 메시지는 내용이 있다면 여러 줄을 허용한다.
public sealed class Diagnostic
{
public string File { get; }
public int Line { get; }
public string Code { get; }
public string Message { get; }
public Diagnostic(string file, int line, string code, string message)
{
File = RequireSingleLine(file, nameof(file));
Code = RequireSingleLine(code, nameof(code));
if (line <= 0)
throw new ArgumentOutOfRangeException(nameof(line));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("진단 메시지가 필요합니다.", nameof(message));
Line = line;
Message = message;
}
private static string RequireSingleLine(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value) || value.Contains('\r') || value.Contains('\n'))
throw new ArgumentException("비어 있지 않은 한 줄 값이 필요합니다.", parameterName);
return value;
}
}
public interface IDiagnosticFormatter
{
// 문자열 변환만 수행한다. 출력이나 입력 변경은 하지 않는다.
// 같은 입력과 고정된 설정에는 같은 결과를 반환한다.
// 반환값은 비어 있지 않아야 하며 실제 CR/LF를 포함하지 않는다.
string Format(Diagnostic diagnostic);
}잘못된 인자는 인자 관련 예외로, 포매터의 출력 계약 위반은 InvalidOperationException으로 알린다. 출력 장치의 IOException도 숨기지 않고 호출자에게 전달한다.
형식별 구현
사람이 읽는 형식과 JSON 형식은 같은 진단을 받아도 출력 문법이 다른데, 그래도 본체가 둘을 바꿔 쓸 수 있는 것은 공통 약속을 지키기 때문이다. 이 예제에서는 비어 있지 않은 CR/LF 없는 문자열을 만들고, 출력은 본체에 맡기면 된다.
public sealed class HumanDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic)
{
ArgumentNullException.ThrowIfNull(diagnostic);
var line = diagnostic.Line.ToString(CultureInfo.InvariantCulture);
var message = diagnostic.Message
.Replace("\r", "\\r")
.Replace("\n", "\\n");
return $"{diagnostic.File}:{line}: {diagnostic.Code}: {message}";
}
}
public sealed class JsonDiagnosticFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic)
{
ArgumentNullException.ThrowIfNull(diagnostic);
// JSON 문자열을 직접 이어 붙이지 않는다.
// 따옴표와 줄바꿈의 이스케이프는 직렬화기에 맡긴다.
return JsonSerializer.Serialize(new
{
schemaVersion = 1,
file = diagnostic.File,
line = diagnostic.Line,
code = diagnostic.Code,
message = diagnostic.Message
});
}
}System.Text.Json은 메시지의 줄바꿈을 이스케이프한 JSON 문자열을 만든다. schemaVersion과 필드 이름은 이 예제에서 정한 형식이다.10
본체는 포맷 결과를 출력한다
DiagnosticReporter는 어떤 형식인지 알아내지 않고 포매터가 돌려준 문자열을 쓴다. 출력 장치도 밖에서 전달받으므로 테스트에서는 StringWriter를 사용하고, 실제 프로그램에서는 파일이나 표준 오류 출력으로 연결할 수 있다.
public sealed class DiagnosticReporter
{
private readonly IDiagnosticFormatter formatter;
public DiagnosticReporter(IDiagnosticFormatter formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
this.formatter = formatter;
}
public void Report(Diagnostic diagnostic, TextWriter output)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(output);
var rendered = formatter.Format(diagnostic);
// 인터페이스 구현만으로는 행동 계약을 강제할 수 없다.
// 본체가 즉시 확인할 수 있는 출력 형태를 검사한다.
if (string.IsNullOrEmpty(rendered) || rendered.Contains('\r') || rendered.Contains('\n'))
throw new InvalidOperationException("포매터가 비어 있지 않은 CR/LF 없는 출력 계약을 어겼습니다.");
output.WriteLine(rendered);
// 호출자가 넘긴 writer를 여기서 닫지 않는다.
}
}본체는 빈 문자열과 실제 CR/LF만 검사한다. JSON 필드나 형식별 의미가 올바른지는 각 포매터의 테스트에서 확인한다.
선택과 조립
형식을 문자열로 받는 프로그램이라면 어딘가에서 그 문자열을 구현체에 대응시켜야 한다. 그 작은 분기는 남겨두어도 된다.
public static class DiagnosticFormatterFactory
{
public static IDiagnosticFormatter Create(string format)
{
ArgumentNullException.ThrowIfNull(format);
return format switch
{
"human" => new HumanDiagnosticFormatter(),
"json" => new JsonDiagnosticFormatter(),
_ => throw new ArgumentException(
"지원하지 않는 출력 형식입니다.", nameof(format))
};
}
}new DiagnosticReporter(DiagnosticFormatterFactory.Create("json"))으로 조립하면 본체는 JSON 포매터를 사용한다. 사용자 입력을 다루는 CLI라면 인자 파싱 단계에서 잘못된 형식 이름을 사용자 오류로 표시하면 되고, 설정에 없는 형식이 들어왔다고 조용히 human으로 바꾸지는 않는다.
새 전략을 추가하면 형식별 구현과 이 팩토리의 등록이 바뀌고, 본체의 출력 흐름은 그대로 유지된다. 개방 폐쇄 원칙과 연결해서 볼 지점도 이처럼 변경 범위를 나눈 데 있으며, 공통 계약 자체가 달라지는 요구라면 본체와 기존 전략까지 함께 검토해야 한다.
부록 B. 함수형·LINQ·TypeScript의 확장 구현
Func와 메서드 그룹으로 전달하기
출력 장치에 쓰는 기능이 필요 없고 변환 결과만 모으려면 다음처럼 함수로 구성할 수 있다. Render는 포매팅 함수를 받아 결과를 검사하고, RenderAll은 그 함수를 진단 목록에 적용하는데, 전달할 함수도 앞에서 정한 무부작용·결정성 계약을 따라야 한다. 문자열 변환만 필요한 호출부를 위한 형태이므로, 출력 기능까지 맡는 DiagnosticReporter와 용도를 구별해서 보면 된다.
public static class DiagnosticFunctions
{
public static string Render(Diagnostic diagnostic, Func<Diagnostic, string> format)
{
ArgumentNullException.ThrowIfNull(diagnostic);
ArgumentNullException.ThrowIfNull(format);
var rendered = format(diagnostic);
if (string.IsNullOrEmpty(rendered) || rendered.Contains('\r') || rendered.Contains('\n'))
throw new InvalidOperationException("포매팅 함수가 비어 있지 않은 CR/LF 없는 출력 계약을 어겼습니다.");
return rendered;
}
public static string[] RenderAll(
IEnumerable<Diagnostic> diagnostics,
Func<Diagnostic, string> format)
{
ArgumentNullException.ThrowIfNull(diagnostics);
ArgumentNullException.ThrowIfNull(format);
// Select로 각 진단을 변환하고, ToArray에서 결과를 확정한다.
return diagnostics.Select(item => Render(item, format)).ToArray();
}
}DiagnosticFunctions.RenderAll(items, new HumanDiagnosticFormatter().Format)처럼 메서드 그룹을 넘겨도 되고, item => json.Format(item)처럼 람다를 넘겨도 된다. 사용할 format 함수는 호출자가 고르고, Select는 그 함수를 여러 입력에 적용하며, Render는 빈 결과와 CR/LF를 검사한다. 출력의 의미와 무부작용·결정성은 전달한 함수가 지켜야 할 계약으로 남는다.
이미 만든 포매터는 메서드를 함수 값으로 넘기고, 별도 설정이나 자원 수명이 없는 작은 규칙은 처음부터 함수로 작성하면 된다.
LINQ로 정렬 전략 만들기
정렬에서는 키만 바꿔도 되는지, 오름차순과 내림차순이나 우선순위까지 함께 바꿔야 하는지를 먼저 보면 된다. OrderBy(product => product.PriceWon)의 람다는 키를 고르는 함수이고, 아래 예제의 order는 OrderBy와 ThenBy를 묶은 정렬 규칙 전체를 전달하는 함수다.
public sealed record CatalogProduct(int Id, decimal PriceWon);
public static class ProductOrdering
{
public static IOrderedEnumerable<CatalogProduct> CheapestFirst(
IEnumerable<CatalogProduct> products) =>
products.OrderBy(product => product.PriceWon).ThenBy(product => product.Id);
// 같은 규칙을 LINQ 질의 구문으로 표현한 변형이다.
public static IOrderedEnumerable<CatalogProduct> CheapestFirstQuery(
IEnumerable<CatalogProduct> products) =>
from product in products
orderby product.PriceWon, product.Id
select product;
public static IOrderedEnumerable<CatalogProduct> ExpensiveFirst(
IEnumerable<CatalogProduct> products) =>
products.OrderByDescending(product => product.PriceWon).ThenBy(product => product.Id);
public static CatalogProduct[] Sort(
IEnumerable<CatalogProduct> products,
Func<IEnumerable<CatalogProduct>, IOrderedEnumerable<CatalogProduct>> order)
{
ArgumentNullException.ThrowIfNull(products);
ArgumentNullException.ThrowIfNull(order);
// 원본 열거를 한 번 마친 뒤 검증과 정렬에 같은 입력을 사용한다.
var snapshot = products.ToArray();
foreach (var product in snapshot)
{
ArgumentNullException.ThrowIfNull(product);
if (product.Id <= 0 || product.PriceWon < 0)
throw new ArgumentOutOfRangeException(nameof(products), "ID는 양수, 가격은 0 이상이어야 합니다.");
}
// 전략은 원소를 추가하거나 누락하지 않고 순서만 바꾼다는 계약이다.
return order(snapshot).ToArray();
}
public static Func<IEnumerable<CatalogProduct>, IOrderedEnumerable<CatalogProduct>>
PriorityFirst(int productId)
{
if (productId <= 0)
throw new ArgumentOutOfRangeException(nameof(productId));
// productId는 반환된 함수가 사용할 고정 설정이다.
return products => products
.OrderBy(product => product.Id == productId ? 0 : 1)
.ThenBy(product => product.PriceWon)
.ThenBy(product => product.Id);
}
}ProductOrdering.Sort(products, ProductOrdering.CheapestFirst)와 ProductOrdering.Sort(products, ProductOrdering.ExpensiveFirst)는 입력 검증과 결과를 만드는 흐름은 그대로 두고 정렬 규칙만 달리한다. PriorityFirst(3)을 넘기면 ID가 3인 상품을 먼저 두고 나머지는 가격과 ID 순으로 정렬하는데, 해당 상품이 없으면 전체가 가격과 ID 순서가 된다. 이 예제는 ID 중복을 금지하지 않으므로 같은 ID가 여러 번 나오면 그 항목들 모두가 우선순위에 들어간다.
CheapestFirstQuery는 같은 규칙을 from, orderby, select로 쓴 변형이다. 이 경우 컴파일러는 .OrderBy(...).ThenBy(...)에 대응하는 호출로 바꾸므로, 읽기 편한 표기를 고르면 된다.11
PriorityFirst는 함수를 반환하는 고차 함수이고, 반환된 람다가 productId를 기억하는 부분이 클로저다. 설정값 하나마다 전략 클래스를 추가하는 대신 설정된 함수를 만들어 전달한 것으로, C# 람다는 이런 바깥 변수를 캡처할 수 있다.12 변경 가능한 설정 객체를 캡처하는 경우에는 나중의 실행도 그 객체가 바뀐 값의 영향을 받을 수 있으므로, 설정을 고정할지 바뀐 값을 읽게 할지도 함께 정해야 한다.
Enumerable.OrderBy는 지연 실행된다. 위 Sort는 입력과 결과를 ToArray()로 확정하므로 호출 안에서 정렬을 마치고 배열을 반환한다.13
이 함수의 계약은 순서를 바꾸되 원소를 보존한다는 것이지만, Func의 타입만으로 그 약속까지 강제할 수는 없다. 함수를 받는 쪽은 입력을 검증하고, 전달할 정렬 전략은 빈 입력과 동률, 원소 보존 같은 성질을 테스트해야 한다. Where로 일부를 걸러낸 뒤 정렬하는 함수도 같은 반환 타입을 만들 수 있으니, 그것을 그대로 끼우면 타입은 맞아도 이 계약에는 맞지 않는다.
TypeScript에서 비교 함수를 전달하기
Readonly<T>와 readonly T[]는 타입 검사상의 제약이며, 런타임에서 객체나 배열을 동결하지는 않는다.14
TypeScript에서는 상품 정렬의 비교 함수를 전략으로 전달할 수 있다. 다음 예제는 앞의 C# 코드와 별도로 실행하며, 가격은 0 이상의 안전한 정수 원 단위, ID는 양의 안전한 정수라고 정한다. 가격이 같으면 두 전략 모두 ID가 작은 상품을 먼저 둔다.
type Product = Readonly<{ id: number; priceWon: number }>;
type ProductComparison = (left: Product, right: Product) => number;
// 비교 함수는 입력을 수정하지 않고 음수, 0, 양수로 순서를 표현한다.
const byPriceAscending: ProductComparison = (left, right) =>
left.priceWon - right.priceWon || left.id - right.id;
const byPriceDescending: ProductComparison = (left, right) =>
right.priceWon - left.priceWon || left.id - right.id;
function sortProducts(
products: readonly Product[],
compare: ProductComparison,
): Product[] {
for (const product of products) {
if (!Number.isSafeInteger(product.priceWon) || product.priceWon < 0) {
throw new RangeError("가격은 0 이상의 안전한 정수여야 합니다.");
}
if (!Number.isSafeInteger(product.id) || product.id <= 0) {
throw new RangeError("ID는 양의 안전한 정수여야 합니다.");
}
}
// sort는 배열을 변경하므로 배열을 복사한 뒤 정렬한다.
// 상품 객체 자체를 깊게 복사하는 것은 아니다.
return [...products].sort(compare);
}
const products: readonly Product[] = [
{ id: 3, priceWon: 2000 },
{ id: 2, priceWon: 1000 },
{ id: 1, priceWon: 1000 },
];
const cheapFirst = sortProducts(products, byPriceAscending);
const expensiveFirst = sortProducts(products, byPriceDescending);
console.log(cheapFirst.map((product) => product.id)); // [1, 2, 3]
console.log(expensiveFirst.map((product) => product.id)); // [3, 1, 2]
console.log(products.map((product) => product.id)); // [3, 2, 1]흐름은 여전히 상품 배열을 복사한 뒤 sort를 호출하는 것이고, 비교 함수에 따라 저가순과 고가순이 달라진다. 여기서 교체하는 전략은 정렬 순서를 정하는 비교 규칙이다.
정렬 완료를 알리는 callback과 정렬 순서를 정하는 비교 callback은 둘 다 함수지만 쓰임은 다르다. 이 중 비교 함수는 같은 역할을 수행하는 여러 방식 중 하나를 전달한다는 점에서 전략의 의도와 맞으며, 호출 때마다 순서를 뒤집거나 입력을 수정한다면 그 약속을 깨뜨린다. 함수 타입만으로는 순서의 일관성까지 검사할 수 없으므로 JavaScript의 비교 함수도 같은 입력에 대한 일관된 결과, 반대칭성과 추이성 등의 조건을 지켜야 한다.5
부록 C. 확장 구현의 실행 테스트
다음 코드를 부록 A~B의 C# 코드와 같은 파일에 넣으면 38개 검사를 실행한다. StringWriter를 사용해 실제 파일을 만들지 않고, 형식 선택과 출력 결과, 잘못된 입력을 확인한다.
public static class Program
{
private static int checks;
public static void Main()
{
var diagnostic = new Diagnostic("main.pgy", 12, "E001", "이름을 찾을 수 없습니다");
var multiline = new Diagnostic("main.pgy", 12, "E001", "첫 줄\r\n둘째 줄 \"x\"");
var human = DiagnosticFormatterFactory.Create("human");
var json = DiagnosticFormatterFactory.Create("json");
// 전후 모두 같은 진단을 같은 출력 장치에 기록하는 동작이다.
foreach (var format in new[] { "human", "json" })
{
foreach (var item in new[] { diagnostic, multiline })
{
using var before = new StringWriter(CultureInfo.InvariantCulture);
using var after = new StringWriter(CultureInfo.InvariantCulture);
new BranchingDiagnosticReporter().Report(item, format, before);
new DiagnosticReporter(DiagnosticFormatterFactory.Create(format)).Report(item, after);
Equal(before.ToString(), after.ToString());
}
}
Equal("main.pgy:12: E001: 이름을 찾을 수 없습니다", human.Format(diagnostic));
Equal(true, human is HumanDiagnosticFormatter);
Equal(true, json is JsonDiagnosticFormatter);
foreach (var formatter in new[] { human, json })
{
var rendered = formatter.Format(multiline);
Equal(false, string.IsNullOrEmpty(rendered));
Equal(false, rendered.Contains('\r') || rendered.Contains('\n'));
Equal(rendered, formatter.Format(multiline));
}
using (var parsed = JsonDocument.Parse(json.Format(multiline)))
{
var root = parsed.RootElement;
Equal(1, root.GetProperty("schemaVersion").GetInt32());
Equal(multiline.File, root.GetProperty("file").GetString());
Equal(multiline.Line, root.GetProperty("line").GetInt32());
Equal(multiline.Code, root.GetProperty("code").GetString());
Equal(multiline.Message, root.GetProperty("message").GetString());
}
// 기본 인코더의 표기와 파싱 후의 값을 별도로 확인한다.
// 이 표기를 모든 JSON 포매터의 공통 계약으로 요구하는 것은 아니다.
var encodingProbe = new Diagnostic("main.pgy", 1, "E001", "한글<>&'\"");
using (var parsed = JsonDocument.Parse(json.Format(encodingProbe)))
{
var message = parsed.RootElement.GetProperty("message");
Equal("\"\\uD55C\\uAE00\\u003C\\u003E\\u0026\\u0027\\u0022\"", message.GetRawText());
Equal(encodingProbe.Message, message.GetString());
}
using var output = new StringWriter(CultureInfo.InvariantCulture);
var reporter = new DiagnosticReporter(human);
reporter.Report(diagnostic, output);
// 줄 구분자를 하드코딩하지 않고 이 writer에 설정된 NewLine을 따른다.
Equal(human.Format(diagnostic) + output.NewLine, output.ToString());
output.Write("still open"); // 호출자 소유 writer가 열려 있는지 확인한다.
Equal(true, output.ToString().EndsWith("still open", StringComparison.Ordinal));
Expect<ArgumentOutOfRangeException>(() => new Diagnostic("main.pgy", 0, "E001", "오류"));
Expect<ArgumentException>(() => new Diagnostic("main.pgy", 1, "E001", " "));
Expect<ArgumentException>(() => new Diagnostic("main\n.pgy", 1, "E001", "오류"));
Expect<ArgumentException>(() => DiagnosticFormatterFactory.Create("jsno"));
Expect<ArgumentNullException>(() => new DiagnosticReporter(null!));
using var rejectedOutput = new StringWriter(CultureInfo.InvariantCulture);
Expect<InvalidOperationException>(() =>
new DiagnosticReporter(new BrokenFormatter()).Report(diagnostic, rejectedOutput));
Equal("", rejectedOutput.ToString());
using var failingOutput = new FailingWriter();
Expect<IOException>(() => reporter.Report(diagnostic, failingOutput));
// 메서드 그룹과 람다는 같은 함수 계약으로 전달할 수 있다.
Func<Diagnostic, string> formatFunction = human.Format;
Equal(human.Format(diagnostic), DiagnosticFunctions.Render(diagnostic, formatFunction));
var jsonLines = DiagnosticFunctions.RenderAll(
new[] { diagnostic, multiline }, item => json.Format(item));
Equal(2, jsonLines.Length);
Equal(json.Format(multiline), jsonLines[1]);
var catalog = new[]
{
new CatalogProduct(3, 2000m),
new CatalogProduct(2, 1000m),
new CatalogProduct(1, 1000m)
};
var cheap = ProductOrdering.Sort(catalog, ProductOrdering.CheapestFirst);
var cheapQuery = ProductOrdering.Sort(catalog, ProductOrdering.CheapestFirstQuery);
var expensive = ProductOrdering.Sort(catalog, ProductOrdering.ExpensiveFirst);
var priority = ProductOrdering.Sort(catalog, ProductOrdering.PriorityFirst(3));
Equal("1,2,3", string.Join(",", cheap.Select(product => product.Id)));
Equal("1,2,3", string.Join(",", cheapQuery.Select(product => product.Id)));
Equal("3,1,2", string.Join(",", expensive.Select(product => product.Id)));
Equal("3,1,2", string.Join(",", priority.Select(product => product.Id)));
Equal("3,2,1", string.Join(",", catalog.Select(product => product.Id)));
Console.WriteLine($"C# checks: {checks}");
}
private static void Equal<T>(T expected, T actual)
{
if (!System.Collections.Generic.EqualityComparer<T>.Default.Equals(expected, actual))
throw new Exception($"기댓값: {expected}, 실제값: {actual}");
checks++;
}
private static void Expect<TException>(Action action) where TException : Exception
{
try { action(); }
catch (Exception error)
{
// 파생 예외까지 허용하는 catch (TException)과 구별한다.
if (error.GetType() == typeof(TException)) { checks++; return; }
throw new InvalidOperationException(
$"예외 형식 불일치: 기댓값 {typeof(TException).Name}, 실제값 {error.GetType().Name}", error);
}
throw new InvalidOperationException($"{typeof(TException).Name} 예외가 필요합니다.");
}
private sealed class BrokenFormatter : IDiagnosticFormatter
{
public string Format(Diagnostic diagnostic) => "first\nsecond";
}
private sealed class FailingWriter : StringWriter
{
public override void WriteLine(string? value) =>
throw new IOException("테스트용 출력 실패");
}
}부록 D. 구현할 때 확인할 점
필요한 입력을 어디까지 넘길 것인가
포매터가 진단만 필요하다면 진단을 넘기면 된다. ApplicationContext, DbContext, 설정 저장소, 사용자 정보까지 전부 넘겨서 포매터가 알아서 찾아 쓰게 만들면 시그니처는 짧아지지만, 실제로 어떤 정보와 권한에 의존하는지는 호출부에서 보이지 않는다.
알고리즘이 본체의 큰 자료구조 일부에 접근해야 한다면 필요한 조회 기능을 가진 좁은 인터페이스를 제공할 수도 있다. 통째로 넘기기 전에 접근 범위를 설명할 수 있는지, 전략을 테스트하려고 애플리케이션 전체를 띄워야 하는지를 확인하는 편이 낫다.
동기와 비동기를 억지로 맞추지 않는다
이 포매터의 Format()은 문자열을 만드는 동기 계산이다. 원격 서비스를 호출하는 구현까지 받으려면 반환형과 취소, 실패 처리도 그에 맞게 계약에 포함해야 한다.
상태와 수명을 함께 본다
이 예제의 사람용·JSON 포매터는 인스턴스 상태가 없어 하나를 여러 본체가 공유할 수 있다. 최근 요청이나 임시 버퍼를 필드에 저장하는 전략은 사정이 달라서 요청 사이의 데이터 섞임과 동시 접근을 검토해야 하며, 함수로 바꾸어도 closure가 변경 가능한 상태를 붙잡으면 같은 문제가 생긴다.
전략이 파일 핸들이나 네트워크 연결을 소유한다면 생성과 해제 책임도 정해야 한다. 생성자로 전달할 때 소유권을 넘기는지 빌려주는지를 계약에 밝혀야 하며, 이 예제의 writer는 호출자 소유이므로 해제도 호출자가 맡는다.
각주
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994 (copyright 1995), Strategy 장. Duke University 수업 자료의 발췌. ↩
- Martin Fowler. Inversion of Control Containers and the Dependency Injection pattern, 2004. 조립과 사용의 분리, 생성자 주입. ↩
- Microsoft Learn. Using delegates. 메서드를 값으로 전달하고 구체 구현을 모른 채 호출하는 C# delegate의 동작. ↩
- Microsoft Learn. Enumerable.ThenBy. 보조 정렬 기준과 새
OrderBy호출의 차이. ↩ - MDN. Array.prototype.sort. 배열의 직접 변경과 비교 함수가 지켜야 하는 조건. ↩
- Robert Nystrom. Game Programming Patterns, State 저자 원문. 상태 머신,
enum + switch, 상태별 객체에 위임하는 State 패턴의 구별. ↩ - Addison-Wesley / InformIT 출판사 서지. Design Patterns의 출판일과 저작권 표기. ↩
- Erich Gamma, Richard Helm, Ralph Johnson, Larry O'Brien. Design Patterns 15 Years Later, 2009. 언어별 기능과 패턴 적용 범위에 관한 저자 인터뷰. ↩
- Microsoft Learn. C# language versioning. 대상 프레임워크와 기본 언어 버전의 관계. ↩
- Microsoft Learn. How to serialize JSON in C#. System.Text.Json으로 JSON 문자열을 만드는 방법. ↩
- Microsoft Learn. C# 명세 §12.23.3 Query expression translation. 질의 구문을 메서드 호출로 변환하는 규칙. ↩
- Microsoft Learn. Lambda expressions. 람다와 바깥 변수 캡처. ↩
- Microsoft Learn. Enumerable.OrderBy. 키 선택 함수와 지연 실행, 정렬 동작. ↩
- TypeScript Handbook. Object Types: readonly Properties.
readonly의 타입 검사 범위와 런타임 동작의 구별. ↩