언리얼 팀프로젝트 개발기 4 - 보스 AI와 Subtree
보스 AI에서 반복되는 Task 묶음을 개선하기 위해 Subtree를 찾아보고, 페이즈별 판단과 공격 행동을 나눈 과정을 정리했습니다.
보스 AI의 반복되는 Task 묶음을 Subtree로 분리하기
Behavior Tree란 무엇인가
Behavior Tree(행동 트리)는 AI의 행동 선택을 조건과 실행 순서로 나누어 트리 형태로 구성하는 방식이다. 보스 AI를 예로 들면 현재 페이즈와 체력 상태를 확인하고, 그 결과에 따라 공격하거나 페이즈를 종료하는 흐름을 노드로 연결할 수 있다.
트리의 분기는 어떤 행동을 선택할지 정하고, 끝에 연결된 Task는 이동이나 공격 같은 실제 동작을 수행한다. 각 행동의 결과가 상위 노드로 전달되면 그 결과에 따라 다음 행동이 이어진다. 이렇게 판단 조건과 행동 순서를 시각적으로 배치하면 보스가 어떤 상황에서 어떤 패턴을 사용하는지 살펴보기 쉽다. Epic 공식 개요
Behaviour Tree의 구성요소와 각 역할
Blackboard는 Behavior Tree가 판단하는 데 필요한 값을 보관한다. 일반적으로 AIController가 Pawn을 제어하면서 트리를 실행한다. Epic 공식 개요
첨부한 보스 트리에서는 BB_Sevarog를 사용한다. Phase, HealthRate, RandomNumber, Player를 참조하는 노드가 보이며, 이 값들이 페이즈와 공격 선택을 연결한다.
| 구성 요소 | 역할 | 보스 트리에서의 사용 |
|---|---|---|
| Blackboard | 판단과 행동에 필요한 값을 저장하고 공유한다. | Phase, HealthRate, RandomNumber, Player 저장 |
| Selector | 왼쪽부터 자식을 시도하고 성공한 자식이 있으면 선택을 마친다. | 페이즈와 공격 분기 선택 |
| Sequence | 왼쪽부터 실행하며 하나라도 실패하면 뒤의 자식을 실행하지 않는다. | 이동, 공격, 대기를 순서대로 연결 |
| Task | 실제 행동을 수행한다. | 이동, 애니메이션 재생, 투사체 생성 |
| Decorator | 분기의 조건을 검사하거나 실행을 제어한다. | 페이즈 비교, 체력 검사, 이동 시간 제한 |
표에서 Selector와 Sequence는 모두 자식 노드를 왼쪽부터 실행하지만, 결과를 처리하는 방식이 다르다. Selector는 자식이 실패하면 다음 자식을 시도하고, 성공하면 선택을 마친다. 그래서 보스 트리에서는 페이즈 종료를 왼쪽에, 공격을 오른쪽에 배치해 종료 조건을 먼저 확인하도록 구성했다.
Sequence는 자식이 성공해야 다음 자식으로 진행하고, 하나라도 실패하면 해당 Sequence를 종료한다. 근접 공격에서 이동, 대상 바라보기, 공격, 대기를 순서대로 연결한 이유다. 두 노드 모두 자식이 실행 중이면 완료 결과를 기다리며, Decorator의 Abort 설정에 따라 도중에 중단될 수도 있다. 이러한 차이를 기준으로 행동을 선택할 때는 Selector를, 여러 동작을 순서대로 수행할 때는 Sequence를 사용했다. Epic Composite 노드 문서
보스 AI를 만들며 Subtree를 찾아본 계기
이번에는 보스의 AI를 만들면서 Behavior Tree를 구성했다. 보스는 페이즈에 따라 사용할 공격이 달라지고, 체력이 소진되면 다음 페이즈로 넘어가거나 사망하도록 행동을 선택해야 했다.
트리를 만들다 보니 중복되는 Task 묶음이 많이 생겼다. 같은 공격을 여러 페이즈에서 사용하려면 해당 노드들을 반복해서 배치해야 했다. 공격 순서나 대기 시간을 수정할 때도 같은 행동이 들어 있는 곳을 함께 수정해야 하는 구조였다.
이 반복을 개선할 방법을 찾다가 Subtree 기능을 찾아보게 됐다. 공격이나 페이즈 종료처럼 하나의 행동을 이루는 Task 묶음을 별도 Behavior Tree로 만들고, 필요한 분기에서 호출하는 방식이다. 이번 글에서는 보스의 전체 판단 흐름과 분리한 하위 트리를 함께 정리한다.
Subtree 적용 전후: 중복되는 노드 줄이기
Subtree 적용 전

