Lesson · Design Patterns
좋은 설계란 무엇인가?
에릭 감마 · GOF의 디자인 패턴
좋은 설계란 무엇인가? 에릭 감마와 GoF는 객체지향 설계에서 반복되는 문제와 그 해법을 23개의 패턴으로 정리했습니다. 각 패턴이 ‘무엇을 푸는가 → 어떻게 푸는가 → 언제 쓰는가’를 따라가면, 외우기보다 설계의 원리를 이해할 수 있습니다.
현재 노트: 22개 패턴 · 생성 5 · 구조 7 · 행위 10 · 총 26개 절
원서의 23개 중 인터프리터 패턴은 현재 노트에 포함하지 않았습니다. 수록 범위와 분류는 다음 자료를 참고했습니다(refactoring.guru ↗). 설명과 예시는 번역이 아니라 이 노트의 관점으로 새로 쓴 글입니다.
생성 수정
들어가며
디자인 패턴이란 무엇인가
디자인 패턴은 설치해서 쓰는 코드가 아니라, 자주 되풀이되는 설계 문제에 대한 해법의 틀입니다. 같은 문제를 여러 사람이 각자 풀다 보니 비슷한 모양이 반복해서 나왔고, 그 모양에 이름을 붙인 것이 패턴입니다.
- 라이브러리가 아니다 — 복사해 넣는 코드가 아니라 상황에 맞춰 다시 구현하는 설계 방식이다
- 알고리즘과 다르다 — 알고리즘은 "무엇을 계산하는가"의 절차이고, 패턴은 "객체를 어떻게 배치하는가"의 구조다
- 가장 큰 값어치는 이름이다 — "여기 옵서버로 가자" 한마디가 다이어그램 한 장을 대신한다
- 1994년 GoF(Gang of Four) 책이 23개를 정리했고, 지금은 그중 22개가 실무 카탈로그로 통용된다
패턴 하나를 읽는 순서
패턴 설명은 대개 같은 순서로 쓰여 있습니다. 그 순서대로 읽으면 외우지 않아도 됩니다 — 중요한 것은 이름이 아니라 어떤 문제 앞에서 이 이름이 떠오르는가입니다.
- 의도 — 이 패턴이 한 문장으로 무엇을 하는가
- 문제 — 패턴 없이 짰을 때 무엇이 아픈가. 이 대목이 안 와닿으면 그 패턴은 아직 필요 없다
- 해법 — 어떤 인터페이스를 두고 무엇을 갈아 끼우게 만드는가
- 결과 — 무엇을 얻고 무엇을 잃는가. 패턴은 대부분 클래스 수를 늘려 유연함을 산다
- 읽는 순서를 뒤집지 말 것 — 해법부터 외우면 문제가 없는 곳에 패턴을 쓰게 된다
세 갈래 — 생성 · 구조 · 행위
22개를 셋으로 나누는 기준은 "객체의 어느 국면을 다루는가"입니다. 만드는 순간을 다루면 생성, 조립된 모양을 다루면 구조, 주고받는 방식을 다루면 행위입니다.
- 생성(Creational) 5개 — 객체를 만드는 방법을 감춰서, 무엇을 만들지 바뀌어도 쓰는 쪽이 안 바뀌게 한다
- 구조(Structural) 7개 — 클래스와 객체를 조립해 더 큰 구조를 만들되, 조립 방식이 바뀌어도 부서지지 않게 한다
- 행위(Behavioral) 10개 — 객체들이 일을 나눠 맡고 서로 신호를 주고받는 방식을 정리한다
- 분류는 외울 대상이 아니라 찾는 색인이다 — "만들기가 지저분하다"면 생성, "연결이 지저분하다"면 구조·행위를 먼저 본다
생성
팩토리 메서드 (Factory Method)
무엇을 만들지 결정하는 자리를 상위 클래스의 메서드 하나로 몰아두고, 실제로 어떤 것을 만들지는 하위 클래스가 정하게 합니다. new 가 코드 곳곳에 흩어지는 것을 막는 가장 기본적인 방법입니다.
- 문제 — 새 종류를 하나 추가할 때마다 new 를 쓴 자리를 전부 찾아 고쳐야 한다
- 해법 — 생성만 담당하는 메서드를 만들고, 종류별 하위 클래스가 그 메서드만 재정의한다
- 언제 — 만들어야 할 종류가 앞으로 늘어날 것이 분명하고, 쓰는 쪽 코드는 그대로 두고 싶을 때
- 주의 — 종류 하나에 클래스 하나가 늘어난다. 두세 종류로 끝날 일이면 조건문이 더 정직하다
- 예 — 결제 수단(카드·계좌이체·간편결제)마다 다른 처리기를 만들되 주문 흐름은 하나로 둔다
추상 팩토리 (Abstract Factory)
서로 어울려야 하는 물건들을 한 세트로 묶어 만듭니다. 팩토리 메서드가 물건 하나를 다룬다면, 추상 팩토리는 "같은 계열끼리만 조합되게" 만드는 일을 맡습니다.
- 문제 — 버튼은 맥 스타일인데 체크박스는 윈도 스타일로 섞여 만들어지는 사고를 코드로 막을 방법이 없다
- 해법 — 계열마다 팩토리를 하나씩 두고, 그 팩토리가 세트 전체를 만들게 한다
- 언제 — 제품군이 여럿이고 한 화면·한 요청 안에서는 같은 계열만 써야 할 때
- 주의 — 세트에 물건을 하나 추가하면 모든 계열의 팩토리를 다 고쳐야 한다. 세트 구성이 자주 바뀌면 맞지 않는다
- 예 — 개발·스테이징·운영마다 다른 저장소·큐·알림 구현을 한 세트로 갈아 끼운다
빌더 (Builder)
복잡한 객체를 한 번에 만들지 않고 단계로 나눠 조립합니다. 생성자 인자가 열 개씩 늘어나고 그중 절반이 선택 항목일 때 쓰는 패턴입니다.
- 문제 — 인자가 많은 생성자는 읽을 수 없고, 조합마다 생성자를 늘리면 개수가 폭발한다
- 해법 — 단계별로 값을 채우는 빌더를 두고, 마지막에 완성품을 돌려받는다
- 언제 — 선택 항목이 많거나, 같은 조립 절차로 다른 표현을 만들어야 할 때
- 주의 — 필수 값이 빠진 채로도 build 가 불리는 것을 막아야 한다. 검증을 빌더 안에 두는 것이 핵심이다
- 예 — 쿼리 빌더, 리포트 생성기, 설정 객체 조립
프로토타입 (Prototype)
새로 만드는 대신 이미 있는 것을 복제합니다. 만드는 비용이 크거나, 만들 때 필요한 정보를 쓰는 쪽이 갖고 있지 않을 때 씁니다.
- 문제 — 객체를 처음부터 만들려면 내부 상태를 다 알아야 하는데, 그 상태가 감춰져 있다
- 해법 — 객체 자신이 자기를 복제하는 메서드를 갖게 한다
- 언제 — 초기화가 무겁거나, 같은 설정을 조금만 바꿔 여러 벌 만들어야 할 때
- 주의 — 얕은 복사와 깊은 복사를 구분해야 한다. 내부에 참조가 있으면 복제본이 원본과 같은 것을 공유한다
- 예 — 편집기에서 도형 복사·붙여넣기, 기본 설정을 바탕으로 한 사용자별 설정
싱글턴 (Singleton)
클래스의 인스턴스가 하나만 존재하도록 보장하고, 어디서든 그 하나에 접근하게 합니다. 가장 유명하고, 가장 많이 오용되는 패턴입니다.
- 문제 — 설정·커넥션 풀처럼 여러 벌이면 곤란한 자원이 있다
- 해법 — 생성자를 막고, 정해진 통로로만 그 하나를 얻게 한다
- 주의 1 — 사실상 전역 변수다. 어디서 바뀌었는지 추적하기 어렵고 테스트에서 상태가 남는다
- 주의 2 — 멀티스레드에서 초기화 경쟁이 생긴다. 언어가 제공하는 지연 초기화 보장을 쓰는 편이 안전하다
- 대안 — 요즘은 의존성 주입(DI) 컨테이너가 수명 관리를 대신하는 쪽이 더 흔하고 시험하기 쉽다
구조
어댑터 (Adapter)
맞지 않는 두 인터페이스 사이에 끼워 서로 통하게 만듭니다. 남의 코드를 고칠 수 없을 때 쓰는 패턴입니다.
- 문제 — 우리 코드는 A 인터페이스를 기대하는데 가져다 쓸 라이브러리는 B 로 말한다
- 해법 — A 처럼 생긴 껍데기를 만들고, 그 안에서 B 를 호출해 변환한다
- 언제 — 외부 SDK·레거시 모듈을 교체 가능하게 감싸고 싶을 때
- 주의 — 어댑터가 변환하는 곳이지 로직을 넣는 곳이 아니다. 여기서 규칙이 자라면 나중에 아무도 못 찾는다
- 예 — 결제사 SDK 세 곳을 우리 쪽 PaymentGateway 하나로 맞춘다
브리지 (Bridge)
추상(무엇을 하는가)과 구현(어떻게 하는가)을 따로 두고 각자 자라게 합니다. 두 축이 곱해져서 클래스가 폭발하는 것을 막습니다.
- 문제 — 종류 3개 × 플랫폼 3개면 클래스가 9개가 되고, 한 축이 늘 때마다 곱셈으로 늘어난다
- 해법 — 한 축을 인터페이스로 뽑아 다른 축이 그 참조를 들고 있게 한다(상속 대신 합성)
- 언제 — 독립적으로 변하는 축이 둘 이상이라고 판단될 때
- 주의 — 축이 실제로 둘인지 먼저 확인해야 한다. 한 축만 변한다면 그냥 상속이 낫다
- 예 — 알림 종류(경고·공지) × 전송 수단(메일·슬랙·SMS)
컴포지트 (Composite)
개별 객체와 그 묶음을 같은 인터페이스로 다루게 합니다. 트리 구조를 다루는 코드에서 "잎인지 가지인지" 묻는 조건문을 없앱니다.
- 문제 — 폴더 안에 파일과 폴더가 섞여 있어, 크기를 구하려면 매번 종류를 검사해야 한다
- 해법 — 잎과 가지가 같은 인터페이스를 구현하고, 가지는 자식들에게 그대로 위임한다
- 언제 — 데이터가 트리 모양이고 전체·부분을 똑같이 다루고 싶을 때
- 주의 — 인터페이스가 잎에는 의미 없는 메서드(add·remove)까지 갖게 되기 쉽다
- 예 — 파일 시스템, 화면 컴포넌트 트리, 조직도 합계
데코레이터 (Decorator)
객체를 같은 인터페이스의 껍데기로 감싸 기능을 덧붙입니다. 상속으로 조합을 만들면 클래스가 폭발하는 자리에서, 실행 중에 겹쳐 쌓는 방식으로 대신합니다.
- 문제 — 압축·암호화·로깅을 조합하려면 조합 수만큼 하위 클래스가 필요하다
- 해법 — 원본과 같은 인터페이스를 가진 래퍼를 만들어 호출 전후에 일을 더한다. 래퍼는 겹쳐 쌓을 수 있다
- 언제 — 기능을 켜고 끄는 조합이 많고, 그 조합이 실행 중에 정해질 때
- 주의 — 겹겹이 쌓이면 스택 추적이 길어지고 어디서 값이 바뀌었는지 보기 어렵다
- 예 — 스트림 래퍼, HTTP 미들웨어, 캐시·재시도 데코레이터
퍼사드 (Facade)
복잡한 하위 시스템 앞에 단순한 창구를 하나 둡니다. 쓰는 쪽이 알아야 할 것을 줄이는 것이 목적입니다.
- 문제 — 기능 하나를 쓰려고 클래스 다섯 개를 정해진 순서로 호출해야 한다
- 해법 — 그 절차를 메서드 하나로 감싸 앞에 내놓는다
- 언제 — 하위 시스템은 그대로 두고 진입점만 정리하고 싶을 때
- 주의 — 퍼사드가 자꾸 커져 모든 것을 아는 신 객체가 되지 않게 한다
- 예 — 주문 완료 한 번에 결제·재고·알림을 순서대로 부르는 서비스 계층
플라이웨이트 (Flyweight)
여러 객체가 공유할 수 있는 부분을 밖으로 빼내 메모리를 아낍니다. 같은 데이터를 수만 벌 들고 있을 때만 의미가 있습니다.
- 문제 — 나무 100만 그루가 각자 같은 텍스처·색 데이터를 따로 들고 있어 메모리가 터진다
- 해법 — 변하지 않는 공유 상태를 한 벌만 두고, 객체마다 다른 값(좌표 등)만 밖에서 넘긴다
- 언제 — 객체 수가 아주 많고 그중 대부분이 같은 값을 반복해서 들고 있을 때
- 주의 — 공유 객체는 반드시 불변이어야 한다. 하나를 고치면 전부 바뀐다
- 예 — 문자 렌더링의 글리프 캐시, 타일 맵, 대량 파티클
프록시 (Proxy)
원본과 같은 인터페이스를 가진 대리인을 앞에 세워, 접근 자체를 통제합니다. 데코레이터가 기능을 더한다면 프록시는 접근을 관리합니다.
- 문제 — 무거운 객체를 미리 만들고 싶지 않거나, 접근에 권한·로깅·캐시를 걸어야 한다
- 해법 — 같은 인터페이스의 대리 객체를 두고, 진짜 객체 호출을 그 안에서 통제한다
- 언제 — 지연 초기화, 접근 제어, 원격 호출, 결과 캐싱
- 주의 — 데코레이터와 구조가 같아 헷갈린다. 의도가 "기능 추가"면 데코레이터, "접근 통제"면 프록시다
- 예 — ORM 지연 로딩, API 클라이언트 캐시, 권한 검사 래퍼
행위
책임 연쇄 (Chain of Responsibility)
요청을 처리기들의 사슬에 흘려보내, 처리할 수 있는 곳에서 멈추게 합니다. 누가 처리할지 보내는 쪽이 몰라도 됩니다.
- 문제 — 검증·인증·로깅이 하나의 거대한 조건문 덩어리로 자란다
- 해법 — 각 단계를 독립된 처리기로 만들고 다음 처리기를 참조로 잇는다
- 언제 — 처리 단계가 늘고 줄고, 순서가 바뀔 수 있을 때
- 주의 — 아무도 처리하지 않고 끝나는 경우를 반드시 정해 둬야 한다
- 예 — 웹 미들웨어 파이프라인, 결재 승인 단계, 예외 처리 계층
커맨드 (Command)
요청을 객체로 만듭니다. 무엇을 할지가 값이 되면 큐에 넣고, 로그로 남기고, 되돌릴 수 있습니다.
- 문제 — 버튼·단축키·메뉴가 같은 동작을 각자 직접 호출해 중복되고, 취소 기능을 넣을 자리가 없다
- 해법 — 동작을 execute() 를 가진 객체로 만들고, 호출하는 쪽은 그 객체만 안다
- 언제 — 실행 취소(undo), 작업 큐, 매크로·재실행이 필요할 때
- 주의 — 되돌리려면 실행 전 상태를 함께 보관해야 한다(메멘토와 자주 함께 쓴다)
- 예 — 편집기의 undo 스택, 백그라운드 작업 큐, 트랜잭션 로그
이터레이터 (Iterator)
컬렉션의 내부 구조를 드러내지 않고 원소를 하나씩 훑는 방법을 제공합니다. 요즘 언어에는 문법으로 들어와 있어 가장 익숙한 패턴입니다.
- 문제 — 순회 코드가 컬렉션의 내부 구조(배열인지 트리인지)에 묶여 버린다
- 해법 — 순회 상태를 담은 별도 객체를 두고, 그것만 다음 원소를 안다
- 언제 — 자료구조가 여럿인데 순회 코드는 하나로 두고 싶을 때, 지연 순회가 필요할 때
- 주의 — 순회 중에 원본이 바뀌면 무엇을 보장할지 정해야 한다
- 예 — 파이썬 제너레이터, 자바 Iterator, DB 커서 페이지네이션
중재자 (Mediator)
서로 직접 대화하던 객체들을 가운데 하나를 통해서만 이야기하게 만듭니다. 연결선이 그물이 되는 것을 막습니다.
- 문제 — 화면 요소끼리 서로를 직접 참조해 하나를 고치면 다른 것들이 줄줄이 깨진다
- 해법 — 중재자가 관계 규칙을 갖고, 각 객체는 중재자에게만 알린다
- 언제 — 다대다 연결이 생겨 의존 관계가 그물처럼 얽힐 때
- 주의 — 중재자가 모든 규칙을 흡수해 거대해지기 쉽다. 규칙이 커지면 도메인 서비스로 쪼갤 때다
- 예 — 폼 위젯 간 활성/비활성 규칙, 채팅방, 이벤트 버스
메멘토 (Memento)
객체의 내부 상태를 캡슐화를 깨지 않고 저장했다가 나중에 되돌립니다. 되돌리기의 저장 담당입니다.
- 문제 — 상태를 되돌리려면 내부 필드를 외부에서 읽어야 하는데, 그러면 캡슐화가 깨진다
- 해법 — 객체 스스로 스냅샷을 만들고, 밖에서는 그 스냅샷을 보관만 한다(내용은 못 본다)
- 언제 — 실행 취소, 체크포인트, 임시 저장
- 주의 — 스냅샷이 크면 메모리를 먹는다. 전체 복사 대신 변경분만 남기는 편이 나을 때가 많다
- 예 — 편집기 undo, 게임 세이브, 폼 임시 저장
옵서버 (Observer)
한 객체의 상태가 바뀌면 그것을 구독한 객체들에게 알립니다. 알리는 쪽은 누가 듣는지 모르고, 듣는 쪽은 언제든 빠질 수 있습니다.
- 문제 — 값이 바뀔 때마다 갱신해야 할 곳이 늘어나고, 그 목록이 코드에 박힌다
- 해법 — 구독 목록을 두고 변경 시 순회하며 알린다
- 언제 — 한 사건에 반응할 곳이 여럿이고 그 목록이 실행 중에 바뀔 때
- 주의 — 구독 해제를 빼먹으면 그대로 메모리 누수다. 알림이 알림을 부르는 연쇄도 조심해야 한다
- 예 — UI 상태 구독, 도메인 이벤트, 웹훅
상태 (State)
객체의 상태를 클래스로 만들어, 상태가 바뀌면 행동도 통째로 바뀌게 합니다. 상태 값에 따라 갈라지는 조건문 뭉치를 대신합니다.
- 문제 — 메서드마다 "지금 어떤 상태인가"를 검사하는 분기가 반복된다
- 해법 — 상태마다 클래스를 만들고, 현재 상태 객체에게 일을 위임한다. 전이도 상태가 결정한다
- 언제 — 상태 수가 셋 이상이고 상태별 행동 차이가 클 때
- 주의 — 전이 규칙이 여러 상태 클래스에 흩어지면 전체 그림을 보기 어렵다. 다이어그램을 함께 남길 것
- 예 — 주문(결제 대기·배송 중·완료·취소), 문서 승인 흐름
전략 (Strategy)
같은 목적의 알고리즘 여럿을 각각 클래스로 만들어 갈아 끼웁니다. 실무에서 가장 자주 쓰게 되는 패턴입니다.
- 문제 — 정렬·요금 계산·할인 방식이 늘 때마다 한 메서드 안의 조건문이 길어진다
- 해법 — 같은 인터페이스를 구현한 전략 객체를 만들고, 쓰는 쪽은 하나를 주입받아 호출만 한다
- 언제 — "같은 일을 여러 방법으로" 하고, 그 방법이 앞으로 늘어날 때
- 주의 — 상태 패턴과 구조가 같다. 스스로 전이하면 상태, 밖에서 골라 주면 전략이다
- 예 — 배송비 정책, 추천 알고리즘 교체, 파일 압축 방식 선택
템플릿 메서드 (Template Method)
전체 절차의 뼈대를 상위 클래스에 고정하고, 달라지는 단계만 하위 클래스가 채우게 합니다. 순서는 지키게 하고 내용은 열어 두는 방식입니다.
- 문제 — 절차는 같은데 중간 한두 단계만 다른 코드가 여러 벌 복사된다
- 해법 — 상위 클래스가 순서를 정의하고, 달라지는 단계는 추상 메서드로 비워 둔다
- 언제 — 순서를 절대 바꾸면 안 되는 처리 흐름이 있을 때
- 주의 — 상속에 묶이므로 유연함은 전략보다 낮다. 실행 중에 바꿔야 하면 전략이 맞다
- 예 — 데이터 적재 파이프라인(읽기→검증→변환→저장), 테스트 셋업/실행/정리
비지터 (Visitor)
구조를 바꾸지 않고 새 동작을 추가합니다. 객체 구조는 안정적인데 그 위에서 하고 싶은 일이 계속 늘어날 때 씁니다.
- 문제 — 노드 종류마다 새 연산을 추가할 때마다 모든 클래스를 고쳐야 한다
- 해법 — 연산을 방문자 객체로 빼고, 각 노드는 자기를 방문자에게 넘긴다
- 언제 — 노드 종류는 잘 안 바뀌고 연산만 늘 때
- 주의 — 반대로 노드 종류가 자주 늘면 모든 방문자를 고쳐야 해 최악이다. 이 조건을 먼저 확인할 것
- 예 — AST 순회(타입 검사·코드 생성·포매팅), 문서 트리 내보내기
마무리
패턴을 쓰지 말아야 할 때
패턴은 복잡도를 없애지 않고 옮깁니다. 조건문에 있던 복잡도가 클래스 관계로 옮겨갈 뿐입니다. 그 교환이 이득일 때만 쓰는 것이 맞습니다.
- 변화가 실제로 오지 않았다면 미루십시오 — "나중에 늘어날 것 같아서"는 대개 틀린 예측이다
- if 문 두 개는 if 문 두 개로 두는 편이 낫다. 세 번째가 왔을 때 패턴으로 옮기면 늦지 않다
- 이름부터 쓰지 말 것 — 클래스 이름에 Factory·Manager·Strategy 를 붙인다고 패턴이 되지 않는다
- 언어가 이미 주는 것을 다시 만들지 말 것 — 일급 함수가 있으면 전략은 함수 하나로 끝나는 경우가 많다
- 패턴의 가장 큰 실익은 대화 비용 절감이다. 팀이 그 이름을 모르면 그 값어치도 사라진다
LangTailor