비헤이비어 트리의 기본 이해
셀렉터·시퀀스·태스크·데코레이터·서비스와 블랙보드의 역할을 익혀 AI 행동 트리의 뼈대를 구성합니다.
적 캐릭터가 플레이어를 보면 쫓아오고, 놓치면 마지막 위치를 살펴본 뒤, 아무것도 없으면 다시 순찰을 도는 장면을 생각해 봅시다. 이 판단을 캐릭터 블루프린트의 Tick 안에 Branch 노드로 쌓아 올리면, 조건이 하나 늘 때마다 그래프가 얽히고 "지금 왜 이 행동을 하는가"를 알아보기 어려워집니다. 언리얼 엔진은 이런 판단을 계층 구조로 정리하는 비헤이비어 트리(Behavior Tree)를 제공합니다.
이 장에서는 비헤이비어 트리를 중심으로 AI를 만드는 방법을 다룹니다. 이 글에서는 비헤이비어 트리를 이루는 노드의 종류와, 진행 중인 행동이 있을 때 트리가 다음 행동을 어떻게 고르는지 살펴봅니다. 그리고 데이터를 담는 블랙보드(Blackboard)와 함께 트리를 만들어 AI 컨트롤러에서 실행하는 데까지 연결합니다.
NPC의 판단을 트리로 정리하기
AI 시스템은 플레이어가 조종하지 않는 캐릭터, 즉 NPC(Non-Player Character)가 지능을 가진 것처럼 행동하도록 만드는 로직과 기술을 통틀어 말합니다. NPC는 정해진 경로를 따라 걷는 단순한 행동부터, 플레이어를 쫓아가 공격하거나 엄폐물을 찾아 움직이는 전술적인 행동까지 다양한 모습을 보여 줄 수 있습니다.
비헤이비어 트리는 이런 NPC의 의사결정과 행동 패턴을 계층적인 트리 구조로 표현하고 관리하는 도구입니다. 순서도나 의사결정 트리처럼, 어떤 상황에서 어떤 행동을 할지와 그 행동의 우선순위, 조건을 한 그림 안에 정의합니다.
트리로 정리하면 여러 행동과 조건이 얽힌 AI 로직을 위에서 아래로, 왼쪽에서 오른쪽으로 읽을 수 있습니다. 노드 기반 에디터에서 흐름을 설계하고 실행 중의 상태를 보며 디버깅할 수 있으므로, AI 디자이너와 프로그래머가 같은 그림을 보며 협업하기도 쉽습니다. 새 행동이나 조건을 가지 하나로 더하거나 고치기 쉽고, 잘 만든 하위 트리(sub-tree)는 여러 AI 캐릭터에서 다시 쓸 수 있습니다.
공식 문서에 따르면 언리얼 엔진의 비헤이비어 트리는 이벤트 기반(event-driven)으로 동작합니다. 매 프레임 모든 조건을 다시 검사하는 대신, 블랙보드 값이 바뀌는 것 같은 사건을 기다렸다가 필요할 때만 판단을 다시 합니다. 이 특징은 뒤에서 볼 Observer Aborts 설정과 이어집니다.
트리를 이루는 노드
비헤이비어 트리는 여러 종류의 노드가 계층적으로 연결되어 NPC의 행동을 지시합니다. 노드는 맡는 일에 따라 다섯 가지로 나뉩니다.
루트와 컴포지트
루트(Root) 노드는 비헤이비어 트리의 맨 위에 있는 시작점입니다. 모든 비헤이비어 트리는 이 노드에서 시작해 아래로 내려갑니다.
컴포지트(Composite) 노드는 여러 자식 노드의 실행 흐름을 제어하는 부모 노드로, 트리의 의사결정 구조를 만듭니다. 가장 많이 쓰는 것은 셀렉터와 시퀀스이며, 공식 문서는 여기에 자식을 두 개만 갖는 Simple Parallel을 더해 세 가지를 컴포지트로 소개합니다.
셀렉터(Selector)는 자식 노드를 왼쪽에서 오른쪽 순서로 실행하면서 처음으로 성공하는 자식을 찾습니다. 자식 하나가 성공하면 셀렉터도 성공하고 나머지 자식은 실행하지 않으며, 모든 자식이 실패해야 셀렉터도 실패합니다. 그래서 "플레이어가 보이면 공격, 근처에 아이템이 있으면 줍기, 둘 다 아니면 순찰"처럼 여러 대안 중 하나를 고를 때 쓰며, 우선순위가 높은 행동을 왼쪽에 둡니다.
시퀀스(Sequence)는 반대로 자식을 왼쪽에서 오른쪽 순서로 실행하면서 처음으로 실패하는 자식을 찾습니다. 자식 하나가 실패하면 시퀀스도 실패하고 나머지는 실행하지 않으며, 모든 자식이 성공해야 시퀀스가 성공합니다. "플레이어에게 다가가기, 공격 애니메이션 재생, 피해 적용"처럼 여러 단계가 차례로 모두 성공해야 하는 행동에 씁니다. 짧게 말하면 셀렉터는 자식이 실패해야 다음 대안으로, 시퀀스는 자식이 성공해야 다음 단계로 넘어갑니다.
데코레이터, 서비스, 태스크
데코레이터(Decorator)는 자신이 붙은 노드를 실행해도 되는지 조건을 검사하는 노드입니다. 조건이 맞지 않으면 그 노드와 아래 하위 트리 전체가 실행되지 않으므로, 트리의 한 부분에 조건을 걸 수 있습니다. "플레이어가 사정거리 안에 있는가", "체력이 충분한가", "쿨다운이 끝났는가" 같은 조건을 데코레이터로 둡니다. 공식 문서는 조건을 말단의 태스크로 만들지 말고 데코레이터로 둘 것을 권합니다. 그래야 말단은 행동만 맡고, 어느 가지가 막혀 있는지도 트리에서 바로 보입니다.
서비스(Service)는 컴포지트나 태스크에 붙어, 그 가지가 활성인 동안 정해진 주기로 실행되며 정보를 갱신하는 노드입니다. 주기적으로 플레이어의 위치를 확인해 블랙보드에 써 넣거나, 체력을 검사해 전투 상태로 바꿀지 판단하는 데 씁니다. 서비스는 붙은 가지에 실행이 머무는 동안만 동작하고, 실행이 그 가지를 벗어나면 멈춥니다.
태스크(Task)는 트리의 가장 끝에 놓이는 노드로, NPC가 실제로 할 구체적인 행동 하나를 정의합니다. 엔진에 들어 있는 Move To, Play Animation, Wait 같은 태스크도 있고, 공격처럼 게임마다 다른 행동은 직접 태스크로 만듭니다. 태스크가 진행 중인 동안 부모는 그 결과를 기다리고, 태스크가 끝나면 성공 또는 실패를 알립니다. 바깥 조건 때문에 도중에 중단(Abort)되는 것은 정상적으로 끝나는 것과 다른 결과로 다룹니다.
더 깊이: 언리얼의 트리에 일반적인 Parallel 노드가 없는 이유
전통적인 비헤이비어 트리에는 모든 자식을 한꺼번에 시작하는 병렬(parallel) 컴포지트가 흔히 있고, 자식 가운데 일부가 끝났을 때 어떻게 할지를 정하는 별도의 규칙이 따라붙습니다. 비헤이비어 트리 개요에 따르면 언리얼 엔진은 이런 복잡한 병렬 노드 대신 Simple Parallel과 서비스, 데코레이터의 Observer Aborts로 같은 종류의 행동을 만듭니다. Simple Parallel은 자식을 둘만 가지며, 하나는 태스크 하나여야 하고 다른 하나는 하위 트리 전체가 될 수 있습니다. 문서는 이것을 "A를 하는 동안 B도 한다"는 구조로 설명하는데, 적을 공격하는 동안 적에게 다가가는 것이 그 예입니다.
이렇게 설계한 이유는 앞에서 본 이벤트 기반 동작과 이어집니다. 동시에 실행되는 하위 트리가 적을수록 이벤트 기반 최적화를 적용하기 쉽고, 그래프가 단순해 읽고 디버깅하기도 쉽습니다. 또 매 프레임 트리 전체를 다시 훑어 같은 행동을 또 고르는 일이 없으므로, 실행 기록을 앞뒤로 넘겨 보며 디버깅할 때 실행 위치나 블랙보드 값이 실제로 바뀐 순간만 남습니다. 조건이 깨졌을 때 진행 중인 행동을 멈추는 일도, 병렬 자식이 계속 조건을 검사하는 대신 조건을 맡은 데코레이터가 값을 지켜보다가 중단하는 방식으로 처리합니다.
진행 중인 자식이 있을 때
셀렉터와 시퀀스의 규칙은 자식이 바로 성공하거나 실패할 때는 단순합니다. 그런데 이동처럼 시간이 걸리는 태스크가 진행 중일 때 트리가 무엇을 하는지는 헷갈리기 쉽습니다. 자식 A, B, C를 가진 두 컴포지트로 확인해 보겠습니다.
진행 중인 자식의 결과를 기다린 뒤 다음 행동을 정합니다
Selector A 실패와 Sequence A 성공 뒤 B 진행 중/완료에서 C 실행 여부가 달라진다. 두 독립 트리를 같은 시점 프레임으로 비교하고 아직 없는 B 결과를 미리 확정하지 않는다.
SELECTOR · SEQUENCE · RUNNING CHILD
진행 중인 자식의 결과를 기다린 뒤 다음 행동을 정합니다
두 그림은 자식 A·B·C를 가진 서로 다른 실행 예입니다. 현재 B가 진행 중인 시점이며, C는 아직 실행하지 않았습니다.
조건 변화와 중단은 별도입니다
실행을 허용하는 조건은 Decorator에 둡니다. 키 변경으로 진행 중인 분기를 바꾸려면 Observer Aborts 설정을 확인합니다. Task의 정상 완료와 Abort를 같은 결과로 처리하지 않습니다.
활성 분기에 붙인 Service는 해당 수명과 주기 설정에 따라 정보를 갱신합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 도표 제목 | Selector와 Sequence의 완료 결과 분기 |
| 관계·조건 설명 | 각 그림은 A를 마친 뒤 B가 진행 중인 별개의 실행 예이다. B에서 나가는 조건 간선은 이미 나온 결과가 아니라 앞으로의 성공·실패 경로다. Selector는 B가 실패하면 C를 실행하고, 성공하면 종료한다. Sequence는 B가 성공하면 C를 실행하고, 실패하면 종료한다. Abort는 이 정상 완료 그림과 별개다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 Composite Selector와 Sequence의 A 결과를 나란히 둔다. |
|
|
| B Running 각 사례에서 B가 아직 진행 중이다. |
|
|
| B 완료 완료 결과가 다음 Child 또는 Composite 종료를 결정한다. |
|
|
진행 중인 자식의 결과를 기다린 뒤 다음 행동을 정합니다
Selector A 실패와 Sequence A 성공 뒤 B 진행 중/완료에서 C 실행 여부가 달라진다. 두 독립 트리를 같은 시점 프레임으로 비교하고 아직 없는 B 결과를 미리 확정하지 않는다.
SELECTOR · SEQUENCE · RUNNING CHILD
진행 중인 자식의 결과를 기다린 뒤 다음 행동을 정합니다
두 그림은 자식 A·B·C를 가진 서로 다른 실행 예입니다. 현재 B가 진행 중인 시점이며, C는 아직 실행하지 않았습니다.
조건 변화와 중단은 별도입니다
실행을 허용하는 조건은 Decorator에 둡니다. 키 변경으로 진행 중인 분기를 바꾸려면 Observer Aborts 설정을 확인합니다. Task의 정상 완료와 Abort를 같은 결과로 처리하지 않습니다.
활성 분기에 붙인 Service는 해당 수명과 주기 설정에 따라 정보를 갱신합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 도표 제목 | Selector와 Sequence의 완료 결과 분기 |
| 관계·조건 설명 | 각 그림은 A를 마친 뒤 B가 진행 중인 별개의 실행 예이다. B에서 나가는 조건 간선은 이미 나온 결과가 아니라 앞으로의 성공·실패 경로다. Selector는 B가 실패하면 C를 실행하고, 성공하면 종료한다. Sequence는 B가 성공하면 C를 실행하고, 실패하면 종료한다. Abort는 이 정상 완료 그림과 별개다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 Composite Selector와 Sequence의 A 결과를 나란히 둔다. |
|
|
| B Running 각 사례에서 B가 아직 진행 중이다. |
|
|
| B 완료 완료 결과가 다음 Child 또는 Composite 종료를 결정한다. |
|
|
위 그림의 두 트리는 서로 다른 실행 예입니다. 왼쪽 셀렉터에서는 A가 실패해 B로 넘어갔고, 오른쪽 시퀀스에서는 A가 성공해 다음 단계인 B로 넘어갔습니다. 두 경우 모두 지금은 B가 진행 중이며 트리는 B의 결과를 기다리고 있고, C는 아직 실행되지 않았습니다. B에서 나가는 조건 선은 이미 일어난 결과가 아니라 앞으로의 성공과 실패 경로입니다. 셀렉터는 B가 실패하면 다음 대안인 C를 실행하고 B가 성공하면 C를 실행하지 않고 성공으로 끝나며, 시퀀스는 B가 성공하면 다음 단계인 C를 실행하고 B가 실패하면 시퀀스 전체가 실패로 끝납니다.
단계 그림은 이 과정을 세 단계로 나눕니다. 첫 단계는 두 컴포지트에서 A의 결과를 나란히 보여 주고, 둘째 단계는 두 경우 모두 B가 아직 진행 중인 순간을 보여 줍니다. 셋째 단계에서는 B의 완료 결과가 다음 자식을 실행할지, 컴포지트를 끝낼지를 정합니다.
그림 아래의 설명은 정상 완료와 중단을 따로 봐야 한다는 점을 짚습니다. 실행을 허용하는 조건은 데코레이터에 두고, 블랙보드 키가 바뀔 때 진행 중인 가지를 바꾸고 싶다면 데코레이터의 Observer Aborts 설정을 확인해야 합니다. 태스크가 정상적으로 끝난 것과 중단된 것은 같은 결과로 처리하지 않으며, 활성 가지에 붙은 서비스는 그 가지가 활성인 동안 정해진 주기로 정보를 갱신합니다.
데이터는 블랙보드에
비헤이비어 트리는 AI의 판단을 맡지만, 플레이어의 위치나 NPC의 체력, 현재 목표 같은 게임플레이 데이터를 직접 저장하지는 않습니다. 이런 데이터는 블랙보드라는 별도의 에셋에 저장되며, 트리의 노드는 블랙보드에서 필요한 정보를 읽거나 씁니다. 블랙보드는 비헤이비어 트리에 지정해서 씁니다.
블랙보드는 같은 AI의 서비스, 데코레이터, 태스크가 키(key)와 값(value)의 쌍으로 데이터를 공유하는 저장소입니다. 예를 들어 TargetLocation, TargetActor, IsAggro, CurrentHealth 같은 값을 블랙보드 키로 만들어 두면, 서비스가 키에 값을 써 넣고, 데코레이터가 그 값을 조건으로 검사하고, 태스크가 그 값을 목표로 행동합니다. 블랙보드 에셋 하나를 여러 NPC가 함께 쓰더라도, 기본적으로는 NPC마다 자기 값을 따로 가지므로 모든 NPC가 하나의 값을 공유하는 전역 메모리가 아닙니다.
트리를 만들고 실행하기
이제 비헤이비어 트리와 블랙보드를 만들고, NPC가 생기면 트리가 실행되도록 연결해 보겠습니다.
먼저 콘텐츠 브라우저의 빈 곳을 우클릭해 Artificial Intelligence > Behavior Tree를 고르고 이름을 BT_EnemyAI로 정합니다. 같은 메뉴의 Artificial Intelligence > Blackboard로 블랙보드 에셋을 만들어 이름을 BB_EnemyAI로 정합니다. 비헤이비어 트리 에디터의 New Blackboard 버튼으로 만들어도 됩니다. 그다음 BT_EnemyAI를 열고, 트리의 Blackboard Asset 속성에 BB_EnemyAI를 지정합니다.
다음으로 트리를 실행할 AI 컨트롤러(AI Controller) 블루프린트를 만들어 이름을 AIC_Enemy로 정합니다. AIC_Enemy의 이벤트 그래프에서 Event On Possess 뒤에 Run Behavior Tree를 연결하고, BTAsset 핀에 BT_EnemyAI를 지정합니다. On Possess는 컨트롤러가 조종할 폰을 얻은 순간 실행되므로, 트리가 제어할 대상이 생긴 뒤에 시작됩니다. Run Behavior Tree가 돌려주는 Boolean 값으로 트리가 제대로 시작했는지 확인할 수 있습니다. 컴파일하고 저장합니다.
마지막으로 NPC 폰 블루프린트(예: BP_EnemyCharacter)를 열고, Class Defaults의 AI Controller Class를 AIC_Enemy로 설정합니다. Auto Possess AI는 NPC를 레벨에 배치하는지, 게임 중에 스폰하는지에 맞춰 고르며, 두 방식을 다 쓴다면 Placed in World or Spawned를 고릅니다. 컴파일하고 저장합니다.
이렇게 설정하면 AI 컨트롤러가 폰을 조종하기 시작하는 순간 트리가 실행됩니다. 다만 트리가 실행된다고 NPC가 바로 움직이지는 않습니다. Move To 같은 이동 태스크가 동작하려면 레벨의 내비메시(NavMesh)와 알맞은 Movement Component도 있어야 하며, 이 부분은 내비게이션 시스템을 통한 기본 AI 이동에서 다룹니다.
정리
- 비헤이비어 트리는 NPC의 판단을 루트, 컴포지트, 데코레이터, 서비스, 태스크로 이루어진 계층 구조로 정리하며, 이벤트 기반으로 동작합니다.
- 셀렉터는 자식이 실패해야 다음 대안으로, 시퀀스는 자식이 성공해야 다음 단계로 넘어가며, 자식이 진행 중이면 그 결과를 기다립니다.
- 조건은 데코레이터에, 주기적인 정보 갱신은 서비스에, 실제 행동은 태스크에 둡니다.
- 블랙보드는 같은 AI의 노드들이 키와 값으로 데이터를 공유하는 저장소이며, 기본적으로 NPC마다 값을 따로 가집니다.
- AI 컨트롤러의
Event On Possess에서Run Behavior Tree를 호출하고, 폰의AI Controller Class와Auto Possess AI를 설정해 트리를 실행합니다.
연습 문제
1. 진행 중인 자식 다음에 실행되는 것
셀렉터의 A는 실패했고 B가 이동 중입니다. C는 실행하면 성공할 수 있다고 가정합니다. B의 결과가 아직 없고 바깥 조건에 따른 중단도 없다면 셀렉터가 C를 미리 실행할까요? 같은 시점에 별개의 시퀀스에서는 A가 성공한 뒤 B가 이동 중입니다. 이 B가 나중에 실패하면 시퀀스는 C를 실행할까요?
해설 보기
두 트리 모두 B가 진행 중인 동안에는 C를 실행하지 않습니다. C가 성공할 수 있다는 가정은 지금 진행 중인 B를 건너뛸 이유가 되지 않습니다.
B가 실패하면 셀렉터는 다음 대안인 C를 시도하지만, 시퀀스는 C를 실행하지 않고 실패로 끝납니다. 반대로 B가 성공하면 셀렉터는 거기서 성공으로 끝나고, 시퀀스는 다음 단계인 C를 실행합니다. 이 문제는 정상적으로 끝나는 경우만 다루며, 블랙보드 키가 바뀌어 가지를 중단하는 Observer Aborts는 따로 확인해야 하는 동작입니다.
2. 목표를 잃었는데 계속 쫓아가는 적
추적 가지에 "블랙보드의 TargetActor가 설정되어 있음"을 검사하는 데코레이터를 붙였고, 그 아래에서 Move To로 플레이어를 쫓습니다. 이동 도중 다른 노드가 TargetActor를 비웠는데도 적이 목적지에 닿을 때까지 계속 이동합니다. 왜 그럴까요?
해설 보기
데코레이터의 Observer Aborts가 None이면, 데코레이터는 가지에 들어갈 때만 조건을 검사하고 그 뒤의 값 변화는 지켜보지 않습니다. 그래서 이동이 시작된 뒤에 TargetActor가 비어도 진행 중인 Move To는 중단되지 않고 끝까지 진행됩니다.
조건이 거짓이 되는 순간 추적을 멈추게 하려면 그 데코레이터의 Observer Aborts를 Self로 설정합니다. 그러면 데코레이터가 키의 변화를 지켜보다가 조건이 깨지면 자신이 붙은 가지를 중단하고, 트리가 다른 행동을 다시 고릅니다.
3. 같은 블랙보드를 쓰는 두 적
레벨에 BP_EnemyCharacter 두 마리를 배치했고, 둘 다 같은 BT_EnemyAI와 BB_EnemyAI를 씁니다. 첫 번째 적이 플레이어를 발견해 자기 블랙보드의 TargetActor에 플레이어를 써 넣었습니다. 두 번째 적도 바로 플레이어를 쫓기 시작할까요?
해설 보기
기본 설정이라면 쫓지 않습니다. 같은 블랙보드 에셋을 쓰더라도 AI마다 자기 블랙보드 값을 따로 가지므로, 첫 번째 적이 쓴 TargetActor는 두 번째 적의 블랙보드에 나타나지 않습니다.
여러 적이 발견 정보를 공유하게 하고 싶다면, 그 키를 모든 인스턴스가 함께 쓰도록 따로 설정하거나 공유할 정보를 다른 객체를 통해 전달하는 구조를 설계해야 합니다. 블랙보드를 처음부터 전역 메모리로 가정하면 이런 의도하지 않은 결과를 놓치기 쉽습니다.
비헤이비어 트리의 이벤트 기반 동작과 노드 종류는 언리얼 엔진 공식 문서의 비헤이비어 트리 개요에서 확인할 수 있습니다.
다음 글 EQS의 기본 활용법에서는 비헤이비어 트리가 "어디로 갈지"를 정할 때 쓰는 EQS로, 후보 위치를 만들고 걸러 내 점수를 매겨 AI의 엄폐 지점을 고릅니다.