*Subtree 적용 전의 전체 구조. 각 페이즈 아래에 공격과 페이즈 종료의 세부 Task를 직접 연결했다.*
Subtree 적용 후

*Subtree 적용 후의 전체 구조. 반복되던 행동 묶음을 별도 트리로 분리하고 Run Behavior로 호출했다.*
두 스크린샷을 비교하면 중복되는 노드가 많이 사라진 모습을 확인할 수 있다. 여러 페이즈 아래에 펼쳐 놓았던 근접 공격과 페이즈 종료의 Task 묶음이 각각 하나의 Run Behavior 호출로 정리됐다. 세부 행동은 하위 트리에 두고 같은 에셋을 재사용하므로, 상위 트리에서는 페이즈별 조건과 공격 선택 흐름이 더 잘 드러난다.
처음에는 하나의 Behavior Tree 안에 각 페이즈의 행동을 모두 펼쳐서 구성했다. 스크린샷에서도 Phase 0, Phase 1, Phase 2 아래로 여러 Sequence와 Task가 이어지고, 뒤쪽 페이즈로 갈수록 공격 분기가 늘어나면서 트리가 넓고 깊어지는 모습을 볼 수 있다.
이렇게 구성하면 한 에셋에서 모든 연결을 볼 수 있지만, 같은 행동 묶음도 사용하는 위치마다 따로 배치하게 된다. 여러 페이즈에서 사용하는 근접 공격이나 페이즈 종료 처리가 대표적이다. 공통 행동의 순서나 설정을 바꾸려면 반복된 묶음을 찾아 각각 수정해야 하고, 한 곳을 빠뜨리면 같은 공격이 페이즈에 따라 다르게 동작할 수 있다.
전체 구조를 한 화면에 넣기 위해 축소하면 개별 노드의 이름과 설정을 읽기 어려웠다. 반대로 노드가 보이도록 확대하면 페이즈 사이의 큰 흐름을 한눈에 살펴보기 어려워진다. 첨부한 캡처는 세부 설정을 읽기보다, 행동을 모두 펼쳤을 때 그래프가 얼마나 커지는지 보여주는 자료다.
그래서 반복되는 행동의 실행 순서 자체를 하나의 재사용 단위로 묶는 방법이 필요했다. Subtree를 적용한 구조에서는 근접 공격과 페이즈 종료를 각각 별도 에셋으로 관리하고, 상위 트리의 필요한 위치에서 호출하도록 나눴다. 공통 행동을 수정할 때도 해당 Subtree에서 순서와 설정을 함께 관리할 수 있게 됐다.
보스의 전체 Behavior Tree
위의 적용 후 전체 스크린샷을 기준으로 페이즈별 분기를 살펴보면 다음과 같다.
최상위 Selector 아래에는 세 개의 페이즈 분기가 있다. 각 분기의 ComparePhase Decorator가 현재 페이즈를 검사하고 해당 페이즈의 행동으로 연결한다.
Phase 0: 근접 공격과 페이즈 종료

*Phase 0 확대. 왼쪽에는 페이즈 종료, 오른쪽에는 근접 공격을 배치했다.*
Phase == 0 조건 아래의 Selector는 HealthRate <= 0이면 BT_Sevarog_FinishCurrentPhase를 호출한다. 해당 조건을 만족하지 않으면 BT_Sevarog_MeleeAttack을 실행한다. 공격의 세부 Task를 이곳에 펼쳐 놓지 않고 호출 노드 하나로 연결했다.
Phase 1: 근접 공격과 원거리 공격 선택

*Phase 1 확대. 페이즈 종료를 우선 확인하고, 공격 분기에서는 난수를 생성해 행동을 선택한다.*
Phase == 1에서도 왼쪽의 페이즈 종료 Subtree를 재사용한다. 오른쪽 Sequence는 BTT_RandomInt, 공격 선택 Selector, 1초 대기로 구성되어 있다. 난수 Task에는 Min: 0, Max: 4, 기록할 키로 RandomNumber가 표시되어 있다.
공격 선택 조건은 RandomNumber == 0이면 근접 공격, RandomNumber >= 1이면 원거리 공격이다. Phase 0에서 사용한 근접 공격 Subtree를 그대로 호출하므로, 근접 공격 순서를 수정할 때 두 페이즈의 Task 묶음을 각각 고칠 필요가 없다.
Phase 2: 세 가지 공격과 사망 처리

