TIL - 기술적 의사결정 사례

Kyu_·2026년 7월 22일

35주차

목록 보기
3/3

기술적 의사결정 사례

시스템을 처음 도입할 때 여러 선택지를 놓고 고민했던 기록. git 커밋 로그뿐 아니라 당시 설계 메모(세션 기록)까지 다시 뒤져서, 왜 그 방식을 택했는지 이유까지 정리했다.


1. 드롭 아이템(재화) — 복제 액터 대신 프록시 방식

해결하려는 문제: 몬스터가 죽을 때마다 재화를 드롭해야 하는데, 몹 하나당 드롭 개수가 여러 개일 수 있어서 방식에 따라 네트워크 비용이 크게 달라지는 상황이었다.

고려한 방법

  • 방법 1 — 복제 액터 방식: 재화 하나당 실제 AActor를 서버에서 스폰하고 모든 클라이언트에 복제.

    • 장점: 구현이 직관적. 리플리케이션을 엔진이 알아서 처리.
    • 단점: 몹당 드롭이 많아지면 액터 수만큼 복제 비용이 그대로 늘어남. 몹이 몰려 죽는 구간에서 트래픽이 급증할 위험.
  • 방법 2 — 서버 레지스트리 + 클라 로컬 비주얼: 서버는 복제 액터를 두지 않고 데이터(레지스트리)만 들고 있다가, 각 플레이어에게는 "너에게만 보이는" 프록시로 필요한 정보만 전달하고, 실제 눈에 보이는 비주얼은 클라이언트가 로컬로 만든다.

    • 장점: 드롭 개수가 늘어나도 복제되는 액터 수는 늘지 않음(플레이어당 프록시 1개로 고정).
    • 단점: 구조가 3단으로 나뉘어 초기 설계 비용이 큼. "서버 데이터 - 프록시 - 로컬 비주얼" 사이 동기화를 직접 관리해야 함.

최종 선택: 방법 2

선택 이유: 몹당 드롭 개수가 늘어나도 서버 복제 비용이 액터 수만큼 늘지 않는다는 점이 가장 컸다. 그리고 이미 투사체 시스템이 ProjectileManagerComponent(레지스트리) + ReplicationProxy + ProjectileVisual(클라 로컬)이라는 똑같은 3단 구조로 구현되어 있어서, 새 패턴을 발명하는 대신 이미 검증된 패턴을 그대로 미러링하기로 했다.

구현 구조

[몬스터 처치] 서버
      │
      ▼
UNSCurrencyDropSubsystem.RegisterDrop(종류, 등급, 수량, 위치...)
      │   서버 전용 레지스트리. ActiveDrops 맵에 데이터만 기록 (복제 액터 없음)
      │   DropId 발급
      │
      ▼
등록된 플레이어별 프록시(Proxies)에 전부 SendSpawnEvent
      │
      ▼
ANSCurrencyReplicationProxy (플레이어당 1개, Owner-Only 복제)
      │   Client_SpawnCurrency(Event) RPC로 "이 플레이어에게만" 전달
      │
      ▼
ANSLocalCurrencyPickup 생성 (클라이언트 로컬, 서버에 복제 안 됨)
      │   실제로 화면에 보이는 비주얼. 로컬에서 오버랩 감지
      │
      ▼
[플레이어가 주움] 클라 → 서버에 TryCollect(DropId) 요청
      │
      ▼
서버가 ActiveDrops 기준으로 재검증
      ├─ 성공 → SendRemoveEvent → Client_RemoveCurrency → 로컬 픽업 제거, 재화 지급
      └─ 실패 → SendRestoreEvent → Client_RestoreCurrency → 로컬 픽업 다시 보이게

결과

Before (방법 1 가정)After (방법 2 적용)
서버가 복제하는 액터 수드롭 개수만큼 증가플레이어당 프록시 1개로 고정
드롭 100개 발생 시복제 액터 100개 관리ActiveDrops 맵 데이터 100줄 + 이벤트 전송만
새 드롭 타입 추가매번 새로운 복제 구조 설계같은 3단 구조 재사용(파츠, 회복 아이템)

얻은 효과

