디자인 패턴의 목적과 선택
디자인 패턴을 이름이 아니라 되풀이되는 문제와 바뀌는 지점으로 읽고, GoF의 23개 패턴을 생성, 구조, 행위로 나누어 정리합니다. 추천 규칙을 바꾸는 Strategy와 옛 저장소를 새 인터페이스에 맞추는 Adapter를 실행해 보고, 패턴을 쓰지 않는 편이 나은 경우도 살펴봅니다.
모임 추천 기능에서 "먼저 등록된 모임"을 추천하는 규칙과 "지금 인원이 가장 적은 모임"을 추천하는 규칙을 바꾸어 가며 쓰려고 합니다. 이때 추천하는 코드 전체를 두 벌 만들기보다, 달라지는 선택 규칙만 바꾸어 끼울 수 있게 만들 수 있습니다. 디자인 패턴(design pattern)은 이처럼 되풀이되는 설계 문제와 그 해결 구조에 이름을 붙여 설명하는 도구이며, 모든 코드를 복잡한 클래스 계층으로 바꾸라는 규칙이 아닙니다. 이 글에서는 패턴을 고르는 기준을 정리하고, Strategy와 Adapter를 직접 실행해 봅니다.
문제와 맥락과 결과를 먼저 읽는다
패턴을 고르려면 이름보다 먼저 되풀이되는 문제가 있어야 합니다. 무엇이 바뀌고 무엇은 그대로 두어야 하는지, 지금 지키고 있는 인터페이스가 무엇인지, 그 선택으로 생길 비용이 무엇인지를 적어 봅니다. 이름만 알고 코드를 덧붙이면 문제에 없던 확장 지점과 호출 단계만 늘어날 수 있습니다.
Strategy(전략) 패턴은 달라질 수 있는 알고리즘이나 정책을 같은 호출 방식 뒤에 두고 골라 쓰게 하는 구조입니다. 추천에서는 후보를 거르는 일은 같고, 후보 가운데 하나를 고르는 정책만 달라집니다. Adapter(어댑터) 패턴은 이미 있는 구현의 인터페이스를 쓰는 쪽이 원하는 인터페이스에 맞추어 주는 구조로, 옛 저장소의 read와 write를 새 코드가 쓰는 load와 save로 이어 주는 것이 그 예입니다.
패턴의 이름은 함께 일하는 사람들 사이의 공통 언어로도 쓸모가 있습니다. 다만 "선택 정책을 Strategy로 나누자"고 말했다면, 그 정책이 무엇을 받고 무엇을 돌려주며 실패하면 어떻게 되는지까지 설명해야 합니다. 같은 패턴도 언어마다 다른 모양으로 나타나며, JavaScript에서는 함수를 인수로 넘기는 것만으로 충분한 경우가 많습니다.
SOFTWARE ENGINEERING · CH7-2
변하는 선택 정책을 같은 계약으로 교체한다
추천 후보 준비는 유지하고 하나를 고르는 규칙만 바꿉니다.
추천 호출은 같은 후보 선택 계약을 사용하고 첫 후보 전략 또는 인원 최소 전략을 대입할 수 있으며 빈 후보 결과는 둘 다 null입니다.
GoF 분류를 지도로 쓴다
GoF(Gang of Four)는 에릭 감마(Erich Gamma), 리처드 헬름(Richard Helm), 랄프 존슨(Ralph Johnson), 존 블리시디스(John Vlissides) 네 사람을 가리키며, 이들이 『Design Patterns』에서 정리한 23개의 객체 지향 설계 패턴을 흔히 GoF 패턴이라고 부릅니다. 23개는 목적에 따라 다음 세 묶음으로 나뉩니다.
| 목적 | 패턴 | 처음 던질 질문 |
|---|---|---|
| 생성(5개) | Abstract Factory, Builder, Factory Method, Prototype, Singleton | 객체를 만드는 과정이나 함께 쓰는 제품 묶음을 어떻게 떼어 낼까 |
| 구조(7개) | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy | 기존 객체를 어떻게 잇고, 조합하고, 접근하게 할까 |
| 행위(11개) | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | 요청, 알고리즘, 변화의 알림, 상태별 행동을 어떻게 협력시킬까 |
같은 묶음 안에서도 패턴마다 푸는 문제가 다릅니다. 생성 패턴에서 Factory Method는 어떤 객체를 만들지 고르는 일을, Builder는 여러 단계를 거쳐 객체를 조립하는 일을, Prototype은 기존 객체를 복사해 만드는 일을, Singleton은 인스턴스를 하나만 두고 어디서든 쓰게 하는 일을 다룹니다. 구조 패턴에서 Facade는 복잡한 기능에 단순한 창구를 두고, Decorator는 같은 인터페이스를 유지하며 기능을 덧붙이고, Proxy는 실제 객체로 가는 접근을 중간에서 다룹니다. 행위 패턴에서 Observer는 변화를 구독자에게 알리고, Command는 요청을 객체로 만들어 다루며, State는 대상의 상태에 따라 행동을 바꿉니다.
이 표는 23개를 모두 외우라는 것이 아니라, 필요한 문제가 생겼을 때 찾아볼 지도로 쓰라는 것입니다. 이름을 외웠다고 알맞은 패턴을 고를 수 있게 되는 것은 아니므로, 이 글에서는 두 패턴만 깊이 실행해 봅니다.
Strategy: 바뀌는 선택 규칙을 인수로 받는다
추천 함수 chooseSession은 열려 있고 자리가 남은 모임만 후보로 거른 뒤, 그 후보를 인수로 받은 select 함수에 넘깁니다. 두 선택 규칙은 모두 "후보 가운데 하나를 돌려주고, 후보가 없으면 null을 돌려준다"는 같은 약속을 지킵니다. reading-lab/examples/ch7-2.mjs로 저장해 실행합니다.
function chooseSession(sessions, select) {
const available = sessions.filter((s) => s.status === "OPEN" && s.participants.length < s.capacity);
return select(available);
}
function firstAvailable(sessions) {
return sessions[0] ?? null;
}
function fewestParticipants(sessions) {
return [...sessions].sort((a, b) => a.participants.length - b.participants.length)[0] ?? null;
}
const sessions = [
{ id: "A", status: "OPEN", capacity: 3, participants: ["m1", "m2"] },
{ id: "B", status: "OPEN", capacity: 3, participants: [] },
{ id: "C", status: "CLOSED", capacity: 3, participants: [] },
];
console.log("첫 모임:", chooseSession(sessions, firstAvailable)?.id);
console.log("인원 적은 모임:", chooseSession(sessions, fewestParticipants)?.id);
console.log("후보 없음:", chooseSession([sessions[2]], fewestParticipants));
console.log("원래 순서:", sessions.map((s) => s.id).join(","));첫 모임: A
인원 적은 모임: B
후보 없음: null
원래 순서: A,B,C??는 왼쪽 값이 undefined나 null일 때 오른쪽 값을 쓰게 하는 연산자로, 후보가 없을 때 sessions[0]의 undefined 대신 null을 돌려주게 합니다. ?.는 왼쪽 값이 null이나 undefined이면 속성을 읽지 않고 undefined를 돌려주므로 후보가 없어도 오류가 나지 않습니다. 선택 규칙을 바꾸어도 후보를 거르는 코드는 그대로이고, 마감된 모임 C는 어느 규칙에서도 후보에 들지 않습니다. sort는 배열을 제자리에서 정렬하므로, fewestParticipants는 [...sessions]로 사본을 만든 뒤 정렬해 넘겨받은 배열의 순서를 바꾸지 않습니다. 다만 이 예제에서는 chooseSession의 filter가 이미 새 배열을 만들어 넘기므로, 마지막 줄의 A,B,C는 사본을 만들지 않았더라도 똑같이 나옵니다.
후보가 없을 때 null을 돌려줄지 오류를 던질지는 정해 두어야 합니다. 정하지 않으면 호출하는 쪽이 결과의 .id를 바로 읽다가 실패할 수 있습니다. 또 추천은 신청이 아닙니다. 추천한 뒤 실제로 신청할 때까지 모임의 상태가 바뀔 수 있으므로 register가 다시 규칙을 확인해야 하며, 추천 단계에서 자리가 있었다는 것이 자리를 맡아 둔다는 뜻은 아닙니다.
Adapter: 이름보다 뜻을 맞춘다
옛 저장소는 값을 바로 돌려주는 read와 write를 제공하는데, 새 코드는 아키텍처와 관점의 신청 서비스처럼 await로 기다릴 수 있는 load와 save를 원한다고 해 봅시다. 다음 어댑터는 옛 저장소를 감싸 새 인터페이스로 보여 줍니다.
function adaptLegacyStore(legacy) {
return {
async load() {
return legacy.read();
},
async save(next) {
legacy.write(next);
},
};
}
let legacyValue = { id: "legacy", participants: [] };
const legacy = {
read() {
return structuredClone(legacyValue);
},
write(next) {
legacyValue = structuredClone(next);
},
};
const store = adaptLegacyStore(legacy);
await store.save({ id: "legacy", participants: ["m3"] });
console.log("어댑터로 저장한 명단:", (await store.load()).participants.join(","));어댑터로 저장한 명단: m3async 함수는 안에서 값을 바로 돌려주어도 그 값을 Promise로 감싸 돌려주므로, 호출하는 쪽은 옛 저장소인지 모르고 await로 쓸 수 있습니다. 새 코드는 read와 write를 직접 부르지 않았고, 저장소를 바꾸어야 할 때도 어댑터만 고치면 됩니다.
그러나 함수의 이름만 맞춘다고 호환되는 것은 아닙니다. 옛 write가 기존 내용 뒤에 덧붙이는 동작이라면, 전체를 바꾸어 저장한다는 새 save의 약속과 다릅니다. 값을 복사해 돌려주는지, 실패를 어떻게 알리는지, 저장된 값이 없을 때 무엇을 돌려주는지도 새 인터페이스의 약속에 맞추어야 합니다. 이 예제의 옛 저장소는 전체를 바꾸어 저장하고 사본을 돌려주므로 그대로 이을 수 있었습니다.
패턴의 비용과 쓰지 않을 때
선택 규칙이 하나뿐이고 바뀔 계획도 없다면, 전략마다 클래스를 만들고 전략을 등록하는 관리자까지 미리 두는 이점은 작습니다. 간단한 조건문으로 지금의 규칙을 분명하게 설명할 수 있다면 그렇게 시작하고, 변화가 실제로 되풀이되어 호출하는 코드와 정책이 함께 커질 때 나누어도 늦지 않습니다.
패턴을 적용하면 호출이 한 단계 늘고, 새 인터페이스가 생기고, 따라가 읽을 파일도 늘어납니다. 그래서 "유연해졌다"는 주장도 새 구현을 실제로 끼워 넣었을 때 기존 호출과 결과가 그대로인지로 확인해야 합니다. 같은 이유로 모든 조건을 State 패턴으로 바꾸는 것도 알맞지 않습니다. 정원 숫자가 달라질 때마다 클래스를 만드는 것은 상태별 행동의 설계가 아니며, 숫자 비교 한 번이면 충분한 곳에는 비교만 두면 됩니다. 반면 모집 상태마다 받아들이는 연산과 처리 방식이 복잡하게 갈라진다면 State 패턴을 검토할 이유가 생깁니다. 사용한 패턴의 수를 품질 점수처럼 세지 않습니다.
정리
- 디자인 패턴은 되풀이되는 설계 문제와 해결 구조에 붙인 이름이며, 고를 때는 무엇이 바뀌고 무엇을 지켜야 하는지부터 적습니다.
- GoF의 23개 패턴은 생성 5개, 구조 7개, 행위 11개로 나뉘며, 필요한 문제가 생겼을 때 찾아보는 지도로 씁니다.
- Strategy는 바뀌는 정책을 같은 호출 방식 뒤에 두어 바꾸어 끼우게 하며, JavaScript에서는 함수를 인수로 넘겨 구현할 수 있습니다.
- Adapter는 기존 구현을 원하는 인터페이스에 맞추는데, 이름뿐 아니라 저장 방식, 복사, 실패의 뜻까지 맞추어야 합니다.
- 패턴에는 간접 호출과 새 인터페이스의 비용이 있으므로, 바뀌는 지점이 실제로 생겼을 때 적용합니다.
연습 문제
1. 출력 형식과 오래된 메일 도구
출석 요약을 CSV 형식과 사람이 읽는 글 형식 두 가지로 출력해야 합니다. 또 오래된 메일 도구는 send(recipient, title, body)를 제공하는데, 새 코드는 deliver({ to, subject, text })로 부르고 싶어 합니다. 두 요구에 어울리는 패턴이나 단순한 구조를 제안하세요.
해설 보기
출석 요약은 한 번만 계산하고, format(summary)가 문자열을 돌려주는 형식 함수를 CSV용과 글용으로 따로 만들어 넘기면 Strategy로 나타낼 수 있습니다. 출력 형식은 서로 바꾸어 끼우는 정책이기 때문입니다. 메일은 새 객체 인수를 옛 함수의 세 인수로 풀어 넘기는 Adapter가 어울리며, 인수의 모양을 맞추는 문제이기 때문입니다. 기능이 아주 작다면 패턴의 이름을 붙이지 않고 단순한 함수 두 개로 같은 구조를 만들어도 됩니다.
2. 실패를 숨기는 어댑터
옛 메일 도구의 send는 보내지 못하면 false를 돌려줍니다. 다음 어댑터의 문제를 찾고 고치세요.
function adaptMailer(legacy) {
return {
async deliver({ to, subject, text }) {
legacy.send(to, subject, text);
return { ok: true };
},
};
}해설 보기
send가 돌려준 값을 보지 않고 늘 { ok: true }를 돌려주므로, 메일을 보내지 못해도 새 코드는 성공했다고 믿게 됩니다. 인수의 이름만 맞추고 실패의 뜻은 맞추지 않은 것입니다. const sent = legacy.send(to, subject, text);로 결과를 받은 뒤 return sent ? { ok: true } : { ok: false, code: "SEND_FAILED" };처럼 실패를 새 인터페이스의 약속대로 바꾸어 돌려주어야 합니다. 옛 도구가 실패할 때 false를 돌려주는지 오류를 던지는지도 먼저 확인해 둡니다.
3. 저장소를 Singleton으로
"저장소는 하나만 있으면 되니 Singleton으로 만들어 어디서든 가져다 쓰자"는 제안이 나왔습니다. 이 제안의 문제와 다른 방법을 설명하세요.
해설 보기
어디서든 같은 전역 저장소를 쓰면 편해 보이지만, 테스트끼리 저장소의 상태를 함께 쓰게 되어 한 테스트가 남긴 명단이 다른 테스트의 결과를 바꿀 수 있습니다. 또 어떤 코드가 저장소에 기대는지가 함수의 인수에 드러나지 않아 읽기 어렵습니다. 저장소가 하나만 필요하다는 요구는 프로그램이 시작하는 곳에서 저장소 객체를 하나 만들어 createRegistrationService(store)처럼 필요한 곳에 넘기는 것으로도 충족할 수 있으며, 그러면 테스트마다 새 저장소를 넘길 수 있습니다.
패턴은 변경을 설명하는 이름이고, 구현이 옳은지는 인터페이스의 약속과 실행 결과로 확인합니다. 다음 글 저장 방식 변경과 의존성에서는 같은 저장소 인터페이스에 메모리 구현과 JSON 파일 구현을 끼워 보고, 무엇이 그대로이고 어떤 운영상의 한계가 새로 생기는지 살펴봅니다.
원자료와 더 읽기
장원사이버평생교육원 「소프트웨어공학」 PDF 물리 149–158·181–185쪽의 주제를 바탕으로 설명, 도표, 실습을 새로 구성했습니다. 원자료 149쪽에서 GoF 패턴을 25개로 적은 부분은 23개로 바로잡았으며, 패턴의 수는 원 도서에 대한 Pearson의 안내에서 확인할 수 있습니다. 코드 예제는 이 교재의 독서 모임 사례에 맞게 새로 작성했습니다.