TIL - 인런 상점 구현 및 트러블슈팅

Kyu_·2026년 7월 6일

33주차

목록 보기
1/4

인런 상점 구현

변경 사항

  • 인런에서 파츠 구매/리롤/업그레이드 구현
  • Upgrade DT 추가
  • 파츠 Definition에서 파츠 정보를 사용, UpgradeDT에서는 레어도, 레어도별 가격, Reroll 가격등 사용

참고 사항

  • CommonDataConfig에 Upgrade추가
    스크린샷 2026-07-06 130238

  • Data > Reward > DT_PartUpgrade
    스크린샷 2026-07-06 130252

Rarity : 레어도
Value Range : 스텟 증감폭
Reroll Base Cost : 초기 리롤 비용
Reroll Cost Increment : 리롤 횟수마다 증가비용
Upgrade Success Chance : 다음 레어도로 업글될 확률(0~1)
Shop Weight : 각 레어도별 인런 상점 등장 확률
Shop Price : 각 레어도별 상점 가격
임의로 배치, 추후 변경 가능

  • 인런 파츠 NPC위치 Interaction > NPC > PartUpgradeNPC
    스크린샷 2026-07-06 130226

트러블 슈팅

인런 상점 클라 문제

클라에서는 인런 상점 재화가 즉시 줄어들지 않음

리플레케이션 확인

보통 클라에서 이런문제들은 리플레케이션 문제가 많기때문에 리플리케이션 경로를 확인하기로 하였다.
UNSCurrencyComponent의 재화 저장 구조를 추적했는데 모두 정상이였다.

FastArraySerializer 특수화 누락

현재 FNSCurrencyWalletFFastArraySerializer를 상속받고 NetDeltaSerialize()도 직접 구현해뒀지만, TStructOpsTypeTraits<FNSCurrencyWallet> 특수화가 누락되어있었다. 이게 없으면 언리얼 리플리케이션 시스템이 이 구조체에 커스텀 NetDeltaSerialize를 아예 호출하지않고 일반 구조체 복사로 처리한다고 한다.
하지만 FastArray 전용 콜백인 PostReplicateAdd, PostReplicatedChange는 FastArray 델타 경로에서만 호출 -> 클라에서 발동하지 않음 OnTempChanged가 안터짐. 즉, 위젯이 갱신될 여지가 없음
호스트는 멀쩡

결론

결과 통지 RPC에 잔액을 실어보내기로 하였다.
이미 존재하던 reliable Client RPC에 서버가 계산한 차감 후 잔액을 함께 실어 보내는 방식으로 전환을 하기로 마음먹었다.
구매/리롤/업그레이드가 끝나면 서버는 어차피 Client_NotifyUpgradeResult(Slot, Result)를 호출해 클라에 성공/실패/재화부족 등을 알려주고 있었다. 여기에 파라미터 하나만 추가하면 별도 인프라 없이 "결과와 동시에 정확한 잔액"을 보장할 수 있었다.

// NSPartEquipComponent.h
UFUNCTION(Client, Reliable)
void Client_NotifyUpgradeResult(FGameplayTag Slot, ENSPartUpgradeResult Result, int64 NewTempBalance);

RerollStat / UpgradeRarity / Server_RequestPurchase의 모든 성공/실패 분기에서 호출 시점의 Currency->GetTemp()를 함께 전달하도록 수정. 위젯 쪽은 HandleUpgradeResult가 이 값을 받아 Wallet 리플리케이션 도착을 기다리지 않고 즉시 TempBalanceText에 반영한다.

// NSPartUpgradeWidget.cpp
void UNSPartUpgradeWidget::HandleUpgradeResult(FGameplayTag PartSlot, ENSPartUpgradeResult Result, int64 NewTempBalance)
{
    SetBalanceText(NewTempBalance);  // Wallet 프로퍼티 복제와 무관하게 즉시 갱신
    RefreshBuyBox();
    RefreshUpgradePanels();

    OnUpgradeResultReceived(PartSlot, Result);  // WBP 이펙트/토스트용, 시그니처 유지
}

HandleUpgradeResult 함수로 결과를 처리하는데 여기서 텍스트를 바로 갱신해주는 식으로
OnUpgradeResultReceived는 토스트용인데 아직 사용안하고 있음

왜 이 방식을 선택했는가

  • 타이밍 의존성 제거: 프로퍼티 리플리케이션은 NetUpdateFrequency, 우선순위, 대역폭 등 여러 변수에 걸쳐 있어 "언제 도착하는지"를 코드에서 직접 보장하기 어렵다. 반면 Client, Reliable RPC는 이미 결과 통지용으로 호출되고 있었으므로, 여기에 값 하나를 얹는 것만으로 반영 시점을 결과 통지 시점과 완전히 동일하게 고정할 수 있었다.
  • 최소 변경: 새 리플리케이션 채널이나 별도 동기화 인프라를 추가하지 않고, 기존 RPC 파라미터 확장 + 델리게이트(FNSOnUpgradeResult) 파라미터 확장만으로 해결됨. 둘 다 C++ 내부에서만 쓰이는 델리게이트/RPC라 블루프린트 쪽 영향이 없었다 (OnUpgradeResultReceived BlueprintImplementableEvent는 시그니처를 그대로 둬서 WBP 재작업 불필요).
  • 트레잇 수정은 유지: TStructOpsTypeTraits 특수화 자체는 틀린 코드가 아니고 여전히 필요하므로 되돌리지 않았다. 다만 그것만으로 실시간성을 "보장"하기엔 리플리케이션 타이밍이라는 외부 변수가 남아있어, RPC 기반의 확정적인 경로를 추가로 얹어 이중 안전판을 둔 구조가 되었다.