항목내용
성능드롭 개수가 늘어나도 리플리케이션 비용이 "이벤트 데이터 크기 × 플레이어 수"로 고정. 액터 복제(Transform 등 매 틱 동기화)가 없어 대량 드롭 상황에서도 트래픽이 액터 수에 비례해 늘지 않음
유지보수투사체 시스템과 동일 구조라 한쪽을 이해하면 다른 쪽도 바로 이해됨. "서버 데이터 문제"와 "클라 비주얼 문제"가 계층으로 분리돼 원인 추적이 쉬움
확장성같은 패턴을 파츠 드롭(UNSDroppedPartRegistrySubsystem), 회복 아이템 드롭(UNSHealDropSubsystem)에도 재사용. 새 드롭 타입 추가 시 구조를 처음부터 고민할 필요 없음
협업드롭 확률·수치는 DataTable로 분리해 디자이너가 직접 관리, 코드는 연결부만 담당

2. 상호작용 시스템 — 상속 대신 인터페이스로 기능 분리

해결하려는 문제: 상호작용 반응 로직이 ANSInteractableActor(AActor) 하나에 고정돼 있었는데, 파츠 상점 NPC나 펫 NPC는 추후 AIController로 순찰·추적처럼 자율적으로 길을 찾아 움직여야 하는 대상이었다. AActorSetActorLocation 등으로 위치 자체는 옮길 수 있지만, Controller에게 Possess될 수 없고 내비게이션 이동 컴포넌트(UNavMovementComponent 계열)가 없어서 AI가 NavMesh 기반으로 자율 이동시키는 표준 경로를 쓸 수 없다. 이 기능은 APawn부터 생기고, ACharacterUCharacterMovementComponent까지 기본으로 갖춘 표준 클래스다. 즉 NPC는 ACharacter를 상속해야 했는데, 언리얼은 다중 상속이 안 되므로 AActor 기반의 기존 상호작용 클래스를 그대로 상속하면서 동시에 ACharacter를 상속할 수 없었다.

고려한 방법

  • 방법 1 — AActor 상속 구조를 유지: 상호작용 로직을 계속 ANSInteractableActor에 두고, NPC 쪽은 별도로 처리.

    • 장점: 기존 코드를 안 바꿔도 됨.
    • 단점: NPC 종류가 늘어날 때마다 "AI 이동은 되는데 상호작용은 안 되는" 문제가 반복됨. 기능(상호작용)과 정체성(AActor/ACharacter)이 묶여 있어 구조적으로 막힘.
  • 방법 2 — 인터페이스로 기능만 분리: 상호작용에 필요한 동작(CanInteract, OnInteract, 프롬프트 정보)을 INSInteractable 인터페이스로 빼서, NPC는 ACharacter를 상속하면서 이 인터페이스만 구현.

    • 장점: AI 이동이 필요한 대상(ACharacter)과 정적인 대상(AActor) 모두 같은 인터페이스로 상호작용 가능.
    • 단점: 기존 ANSInteractableActor를 상속하던 코드를 인터페이스 구현으로 다시 작성해야 함.

최종 선택: 방법 2

선택 이유: NPC는 AIController에게 Possess되어 자율 이동해야 하니 ACharacter(→APawn)가 필수인데, 다중 상속이 안 되는 언리얼 구조에서는 "기능"을 상속이 아니라 인터페이스로 떼어내는 것만이 유일한 해법이었다.

구현 구조

[Before]
ANSInteractableActor (AActor)
   └─ OnInteract 로직이 이 클래스에 고정
   └─ NPC는 AIController에게 Possess되어 자율 이동해야 함 → APawn/ACharacter 필요 → 다중상속 불가 → 재사용 불가

[After]
INSInteractable (인터페이스: CanInteract / OnInteract / GetPromptText / ...)
   ├─ ANSInteractableNPCBase : ACharacter, INSInteractable   (이동 가능 NPC)
   ├─ ANSDroppedPart        : AActor,     INSInteractable   (드롭 파츠)
   └─ ANSInteractableActor  : AActor,     INSInteractable   (정적 프롭 전용으로 범위 축소)

UNSInteractionComponent → INSInteractable::Execute_OnInteract(Target, PC)
   (대상이 뭔지 몰라도 인터페이스만 보고 호출)

결과

BeforeAfter
NPC 추가 시ACharacter 필요 → 기존 상호작용 베이스 재사용 불가INSInteractable만 구현하면 됨
상호작용 컴포넌트 코드대상 타입별 분기 필요인터페이스 호출 하나로 통일
정적 프롭상호작용 로직과 뒤섞임ANSInteractableActor로 역할 축소, 여전히 인터페이스 구현

얻은 효과

