원칙 — 패턴이 서 있는 바닥
패턴 23개를 외우기 전에 알아야 할 것이 있다. 패턴은 발명된 것이 아니라 발견된 것이다. 좋은 설계를 하다 보니 반복해서 나타난 모양에 이름을 붙인 것이 GoF 카탈로그다.
그러니 순서가 중요하다. 원칙을 알면 패턴은 그 원칙을 지키다 보면 나오는 결과로 읽힌다. 원칙 없이 패턴만 외우면, 필요 없는 곳에 패턴을 넣는 사람이 된다.
패턴이란 무엇인가
- 디자인 패턴 — 자주 반복되는 설계 문제에 대한 검증된 해법 템플릿. 코드가 아니라 설계 아이디어다
- GoF (Gang of Four) — Design Patterns(1994) 저자 4인. 23개를 생성·구조·행위 셋으로 분류했다
- 패턴의 4요소 — 이름 · 문제(언제) · 해법(구조) · 결과(트레이드오프)
결과(트레이드오프)가 4요소에 들어 있다는 점을 놓치지 않는다. 패턴은 공짜가 아니다. 유연함을 얻는 대신 클래스 수와 간접 계층이 늘어난다. 그 대가를 치를 이유가 없으면 안 쓰는 것이 맞다.
추상화 수준을 구분한다
| 용어 | 범위 | 예 |
|---|---|---|
| 이디엄(idiom) | 한 언어 한정 관용구 | try-with-resources, equals/hashCode 쌍 |
| 디자인 패턴 | 언어 무관 객체 설계 | Strategy, Observer |
| 아키텍처 패턴 | 시스템 구조 | MVC, 레이어드, 헥사고날 |
언어가 달라지면 패턴의 필요성도 달라진다. 자바에서 Strategy 를 클래스로 만들던 것을 함수가 일급인 언어에서는 함수 하나로 끝낸다. 패턴은 언어가 부족한 부분을 메우는 것이기도 하다.
결합도와 응집도
거의 모든 패턴이 이 둘을 조절한다.
- 결합도(coupling) — 모듈이 서로에게 얼마나 기대고 있나. 낮을수록 좋다
- 응집도(cohesion) — 한 모듈이 한 가지 일에 얼마나 집중하나. 높을수록 좋다
말은 추상적이지만 코드로 보면 명확하다.
// 결합도가 높다 — OrderService 가 KakaoPayClient 를 직접 안다
class OrderService {
private final KakaoPayClient pay = new KakaoPayClient(); // ← 이 한 줄
void place(Order o) { pay.charge(o.amount()); }
}
이 코드는 결제사를 바꾸려면 OrderService 를 고쳐야 한다. 테스트하려면 진짜 카카오페이를 불러야 한다. 한 줄 때문에 둘이 붙었다.
// 결합도를 낮췄다 — 인터페이스에만 기댄다
class OrderService {
private final PayClient pay;
OrderService(PayClient pay) { this.pay = pay; } // 밖에서 넣어 준다
void place(Order o) { pay.charge(o.amount()); }
}
이제 결제사를 바꿔도 OrderService 는 그대로고, 테스트에서는 가짜를 넣으면 된다. 패턴의 절반은 이 변형의 응용이다.
GoF의 2대 설계 원칙
카탈로그 23개보다 이 두 문장이 먼저다.
① 구현이 아니라 인터페이스에 프로그래밍하라
List<String> names = new ArrayList<>(); // ✓ 변수 타입은 인터페이스
ArrayList<String> bad = new ArrayList<>(); // ✗ 구현에 묶인다
왼쪽으로 쓰면 나중에 LinkedList 로 바꿔도 아래 코드가 안 바뀐다. 의존하는 타입이 곧 내가 감당해야 할 변경 범위다.
② 상속보다 객체 합성을 선호하라
할인 정책이 여러 개 겹치는 상황을 두 방식으로 풀어 보면 차이가 드러난다. 회원 등급 할인, 쿠폰, 첫 구매 혜택이 동시에 적용될 수 있다.
// 상속 — 조합마다 클래스가 필요하다
class Price { long amount() { return base; } }
class PriceWithGrade extends Price { long amount() { return super.amount() * 9 / 10; } }
class PriceWithGradeAndCoupon extends PriceWithGrade { … }
class PriceWithGradeAndCouponAndFirstBuy extends PriceWithGradeAndCoupon { … }
할인 종류가 5개면 조합은 32가지다. 게다가 컴파일 시점에 정해진다 — 사용자마다 적용 대상이 다른데, 그것을 클래스로 미리 만들어 둘 수는 없다.
// 합성 — 런타임에 조립한다
interface Price { long amount(); }
record BasePrice(long value) implements Price {
public long amount() { return value; }
}
record GradeDiscount(Price inner, int percent) implements Price {
public long amount() { return inner.amount() * (100 - percent) / 100; }
}
record CouponDiscount(Price inner, long off) implements Price {
public long amount() { return Math.max(0, inner.amount() - off); }
}
// 이 사용자에게 적용할 것만 골라 쌓는다
Price price = new BasePrice(order.total());
if (user.isVip()) price = new GradeDiscount(price, 10);
if (coupon != null) price = new CouponDiscount(price, coupon.amount());
price.amount();
클래스는 할인 종류만큼만 있으면 되고, 조합은 요청마다 실행 중에 만들어진다. 이것이 Decorator 이고, 원칙 ②의 가장 직접적인 사례다.
여기서 하나 더 보인다 — 쌓는 순서가 결과를 바꾼다. 정률 할인을 먼저 하고 정액 쿠폰을 빼는 것과, 쿠폰을 먼저 빼고 정률을 적용하는 것은 금액이 다르다. 합성은 그 순서를 정책으로 다룰 수 있게 해 준다.
왜 상속이 문제인가 — 상속은 부모의 구현에 자식을 묶는다(캡슐화 약화). 부모가 내부 구현을 바꾸면 자식이 조용히 깨진다. 그리고 자바는 단일 상속이라 상속 한 번이면 그 축은 소진된다. 합성은 개수 제한이 없다.
이 두 원칙이 Strategy · Decorator · Bridge · State · Adapter 를 전부 관통한다. 다섯 패턴을 따로 외우는 대신 원칙 하나로 묶어 이해하는 편이 오래 간다.
SOLID — 원칙을 다섯 가지로 펼친 것
| 원칙 | 한 줄 | 위반 신호 | 관련 패턴 |
|---|---|---|---|
| SRP 단일 책임 | 클래스는 변경 이유가 하나만 | 한 클래스가 UI+DB+검증 | Facade, Mediator |
| OCP 개방-폐쇄 | 확장엔 열고 변경엔 닫는다 | 새 타입마다 if/switch 증식 | Strategy, Template Method, Decorator |
| LSP 리스코프 치환 | 자식은 부모를 완전히 대체할 수 있어야 | 오버라이드가 예외를 던진다 | (상속 설계 전반) |
| ISP 인터페이스 분리 | 안 쓰는 메서드에 의존을 강요하지 않는다 | 빈 구현·UnsupportedOperationException | Adapter |
| DIP 의존 역전 | 추상에 의존하고 구체에 의존하지 않는다 | 고수준이 new MySqlRepo() | Factory, DI |
SRP — "변경 이유가 하나" 가 무슨 뜻인가
"한 가지 일만 한다" 로 외우면 애매하다. 누가 이 코드를 고치라고 요구하는가로 보면 선명해진다.
class Report {
String content() { … } // 기획이 바꾸라고 한다
String toHtml() { … } // 디자인이 바꾸라고 한다
void save() { … } // 인프라가 바꾸라고 한다
}
요구하는 사람이 셋이면 변경 이유가 셋이다. 셋이 한 파일에 있으면 서로 무관한 이유로 같은 파일이 계속 충돌한다.
OCP — 분기가 늘어나는 것이 신호다
// 새 결제 수단이 생길 때마다 이 메서드를 고쳐야 한다 → OCP 위반
int fee(String type, int amount) {
if (type.equals("CARD")) return amount / 100;
if (type.equals("BANK")) return 500;
if (type.equals("POINT")) return 0;
throw new IllegalArgumentException(type);
}
// 새 수단은 '추가' 로 끝난다 — 기존 코드는 안 건드린다
interface FeePolicy { int fee(int amount); }
Map<PayType, FeePolicy> policies = Map.of(
PayType.CARD, amount -> amount / 100,
PayType.BANK, amount -> 500,
PayType.POINT, amount -> 0);
OCP 를 실현하는 도구가 다형성이다. 분기문을 타입으로 바꾸면, 새 경우는 새 타입을 더하는 일이 된다. 이 변형이 곧 Strategy 다.
LSP — 실무에서는 이렇게 나타난다
interface OrderRepository {
Order save(Order o);
void delete(Long id);
}
class JpaOrderRepository implements OrderRepository { … }
class ReadOnlyOrderRepository implements OrderRepository { // 읽기 전용 복제본용
public Order save(Order o) { throw new UnsupportedOperationException(); }
public void delete(Long id) { throw new UnsupportedOperationException(); }
}
계약상 OrderRepository 는 저장할 수 있어야 하는데, 이 구현은 못 한다. 부모를 기대하고 쓴 코드가 자식을 넣었을 때 깨지면 LSP 위반이다.
void archive(OrderRepository repo, Order o) {
repo.save(o.archived()); // 읽기 전용이 들어오면 여기서 터진다
}
컴파일은 통과하고 운영에서만 터진다. UnsupportedOperationException 을 던지는 구현이 나오면 그것은 대개 인터페이스를 잘못 나눈 신호다 — 읽기와 쓰기를 분리했어야 한다(ISP).
Collections.unmodifiableList() 도 같은 문제를 안고 있다. List 인데 add() 가 안 된다. JDK 조차 완전히 피하지 못한 함정이라, 그런 리스트를 받아 수정하려다 터지는 일이 실제로 흔하다.
고전적 반례 —
Square extends Rectangle. 정사각형은 수학적으로 직사각형이지만,setWidth(5); setHeight(4)뒤에 넓이를 20으로 기대하는 코드는 정사각형을 넣으면 16을 받는다. 교훈은 같다 — 현실의 is-a 가 코드의 is-a 를 보장하지 않는다. 판단 기준은 분류학이 아니라 행동 계약이다.
DIP — 화살표의 방향을 뒤집는다
[DIP 전] OrderService ──→ MySqlOrderRepository 고수준이 저수준을 안다
[DIP 후] OrderService ──→ OrderRepository 고수준은 추상만 안다
↑
MySqlOrderRepository 저수준이 추상을 따른다
"역전" 은 의존 화살표의 방향이 뒤집힌다는 뜻이다. 인터페이스를 고수준 쪽(도메인)이 소유하고 구현이 그것을 따르게 하면, DB 를 바꿔도 도메인은 그대로다. 스프링의 DI 가 이것을 배선으로 자동화한 것이고, 헥사고날 아키텍처는 이 원칙 하나를 구조로 굳힌 것이다.
UML 관계 표기 — 다이어그램을 읽기 위한 최소한
| 관계 | 표기 | 의미 |
|---|---|---|
| 일반화(상속) | ─▷ 빈 삼각형 | is-a, extends |
| 실체화(구현) | ┈▷ 점선+빈 삼각형 | implements |
| 연관 | → | 참조를 필드로 갖는다 |
| 집약 | ◇→ 빈 마름모 | has-a, 부품이 따로 살아남는다 |
| 합성 | ◆→ 찬 마름모 | has-a, 전체가 사라지면 부품도 사라진다 |
| 의존 | ┈> | 파라미터·지역변수로 잠깐 쓴다 |
집약과 합성의 차이가 헷갈리는데, 생명주기를 같이 하느냐로 가른다. 학교와 학생은 집약(학교가 없어져도 학생은 산다), 주문과 주문항목은 합성(주문이 지워지면 항목도 의미가 없다)이다.
한눈에 정리
- 패턴은 발견된 것 — 원칙을 지키다 나온 모양에 이름을 붙였다. 순서는 원칙이 먼저다
- 4요소에 트레이드오프가 들어 있다 — 패턴은 공짜가 아니다. 대가를 치를 이유가 없으면 쓰지 않는다
- 인터페이스에 프로그래밍하라 — 의존하는 타입이 곧 감당할 변경 범위다
- 상속보다 합성 — 상속은 컴파일 시점 고정에 단일 상속이고, 합성은 런타임 조립에 개수 제한이 없다
- SRP 는 "누가 고치라고 하는가" 로 센다
- OCP 의 신호는 분기 증식이고, 실현 도구는 다형성이다
- LSP 의 기준은 분류학이 아니라 행동 계약이다
- DIP 는 인터페이스를 고수준이 소유해 화살표를 뒤집는 것이다
- 집약과 합성 은 생명주기를 같이 하느냐로 가른다
출처 — GoF Design Patterns(1994) 서론·§1.6 · Effective Java 3e Item 18(상속보다 컴포지션) · Refactoring 2e(Fowler) · Robert C. Martin, Design Principles and Design Patterns(2000)