2026-08-04 TIL (111일차)
편의점 시뮬레이션 손님 AI — 클래스 6개 역할 분리 정리
손님 파트 구현을 마치고 클래스별 점검 문서를 작성했습니다. 개별 변수·함수 사전이 아니라 “왜 이렇게 쪼갰는가” 와 “손님 한 명이 어떤 순서로 이 시스템을 통과하는가” 를 중심으로 정리합니다.
1. 전체 구조 한눈에
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CSCustomerSpawner (레벨 배치)
│ 타이머로 생성 + ExitLocation 주입
▼
CSCustomerCharacter ─── 몸 (이동 물리, 컴포넌트 보유)
│ │
│ AutoPossessAI └── CSCustomerStateComponent ─ 개인 상태
▼
CSCustomerAIController ─ 머리 (StateTreeAIComponent 그릇)
│
│ ST_Customer 태스크/조건이 호출
▼
CSStoreCustomerSubsystem ─ 중개자 (공유 상태)
│ │
├── ACSShelfActor (팀원 클래스, 직접 참조)
└── ICSCustomerCounterInterface ─ 계약 (계산대 구현체 교체 가능)
| 클래스 | 역할 | 갖고 있는 것 |
|---|---|---|
ACSCustomerCharacter | 몸 — 판단은 안 하고 실행될 몸통 | 상태 컴포넌트, 이동 세팅 |
ACSCustomerAIController | 머리 — StateTree 실행 컴포넌트를 붙인 그릇 | UStateTreeAIComponent |
UCSCustomerStateComponent | 개인 수첩 | 고른 상품, 방문 기록, 계산 완료, 퇴장 좌표 |
UCSStoreCustomerSubsystem | 중개자 — 세상과의 모든 접촉 경유지 | 진열대 목록, 계산대, 대기열 |
ACSCustomerSpawner | 발생기 — 언제/얼마나/어디서/어디로만 담당 | 스폰 설정, 생존 손님 장부 |
ICSCustomerCounterInterface | 계약 — 정체가 아니라 약속 | 함수 선언 1개 |
앞의 5개는 “구체적인 하나의 정체”인데, 인터페이스만 성격이 다릅니다. 정체가 아니라 “계산대이려면 최소한 이건 있어야 한다”는 규칙만 정의하고, 실제 구현은 전혀 모릅니다.
2. 데이터 소유 경계 — 판단 공식 하나
| 데이터 | 어디에 | 이유 |
|---|---|---|
| 진열대 목록, 계산대, 대기열 | 서브시스템 | 모든 손님이 공유 |
| 고른 상품, 방문 기록, 계산 완료, 퇴장 지점 | StateComponent | 손님마다 다름 |
시간, 단계, bCanSpawnCustomers | CSStoreGameState (팀원) | 가게 전체 상태 |
| 상품 가격, 재고 수치, 자금, 평점 | 아직 없음 (편의점 파트) | 그쪽 소유 |
판단 공식: “손님 A와 손님 B가 같은 값을 봐야 하나?” → 예면 서브시스템, 아니면 컴포넌트.
이 경계 덕분에 서브시스템 함수 시그니처의 모양이 정해집니다. 서브시스템은 손님 목록을 갖고 있지 않으므로, “이 손님이 무엇을 봤는지” 를 알려면 호출할 때 방문 기록을 파라미터로 넘겨야 합니다. (HasUnvisitedShelf(VisitedShelves))
3. 손님 한 명의 일생
시작 — 월드가 열릴 때
1
2
레벨 로드 → 서브시스템 자동 생성(레벨 배치 불필요) → OnWorldBeginPlay()
→ RefreshShelves(): TActorIterator<ACSShelfActor>로 레벨 전체를 훑어 수집
진열대는 서브시스템이 찾아나서고, 계산대는 자기가 등록(RegisterCounter)합니다. 비대칭인 이유:
| 대상 | 방식 | 근거 |
|---|---|---|
| 진열대 | 서브시스템이 탐색 | 개수가 많고, 팀원 코드에 등록 코드를 요구하지 않기 위해 |
| 계산대 | 스스로 등록 | 하나뿐이고, 인터페이스 구현 여부를 검사해 걸러야 함 |
① 어디로 갈지 정할 때 (Shopping 진입)
1
2
3
4
5
조건 HasUnvisitedShelf → Subsystem.HasUnvisitedShelf(손님의 VisitedShelves)
통과 → Task PickNextShelf
→ Subsystem.PickRandomUnvisitedShelf(VisitedShelves) // 없으면 nullptr
→ Subsystem.GetShelfBrowseLocation(진열대, 120) // 진열대 위치 + 정면 × 120
→ Output으로 내보냄 (자식 상태가 바인딩)
② 진열대 앞에서 (Browse) — 이 시스템의 핵심 구간
1
2
3
4
5
6
7
8
9
10
1.5초 대기
→ 손님의 MarkVisited(진열대) ← 개인 데이터라 서브시스템 안 거침
→ Subsystem.EvaluatePurchase(손님, 진열대)
→ HasShelfStock(): Attach된 ACSProductPickupActor 수집 → 하나라도 있으면 true
→ 재고 있으면 50% 랜덤 ← 소비심리 파트가 교체할 지점
→ true면 Subsystem.TryTakeOneProduct(진열대, 손님)
→ 랜덤 1개 선택 → ProductId 미리 확보
→ ICSShelfPlaceable::RemovePlacedItem() ★ 진열대 슬롯 비우기
→ 상품 액터 Destroy() → OnItemTaken 방송 → ProductId 반환
→ 손님의 장바구니에 추가 ← 다시 개인 데이터
⚠️ RemovePlacedItem이 중요한 이유: 플레이어가 진열된 물건을 집을 때와 똑같은 경로입니다. 이걸 빠뜨리면 상품은 사라졌는데 진열대는 “그 슬롯 아직 차 있음” 으로 착각해서 그 자리에 다시 진열이 안 되는 버그가 납니다.
③ 계산 줄에 설 때 (Checkout)
1
2
3
4
5
6
7
8
Task WaitInQueue 진입
→ Subsystem.JoinQueue(손님) → Queue 맨 뒤 추가, 순번 반환
→ Subsystem.GetQueueSlotLocation(순번) → Counter를 인터페이스로 캐스팅 → 좌표
→ 그 좌표로 이동
매 틱:
→ bCheckoutDone == true → 상태 성공 종료 (Leave로)
→ 아니면 GetQueueIndex(손님)로 순번 재확인 → 바뀌었으면 새 좌표로 이동
줄이 당겨지는 로직이 따로 없다는 게 포인트입니다.
Queue는 그냥TArray이고RemoveAt(0)하면 나머지가 자동으로 한 칸 당겨집니다. 각 손님이 매 틱 자기 번호만 확인하니, 결과적으로 줄이 움직입니다. 자료구조의 성질에 로직을 위임한 사례.
④ 계산 완료
1
2
3
4
Subsystem.CompleteFrontCheckout() (지금: 목 계산대 2초 뒤 / 나중: 플레이어 상호작용)
→ Queue[0] 제거 ← 이 순간 뒷줄이 당겨짐
→ 그 손님의 bCheckoutDone = true
→ OnCheckoutCompleted 방송 ← 매출 집계 파트가 구독
손님은 자기가 계산됐다는 걸 모릅니다. 서브시스템이 대신 플래그를 켜고, 손님은 자기 플래그만 확인합니다. 계산대가 손님 객체를 직접 만지지 않게 하려는 구조입니다.
⑤ 퇴장
1
2
Task LeaveAndDespawn → ExitLocation으로 이동 (스포너가 스폰 때 주입)
→ 도착하면 Destroy() → Character::Destroyed()가 AIController도 정리
퇴장은 서브시스템을 안 거칩니다. 이동 목표가 개인 데이터라서입니다.
4. 설계 원칙 4가지
| 원칙 | 내용 | 얻는 것 |
|---|---|---|
| 모든 접촉은 서브시스템 경유 | 손님 태스크는 진열대·상품 액터를 직접 만지지 않고 물어보고 결과만 받는다 | 팀원 코드가 바뀌어도 고칠 곳이 한 파일로 갇힌다 |
| 최소 계약 원칙 | 인터페이스에 GetQueueSlotLocation 하나만 넣음 | 결제·스캔 로직은 손님이 알 필요 없음 → 구현체 교체 자유 |
| 코드는 그릇, 내용물은 에셋 | 어떤 StateTree를 돌릴지는 C++에 없고 BP 설정값 | 상태 구조 변경이 컴파일 없이 가능 |
| 개인 데이터는 넘겨받는다 | 서브시스템이 손님 목록을 갖지 않음 | 소유권이 흐려지지 않음 |
최소 계약이 왜 중요한가: 손님 AI가 계산대에 대해 알아야 할 건 “내가 몇 번째로 서면 어디 서 있어야 하는가” 뿐입니다. 그래서 나중에 플레이어 파트가 진짜 계산대를 만들 때 손님 코드를 한 줄도 안 고치고 인터페이스만 구현하면 연동됩니다.
5. 클래스별로 짚어둘 것
ACSCustomerCharacter — Destroyed()의 순서가 중요
1
2
3
① Super::Destroyed() 호출 전에 GetController()를 변수에 캐시
② Super::Destroyed() 실행 ← 이 시점에 Possess 관계가 끊어짐
③ 캐시해둔 컨트롤러를 Destroy()
Super 이후에는 GetController()가 유효한 값을 반환하지 않을 수 있습니다. 이 순서를 안 지키면 LeaveAndDespawn이 Destroy()를 호출할 때마다 AIController가 고아 상태로 레벨에 계속 쌓이는 누수가 생깁니다.
그 외 생성자 세팅: bCanEverTick = false(판단은 전부 StateTree 태스크에서), bUseControllerRotationYaw = false + bOrientRotationToMovement = true(걸어가는 방향을 보고 걷게), RotationRate = (0, 360, 0)(사실상 즉시 회전).
ACSCustomerAIController — 로직이 거의 없는 게 의도
생성자가 StateTreeAI 컴포넌트를 하나 만드는 게 전부입니다. 어떤 트리를 돌릴지와 언제 시작할지(Start Logic Automatically)는 BP_CSCustomerAIController의 에디터 세팅에 있습니다. GetStateTreeAI()는 현재 호출하는 곳이 없고, 나중에 “현재 Shopping 상태인 손님 수” 같은 관측·치트 기능을 위해 열어둔 훅입니다.
UCSCustomerStateComponent — 필드 추가 기준
VisitedShelves의 원소 타입이 AActor*인 이유는, 진열대 종류가 늘어나도(냉장고 등) 손님 코드를 안 바꾸기 위해 일부러 넓혀둔 것입니다. 지금은 사실상 전부 ACSShelfActor입니다.
ACSCustomerSpawner — 스폰 실패로 조용히 사라지지 않게
SpawnCollisionHandlingOverride = AdjustIfPossibleButAlwaysSpawn으로 스폰 지점이 겹쳐도 밀어내서라도 생성합니다. 매 스폰 시도마다 SpawnedCustomers를 뒤에서부터 훑어 IsValid()가 false인 항목을 제거한 뒤 상한과 비교하는 장부 청소가 들어 있습니다.
ICSCustomerCounterInterface — U/I 쌍과 빈 .cpp
- UE 인터페이스는 항상
U+I쌍으로 만듭니다.U쪽은 리플렉션 시스템용 표지일 뿐이고 실제 함수 선언·구현은 전부I쪽에 들어갑니다. meta = (CannotImplementInterfaceInBlueprint)로 BP 구현 경로를 차단했습니다. 계산대는 C++로 만들어질 것을 전제한 설정입니다.CSCustomerInterfaces.cpp는 include 한 줄뿐입니다.UINTERFACE헤더는 UHT가 만든.generated.h가 어떤.cpp엔가 include되어 있어야 링커가 처리하기 때문입니다. 엔진 소스의StateTreeConditionBase.cpp도 같은 패턴입니다.
오늘의 정리
| 배운 것 | 내용 |
|---|---|
| 소유권 경계가 곧 함수 시그니처 | “공유 데이터인가”를 먼저 정하면, 서브시스템이 개인 데이터를 파라미터로 받아야 한다는 결론이 자동으로 나온다 |
| 중개자 하나를 고집한 보상 | 앞으로 예상되는 변경 5개가 전부 서브시스템 내부에 갇힌다. 태스크·Character·Spawner는 수정 불필요 |
| 최소 계약이 교체 가능성을 만든다 | 인터페이스에 함수 하나만 두니 목 계산대 → 실제 계산대 교체가 손님 코드 수정 0줄 |
| 자료구조에 로직을 위임 | 대기열 당기기를 TArray::RemoveAt(0) + 각자 순번 재확인으로 해결. 별도 로직 없음 |
| 조용한 실패는 문서로 막는다 | Start Logic Automatically, GameMode Override처럼 컴파일 에러 없이 실패하는 지점은 미리 목록화 |
| 소멸 순서는 명시적으로 | Super::Destroyed() 전에 컨트롤러를 캐시. RAII가 없는 엔진 객체는 해제 순서를 직접 설계해야 한다 |