항목내용
성능직접적 영향 없음 — 구조적 결정
유지보수새 NPC/오브젝트를 추가할 때 상속 트리를 고민할 필요 없이 인터페이스 구현 여부만 확인하면 됨
확장성파츠 상점 NPC, 펫 NPC, 캐릭터 선택 NPC, 드롭 파츠까지 전부 같은 인터페이스로 확장됨
협업상호작용 로직 담당자가 대상의 내부 구현을 몰라도 인터페이스 계약만으로 연동 가능

3. 상호작용 시스템 — 감지 컴포넌트를 어디에 붙일 것인가

해결하려는 문제: 인터페이스로 기능은 분리했지만, "누가 상호작용 후보를 감지하고 판정하는가"는 별도로 정해야 했다.

고려한 방법

  • 방법 1 — 대상 액터(NPC/드롭 파츠)에 감지 컴포넌트 부착

    • 장점: 대상이 자기 감지 범위를 스스로 관리.
    • 단점: 상호작용을 "시작"하는 주체는 결국 플레이어인데, 감지·판정 로직이 대상마다 중복됨.
  • 방법 2 — PlayerController에 부착

    • 장점: 플레이어 쪽으로 옮겨서 중복은 줄었음.
    • 단점: 리슨서버에서는 컨트롤러가 클라이언트별로 다르게 존재해서, 로컬/원격 구분이 애매해짐.
  • 방법 3 — 캐릭터(Pawn)에 부착 (최종)

    • 장점: 판정 주체(플레이어)와 감지 위치가 정확히 일치. IsLocallyControlled()로 로컬/원격 구분도 명확.
    • 단점: 없음 — 실제로 두 방법을 먼저 시도해보고 나서 확정된 결론.

최종 선택: 방법 3

선택 이유: 실제로 감지 컴포넌트를 대상 액터 → 컨트롤러 → 캐릭터 순서로 세 번 옮겨본 뒤에야 확정했다. 컨트롤러는 리슨서버에서 클라마다 다르게 존재하지만, 판정 로직은 캐릭터(폰) 기준이 자연스럽다는 걸 직접 붙여보고 나서 알게 됐다.

구현 구조

[시도 1] 대상 액터(NPC/파츠) ← 상호작용 컴포넌트 부착
    → NPC/파츠마다 감지 로직 중복, 대상이 늘어날수록 반복 비용 증가

[시도 2] PlayerController ← 상호작용 컴포넌트 부착
    → 리슨서버에서 컨트롤러가 클라마다 다르게 존재 → 로컬/원격 구분 애매

[최종] ACharacter(Pawn) ← UNSInteractionComponent 부착
    → EnableLocalInteraction()에서 IsLocallyControlled() 확인 후에만 감지 활성화
    → 판정 주체(플레이어)와 감지 위치가 일치

결과

Before (시도 1~2)After (최종)
감지 로직 위치대상별 중복 또는 컨트롤러 종속캐릭터 하나에 통일
리슨서버 로컬/원격 구분애매함 (실제로 위젯 누출 버그로 드러남)IsLocallyControlled()로 명확
대상 추가 비용대상마다 감지 로직 추가인터페이스만 구현, 감지는 그대로

얻은 효과

항목내용
성능오버랩 후보가 없으면 틱을 끄는 최적화를 캐릭터 컴포넌트 한 곳에서만 관리하면 됨
유지보수리슨서버 관련 버그(호스트 화면에 클라 위젯 노출 등)가 났을 때 확인할 곳이 한 컴포넌트로 좁혀짐
확장성NPC/드롭 오브젝트 종류가 늘어도 캐릭터 쪽 감지 로직은 변경 없음
협업"상호작용이 이상하다"는 리포트가 오면 항상 같은 컴포넌트부터 확인하면 되는 관례가 생김

4. 파츠 시스템 — 능력치 적용 방식 (개별 GE → 공용 GE)

해결하려는 문제: 파츠를 장착하면 능력치가 올라야 하는데, 파츠 종류가 계속 늘어날 예정이라 "파츠 하나당 GameplayEffect 하나"로 가면 애셋이 선형으로 늘어날 게 뻔했다.

