<aside> 📌
커맨드 패턴을 "브로드캐스트 + 자기 필터링"으로 잘못 쓰면 매 틱 불필요한 가상함수 호출이 누적된다. 이 페이지는 그 원인(가상함수 비용)과 올바른 구조(구독 기반 선별 실행), 그리고 커맨드 패턴이 실제로 적합한 케이스를 일반 지식으로 정리한 문서다.
</aside>
커맨드 패턴은 Invoker가 "누구를 실행할지"를 알고 있어야 한다. 각 커맨드가 스스로 자기 차례인지 판단하게 만들면, 실행 대상이 아닌 객체까지 전부 호출되어 가상함수(동적 바인딩) 비용이 낭비된다.
| 구분 | 비효율적 설계 | 권장 설계 |
|---|---|---|
| 구조 | Invoker → 전체 N개 순회 → 커맨드가 스스로 필터 | Invoker → 선별 후 실행 → 커맨드는 실행만 담당 |
| 호출 비용 | 가상함수 N회 호출 (불필요한 낭비) | 가상함수 호출 최소화 (필요한 것만) |
| 선별 책임 | 커맨드(Command)에 있음 | 인보커(Invoker)에 있음 |
| 한 줄 요약 | "일단 다 부르고 본인이 판단" | "인보커가 거른 뒤 실행만 시킴" |
커맨드 패턴의 의도는 "필요한 커맨드만 선별 실행"이다. 그런데 아래 구조는 반대로 전체를 매번 순회한 뒤 각자 알아서 걸러낸다.
[잘못된 구조 - Invoker가 선별 책임을 포기]
CommandInvoker
└── commands_[MAX_COUNT] // N개 전부 flat array
│
├── onTrigger() 호출 (N회 전부)
│ ├── Command[0] // isActive() == false → return ┐
│ ├── Command[1] // isActive() == false → return │ 대다수 탈락
│ ├── ... // isActive() == false → return ┘
│ ├── Command[k] // isActive() == true, 로직 없음 → return ┐ 소수 탈락
│ ├── ... // isActive() == true, 로직 없음 → return ┘
│ └── Command[n] // 실제 실행 ← 극소수
│
└── 결과: N번 가상함수 호출(vtable lookup)
| 잘못된 결정 | 비용 |
|---|---|
| 전체를 flat array에 넣고 매번 전체 순회 | 약 800회 루프 |
| 활성화 여부 필터를 객체 내부에서 처리 | 호출 자체는 막을 수 없음 |
| 가상함수(동적 바인딩)로 구현 | vtable lookup 800회, 인라인 최적화 불가 |
| 커맨드 등록/해제 없이 정적 배열 유지 | 이벤트가 꺼져도 계속 호출됨 |
obj->execute() 호출
├── 1. obj 포인터 역참조 → vtable 포인터 로드
│ └── L1 miss: +4~5ns / L2 miss: +12ns / DRAM miss: +60~100ns
├── 2. vtable에서 함수 포인터 로드 (간접 참조 1회 추가)
│ └── 또 다른 캐시 미스 가능성
├── 3. 간접 점프 (indirect call)
│ └── 분기 예측 실패 시: +15~20 cycle 패널티
└── 4. 함수 본체 실행
└── isActive() == false → return (본체가 이게 전부)
호출 흐름 (약 800회 가정)
execute() × 800회 (가상함수)
├── isActive()==false → return : 700회 → 최악 (순수 낭비)
├── isActive()==true, 로직 없음 : 90회 → 그래도 낭비
└── 실제 실행 : 10회 → 권장
| 시나리오 | 콜당 비용 | 약 800회 합계 |
|---|---|---|
| L1 캐시 올인 | ~3 ns | ~2.5 μs |
| L2 캐시 히트 | ~10 ns | ~8.2 μs |
| LLC 히트 (현실적) | ~30 ns | ~24.6 μs |
| DRAM 미스 (최악) | ~80 ns | ~65.5 μs |
<aside> ⚠️
위 표는 이론값 기반 추정이다. 매 틱 도는 핫 루프라면 포인터 배열은 순차접근으로 프리페치가 잘 되고, 같은 커맨드 타입 반복이면 vtable·분기예측기도 데워져 실제론 L1/L2(약 3~10ns)에 가까울 수 있다. 절대 수치는 환경에 따라 달라지므로 비율보다 "구조적 낭비가 있다"는 점에 주목할 것.
</aside>
트리거 종류별로 구독자 목록을 두고, 해당 트리거의 구독자만 실행한다. 나머지는 호출조차 되지 않는다.