안티패턴과 헷갈리는 비교
패턴을 아는 것보다 언제 쓰지 않을지 아는 것이 실무에서 더 자주 필요하다. 이 편은 두 가지를 다룬다 — 실제로 코드를 망치는 안티패턴들, 그리고 면접·리뷰에서 매번 헷갈리는 패턴 쌍의 구분선.
대표 안티패턴
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
구분선은 하나다 — 다음 것을 누가 아는가. 전략이 다른 전략을 반환하기 시작하면 그것은 이미 State 다.
Factory Method vs Abstract Factory
// Factory Method — 한 제품, 하위 클래스가 결정 (상속)
abstract class Dialog {
protected abstract Button createButton(); // 하나만 만든다
}
// Abstract Factory — 제품 군, 팩토리 객체가 결정 (합성)
interface UiFactory {
Button createButton(); // 어울리는 한 벌을
Checkbox createCheckbox();
}
- Factory Method — 상속 기반, 한 제품
- Abstract Factory — 합성 기반, 여러 제품이 한 벌
그리고 정적 팩토리 메서드(List.of, Integer.valueOf)는 둘 다 아니다. GoF 패턴이 아니라 Effective Java Item 1 의 관용구다.
Adapter vs Bridge vs Decorator vs Proxy vs Facade
다섯 다 감싸는 모양이라 한 표에 모아 둔다.
| 패턴 | 핵심 의도 | 인터페이스 | 언제 정하나 |
|---|---|---|---|
| Adapter | 안 맞는 것을 변환 | 다른 것 → 기대하는 것 | 사후 |
| Bridge | 두 축을 분리 | 두 축이 독립 | 사전 설계 |
| Decorator | 기능 추가 | 같음 + 겹겹이 | 런타임 조립 |
| Proxy | 접근 통제 | 같음 | 보통 프레임워크가 |
| Facade | 단순화 창구 | 새 단순 인터페이스 | 설계 |
구분이 어려울 때 던질 질문 셋이다.
- 인터페이스가 그대로인가? — 그대로면 Decorator/Proxy, 바뀌면 Adapter/Facade
- 여러 겹 쌓는 것이 정상인가? — 그렇다면 Decorator
- 쓰는 쪽이 감싼 걸 아는가? — 모르면 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; // 통째로 갈아 끼운다
}
- Template Method — 상속, 컴파일 시점, 일부 교체
- Strategy — 합성, 런타임, 통째 교체
선택지가 있으면 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 이다.
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" 절