고려한 방법

  • 방법 1 — 부위별로 다른 GAS 연동 방식 (초기 설계): 바디 파츠(체력↔실드 연동)는 MMC(ModifierMagnitudeCalculation)로 MaxHealth 기반 계산, 암/레그 파츠(단순 증가형)는 SetByCaller로 값 주입.

    • 장점: 각 부위 특성에 맞는 계산 방식을 쓸 수 있음.
    • 단점: 파츠 종류가 늘어날수록 GE 애셋도 함께 늘어남. 부위별로 다른 방식을 쓰다 보니 관리 규칙이 두 갈래로 나뉨.
  • 방법 2 — 공용 GE + StatTag 매핑 (최종): 파츠 종류와 무관하게 GE 하나(SharedPartEffectClass)를 두고, 이 GE에 모든 스탯의 Modifier를 미리 정의해둔 뒤, 파츠가 담당하는 스탯 하나만 SetByCaller로 값을 채움.

    • 장점: 파츠가 몇 개든 GE 애셋은 1개. 새 파츠 추가 시 GE를 새로 만들 필요 없이 StatTag만 지정.
    • 단점: 매핑 테이블(StatTag → SetByCaller 태그/Attribute) 관리가 필요하고, 값이 잘못 채워지면 원인 추적이 한 단계 더 필요.

최종 선택: 방법 2. 다만 처음부터 방법 2로 시작한 게 아니라, 방법 1로 먼저 구현하고 파츠 수가 늘어나면서 애셋 관리 비용이 커지는 걸 실제로 겪은 뒤 방법 2로 다시 통합했다.

선택 이유: 파츠 종류가 늘어날수록 개별 GE 방식의 관리 비용이 선형으로 커진다는 게 실제로 드러났기 때문이다. 처음부터 정답을 맞히기보다, 실제 파츠 수가 늘어났을 때의 비용을 겪어보고 통합하는 순서를 밟았다.

구현 구조

[Before] 파츠 A → GE_PartA (Modifier: StatA)
         파츠 B → GE_PartB (Modifier: StatB)
         파츠 N → GE_PartN (...)              ← 파츠 종류만큼 GE 애셋 증가

[After]  파츠 A/B/N → SharedPartEffectClass (GE 1개, 모든 스탯 Modifier 내장)
              │
              ▼
   NSCombatStatAttribute::InitializeNeutralSetByCallers(Spec)   모든 칸을 중립값(Add=0, Multiply=1)으로 채움
              │
              ▼
   StatTag → 매핑 테이블에서 이 파츠가 채울 SetByCaller 태그 결정
              │
              ▼
   Spec.SetSetByCallerMagnitude(그 태그, Part->CurrentValue)   해당 칸만 실제 값으로 덮어씀

결과

Before (개별 GE)After (공용 GE)
파츠 10종 추가 시GE 애셋 10개 추가 필요GE 애셋 0개, StatTag만 지정
새 스탯 종류 추가관련 GE들을 각각 수정매핑 테이블 + 공용 GE 한 곳만 수정
관리 방식부위별로 MMC/SetByCaller 두 갈래SetByCaller 방식으로 통일

얻은 효과

항목내용
성능GE 애셋 수가 늘지 않아 로드/평가해야 할 GameplayEffect 종류가 고정됨
유지보수능력치 적용 버그가 나면 "공용 GE 자체"와 "매핑 테이블" 두 곳만 확인하면 됨
확장성새 파츠 추가가 "StatTag 지정 + 값 범위 DT 등록"으로 끝남, GE 신규 제작 불필요
협업파츠 밸런스 담당자가 DT에 값만 넣으면 되고, GE 구조는 건드릴 필요 없음

5. 파츠 시스템 — 슬롯 식별 방식 (enum → GameplayTag)

해결하려는 문제: 파츠가 장착되는 부위(슬롯)를 어떻게 식별할지 정해야 했다. 초기엔 "Slot/Rarity는 enum 유지, 태그 불필요"로 결정했었는데, 슬롯 종류가 데이터 주도로 늘어날 필요가 생겼다.

고려한 방법

  • 방법 1 — enum (초기 결정): ENSPartSlot { Body, Arm, Leg } 같은 고정 열거형.

    • 장점: 타입 안전, 코드에서 다루기 쉬움.
    • 단점: 새 슬롯을 추가하려면 코드를 고치고 재컴파일해야 함. UI도 enum 값을 코드로 순회하며 하드코딩된 위젯을 만들어야 함.
  • 방법 2 — GameplayTag (전환 후): FGameplayTag Slot, 실제 슬롯 목록은 DataTable 행으로 정의.

    • 장점: 슬롯을 코드 수정 없이 데이터 추가만으로 늘릴 수 있음. UI도 슬롯 DT를 순회해 동적으로 위젯을 만들 수 있음.
    • 단점: 태그 오타/미등록 시 런타임에야 문제가 드러남(컴파일 타임 체크 불가).

