자바 디자인 패턴 학습 노트 목차

원칙 — 패턴이 서 있는 바닥

패턴 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 인터페이스 분리안 쓰는 메서드에 의존을 강요하지 않는다빈 구현·UnsupportedOperationExceptionAdapter
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)

생성 패턴 — 객체를 만드는 일을 떼어낸다