2026-08-05 TIL (112일차)
손님 파트 서브시스템 리팩터링 — 이름·책임·확장 지점
어제 정리하면서 “이름이
Store...로 시작해서 가게 시스템으로 오해된다” 는 논의가 있었는데, 오늘 그걸 실제로 반영했습니다. 이름만 바꾼 게 아니라 책임 범위까지 줄이는 리팩터링이 됐습니다.
1. 오늘 변경한 것 요약
| 변경 | 이전 | 이후 | 이유 |
|---|---|---|---|
| 클래스명 | UCSStoreCustomerSubsystem | UCSCustomerRuntimeSubsystem | Store가 가게 전체 시스템으로 오해됨 |
| 경로 | Features/Customers/ | Systems/CustomerFlow/ | 특정 액터의 기능이 아니라 파트 전역 시스템 |
| 구매 판정 | EvaluatePurchase() 보유 | 제거 | 판단은 소비심리 파트 몫 |
| 집기 함수 | TryTakeOneProduct() 하나 | TakeProduct() + TakeRandomProduct() | 범용 / 임시를 분리 |
| 추가 API | – | LeaveQueue, GetFrontCustomer, GetShelves | 안전장치 + 다른 파트 창구 |
| 인터페이스 호출 | 직접 호출 | Execute_RemovePlacedItem | BlueprintNativeEvent 규약 |
2. 이름을 바꾼 기준
Store → CustomerRuntime. 이 서브시스템에는 가격·자금·상품DB가 없습니다. 담고 있는 건 진열대 목록, 계산대, 대기열 — 전부 손님 흐름에 필요한 공유 상태뿐입니다.
이름은 “무엇을 담고 있는가”가 아니라 “누가 소유하는가” 를 드러내야 합니다.
Store...는 소유자를 가게 파트로 오해하게 만들고, 그러면 다른 파트가 여기에 자기 데이터를 추가하려 합니다.CustomerRuntime은 손님 파트 소유라는 게 이름에서 드러납니다.
경로도 같은 이유로 옮겼습니다. Features/Customers/는 손님 액터 관련 기능들이 모인 곳이고, 이건 특정 액터에 붙는 게 아니라 월드 전역에 하나 존재하는 시스템이라 Systems/CustomerFlow/가 맞습니다.
3. “판단은 하지 않는다” — 책임을 줄인 것이 핵심
어제까지는 서브시스템에 EvaluatePurchase()가 있었고, 그 안에 50% 랜덤이라는 구매 판정이 들어 있었습니다. 오늘 이걸 뺐습니다.
| 구분 | 서브시스템의 역할 |
|---|---|
| ✅ 한다 | “3번 진열대에 뭐가 있다” 를 알려준다 |
| ✅ 한다 | “그거 집어줘” 를 실행한다 |
| ❌ 안 한다 | “어린 손님이니 장난감을 산다” 는 판단 |
판정은 지금 태스크(FCSTask_BrowseShelf)에서 확률로 임시 처리하고 있고, 소비심리 파트가 완성되면 그쪽이 결정한 상품을 TakeProduct()에 넘기는 형태로 바뀝니다.
얻은 것: 서브시스템이 조회 + 실행 전담이 되어 소유권 경계가 훨씬 선명해졌습니다. 판단 로직이 여기 있으면 소비심리 파트가 이 파일을 수정해야 하는데, 그건 어제 세운 “다른 파트는 서브시스템에 함수를 추가하지 않는다” 원칙과 충돌합니다.
4. TakeProduct / TakeRandomProduct 분리 — 확장 지점 설계법
1
2
3
4
5
// 범용: 지정한 상품을 집는다 (소비심리 파트가 고른 상품을 넘길 자리)
FName TakeProduct(AActor* ShelfActor, AActor* ProductActor, ACSCustomerCharacter* Customer);
// [임시/테스트용] 아무거나 하나 집는다 → 내부에서 TakeProduct를 호출
FName TakeRandomProduct(AActor* ShelfActor, ACSCustomerCharacter* Customer);
TakeRandomProduct는 랜덤으로 하나 고른 뒤 TakeProduct를 그대로 부릅니다. 즉 실제 로직은 한 곳에만 있고, 임시 함수는 “어떤 상품을 고르는가”만 담당하는 얇은 껍데기입니다.
교체 예정인 로직은 얇은 껍데기로 감싸 분리해 둔다. 나중에 소비심리 파트가 붙으면 껍데기(
TakeRandomProduct) 호출을TakeProduct(진열대, 그쪽이_고른_상품, 손님)으로 바꾸는 것만으로 끝나고, 집기 실행 로직은 한 줄도 안 건드립니다.
TakeProduct 내부 순서에도 이유가 있습니다.
1
2
3
4
1. ProductId를 미리 확보 ← 파괴 후엔 읽을 수 없으므로 반드시 먼저
2. RemovePlacedItem() ← 슬롯 점유 해제
3. Product->Destroy()
4. OnItemTaken 방송 + ProductId 반환
⚠️ 2번을 빠뜨리면 상품은 사라졌는데 진열대는 “그 슬롯 아직 차 있음” 으로 착각해 재진열이 안 되는 버그가 납니다. 플레이어가 진열품을 다시 집을 때 타는 것과 같은 규약입니다.
5. 팀원 코드를 건드리지 않기 위한 3가지 우회
이번 리팩터링에서 가장 많이 신경 쓴 부분입니다. 팀원 파일을 수정하지 않는다는 제약 아래 세 가지 방법을 썼습니다.
| 문제 | 우회 방법 | 근거 |
|---|---|---|
ACSShelfActor::Slots가 protected라 재고를 못 읽음 | Attach 기반 판정 — GetAttachedActors()로 ACSProductPickupActor만 필터 | 팀원의 TryPlaceItem이 상품을 진열대에 Attach함 → “Attach된 상품 = 진열된 재고” 성립 |
| 진열대가 스스로 등록해주지 않음 | RefreshShelves()로 찾아나섬 (TActorIterator) | 팀원 코드에 등록 코드를 추가해달라고 요청하지 않기 위해 |
| 팀원 헤더 수정 시 손님 파트 전체 재컴파일 | 헤더에는 전방선언, include는 .cpp에만 | 연쇄 재컴파일 차단 |
1
2
3
4
5
6
7
// 헤더 — 전방선언만
class ACSCustomerCharacter;
class ACSShelfActor;
// .cpp — 실제 include는 여기서만
#include "Features/Shelves/CSShelfActor.h"
#include "Features/Items/CSProductPickupActor.h"
부수 효과가 하나 더 있었습니다. 별도 재고 카운터를 두지 않고 Attach를 보게 되니, 플레이어가 진열해야 손님이 살 수 있는 게임 루프가 자동으로 맞물립니다. 제약 때문에 택한 우회가 오히려 설계상 더 맞는 결과가 됐습니다.
Shelves를 TArray<TObjectPtr<AActor>>로 넓게 잡은 것도 같은 맥락입니다. 실제 원소는 전부 ACSShelfActor지만, 구체 타입 캐스팅이 이 파일 .cpp 안에서만 일어나게 격리했습니다.
6. BlueprintNativeEvent는 반드시 Execute_ 경유
오늘 새로 알게 된 규약입니다.
1
2
3
4
if (ShelfActor->Implements<UCSShelfPlaceable>())
{
ICSShelfPlaceable::Execute_RemovePlacedItem(ShelfActor, Product);
}
| 항목 | 내용 |
|---|---|
왜 Execute_인가 | ICSShelfPlaceable이 BlueprintNativeEvent(..._Implementation 형태)라, 직접 호출하면 BP 오버라이드가 무시된다 |
왜 Implements<> 검사 | 미구현 액터에 호출하면 크래시. U 접두사 클래스를 템플릿 인자로 넘긴다는 점 주의 |
| 인터페이스 검사 2종 | 순수 C++ 인터페이스는 Cast<I...>, BP 구현 가능 인터페이스는 Implements<U...> |
실제로 이 코드에는 두 방식이 모두 쓰였습니다. 계산대는 Cast<ICSCustomerCounterInterface>(BP 구현 차단됨), 진열대 슬롯은 Implements<UCSShelfPlaceable>(BP 오버라이드 가능). 인터페이스의 성격에 따라 검사 방법이 달라진다는 걸 정리해 둡니다.
7. 임시 구현에 태그를 붙여 남겨두기
[임시/테스트용] 주석을 3곳에 명시했습니다.
| 함수 | 왜 임시인가 | 누가 교체 |
|---|---|---|
PickRandomUnvisitedShelf | “어디 갈지”는 목표구매/충동구매 구분 영역 | 소비심리 파트 |
HasUnvisitedShelf | 위와 같은 판정의 bool 버전 | 소비심리 파트 |
TakeRandomProduct | “뭘 살지”도 그쪽 담당 | 소비심리 파트 |
지우지 말고 유지해야 합니다. 그쪽 로직이 나올 때까지 동선 검증에 필요하기 때문입니다. 임시 코드를 지우면 검증 수단이 없어지고, 태그를 안 붙이면 나중에 이게 확정 로직인 줄 알고 그대로 갑니다.
PickRandomUnvisitedShelf의 반환값 nullptr에도 의미를 부여했습니다. “더 볼 진열대가 없다” = 쇼핑 종료 신호이고, 태스크가 이걸 받아 상태를 실패 처리하면 그 실패가 전이를 통해 계산대/퇴장으로 이어집니다. 정상 흐름을 실패로 표현하는 게 StateTree에서는 자연스럽다는 점이 처음엔 헷갈렸습니다.
8. 대기열 — 자료구조에 로직을 위임
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void UCSCustomerRuntimeSubsystem::CompleteFrontCheckout()
{
if (Queue.Num() == 0) return;
ACSCustomerCharacter* Front = Queue[0];
Queue.RemoveAt(0); // 이 순간 뒷줄이 자동으로 한 칸씩 당겨진다
if (Front != nullptr)
{
if (UCSCustomerStateComponent* State = Front->GetCustomerState())
{
State->bCheckoutDone = true;
}
OnCheckoutCompleted.Broadcast(Front);
}
}
- 줄 전진 로직이 따로 없습니다.
RemoveAt(0)하면 나머지 인덱스가 자동으로 줄어들고, 각 손님이 매 틱GetQueueIndex로 자기 번호를 확인하다가 바뀐 걸 보고 앞으로 걸어갑니다. JoinQueue의 중복 방지: 이미 줄에 있으면 기존 순번을 반환합니다. 태스크가 재진입해도 줄이 두 번 늘어나지 않게 하는 방어입니다.LeaveQueue는 안전장치:bCheckoutDone이 false인 채 상태를 떠날 때 호출합니다. 나중에 인내심 타임아웃(포기) 기능이 생기면 그때도 이 함수를 씁니다.GetQueueIndex는 선형 탐색: 손님 수십 명 규모에선 문제없습니다. 수백 명이 되면 그때 맵 구조를 고려하면 되고, 지금 최적화할 이유가 없다는 판단을 문서에 남겨두는 것이 나중을 위해 중요합니다.
9. 이벤트는 구독자 0명인 채로 먼저 파둔다
1
2
3
4
5
UPROPERTY(BlueprintAssignable, Category = "Store")
FCSOnCheckoutCompleted OnCheckoutCompleted; // 계산 완료 → 매출 집계
UPROPERTY(BlueprintAssignable, Category = "Store")
FCSOnItemTaken OnItemTaken; // 상품 집기 → 실시간 판매 통계
둘 다 현재 구독자 0명입니다. 편의점·통계 파트가 붙을 자리를 미리 파둔 것으로, 팀원의 CSStoreGameState::OnScheduleEventFired가 구독자 0인 채 준비돼 있는 것과 같은 패턴입니다.
델리게이트는 “내가 알려줄 수 있는 사건”의 목록이라, 구독자가 없어도 먼저 정의해두면 다른 파트가 이 파일을 수정하지 않고 붙을 수 있습니다. 팀 프로젝트에서 델리게이트가 결합도를 낮추는 방식이 이제 이해됩니다.
10. 남은 확장 과제
| 과제 | 내용 | 바뀌는 범위 |
|---|---|---|
| 진열 높이(상/중/하) | 소비심리 파트가 눈높이 골든존 가중치에 필요. Slots가 protected라 ① 팀원이 getter 제공 ② 상품 액터 Z좌표로 역산 중 택1 → 회의 안건 | 서브시스템 or 팀원 헤더 |
| 계산대 복수 대응 | Counter가 단수. 배열화 + “어느 줄에 설지” 선택 로직 | 서브시스템 |
| 재고 조회 캐시 | 매번 GetAttachedActors() → 캐시 + 역인덱스 | 서브시스템 .cpp 내부만 |
| 상품 들고 다니기 | Destroy() 한 줄을 Attach로 교체 | TakeProduct 내부 |
전부 이 파일 내부에 갇힌 변경입니다. 태스크·조건·Character·Spawner는 수정 불필요.
(어제 발견한 CartItemIds / SelectedItemIds 표기 불일치는 아직 남아 있습니다. 헤더 주석은 SelectedItemIds로 적혀 있으니 실제 코드 기준으로 통일이 필요합니다.)
오늘의 정리
| 배운 것 | 내용 |
|---|---|
| 이름은 소유자를 드러내야 한다 | Store...는 소유 파트를 오해하게 만든다. 이름이 잘못되면 다른 파트가 여기에 데이터를 추가하려 한다 |
| 책임을 줄이는 것도 리팩터링 | EvaluatePurchase 제거로 “조회 + 실행 전담”이 되어 경계가 선명해졌다 |
| 교체 예정 로직은 얇은 껍데기로 | TakeRandomProduct가 TakeProduct를 부르는 구조라, 교체 시 실행 로직은 무수정 |
| 제약이 더 나은 설계를 만들기도 | Slots가 protected라 택한 Attach 기반 판정이, 오히려 “진열해야 팔린다”는 게임 루프와 맞물렸다 |
BlueprintNativeEvent는 Execute_ 경유 | 직접 호출하면 BP 오버라이드가 무시된다. Cast<I...> vs Implements<U...> 구분도 함께 |
| 임시 코드는 태그를 붙여 남긴다 | 지우면 검증 수단이 없어지고, 태그가 없으면 확정 로직으로 오인된다 |
| 판단 보류도 기록한다 | “선형 탐색을 지금 최적화하지 않는다”는 판단을 남겨두면 나중에 다시 고민하지 않는다 |