최종 선택: 방법 2

선택 이유: 슬롯 개수/종류가 데이터 주도로 늘어날 필요가 생기면서, enum은 늘어날 때마다 재컴파일이 필요하다는 제약이 계속 걸림돌이 됐다.

구현 구조

[Before] enum ENSPartSlot { Body, Arm, Leg }        ← 새 슬롯 추가 시 코드 수정 + 재컴파일
              │
              ▼
   UI가 enum 값을 하드코딩으로 순회 → 슬롯 위젯 3개 고정 생성

[After]  FGameplayTag Slot  (DT_PartSlot 행 = 슬롯 정의)  ← 데이터 추가만으로 슬롯 증가
              │
              ▼
   DataSubsystem::GetAllSlotRows() 순회 → 슬롯 수만큼 위젯 동적 생성

결과

Before (enum)After (GameplayTag)
슬롯 추가코드 수정 + 재컴파일DT에 행 추가
UI 슬롯 목록하드코딩 순회DT 기반 동적 생성
장착/해제 판정 로직enum 값 직접 비교태그 매칭

얻은 효과

항목내용
성능태그 비교는 enum 비교보다 약간 비용이 있지만, 슬롯 수가 적어 체감 차이는 없음
유지보수슬롯 관련 로직이 "슬롯이 몇 개인지 몰라도" 동작하도록 바뀜
확장성새 슬롯 추가가 순수 데이터 작업으로 끝남 (단, 이 전환 도중 장착/해제 회귀 버그가 한 번 발생해서, 구조 전환 시 영향 범위를 미리 점검하는 습관이 생김)
협업기획자가 슬롯 구성을 DT에서 직접 조정 가능

6. 증강 시스템 — 등급별 GAS 연동 방식 (Legendary 전용 처리 vs 전체 태그 통일)

해결하려는 문제: 증강은 등급(Common/Rare/Epic/Legendary)이 있는데, Legendary만 "보유 시 시너지가 발동하는" 특별한 조건이 필요해 보였다. 이걸 등급별로 다르게 처리할지, 전체를 하나의 방식으로 통일할지 정해야 했다.

고려한 방법

  • 방법 1 — Legendary만 특별 취급: C/R/E는 기존처럼 수치 적용만 하고, Legendary만 별도로 "보유 여부" 추적 로직을 추가.

    • 장점: 당장 필요한 것(Legendary 시너지)만 구현하면 됨.
    • 단점: 나중에 C/R/E 등급에도 "이 증강을 갖고 있으면" 조건이 필요해지면 구조를 또 나눠야 함. 등급마다 처리 방식이 달라 코드가 두 갈래로 분기됨.
  • 방법 2 — 모든 등급을 공통 태그로 관리: UNSAugmentDefinitionGrantedTag 필드를 두고, 모든 등급이 이 방식을 공유. GE의 GrantedTags에 설정해 적용/제거 시 ASC 태그맵에 자동 반영.

    • 장점: 등급과 무관하게 "이 증강 보유 시" 조건을 코드 변경 없이 GE의 RequiredTags로 에디터에서만 연결 가능.
    • 단점: Legendary 전용 로직보다 초기 설계가 한 단계 더 필요(태그 필드, GrantedTags 세팅).

최종 선택: 방법 2

선택 이유: C/R/E 등급도 나중에 "이 증강을 갖고 있으면" 조건이 생길 수 있다고 판단했고, Legendary만 따로 처리하면 그 시점에 구조를 다시 나눠야 하는 게 뻔했다. 처음부터 통일해두면 증강을 추가할 때마다 코드 변경 없이 태그만 설정하면 된다.

구현 구조

[방법 1 - Legendary만 특별 취급]
   UNSAugmentDefinition (C/R/E)       → 수치만 관리
   UNSAugmentDefinition (Legendary)   → GrantedTag + 시너지 조건   ← 등급별로 다른 구조

[방법 2(최종) - 전체 공통 태그]
   UNSAugmentDefinition (모든 등급) → GrantedTag 필드
              │
              ▼
   GE.GrantedTags = 이 태그   → 적용/제거 시 ASC 태그맵 자동 반영 ("보유 여부" 추적)
   GE.RequiredTags = 다른 증강 태그  → "이 증강 보유 시" 시너지, 코드 수정 없이 에디터에서 연결
              │
   FNSAugmentInstance → 스택 수("몇 개 보유")는 별도로 관리 (태그와 역할 분리)

