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

안티패턴과 헷갈리는 비교

패턴을 아는 것보다 언제 쓰지 않을지 아는 것이 실무에서 더 자주 필요하다. 이 편은 두 가지를 다룬다 — 실제로 코드를 망치는 안티패턴들, 그리고 면접·리뷰에서 매번 헷갈리는 패턴 쌍의 구분선.


대표 안티패턴

God Object — 모든 것을 아는 클래스

@Service
public class OrderService {          // 3,000줄
    public void place(…)            { … }
    public void cancel(…)           { … }
    public void refund(…)           { … }
    public void calculateSettlement(…) { … }   // 정산이 왜 여기에
    public void issueCoupon(…)      { … }      // 쿠폰도
    public void sendPushToUser(…)   { … }      // 푸시까지
}

증상 — 한 파일에서 머지 충돌이 계속 나고, 이 클래스를 테스트하려면 목이 열 개 필요하다. 새로 온 사람이 이 파일부터 읽다 지친다.

어떻게 이렇게 되나 — 처음부터 3,000줄로 만들지 않는다. "관련 있으니까 여기에" 를 200번 반복한 결과다. 각 결정은 합리적이었다.

처방 — 변경 이유(SRP)로 자른다. OrderPlacementService · SettlementService · CouponService. 자를 기준이 애매하면 "이 메서드를 고치라고 요구하는 사람이 누구인가" 로 묻는다.

Singleton 남용 — 전역 상태

public class AppContext {
    private static final Map<String, Object> STORE = new HashMap<>();
    public static void put(String k, Object v) { STORE.put(k, v); }
    public static Object get(String k) { return STORE.get(k); }
}

// 아무 데서나
AppContext.put("currentUser", user);

증상 — 테스트가 실행 순서에 따라 성공하거나 실패한다. 앞 테스트가 남긴 값이 뒤 테스트에 영향을 준다.

왜 나쁜가 — 의존성이 생성자에 안 보인다. 이 클래스가 무엇에 기대는지 알려면 본문을 다 읽어야 하고, 테스트에서 가짜로 바꿀 방법도 없다. 게다가 HashMap 이라 스레드 안전하지도 않다.

처방 — DI 로 주입받는다. 정말 전역이어야 하는 것(설정)은 불변으로 만든다.

Anemic Domain Model — 데이터만 있는 객체

// 도메인 객체에 게터·세터만 있다
public class Order {
    private OrderStatus status;
    public void setStatus(OrderStatus s) { this.status = s; }
}

// 규칙은 전부 서비스에
public class OrderService {
    public void cancel(Order o) {
        if (o.getStatus() == SHIPPED) throw new IllegalStateException();
        if (o.getStatus() == CANCELLED) throw new IllegalStateException();
        o.setStatus(CANCELLED);
    }
}
public class AdminOrderService {
    public void forceCancel(Order o) {
        if (o.getStatus() == CANCELLED) throw new IllegalStateException();
        o.setStatus(CANCELLED);          // ← 규칙이 미묘하게 다르다. 어느 쪽이 맞나?
    }
}

증상 — 같은 규칙이 여러 서비스에 조금씩 다르게 흩어진다. 어느 것이 진짜인지 아무도 모른다.

처방 — 규칙을 객체 안으로 넣는다.

public class Order {
    private OrderStatus status;

    public void cancel(CancelReason reason) {         // 규칙이 한곳에
        if (!status.cancellable())
            throw new OrderNotCancellableException(no, status);
        this.status = CANCELLED;
        this.cancelReason = reason;
    }
}

세터가 사라지면 잘못된 상태로 갈 수 있는 통로 자체가 없어진다.

Over-engineering — 패턴 떡칠

// 설정값 하나 읽는 데
public interface ConfigReaderStrategy { String read(String key); }
public abstract class AbstractConfigReaderFactory { … }
public class ConfigReaderFactoryProvider { … }
public class DefaultConfigReaderStrategyImpl implements ConfigReaderStrategy { … }

증상 — 값 하나 따라가는 데 파일 여섯 개를 연다. 인터페이스마다 구현이 하나뿐이다.

신호 — 구현체가 하나뿐인 인터페이스가 계속 나오면 대개 과한 것이다. "나중에 바뀔 수도 있으니 미리" 가 가장 비싼 핑계다.

처방 — YAGNI. 두 번째 구현이 실제로 생길 때 인터페이스를 뽑는다. 그때 뽑아도 늦지 않고, 오히려 그때가 되어야 올바른 경계가 보인다.

