던전앤드래곤을 위한 모델 기반 테스팅
이 글은 던전앤드래곤(D&D)의 복잡한 전투 시스템을 형식 모델링 언어인 Quint를 사용하여 모델 기반 테스팅(Model-Based Testing)으로 구현한 경험을 공유합니다. 단순한 타격이 아닌 '카운터스펠(Counterspell)' 연쇄 반응, 전투 중 인터럽트, 전설적 내성(Legendary Resistance) 등 룰의 복잡한 상호작용을 상태 기계(State Machine)로 엄밀하게 모델링하여 극단적인 에지 케이스에서 발생할 수 있는 데드락이나 오류를 조기에 검증하는 것이 핵심입니다. 이러한 접근은 복잡한 비즈니스 로직과 상태 전환이 얽힌 시스템의 설계와 테스트에 있어 훌륭한 기술적 참고 자료가 됩니다.
던전앤드래곤을 위한 모델 기반 테스팅 2026년 4월 7일
지난번 글에서 저는 단일 D&D 캐릭터를 모델링했습니다. 보이드(공허) 속에 홀로 서 있는 하나의 크리처로서, 체력(hit points), 상태(conditions), 주문 슬롯을 추적하는 것이었죠. 이를 위한 Quint 명세(형식 모델링 언어)는 약 6,000줄이었습니다. 이번에는 전투를 추가했습니다. 그냥 '파이터가 고블린을 검으로 때린다'는 식의 단순한 전투가 아니라 진짜 어려운 부분을 말입니다. 카운터스펠(Counterspell) 연쇄, 공격 중반을 끊어내는 실드(Shield) 인터럽트, 집중을 유지하는 준비된 주문(readied spells), 내성 굴림을 뒤집어버리는 전설적 내성(Legendary Resistance) 등이 바로 그것입니다. 그래서 저는 가장 어려운 부분을 먼저 해결했습니다. 그것 없이는 완전한 실행 가능성을 증명할 수 없기 때문입니다. 단순한 케이스만 처리하는 명세는 아무것도 증명하지 못하며, 그저 '투두 앱(Todo App) 프로젝트'와 다를 바 없습니다. 진짜 문제가 되는 병목은 바로 상호작용 속에 존재합니다. 만약 카운터스펠 체인이 상태 기계를 교착 상태(Deadlock)에 빠뜨리거나, 준비된 주문의 집중이 활성화된 효과를 고아(orphan) 상태로 만들 수 있다면, 저는 그 위에 세 겹을 더 쌓은 뒤가 아니라 지금 당장 알아야만 했습니다.
전투 명세는 크리처 코드의 절반도 안 됩니다. 그래서 복잡성도 절반밖에 안 되겠거니 생각하겠지만, 천만에요. 단일 크리처의 상태 공간은 평면적인 리듀서(reducer)일 뿐입니다. 그저 화려하게 꾸민 캐릭터 시트에 불과하죠. 레벨업하고, 피해를 입고, 상태를 얻거나 잃는 것. 상태 공간은 크지만 평면적입니다. 하지만 전투는 이를 심오하게 만듭니다. 두 크리처가 상호작용한다는 것은 모든 행동이 반응을 유발할 수 있고, 모든 반응은 반대 반응을 유발할 수 있으며, 누군가 피해를 입기도 전에 모든 것이 중첩된다는 것을 의미합니다. 초보자는 물론 숙련된 플레이어조차도 종종 이 부분에서 혼란을 겪곤 합니다.
현재 명세가 다루는 내용은 다음과 같습니다: 모든 캐릭터 클래스(12개), 할 수 있는 것과 없는 것을 수정하는 14가지 상태. 다른 크리처의 턴에 행동하는 전설적 크리처. 주문 시전을 방해하는 카운터스펠. 이동 중간에 발동되어 전체 공격 판정 체인을 트리거하는 기회 공격(Opportunity attacks). 캐릭터 목록과 능력치 블록을 가진 플레이어 캐릭터와 몬스터 모두. 데모는 스크립트화된 신비한 전투를 단계별로 재생합니다. 파이어볼, 카운터스펠 체인, 광역 피해(AoE), 죽음 내성 굴림 등이죠. 앞으로 가거나, 뒤로 가거나, 재생 버튼을 누르면 인터럽트 창이 열리고 해결되는 것을 지켜볼 수 있습니다. 지난번에는 보이드 속의 한 크리처였습니다. 이제는 모든 클래스와 상태, 그리고 SRD 1이나 출판된 책의 주문이나 피트(feats)를 추가할 수 있는 프레임워크가 완성되었습니다. 마침내 개념 증명(Proof of Concept)을 넘어섰네요!
인터럽트 체인 (The Interrupt Chain) 단일 공격은 단일 이벤트가 아닙니다. 한 크릭쳐가 다른 크리처를 공격할 때, 규칙은 일련의 인터럽트 창, 즉 참여자가 해결 중간에 반응하고 결과를 바꿀 수 있는 순간들을 엽니다. 각각은 게임 상태를 갈라지게(branch) 할 수 있습니다.
1단계: 공격이 명중합니다. d20이 굴러가고, DM이 이를 방어도(Armor Class)와 비교합니다 — 명중인지 빗나감인지. 하지만 대상은 실드(Shield)를 시전할 수 있습니다(방어도 +5, 잠재적으로 명중을 빗나감으로 뒤집을 수 있음). 바드의 날카로운 언어(Cutting Words)는 굴림 결과에서 주사위 하나의 값을 뺍니다. 이 모든 것은 굴림 후, 명중이 확정되기 전에 일어납니다.
2단계: 피해가 적중합니다. 타격이 들어갑니다. 주사위를 굴립니다. 하지만 로그는 불가사의한 회피(Uncanny Dodge)를 가지고 있습니다 — 반응으로 그 피해를 절반으로 줄입니다. 몽크의 공격 튕겨내기(Deflect Attacks)는 1d10에 민첩 수정치와 레벨을 더한 값을 뺍니다. 이들은 명중 후, 피해가 적용되기 전에 발동합니다.
3단계: 주문이 시전됩니다. 주문 시전은 자체적인 창을 엽니다. 카운터스펠을 준비하고 반응을 사용할 수 있는 크리처라면 누구나 대상에게 건강 내성 굴림(Constitution save)을 강제할 수 있습니다. 시전자가 실패하면 주문은 실패(fizzle)합니다. 충분히 높은 슬롯을 사용하면 자동 성공입니다. 카운터스펠 자체도 주문이므로, 다른 크리처가 그 카운터스펠을 다시 카운터스펠로 무효화할 수도 있습니다. 이 명세에서 이것은 문자 그대로 스택(Stack) 데이터 구조입니다 — 푸시하고, 해결하고, 팝 합니다:
var bSpellStack: List[SpellStackEntry] // 시전 시 푸시, 해결 시 팝
4단계: 내성 굴림이 실패합니다. 대상이 내성 굴림에 실패합니다. 하지만 고대 드래곤은 전설적 내성(Legendary Resistance)을 가지고 있습니다 — 충전량 1개를 소모하면 내성 굴림이 자동 성공합니다. 이것은 내성 굴림 결과가 나온 후, 실패 효과가 적용되기 전에 발동합니다.
이제 이것들을 서로 겹겹이 쌓아보세요. 로그가 적 크리처에게서 멀어지며 이동합니다. 적은 사용하지 않은 반응이 남아 있으므로 기회 공격(Opportunity Attack)을 얻습니다. 즉, 근접 공격 한 번입니다. (공격자가 다회 공격(Multiattack) 속성을 가지고 있더라도 한 번이죠!) (역주: 본문 마지막의 'T'는 잘린 글자로, 문맥상 기회 공격의 상세한 규칙 설명으로 이어집니다.)