결과

Before (방법 1 가정)After (방법 2 적용)
C/R/E 시너지 조건 추가 시구조를 새로 나눠야 함이미 있는 태그 방식 그대로 사용
증강 보유 추적등급별로 다른 코드 경로태그 하나로 통일
시너지 조건 연결코드 수정 필요GE RequiredTags 에디터 설정만

얻은 효과

항목내용
성능GAS의 태그맵 조회는 이미 최적화된 경로라 추가 비용이 거의 없음
유지보수"보유 여부"(태그)와 "스택 수"(인스턴스 데이터)의 책임이 분리돼 헷갈릴 여지가 적음
확장성신규 증강 추가 시 태그만 붙이면 기존 시너지 시스템에 바로 편입됨
협업시너지 조건을 코드 담당자 없이 GE 에디터 설정만으로 기획자가 조정 가능

7. 증강 시스템 — 입력 처리 방식 (IMC 스위칭 → 비차단 게이팅)

해결하려는 문제: 증강 선택창이 열려 있을 때 캐릭터 입력(이동/공격)을 어떻게 처리할지 정해야 했다.

고려한 방법

  • 방법 1 — IMC 스위칭 (초기 설계): 증강창이 열리면 전용 IMC_Augment로 통째로 갈아끼워 게임 입력을 차단하는 모달 방식.

    • 장점: "증강창이 열려 있으면 다른 입력은 무시"라는 규칙을 IMC 레벨에서 강제할 수 있어 구현이 직관적.
    • 단점: 게임 설계가 "증강창이 떠 있어도 이동/전투가 되어야 한다"로 바뀌면서 전제 자체가 깨짐. 다른 UI(파츠 패널 등)와 동시에 열리는 경우 IMC 우선순위 관리가 복잡해짐.
  • 방법 2 — 비차단 게이팅 + 대기열 (최종): 증강 관련 키(1/2/3/T)를 기존 Gameplay IMC에 그대로 두고, C++에서 IsAugmentationPanelOpen() 여부로만 게이팅. 여러 증강 제안이 겹칠 수 있는 상황을 대비해 대기열(PoolQueue, PendingCount)도 함께 도입.

    • 장점: 이동/전투가 항상 정상 동작. IMC를 하나만 유지해 다른 UI와의 충돌 여지가 줄어듦.
    • 단점: 기존 IMC 스위칭 코드(EnterAugmentInputMode/ExitAugmentInputMode 등)를 전부 삭제하고 다시 만들어야 함.

최종 선택: 방법 2

선택 이유: 게임 설계 방향이 "증강창은 비차단 오버레이"로 확정됐고, 증강 관련 키(1/2/3/T)가 스킬 키와 안 겹쳐서 애초에 입력 모드를 통째로 바꿀 이유가 사라졌다.

구현 구조

[Before] 증강창 오픈 → IMC_Augment로 전체 스위칭 → 이동/공격 등 게임 입력 차단(모달)
         패널 닫힘 → 원래 IMC로 복귀

[After]  증강 입력(1/2/3/T)이 항상 Gameplay IMC 안에 존재
              │
              ▼
   Input_AugmentAction 실행 → UIManager->IsAugmentationPanelOpen() 확인만으로 게이팅
              │
              ▼
   이동/공격/스킬은 항상 그대로 동작 (비차단 오버레이)
              │
   여러 증강 제안 발생 시 → PoolQueue(대기열)에 적재 → 순서대로 PendingCount만큼 순차 제시

결과

Before (IMC 스위칭)After (비차단 게이팅)
증강창 열림 중 이동/공격차단됨(모달)정상 동작
다른 UI와 동시 오픈IMC 우선순위 충돌 가능IMC 하나로 유지, 충돌 없음
여러 증강 제안 동시 발생처리 순서 불명확대기열로 순차 처리

얻은 효과

항목내용
성능IMC를 매번 갈아끼우는 비용이 없어짐
유지보수입력 게이팅 로직이 "패널 열림 여부 체크" 한 줄로 단순화
확장성파츠 UI 등 이후 다른 오버레이 UI도 같은 게이팅 패턴을 참고해 설계됨
협업기획 쪽 요구사항("비차단으로 바꿔달라")이 코드 구조 변경 없이 게이팅 조건 하나로 수용됨