Magic Number / String

if (user.getGrade() == 3) { … }                 // 3이 뭔데?
if (order.getStatus().equals("02")) { … }       // 02는?
Thread.sleep(300000);                            // 5분? 계산해야 안다

처방enum 이나 상수로. 특히 상태 코드를 문자열로 비교하는 것은 오타가 컴파일에 안 잡힌다.

if (user.grade() == Grade.VIP) { … }
if (order.status() == OrderStatus.PAID) { … }
Duration.ofMinutes(5);

Premature Optimization

// 프로파일링 없이 "느릴 것 같아서" 캐시를 넣었다
private final Map<Long, User> cache = new HashMap<>();   // 무효화도 크기 제한도 없다

증상 — 최적화한 곳은 병목이 아니었고, 캐시 때문에 낡은 데이터가 보이는 새 버그가 생겼다.

처방 — 측정하고 나서 손댄다. 대부분의 병목은 예상과 다른 곳에 있고, 실제로는 N+1 쿼리나 불필요한 외부 호출인 경우가 많다.

그 밖에

안티패턴증상처방
Golden Hammer모든 문제를 한 도구로 (전부 이벤트로, 전부 MSA 로)문제에 맞는 도구
Spaghetti Code흐름을 따라갈 수가 없다함수 추출·계층화
Copy-Paste Programming같은 로직이 열 곳에, 고칠 때 세 곳만 고침추출하되 우연한 중복은 두라
Shotgun Surgery요구 하나에 파일 열다섯 개 수정응집도를 높인다

마지막 것에 주의 — 중복 제거가 항상 옳지는 않다. 우연히 같아 보이는 코드를 합치면, 나중에 한쪽만 바꿔야 할 때 분기가 생기고 결국 더 복잡해진다. "같은 이유로 함께 변하는가" 를 먼저 묻는다.


패턴으로 가는 리팩토링

패턴은 처음부터 넣는 것이 아니라 문제가 보일 때 처방으로 도입한다.

보이는 문제도입할 패턴
타입에 따른 if/switch 가 여러 메서드에 반복Strategy 또는 State
생성자 인자가 계속 늘고 순서를 헷갈림Builder
비슷한 클래스들이 흐름은 같고 한두 단계만 다름Template Method
한 동작 뒤에 따라붙는 처리가 계속 늘어남Observer
인자 뭉치가 여러 메서드에 함께 다님Parameter Object
외부 API 를 여러 곳에서 직접 호출Adapter + Facade
// 도입 전 — 이 메서드가 계속 자란다
void notify(User u, String type) {
    if (type.equals("SMS"))   smsClient.send(u.phone(), body);
    else if (type.equals("EMAIL")) mailClient.send(u.email(), body);
    else if (type.equals("PUSH"))  fcmClient.send(u.token(), body);
}

// 도입 후 — 채널 추가가 클래스 추가로 끝난다
Map<Channel, Notifier> notifiers;      // 스프링이 구현체를 모아 준다
notifiers.get(channel).send(u, body);

핵심 태도 — 패턴은 목적이 아니라 수단이다. 중복·경직·산탄총 수정이 실제로 보일 때 도입한다.


Strategy vs State

코드 구조가 거의 같아서 가장 자주 헷갈린다.

// Strategy — 외부가 고른다. 전략은 다른 전략을 모른다
shippingCalculator = new ShippingCalculator(new ExpressShipping());

// State — 자기가 다음을 고른다. 상태가 다음 상태를 안다
order.state = order.state.pay();     // CREATED → PAID
StrategyState
누가 고르나외부 클라이언트객체 자신
서로 아는가전략끼리 모른다다음 상태를 안다
언제 바뀌나보통 주입 후 고정사건마다 바뀐다
바꾸는 이유정책 선택사건 발생

구분선은 하나다 — 다음 것을 누가 아는가. 전략이 다른 전략을 반환하기 시작하면 그것은 이미 State 다.


Factory Method vs Abstract Factory

// Factory Method — 한 제품, 하위 클래스가 결정 (상속)
abstract class Dialog {
    protected abstract Button createButton();     // 하나만 만든다
}

// Abstract Factory — 제품 군, 팩토리 객체가 결정 (합성)
interface UiFactory {
    Button   createButton();                      // 어울리는 한 벌을
    Checkbox createCheckbox();
}

그리고 정적 팩토리 메서드(List.of, Integer.valueOf)는 둘 다 아니다. GoF 패턴이 아니라 Effective Java Item 1 의 관용구다.