*Phase 2 확대. 마지막 페이즈에서는 페이즈 종료와 회복 대신 사망 처리를 선택한다.*
Phase == 2에서는 HealthRate == 0 조건의 사망 분기를 먼저 확인한다. 이 분기는 Death_front 애니메이션 재생과 Die Task로 이어진다. 캡처에는 애니메이션의 Blocking: 1이 표시되어 있다.
공격 분기는 난수 생성, 공격 선택, 1초 대기를 순서대로 실행한다. 난수 Task의 설정 범위는 Min: 0, Max: 10이며, Selector 아래에는 근접, 원거리, Nova Subtree 호출이 배치되어 있다.
상위 트리에는 언제 어떤 행동을 선택할지를 남기고, 각 공격의 세부 순서는 하위 트리에서 관리한다. 여러 페이즈에서 같은 근접 공격을 사용할 때도 Task들을 다시 배치하지 않고 같은 Subtree를 호출할 수 있다.
Phase 2의 공격 조건은 RandomNumber <= 2, <= 8, <= 10 순서로 배치되어 있다. 범위가 겹치지만 Selector는 왼쪽부터 시도한다. 앞의 공격이 성공한다는 전제에서는 앞 조건에 해당하지 않는 값이 뒤 분기로 넘어간다. 앞의 공격이 실패하면 뒤 분기도 시도할 수 있으므로, 정확한 선택 확률은 난수 Task의 구현과 공격의 반환 결과까지 확인해야 한다.
Task 묶음을 Subtree로 재사용하기
Task 하나를 재사용해도 이동, 시선 회전, 공격, 대기를 매번 연결하면 전체 행동 순서는 여러 곳에 흩어져 있다. Subtree로 묶으면 순서를 하나의 에셋에서 관리할 수 있다. 근접 공격의 대기 시간이나 공격 준비 동작을 바꿀 때도 수정 지점을 모을 수 있다.
현재 전체 트리는 Run Behavior로 하위 트리를 호출한다. 이 Task는 별도 Behavior Tree를 실행하며 하위 트리의 루트 Decorator를 부모에 주입하는 기능도 지원한다. 런타임에 호출 에셋을 교체하는 용도는 아니다. Epic Task 노드 문서
근접 공격

*그림 2. 근접 공격의 실행 순서를 묶은 Subtree.*
Move To: Player → Look → Swing → Wait: 0.10초
Sequence 아래에서 플레이어에게 이동하고, 대상을 바라본 뒤 BTT_Swing을 실행한다. 마지막에는 짧은 대기를 둔다. 이동에는 5초의 TimeLimit Decorator가 붙어 있다.
Sequence에서는 이동이 실패하면 뒤의 공격으로 진행하지 않는다. 따라서 시간 제한을 넘긴 뒤에도 공격하는 구조로 읽으면 안 된다. 이동 실패 이후 부모 트리가 어떤 분기를 선택하는지도 확인해야 한다.
원거리 공격

*그림 3. 원거리 공격의 실행 순서를 묶은 Subtree.*
Look At → Spawn Bullet → Cast → Wait: 1.00초
대상을 바라보는 Task 다음에 BTT_SpawnBullet과 BTT_Cast가 이어진다. 상위 트리는 원거리 공격을 선택하고, 세부 순서는 이 Subtree에서 관리한다. 실제 투사체 생성 시점과 애니메이션 연결 방식은 각 Task의 구현을 확인해야 한다.
범위 공격

*그림 4. 범위 공격용으로 첨부한 Subtree.*
Move To: Player → Play Recall → Wait: 1.00초
플레이어에게 이동한 뒤 BTT_PlayRecall을 실행하고 대기한다. 전체 트리에는 Nova 공격 호출이 보인다. 첨부한 범위 공격 그래프에는 피해 반경이나 적용 시점이 드러나지 않으므로, 피해 처리는 연결된 Task와 공격 로직에서 확인해야 한다.
현재 페이즈 종료

