Post

2026-08-05 TIL (112일차)

2026-08-05 TIL (112일차)

손님 파트 서브시스템 리팩터링 — 이름·책임·확장 지점

어제 정리하면서 “이름이 Store...로 시작해서 가게 시스템으로 오해된다” 는 논의가 있었는데, 오늘 그걸 실제로 반영했습니다. 이름만 바꾼 게 아니라 책임 범위까지 줄이는 리팩터링이 됐습니다.


1. 오늘 변경한 것 요약

변경이전이후이유
클래스명UCSStoreCustomerSubsystemUCSCustomerRuntimeSubsystemStore가 가게 전체 시스템으로 오해됨
경로Features/Customers/Systems/CustomerFlow/특정 액터의 기능이 아니라 파트 전역 시스템
구매 판정EvaluatePurchase() 보유제거판단은 소비심리 파트 몫
집기 함수TryTakeOneProduct() 하나TakeProduct() + TakeRandomProduct()범용 / 임시를 분리
추가 APILeaveQueue, GetFrontCustomer, GetShelves안전장치 + 다른 파트 창구
인터페이스 호출직접 호출Execute_RemovePlacedItemBlueprintNativeEvent 규약

2. 이름을 바꾼 기준

StoreCustomerRuntime. 이 서브시스템에는 가격·자금·상품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::Slotsprotected라 재고를 못 읽음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를 보게 되니, 플레이어가 진열해야 손님이 살 수 있는 게임 루프가 자동으로 맞물립니다. 제약 때문에 택한 우회가 오히려 설계상 더 맞는 결과가 됐습니다.

ShelvesTArray<TObjectPtr<AActor>>로 넓게 잡은 것도 같은 맥락입니다. 실제 원소는 전부 ACSShelfActor지만, 구체 타입 캐스팅이 이 파일 .cpp 안에서만 일어나게 격리했습니다.


6. BlueprintNativeEvent는 반드시 Execute_ 경유

오늘 새로 알게 된 규약입니다.

1
2
3
4
if (ShelfActor->Implements<UCSShelfPlaceable>())
{
    ICSShelfPlaceable::Execute_RemovePlacedItem(ShelfActor, Product);
}
항목내용
Execute_인가ICSShelfPlaceableBlueprintNativeEvent(..._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. 남은 확장 과제

과제내용바뀌는 범위
진열 높이(상/중/하)소비심리 파트가 눈높이 골든존 가중치에 필요. Slotsprotected라 ① 팀원이 getter 제공 ② 상품 액터 Z좌표로 역산 중 택1 → 회의 안건서브시스템 or 팀원 헤더
계산대 복수 대응Counter가 단수. 배열화 + “어느 줄에 설지” 선택 로직서브시스템
재고 조회 캐시매번 GetAttachedActors() → 캐시 + 역인덱스서브시스템 .cpp 내부만
상품 들고 다니기Destroy() 한 줄을 Attach로 교체TakeProduct 내부

전부 이 파일 내부에 갇힌 변경입니다. 태스크·조건·Character·Spawner는 수정 불필요.

(어제 발견한 CartItemIds / SelectedItemIds 표기 불일치는 아직 남아 있습니다. 헤더 주석은 SelectedItemIds로 적혀 있으니 실제 코드 기준으로 통일이 필요합니다.)


오늘의 정리

배운 것내용
이름은 소유자를 드러내야 한다Store...는 소유 파트를 오해하게 만든다. 이름이 잘못되면 다른 파트가 여기에 데이터를 추가하려 한다
책임을 줄이는 것도 리팩터링EvaluatePurchase 제거로 “조회 + 실행 전담”이 되어 경계가 선명해졌다
교체 예정 로직은 얇은 껍데기로TakeRandomProductTakeProduct를 부르는 구조라, 교체 시 실행 로직은 무수정
제약이 더 나은 설계를 만들기도Slots가 protected라 택한 Attach 기반 판정이, 오히려 “진열해야 팔린다”는 게임 루프와 맞물렸다
BlueprintNativeEventExecute_ 경유직접 호출하면 BP 오버라이드가 무시된다. Cast<I...> vs Implements<U...> 구분도 함께
임시 코드는 태그를 붙여 남긴다지우면 검증 수단이 없어지고, 태그가 없으면 확정 로직으로 오인된다
판단 보류도 기록한다“선형 탐색을 지금 최적화하지 않는다”는 판단을 남겨두면 나중에 다시 고민하지 않는다
This post is licensed under CC BY 4.0 by the author.