8. 세이브 시스템 — 영구 데이터 저장 방식 (구조체 전체 vs ID)

해결하려는 문제: 파츠 보유 정보를 세이브 파일에 어떻게 저장할지 정해야 했다.

고려한 방법

  • 방법 1 — 구조체 통째로 저장 (초기 구현): 파츠의 모든 필드(정의, 등급, 수치 등)를 구조체 그대로 세이브 파일에 직렬화.

    • 장점: 로드 시 바로 쓸 수 있는 완성된 데이터.
    • 단점: 파츠 정의(DT 수치)가 나중에 바뀌면, 이미 저장된 세이브 파일의 값과 최신 정의가 어긋나는 마이그레이션 문제가 생김. 저장 데이터 크기도 더 큼.
  • 방법 2 — ID만 저장 (전환 후): 파츠의 정의 ID만 저장하고, 로드 시 DT에서 최신 정의를 다시 조회해 복원.

    • 장점: 파츠 정의가 바뀌어도 항상 최신 값으로 복원되어 마이그레이션 문제가 없음. 저장 데이터 크기도 작음.
    • 단점: 로드 시점에 DT 조회가 한 번 더 필요(단, 이미 DT는 캐싱돼 있어 비용은 작음).

최종 선택: 방법 2

선택 이유: 파츠 자체가 DA/DT로 정의돼 있으니, 저장 시엔 ID만 들고 있다가 로드 시 DT를 조회해 복원하는 쪽이 데이터량과 마이그레이션 안정성 면에서 유리하다는 걸 확인했기 때문이다.

구현 구조

[Before] SaveGame.OwnedParts: TArray<FNSPartFullStruct>   ← 파츠 전체 스탯/정의를 통째로 저장
              │
              ▼
   파츠 DT 수치가 바뀌면 저장된 값과 최신 정의가 어긋남 (마이그레이션 문제)

[After]  SaveGame.OwnedParts: TArray<FNSPartId>           ← ID만 저장
              │
              ▼
   로드 시 DataSubsystem::GetPartRow(Id)로 최신 정의를 다시 조회해 복원

결과

Before (구조체 저장)After (ID 저장)
파츠 정의 수치 변경 시기존 세이브와 값이 어긋남항상 최신 정의로 복원, 문제 없음
세이브 파일 크기필드 수만큼 큼ID만 저장, 작음
로드 로직저장값 그대로 사용DT 조회 1회 후 복원

얻은 효과

항목내용
성능저장 파일 크기가 작아 저장/로드 자체가 가벼움
유지보수파츠 밸런스를 수정해도 세이브 마이그레이션 스크립트가 필요 없음
확장성같은 "ID만 저장, 로드 시 DT 복원" 원칙이 이후 다른 시스템(가이드 진행 상태 등)의 저장 필드에도 참고 기준이 됨
협업밸런스 담당자가 DT 값을 자유롭게 조정해도 기존 플레이어 세이브가 깨지지 않음

9. 가이드(온보딩) 시스템 — 안내 항목 구조 설계

해결하려는 문제: 유저 테스트에서 "조작법을 모르겠다", "인런에서 뭘 해야 하는지 모르겠다"는 피드백을 받았다. 화면에 안내를 띄워야 하는데, 안내할 내용이 하나가 아니라 여러 개(이동/점프/대시 3가지, 이후 캐릭터 콘솔·게임시작 콘솔·NPC 방문까지)이고, 이 개수는 앞으로도 계속 늘어날 걸로 예상됐다.

고려한 방법

  • 방법 1 — 단일 텍스트 위젯 (실제로 07/14에 먼저 구현했던 방식): HUD에 텍스트 위젯 하나를 두고, 지금 안내해야 할 문구를 DataTable에서 읽어와 그 위젯에 갈아 끼움.

    • 장점: 구현이 가장 빠르고 단순함. 위젯 하나, 문구 DT 하나로 끝남.
    • 단점: 안내 항목이 "동시에 여러 개" 표시돼야 하는 순간(조작법 3단계) 표현이 안 됨. 항목별로 "이건 끝났고 이건 안 끝났다"는 상태를 표시할 방법이 없어, 결국 항목이 늘어날 때마다 위젯을 다시 설계해야 함.
  • 방법 2 — 배열 기반 체크리스트 위젯 (최종 채택): 안내 항목을 배열 데이터로 구성하고, 항목 하나짜리 엔트리 위젯 + 그걸 여러 개 담는 컨테이너 위젯으로 표시. 항목마다 개별 완료 처리가 가능.

    • 장점: 항목이 몇 개든 데이터(배열)만 추가하면 위젯/로직 변경 없이 반영됨. 개별 항목 완료와 "전체 완료 시 컨테이너 닫기" 판정을 컨테이너가 일괄 처리.
    • 단점: 엔트리 위젯 + 컨테이너 위젯 두 개를 새로 만들어야 하고, 이미 만들어 둔 단일 텍스트 위젯을 통째로 버려야 함. 초기 구현 비용이 방법 1보다 큼.