파츠 3D 프리뷰 이슈

클라이언트에서 아웃런NPC의 3D프리뷰가 이상하게 나오던 문제 수정 (모든 파츠가 다 나옴)

스크린샷 2026-07-06 122633

원인 조사

ANSPartPreviewStage 생성자를 보면 CaptureComponentShowOnlyActors.Add(this)를 걸어뒀다 — 이 캡처가 "자기 자신(스테이지 액터) 소속 컴포넌트만" 찍도록 의도한 설계였다.

그런데 실제로는 PrimitiveRenderMode가 기본값(PRM_RenderScenePrimitives)으로 남아있었다. ShowOnlyActors 목록은 PrimitiveRenderModePRM_UseShowOnlyList일 때만 적용되고, 기본 모드에서는 통째로 무시되어 씬 전체가 캡처된다.

그동안 문제가 안 보였던 이유는 스테이지의 배경판(BackdropComponent)이 뒤를 가려줬기 때문. 하지만 UNSDataSubsystem::OnOutGameReferenceAssetsLoaded()에서 호출하는 ANSPartPreviewStage::WarmupAllPartMeshes()3D 프리뷰 텍스처 밉을 미리 로드해두려고 모든 파츠 메시를 상주 컴포넌트로 붙인 별도의 "워밍업 스테이지"를 프리뷰 스테이지와 같은 위치 (0, 0, -1000)에 스폰한다. 캡처가 ShowOnly 필터 없이 씬 전체를 찍으니, 같은 좌표에 겹쳐 있는 워밍업 스테이지의 파츠 메시들까지 전부 프레임에 들어와 "여러 파츠가 한 스켈레탈에 붙은 것처럼" 보인 것


1차 수정: PrimitiveRenderMode를 UseShowOnlyList로 전환

// ANSPartPreviewStage 생성자
CaptureComponent->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;
CaptureComponent->ShowOnlyActors.Add(this);

이제 캡처가 ShowOnlyActors 목록에 있는 액터(자기 자신)의 컴포넌트만 렌더링하도록 명시


회귀: 클라/서버 가릴 것 없이 검은 화면만 나옴

위 수정 직후 재빌드해서 테스트하니, 이번엔 호스트든 클라든 프리뷰가 아예 검은 화면만 나왔다.

원인

ShowOnlyActors.Add(this)생성자에서 호출한 게 문제였다. 언리얼 오브젝트 생성 순서상, 생성자 실행 후 CDO(클래스 기본 오브젝트)로부터 프로퍼티가 복사/적용되는 단계가 있는데, CDO의 ShowOnlyActors 배열에는 (CDO 스스로가 생성자에서 Add(this)를 호출했을 때의) CDO 자기 자신에 대한 포인터가 들어 있다. 그 값이 실제 스폰된 인스턴스에 그대로 복사되면서, 정작 스폰된 인스턴스의 ShowOnlyActors 목록은 자기 자신이 아니라 CDO를 가리키게 된다.

PRM_UseShowOnlyList를 켜기 전에는 이 목록 자체가 무시됐으니 문제가 드러나지 않았지만, 목록을 실제로 사용하는 순간 "목록에 유효한(현재 인스턴스에 해당하는) 액터가 없음 → 아무것도 렌더링 안 됨 → 검은 화면"이 된 것이다.


2차 수정: ShowOnlyActors 등록을 BeginPlay로 이동

// 생성자 — 렌더 모드만 설정
CaptureComponent->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;

// BeginPlay — 인스턴스가 확정된 시점에 등록
void ANSPartPreviewStage::BeginPlay()
{
    Super::BeginPlay();

    CaptureComponent->ShowOnlyActors.Reset();
    CaptureComponent->ShowOnlyActors.Add(this);
    ...
}

BeginPlay는 실제로 스폰되어 월드에 존재하는 인스턴스에서 호출되므로, 이 시점에 등록하면 CDO 복사 문제 없이 항상 올바른 this가 들어간다.


왜 이 방식을 선택했는가

  • 원인에 정확히 대응하는 최소 수정: "왜 씬 전체가 찍히는가"(렌더 모드 누락)와 "왜 검은 화면이 되는가"(생성자에서의 self-reference가 CDO 복사에 덮어써짐)는 서로 다른 원인이었고, 각각을 정확히 짚어 한 줄씩만 손댔다. 렌더 모드는 생성자에 남겨도 무방한 정적 설정이라 그대로 두고, 인스턴스별로 달라야 하는 this 참조만 BeginPlay로 옮겼다.
  • 다른 스테이지 인스턴스와 충돌 없음: 프리뷰 스테이지(카탈로그/인런 상점)와 워밍업 스테이지가 같은 클래스를 같은 좌표에 여러 개 띄우는 구조이기 때문에, "자기 자신만 보이게"라는 원래 설계 의도(ShowOnlyActors)를 실제로 동작하게 만드는 것이 유일한 근본 해결책이었다 — 배경판으로 가리거나 좌표를 분리하는 식의 우회는 워밍업 메시 개수가 늘어날 때마다 다시 깨질 수 있는 임시방편이라 채택하지 않았다.

0개의 댓글