*그림 5. 여러 페이즈에서 호출하는 페이즈 종료 행동 묶음.*
Play Animation: Subjugation → IncreasePhase: Phase += 1 → Heal
HealthRate <= 0 조건 아래에 애니메이션 재생, 페이즈 증가, 회복이 연결되어 있다. 전체 트리의 Phase 0과 Phase 1에서 같은 페이즈 종료 트리를 호출한다. 공격뿐 아니라 페이즈 전환도 재사용할 행동 단위가 될 수 있다.
Sequence의 연결 순서가 애니메이션 종료 대기를 자동으로 보장하지는 않는다. 캡처에는 Play Animation의 Blocking: 0이 표시되어 있으므로, 애니메이션 종료 후 페이즈를 바꾸려는 의도라면 완료 대기 방식을 확인해야 한다. Heal의 회복량도 노드 이름만으로 판단할 수 없다.
페이즈 종료에 Observer Aborts: Lower Priority 사용하기
페이즈 종료는 공격보다 우선해서 처리해야 했다. Selector에서 종료 분기를 왼쪽에 두면 분기를 선택할 때 먼저 검사하지만, 배치 순서만으로 이미 실행 중인 공격을 중단하는 것은 아니다. 공격이나 대기가 끝나기를 기다리지 않고 종료 조건에 반응하기 위해 체력 조건 Decorator의 Observer Aborts를 Lower Priority로 설정했다. 스크린샷의 aborts lower priority가 이 설정을 나타낸다.
Lower Priority는 관찰 중인 조건이 실행 가능한 상태가 되면, 오른쪽에서 실행 중인 낮은 우선순위 분기를 중단하고 더 높은 우선순위 분기를 선택할 수 있게 한다. 여기서 우선순위는 Task 이름이나 공격 종류가 아니라 트리의 실행 순서에 따른다. 이 보스 트리에서는 왼쪽의 페이즈 종료가 오른쪽의 공격보다 우선한다. Epic Decorator 노드 문서
Phase 0과 Phase 1에서는 다음 흐름으로 페이즈 종료를 요청한다.
오른쪽 공격 분기 실행 중
→ 피해 처리로 Blackboard의 HealthRate 갱신
→ 관찰 중인 HealthRate <= 0 조건이 참으로 변경
→ Lower Priority가 실행 중인 공격 분기 중단 요청
→ 중단 처리가 완료되면 왼쪽의 페이즈 종료 Subtree 실행
→ 애니메이션 재생, Phase 증가, Heal
캐릭터의 체력만 바꾸고 Blackboard의 HealthRate를 갱신하지 않으면 이 관찰 조건은 변경을 알 수 없다. Decorator의 Notify Observer도 확인해야 한다. On Result Change는 조건의 참·거짓 결과가 바뀔 때, On Value Change는 관찰하는 키 값이 바뀔 때 재평가한다. 이 설정값 자체는 첨부한 그래프만으로 확인되지 않는다.
Self와의 차이도 중요하다. Self는 자신의 조건이 깨졌을 때 자신 아래에서 실행 중인 분기를 중단하는 용도다. 오른쪽 공격을 끊고 왼쪽 종료 분기로 진입하려는 목적에는 Lower Priority가 맞는다. Both는 두 범위를 모두 포함한다.
또한 Lower Priority는 페이즈 종료 Subtree 자신을 중단하는 설정은 아니다. 종료 과정에서 Heal로 체력 조건이 다시 거짓이 되더라도, 이 설정 자체가 종료 분기를 중단하지는 않는다. 다만 상위의 페이즈 조건이나 다른 Decorator에 의한 중단은 별도로 살펴봐야 한다.
Blackboard 연결 규칙
첨부한 하위 트리들은 모두 BB_Sevarog를 사용한다. 트리를 나눠도 Player, Phase, HealthRate의 의미는 서로 맞아야 한다. 페이즈 종료 트리가 값을 바꾸면 상위 트리의 다음 선택에도 영향을 준다. 누가 값을 변경하고 어떤 조건이 그 값을 읽는지를 함께 정리해야 한다.
Run Behavior Dynamic과의 차이
이번 구조는 고정된 에셋을 Run Behavior로 호출한다. 페이즈마다 다른 분기를 선택하는 것만으로 런타임 에셋 교체가 필요한 것은 아니다.
호출 위치를 유지하면서 실행할 에셋 자체를 런타임에 바꾸려면 Run Behavior Dynamic을 사용할 수 있다. Injection Tag로 호출 위치를 식별하고 BehaviorTreeComponent의 SetDynamicSubtree로 에셋을 지정한다. 이 방식은 하위 트리의 루트 Decorator를 지원하지 않는다. Epic Task 노드 문서
현재는 고정 Subtree의 재사용으로 중복을 줄이고, 동적 교체가 필요한 상황이 생기면 적용 여부를 판단할 수 있다.
분리한 뒤 확인할 부분
Subtree를 사용하면서 상위 트리에서는 페이즈와 공격 선택을, 하위 트리에서는 각 행동의 순서를 살펴볼 수 있게 됐다. 반복되는 Task 묶음의 수정 지점을 모을 수 있다는 점이 이번 구조의 핵심이다.
실제 플레이에서는 다음 흐름을 확인해야 한다.
- 같은 근접 공격 Subtree가 여러 페이즈에서 정상 실행되는지
- 이동 실패나 시간 제한 이후 어떤 분기로 넘어가는지
- 공격 중 체력이 소진되면 공격을 정리하고 페이즈 종료로 전환하는지
- 페이즈 증가와 회복 이후 올바른 페이즈를 선택하는지
- 마지막 페이즈에서는 회복 대신 사망 처리로 이어지는지
각 Subtree의 성공, 실패, 중단 결과가 부모 트리로 돌아왔을 때 어떤 선택으로 이어지는지까지 확인하며 보스의 행동 흐름을 다듬어야 한다.