Adapter vs Bridge vs Decorator vs Proxy vs Facade

다섯 다 감싸는 모양이라 한 표에 모아 둔다.

패턴핵심 의도인터페이스언제 정하나
Adapter안 맞는 것을 변환다른 것 → 기대하는 것사후
Bridge두 축을 분리두 축이 독립사전 설계
Decorator기능 추가같음 + 겹겹이런타임 조립
Proxy접근 통제같음보통 프레임워크가
Facade단순화 창구새 단순 인터페이스설계

구분이 어려울 때 던질 질문 셋이다.

  1. 인터페이스가 그대로인가? — 그대로면 Decorator/Proxy, 바뀌면 Adapter/Facade
  2. 여러 겹 쌓는 것이 정상인가? — 그렇다면 Decorator
  3. 쓰는 쪽이 감싼 걸 아는가? — 모르면 Proxy(스프링이 몰래 감쌈), 알면 Decorator(내가 조립)

Template Method vs Strategy

// Template Method — 상속, 골격의 '일부' 를 교체
abstract class Exporter {
    public final void export(…) { open(); writeRow(…); close(); }   // 흐름 고정
    protected abstract void writeRow(…);                             // 여기만
}

// Strategy — 합성, 알고리즘 '통째' 를 교체
class Exporter {
    private final RowWriter writer;      // 통째로 갈아 끼운다
}

선택지가 있으면 Strategy 쪽이 유연하다(원칙 ②). Template Method 는 단계가 많고 순서가 정말 고정일 때 쓴다. 실무에서는 상속 대신 콜백을 받는 형태(JdbcTemplate)로 절충하는 경우가 많다.


Observer vs Mediator vs Pub-Sub

// Observer — 주제가 관찰자를 직접 안다
orderService.register(pointListener);

// Mediator — 참여자는 중재자만 안다. 중재자가 '결정' 한다
mediator.notify(this, "PAYMENT_DONE");

// Pub-Sub — 발행자·구독자가 서로를 전혀 모른다. 브로커가 사이에
kafkaTemplate.send("order.placed", event);
결합중간 존재로직 위치
Observer주제가 관찰자 목록을 가짐없음각 관찰자
Mediator참여자 ↔ 중재자중재자중재자(조정 규칙)
Pub-Sub완전 분리브로커각 구독자

알림이면 Observer, 조정이면 Mediator, 프로세스를 넘어가면 Pub-Sub 이다.


Composite vs Decorator

// Composite — 자식이 여럿. 트리
record DirNode(String name, List<Node> children) implements Node { }

// Decorator — 자식이 하나. 사슬
class Compression implements DataSource { private final DataSource wrappee; }

구조가 비슷해 보이지만 자식 개수가 다르다. Composite 는 트리를 만들고, Decorator 는 한 줄로 쌓는다. 의도도 다르다 — 전자는 동일하게 다루기, 후자는 기능 더하기.


한눈에 정리

  • God Object 는 한 번에 만들어지지 않는다. "관련 있으니까" 를 200번 반복한 결과다
  • Singleton 남용 의 진짜 문제는 의존성이 생성자에 안 보이는 것 — 테스트가 순서에 의존하게 된다
  • Anemic Domain Model — 규칙이 여러 서비스에 미묘하게 다르게 흩어진다. 세터를 없애면 잘못된 상태로 가는 통로가 사라진다
  • Over-engineering 의 신호 — 구현체가 하나뿐인 인터페이스가 계속 나온다
  • 중복 제거가 항상 옳지는 않다 — "같은 이유로 함께 변하는가" 를 먼저 묻는다
  • Strategy vs State — 다음 것을 누가 아는가
  • Factory Method vs Abstract Factory — 상속·한 제품 vs 합성·제품 군. 정적 팩토리는 둘 다 아니다
  • 감싸는 다섯 — 인터페이스가 그대로인가 / 여러 겹인가 / 쓰는 쪽이 아는가, 셋으로 가른다
  • Template Method vs Strategy — 일부 교체 vs 통째 교체, 상속 vs 합성
  • Observer / Mediator / Pub-Sub — 알림 / 조정 / 프로세스 분리
  • Composite vs Decorator — 자식이 여럿(트리) vs 하나(사슬)

출처Refactoring 2e(Fowler) · Clean Code(Martin) · Brown et al., AntiPatterns(1998) · GoF Design Patterns(1994) 각 패턴의 "Related Patterns" 절

동시성 패턴 — 여럿이 동시에 움직일 때빠른 참조 — 어디서 무엇이 쓰이나