최종 선택: 방법 2

선택 이유: 유저 피드백이 "조작법"에서 끝나지 않고 "인런에서 뭘 해야 하는지"까지 이어졌기 때문에, 안내 항목이 하나로 끝나지 않고 계속 늘어날 게 뻔했다. 실제로도 이동/점프/대시 3개 → 캐릭터 콘솔 → 게임시작 콘솔 → NPC 방문까지 늘어났다. 방법 1로 계속 밀고 갔다면 항목이 늘 때마다 위젯을 다시 만들어야 했을 것이고, 방법 2는 늘어나는 부분을 데이터로만 흡수하기 때문에 장기적으로 비용이 훨씬 적다고 판단했다.

구현 구조

[씬 진입] PlayerController → GuideSubsystem.StartGuide()
      │
      ▼
세이브/DataTable 준비 확인 (아직 안 됐으면 준비 완료 콜백을 기다렸다가 재시도)
      │
      ▼
GetActiveStageRowName()  ← 아웃런/인런 파생 클래스가 "지금 몇 번째 단계인지"만 판단
      │
      ├─ 진행할 단계 없음 → HUDWidget.HideGuideChecklist()
      │
      ▼
BuildStageEntries(RowName, OutEntries)  ← 파생 클래스가 이 단계의 안내 항목을 배열로 구성
      │
      ▼
HUDWidget.ShowGuideChecklist(Entries)   컨테이너 위젯이 항목 수만큼 엔트리 위젯을 생성
      │
   [플레이어가 이동/점프/대시 입력, 콘솔 사용, NPC 방문 등 실제 행동]
      │
      ▼
NotifyXxxInput() → 세이브 플래그 갱신 → CompleteItem(ItemId)  해당 줄만 완료 애니메이션
      │
      ▼
(컨테이너의 모든 줄이 완료되어 비워짐) → NotifyChecklistEmptied()
      │
      ▼
GetActiveStageRowName() 재호출 → 다음 단계 있으면 위 과정 반복, 없으면 숨김

공용 골격(UNSGuideSubsystemBase)이 "준비 확인 → 단계 판단 → 항목 표시 → 완료 감지 → 재평가"라는 흐름 전체를 갖고 있고, 아웃런/인런 파생 클래스는 GetActiveStageRowName()BuildStageEntries() 두 함수만 채운다.

결과

Before (방법 1, 07/14)After (방법 2, 07/21)
안내 항목 표현텍스트 한 줄, 순차 교체배열 항목, 동시에 여러 줄 + 개별 완료 표시
새 안내 단계 추가위젯 로직을 다시 손봐야 함DT/배열에 항목만 추가
대상 씬아웃런 전용 서브시스템공용 베이스 + 아웃런/인런 파생으로 확장

얻은 효과

항목내용
성능CurrentChecklistStage로 "지금 표시 중인 단계와 같으면 재스폰하지 않음" 가드를 둬서, 매 입력마다 위젯을 다시 만드는 대신 필요한 시점에만 컨테이너를 새로 그림
유지보수신규 안내 단계를 추가할 때 건드릴 곳이 파생 클래스의 두 함수(GetActiveStageRowName, BuildStageEntries)로 고정돼 있어, 공용 골격 코드는 안 건드려도 됨
확장성아웃런 전용으로 시작한 구조가 인런 튜토리얼까지 커버하도록 그대로 확장됐고, 이후 웨이포인트 마커 시스템도 이 서브시스템의 판단 결과를 그대로 받아 쓰는 식으로 얹혔음
협업안내 문구는 GuideText DataTable로 분리돼 있어 기획자가 문구만 수정 가능하고, 코드는 "몇 번째 단계인지"와 "그 단계에 뭘 보여줄지"만 담당

0개의 댓글