26년 9월

2주차

이즈나 섬광탄을 맞은 상태에서 한번 더 맞을 경우 크래시 발생 수정, Statistic 복구 안되는 현상 수정
문제

  • 이즈나가 섬광에 연속 피격되면 이동 속도가 정상으로 복구되지 않고, 섬광 GamplayEffect 타이머 만료 후 다시 피격될 때 크래시가 발생

원인

  • 중첩 시 새 GameplayEffect를 적용하고 타이머도 바꿨지만, 활성 목록에는 이전 GameplayEffect가 남았다.
    타이머 만료 시 새 GameplayEffect만 해제하고 스택 정보를 삭제하면서 이전 GameplayEffect가 남았고, 다음 피격에서 삭제된 새 GameplayEffect 스택 정보를 조회해 크래시가 발생했다.

수정

  • 같은 EffectIdentifier일 때, 새로운 GameplayEffect를 등록하지 않고, 중첩하도록 변경
  • EffectIdentifier를 키로 하는 TMap으로 활성 효과를 관리

고민한 내용

  • GameplayEffect 객체를 매번 교체하기보다 식별자별 객체 하나에 적용과 해제 책임을 모아 관리 상태가 어긋나지 않도록 했다.
  • 같은 EffectIdentifier의 GameplayEffect가 여러 번 적용되면 능력치가 바뀐 내역을 모두 보관하고, GameplayEffect 종료 시누적된 변경을 전부 되돌리도록 변경

     

호시노 방패가 화면 전환을 따라가지 못하는 현상 수정
문제

  • 호시노가 방패를 장착하고 방향을 돌리면, 다른 플레이어 화면에서 방패가 캐릭터의 손과 어긋났다.

원인

  • 캐릭터와 장비가 RootYawOffset을 독립적으로 누적해서 계산하는데, 캐릭터와 달리 장비 애니메이션 21개에는 보정값을 해소하는 커브가 없어, 캐릭터와 최대 관측 각도 차이 178.76°가 발생했다.
    • 해당 커브는 제자리 회전에서 일정 각도를 화면 전환해야 캐릭터가 회전하는 임계값을 나타내는 커브

수정

  • 장비의 독립 계산을 제거하고, 부착된 3인칭 캐릭터의 보정값을 공유하도록 변경했다.
  • 1인칭이나 유효한 원본이 없는 경우에는 0으로 초기화한다.

고민한 내용

  • 선택 근거: 장비에 회전 커브와 상태를 복제하면 두 계산을 계속 맞춰야 한다. 회전 결정은 캐릭터가 소유하고, 장비는 결과만 받아 포즈에 적용하도록 책임을 정리했다.
  • 보정값 불일치와 방패 커브 부재를 확인했다.

 

1주차

장비 교체시 쿨타임이 초기화되는 현상 수정
문제

  • 스킬 사용 후 쿨타임 중 장비를 빠르게 교체하면 즉시 재사용할 수 있었다.
  • 장비 교체 중에는 쿨타임을 유지하고, 리스폰 시에만 초기화해야 했다.

 

원인

  • 장비 재활성화 시 캐시된 어빌리티를 재등록하면서 InitAbility()가 진행 중인 쿨타임 타이머를 삭제하고 있었다.

 

수정

  • 재등록 시 타이머를 유지하고 UI에 남은 시간을 전달하도록 변경했다.
  • 리스폰 시에는 서버가 비활성 장비를 포함한 캐시 전체의 쿨타임과 충전 상태를 초기화하고 소유 클라이언트에 반영하도록 했다.

 

고민한 내용

  • 리스폰에 따른 초기화 요청은 어빌리티 생명주기를 관리하는 컴포넌트가 맡고, 어빌리티는 초기화 요청을 수행하도록 책임을 분리했다.

 

LoadMap으로 월드를 전환하거나 엔진을 종료할 때, 남아 있던 오브젝트를 정리하는 과정에서 크래시가 발생

원인

장비 정리를 OnComponentDestroyed()에서 시작해 소유 캐릭터와 월드 상태가 BeginDestroy 플래그가 설정됐거나 정리된 뒤 Deactivate()가 호출될 수 있었다. 이때 무효화된 월드나 소유 캐릭터를 검사하지 않고 참조한 것이 직접적인 크래시 원인

 

수정

  • 장비 정리를 OnComponentDestroyed()에서 EndPlay()로 옮겨 LevelTransition과 정상 종료의 Quit 경로에서 파괴 판정 전에 먼저 실행되도록 했다.
  • World, 소유 캐릭터, 총기의 유효성 검사를 추가

 

고민한 내용

  • null 검사만 적용하면 크래시는 피할 수 있지만 장비 상태 정리가 누락될 수 있어, RouteEndPlay() 단계에서 비활성화와 파괴를 시작하도록 수명주기를 변경했다. 다만 EndPlay()는 GC 콜백이 아니며 강제 종료와 최종 UObject 파괴 단계에는 호출이 보장되지 않으므로, 파괴 과정에서는 월드 의존적인 코드를 수행하지 않도록 방어 코드도 함께 추가

 

26년 8월

4주차

슬라이딩 중 점프 입력시 점프되도록 변경
기획의 요청에 따라 점프되도록 변경 진행

 

수정

  • 슬라이딩을 Walk로 전환한 뒤 점프를 예약하고, UpdateCharacterStateAfterMovement()에서 실제 점프를 요청하도록 변경
  • 기립할 공간이 없거나 실제 캡슐이 여전히 웅크린 상태라면 Crouch로 복구
  • 크라우치 해제·슬라이딩 종료·점프 처리에 중복되어 있던 CanStand() 분기를 제거하고, ValidateActionState()가 기립할 수 없는 Walk 전이를 Crouch로 보정하도록 통합하여 중복 코드 제거

 

고민한 내용

  • 캐릭터 무브먼트의 DoJump()를 직접 호출하면 점프 횟수와 홀드 처리 같은 엔진 규칙을 우회하므로, 한 번의 이동 갱신 지연을 감수하고 기존 Jump() 경로를 유지

 

시행착오

처음에는 예약 점프를 UpdateCharacterStateBeforeMovement()에서 소비했지만, 여기서 설정한 입력이 같은 프레임의 ClearJumpInput()에 제거되어 액션 상태만 Jumping으로 남고 VelocityZ=0, IsFalling=false가 유지되어 점프 무브먼트가 되지 않음

 

AnimBP Main 그래프 모듈화
문제
캐릭터별 1인칭 AnimBP에 Main 상태 머신이 각각 들어 있어 공통 전이 수정이 중복


수정

  • Main 스테이트 머신을 AT_Character_1P_MainModule Linked Anim Graph로 분리하고 캐릭터별 AnimBP가 공유하도록 변경

 

고민한 내용
공유하는 AnimBP 상속 방식도 검토했다. 하지만 상속은 Main만 선택적으로 가져오는 것이 아니라 전체 AnimGraph를 묶어 캐릭터별 모든 그래프와 Skeleton 구성을 제약하므로, 기존 AnimBP는 유지하면서 Main만 Template Linked Anim Graph로 호출하는 방식을 선택

 

3주차

충전형 어빌리티의 카운트 관련을 컴포넌트로 리팩토링

문제

이즈나 대시 어빌리티가 충전 횟수, 재충전 타이머, 클라이언트 동기화와 UI 알림까지 직접 관리해 다른 충전형 어빌리티에서 재사용하기 어려워 확장성이 떨어짐

 

원인

진짜 문제는 충전 규칙이 공통 어빌리티 구조가 아니라 MFGAIzunaDashMove에 종속되어 있었다는 점이다.
기본 AMFAbility도 단일 쿨다운만 처리해 충전이 남은 상태를 표현하지 못했다.

 

수정

MFRechargeComponent를 추가해 서버 권한의 횟수 소비·복구와 클라이언트 상태 전달을 담당하게 했다.
AMFAbility는 컴포넌트가 있으면 활성화 가능 횟수와 재충전 시간을 사용

비충전형 어빌리티의 기존 쿨다운 경로는 유지

 

받는 피해 배율 통계치 구현

문제

  • 기존 UMFSSReduceDamage는 피해 감소만 표현할 수 있어, 받는 피해 증가와 고정 피해 감소를 하나의 통계 체계에서 처리하기 어려웠다.
  • 피해 계산 책임도 MFDamageHandlerComponent에 섞여 있었다.

원인

  • 기존 통계가 감소율 중심으로 설계됐고, Handler가 비율 및 고정 감소 공식을 직접 계산하고 있었다. 이 구조에서는 피해 증가나 DamageType별 보정 우회 정책을 확장하기 어려웠다.

수정

  • UMFSSReduceDamage를 UMFSSIncomingDamageModifier로 교체하고 IncomingDamagePercentModifier와 FlatDamageReduction을 추가했다.
  • 비율 보정은 RawDamage × (1 + Percent / 100)으로 계산하며, 하한만 -100으로 제한하고 양수 증가에는 상한을 두지 않았다.
  • 계산 책임을 StatisticSet으로 이동하고, Handler는 DamageType 정책 확인과 계산 호출만 담당하도록 정리했다.
  • 즉사 DamageType은 보정을 우회하며, 미등록 StatisticSet을 참조하는 GameplayEffect는 경고를 남기고 해당 Modifier만 건너뛰도록 수정했다.
  • 모든 캐릭터에 강제 등록하지 않고 Blueprint별 opt-in 구조를 유지했으며, 기존 BP는 새 퍼센트 단위에 맞게 마이그레이션했다.


서버에서 장비 교체 어빌리티 UI가 활성화되지 않는 현상 수정

원인

  • 서버에서 장비 교체 어빌리티는 실행되지만, 활성화 알림이 정상 전달되지 않아 UI가 활성화되지 않았다.
  • Activate() 도중 장비를 교체하고 장비의 어빌리티를 변경하면서 RegisteredAbilities 배열이 변경됐다. 이때 기존 AbilityContainer 포인터가 무효화됐지만, 실행 후 다시 참조해 어빌리티 태그를 가져오고 있었다.

수정

  • Activate() 호출 전에 AMFAbility*와 FGameplayTag를 복사하도록 변경했다. 컨테이너가 무효화되더라도 보존한 태그로 ClientNotifyActivateAbility()와 Server_NotifyActivateAbility()를 호출한다.

고민한 내용
어빌리티 실행 후 컨테이너를 다시 조회하는 방법도 있지만, 활성화 과정에서 배열 상태가 달라질 수 있어 실행 대상과 태그를 미리 보존하는 방식을 선택


호시노 권총 탄창 UI에 업데이트 안되는 버그 수정

원인

  • UI는 장비의 AvailableCountType을 검사한 뒤, 유효한 경우에만 탄약 텍스트를 변경한다. 호시노 권총·방패 에셋에는 이 설정이 없어 기본값 Invalid로 처리되면서 갱신 분기를 통과하지 못했다.

수정

  • BP_DWHoshino_Pistol_With_Shield의 AvailableCountType을 Available로 설정했다.
  • MFGAEquipEquipment에 bShowAvailableCount를 추가하고 MFA_SwapMod에 적용해, 무기 슬롯 UI와 별개인 어빌리티 UI에는 불필요한 장비 수량이 표시되지 않도록 분리했다.

고민한 내용
탄창 갱신 이벤트나 UI 그래프를 새로 만드는 방식은 사용하지 않았다.

 

2주차

슬라이딩 도중 벽타기를 시도한 뒤 빠르게 종료하면, 평지에서도 1인칭 벽타기 애니메이션이 계속 재생되는 현상 수정
원인

  • 벽타기 상태 진입이 거부된 뒤에도 어빌리티가 성공 여부를 확인하지 않고 몽타주를 재생했으며, 실제 Climbing 상태가 아니어서 Climbing 종료 델리게이트도 발생하지 않음

수정

  • 기획 의도에 맞춰 지상에서는 Walk/Sprint일 때만, 공중에서는 이동 상태와 관계없이 벽타기에 진입하도록 기존 가드를 수정
  • Climbing()이 상태 전환 성공 여부를 반환하고, 진입 실패 시 벽타기 어빌리티에서 몽타주를 취소한 뒤 재생하지 않도록 변경

고민한 내용
단순히 bWantsToCrouch만 확인하면 슬라이딩·앉기 상태로 공중에 진입한 경우까지 벽타기가 차단된다. 따라서 지상은 액션 상태로 제한하고, 공중은 IsFalling()으로 별도 판정해 기획 의도를 명확히 표현

 

연막이 클라에서 폭발 반응이 안되는 현상 수정
원인
폭발 반응이 서버의 렌더 타깃과 동적 머티리얼에만 적용됐으며, 연막 액터와 시각 효과 변경이 클라이언트로 전파되지 않았다.
수정

  • 연막 액터의 리플리케이션을 활성화했다.
  • 서버에서 폭발 위치와 구멍 반지름을 계산한 뒤 Reliable NetMulticast RPC를 호출해 모든 클라이언트가 각자의 머티리얼과 렌더 타깃에 동일한 구멍을 그리도록 변경했다.
  • 폭발 유효성 및 구멍 크기 계산은 서버 권한으로 제한해 판정이 중복되거나 달라지지 않게 했다.

 

연막의 복셀 마스크와 차폐 판정 개선
연막은 구 모양의 볼륨을 그리는 것만으로 끝나지 않는다. 벽을 만나면 반대편으로 퍼지지 않아야 하고, 실제 연기가 닿은 플레이어만 차폐 상태로 판정해야 한다.

문제
연막은 BFS로 장애물을 피해 복셀을 채우고 있었지만, 실제 결과에는 세 가지 문제가 있었다.

  • 일단 벽이나 바닥처럼 장애물이 있는 곳에서 연기가 지오메트리 안으로 퍼지거나, 반대로 표면과 연막 사이에 빈 틈이 생겼다.
  • BFS가 찾아낸 복셀마다 머터리얼 파라미터를 바꾸고 `DrawMaterialToRenderTarget`를 호출했다. 연막 하나의 마스크를 만들기 위해 수천 번의 렌더 타깃 갱신이 여러 프레임에 걸쳐 발생했고, 여러 연막을 동시에 생성하면 렌더 스레드와 GPU 부하가 한꺼번에 증가했다.
  • 화면과 게임플레이 판정이 서로 다른 정보를 사용한다는 점이었다. 화면은 BFS 결과에 따라 벽에서 막혔지만 플레이어 차폐 판정은 연막 전체의 SphereCollision만 확인했다. 벽 뒤라서 실제 연기가 도달하지 않은 플레이어도 구형 콜리전 안에 있으면 차폐된 것으로 처리될 수 있었다.


원인

  • BFS로 판정 후 복셀 하나마다 렌더 타깃을 다시 그림
    BFS 결과를 그대로 볼륨 텍스처로 만들었다
    기존 구조는 BFS 결과를 CPU 배열로 유지하지 않고 각 복셀을 2D Vanish 렌더 타깃에 구 형태로 누적했다. 복셀 수가 늘어나면 `DrawMaterialToRenderTarget` 호출 수도 그대로 증가하여 여러 프레임에 걸쳐 렌더 스레드와 GPU 작업을 계속 만들었다.
    한 프레임에 처리할 복셀 수를 나눠 순간 부하는 분산했지만 총작업량은 줄지 않았다. 오히려 연막 마스크가 완성될 때까지 같은 렌더 경로가 여러 프레임 동안 유지됐다.
  • 차폐 판정이 BFS 결과를 확인하지 않았다
    기존 `OnBeginOverlap`은 적 플레이어가 구형 콜리전에 들어오고 일정 시간이 지나면 바로 `OverlapCount`를 증가시켰다. 렌더링에서는 비어 있는 벽 뒤 공간과 실제 연기가 채워진 공간을 게임플레이 판정에서는 구분하지 못했다.


수정

  • BFS 결과를 볼륨 텍스처로 만들었다
    BFS가 채운 셀을 `TBitArray`인 `FilledGrid`에 기록한다. BFS가 끝나면 이 배열로 `PF_G8` 볼륨 텍스처를 한 번 생성한다.
    머터리얼은 생성된 볼륨 텍스처를 직접 샘플링한다. 이에 따라 복셀별 Vanish 렌더 타깃, 동적 머터리얼과 드로우 스케줄러를 제거했다.
    격자 좌표는 인덱스가 증가할수록 로컬 음의 방향으로 이동하지만 볼륨 UV의 0은 로컬 최소점이다. 마스크가 반대로 기록되지 않도록 세 축을 모두 다음과 같이 뒤집었다. 여기서 좌표축을 그대로 복사하면 마스크가 반대로 나왔다. 격자 좌표는 월드 변환에서 인덱스가 커질수록 로컬 음의 방향으로 이동하지만 볼륨 UV의 0은 로컬 최소점이기 때문이다. 따라서 X·Y·Z를 모두 `Size - 1 - Grid`로 뒤집어 기록했다.
    텍스처 주소 모드도 기본 `Wrap` 대신 `Clamp`로 지정했다. 그렇지 않으면 경계 밖 샘플이 반대편 마스크를 끌어와 연막 끝에 잘못된 밀도를 만든다.
  • 화면과 판정이 달랐다는 점이었다
    렌더링만 고치면 벽 뒤로 연기가 보이는 문제는 해결된다. 그런데 플레이어 차폐 판정은 여전히 연막 전체의 `SphereCollision` 진입 여부만 사용하고 있었다. 화면에는 벽 때문에 연기가 도달하지 않았는데도 구 안에 있다는 이유만으로 적을 가린 것으로 계산할 수 있었다.
  • 실제로 채워진 복셀에서만 차폐로 판정했다
    이제 구형 콜리전은 후보를 찾는 브로드페이즈로만 쓴다. 오버랩 유지 시간이 지나면 플레이어의 월드 위치를 복셀 인덱스로 바꾸고 `FilledGrid`를 확인한다. 실제 BFS가 채운 복셀일 때만 `OverlapCount`를 올린다. 아직 연기가 닿지 않는 위치라면 타이머를 다시 걸어 다음 임계 구간에 재검사한다.구형 범위 안에 있더라도 벽 때문에 연기가 도달하지 않은 셀이면 타이머를 다시 걸고 다음 임계 구간에 재검사한다.


Unreal Insights Trace 측정

연막 10개를 5열로 한 번에 생성하고, 같은 코드 경로의 호출 수와 누적 시간을 확인했다.

| 항목 | 개선 전 | 개선 후 | 변화 |
| `SpawnSmokeBatch` GameThread 구간 | 162.263 ms | 57.890 ms | 104.373 ms 감소, **64.3% 감소** |
| `DrawMaterialToRenderTarget` 호출 | 12,140회 | 40회 | 12,100회 감소, **99.67% 감소** |
| `DrawMaterialToRenderTarget` 누적 CPU 시간 | 721.022 ms | 10.378 ms | 710.645 ms 감소, **98.56% 감소** |
| GPU `CanvasDrawTiles` 호출 | 1,390회 | 110회 | 1,280회 감소, **92.1% 감소** |
| GPU `CanvasDrawTiles` 누적 시간 | 153.915 ms | 10.761 ms | 143.154 ms 감소, **93.0% 감소** |

발밑 수류탄 자폭 반동이 적게 Launch되는 현상 수정
원인

  1. 관통 해소 가드가 런치를 되돌렸다. 발밑 폭발은 캐릭터 캡슐이 발사체와 겹친 상태에서 일어나므로 같은 프레임에 관통 해소가 돈다. 그러면 PenetrationVelocityGuardFrames가 무장되고, 프레임 끝의 OnMovementUpdated가 속도 급증을 관통 역산으로 오인해 Velocity = OldVelocity로 되돌렸다.
  2. 수류탄 발사체가 캐릭터의 베이스로 잡혔다.
    발사체는 폭발과 같은 프레임에 파괴되는데, 그 프레임의 클라 위치 보정이 이미 사라진 컴포넌트를 NewBase로 참조하게 되어 ClientAdjustPosition이 보정을 통째로 폐기했다.


수정

  1. HandlePendingLaunch 오버라이드로 런치 프레임을 표시하고,  OnMovementUpdated에서 그 프레임의 관통 보정을 건너뜀
  2. 가드 카운터 감소는 런치 여부와 무관하게 수행한다. 조기 반환으로 건너뛰면 가드가 한 프레임 더 살아남아, 런치 다음 프레임의 착지 충격·2차 관통 해소가 되돌림 대상이 된다. 지키려던 넉백이 결국 다시 지워진다.
  3. AMFSOProjectileMovement::CanBeBaseForCharacter가 false를 반환한다. 발사체는 애초에 캐릭터가 딛고 설 대상이 아니다.

 

시로코 점프 중 수류탄 자폭 반동 미적용 수정

원인

  • 투척물이 던진 사람과 충돌할 때의 처리 경로가 두 갈래(`NotifyHit` / `OnProjectileOverlap`)로 나뉘어 있었고, 자기 자신 제외 로직이 Overlap 쪽에만 있었다.
  • IgnoreActorWhenMoving은 자기 액터가 스윕할 때만 소비되는 단방향 설정이라, 다른 액터가 움직여 오버랩되는 경우(점프 낙하 등)를 막지 못한다.
    이때 엔진이 bSelfMoved=false로 NotifyHit을 보내고, 투척물이 시전자 몸에 붙어 멈추거나 즉시 터지면서 속도 Z 값이 의도와 다른 반대 방향이었고 의도한 자폭 반동이 되지 않음


해결

  • Overlap과 Hit이 동일한 조건을 체크하도록 동일한 함수 사용 : 유효성, 자기 자신, Owner, Instigator를 한 곳에서 걸러낸다. 파생 클래스가 조건을 완화하지 못하도록 비가상으로 둠
  • Overlap과 Hit은 가드만 수행하고, 실제 처리는 `HandleBlockingHit` / `HandleOverlap` 훅에 위임하도록 정리. `NotifyHit`은 `final` 처리하여 파생 클래스도 같은 조건을 검사하도록 변경

26년 7월

5주차

아군 캐릭터에 쏠 때 이펙트가 발생하는 현상 수정
문제
아군에게 사격하면 데미지/디버프는 안 들어가는데 히트 이펙트(피격 파티클, 탄흔)는 그대로 재생돼서, 맞은 것처럼 보이는 오인 피드백이 발생했다.

원인

팀 판정이 이펙트 경로에 없었다.
어빌리티들이 각자 IsSameTeam()을 중복 구현해 어빌리티 단계에서만 걸렀고, 실제 이펙트를 스폰하는 곳에서는 팀을 전혀 보지 않았다.
델리게이트도 AActor*만 넘겨서 소비자마다 Cast와 팀 계산을 반복하는 구조였다.


해결

  • FMFShotHitResult 구조체 신설 하고 한 발이 무엇을 맞혔는지(HitActor/HitCharacter/ImpactPoint/BodyPart/오브젝트 타입/아군 여부)를 트레이스 직후 한 번만 확정하여 저장
  • OnHitActor(AActor*) → OnShotHit(const FMFShotHitResult&)로 델리게이트 교체하여 데미지·이펙트·어빌리티가 같은 결과를 공유
  • 팀 판정을 UMFGameplayStatics::IsSameTeam(Instigator, HitActor)로 일원화하고 두 어빌리티의 중복 IsSameTeam() 제거.
  • 아군 히트 시 모든 연출을 생략하여 배경 탄흔까지 막는 게 의도 — 탄흔만 찍히면 아군을 관통한 것처럼 보이기 때문
  • 레이캐스트 발사체는 머신에서 로컬 생성되므로 FMFShotHitResult는 리플리케이트하지 않음


클라에서 호시노 방패 돌진이 느린 현상 수정
원인

  • 돌진 전진 속도를 서버 어빌리티 Tick에서 직접 Movement->Velocity에 꽂고 있었다.
    클라는 이 코드를 타지 않으므로 로컬에서는 그냥 걷기 속도로 움직였고, 매 프레임 서버 위치 보정에 끌려다니는 모양이 됐다. 느린 속도 + 끊김은 같은 원인의 두 증상
  • 액터 틱에서 속도를 덮어쓰는 방식 자체도 문제였다. CMC의 이동 파이프라인 바깥이라 클라 예측과 보정 후 재생(replay)이 재현할 수 없고, 지상에서는 마찰로 깎이고 공중에서는 안 깎여 점프할 때만 빨라지는 현상도 발생


수정

  1. 속도 결정을 CMC 안으로 옮겼다.
    BeginDash/EndDash와 CalcVelocity 오버라이드를 추가해, 대시 중에는 입력·마찰·감속을 무시하고 전방으로 DashSpeed를 고정한다.
    중력이 만든 Z는 보존해 낙하는 PhysFalling이 이어받는다.
  2. 서버와 로컬 클라 모두 CMC Dash를 요청
    같은 함수가 CalcVelocity 안에서 같은 값을 내므로 예측과 재생이 서버와 일치한다.
  3. 어빌리티 쪽은 판정만 남겼다. 속도 설정 코드를 제거하고, 틱은 서버의 TraceChargeHit만 호출
    틱도 bStartWithTickEnabled = false로 두고 활성/비활성에서 켜고 끈다.


메모
대시가 이동 파이프라인 안에 들어오면서 공중 상태를 CMC가 일관되게 다룬다.
Begin은 클라→서버, End는 서버→클라 순서를 전제로 CMC에 별도 RPC를 두지 않았다. 두 번 불려도 안전하다.

 

호시노 ADS 중 방패 돌진 시 마우스 감도 오염 수정 / 돌진 시 ADS 해제
문제
호시노 방패 돌진에서 두 가지가 얽혀 있었다.

  • 감도 조정이 CachedCurrentSensitivity / SetMFCharacterSensitivity / RestoreCachedSensitivity의 단일 캐시 방식이었다. ADS 중 돌진하면 돌진이 ADS 배율이 곱해진 값을 캐시로 잡고, 해제 순서가 엇갈리면서 감도가 영구히 오염
  • 돌진 중에도 ADS 상태가 유지돼 조준/돌진이 동시에 성립

해결

  • 감도를 키 기반 조정자 맵으로 전환
    • 캐시-복원 대신 기본값에 대해 곱셈 누적이라 해제 순서와 무관해짐
    • 오브젝트의 FName을 키로 하여 재등록은 덮어쓰기
    • 설정 변경(ApplyLocalUserSettings)은 캐릭터의 기본값만 갱신하고 재계산하므로, 진행 중인 ADS/돌진 배율이 날아가지 않음
  • 돌진 시 ADS 해제 : 상태 태그 소유권 설계로 해결
    돌진 진입 시 현재 활성 장비에 ChargeFireArmStateTag를 붙여 ADS를 막음
    UMFEquipmentManager를 통해 현재 장비에 StateTag를 추가하고, 장비는 파츠에 전파하여 Sight Block 태그가 추가될 때 ADS 해제하도록 구현
    호출자는 태그 소유권을 판단해 자기가 붙인 태그만 되돌림
    돌진 도중 장비가 바뀌어도 떠난 장비가 스스로 태그를 비우므로 태그가 유지되지 않음

 

월드 종료 중 게임플레이 이벤트 브로드캐스트 차단
PIE 종료로 월드가 내려가는 도중 게임플레이 이벤트가 계속 흘러 엔진 ensure에 걸렸다. 종료 중 위젯을 새로 만드는 경로가 대표적이었다.

원인
UMFGameplayEventSubsystem::Get()은 월드가 유효하기만 하면 서브시스템을 돌려줬다. 서브시스템 자체는 월드 정리 마지막까지 살아 있으므로, 종료가 시작된 뒤에도 델리게이트가 정상적으로 브로드캐스트됐다. 이때 구독자 중에는 이미 파괴 절차에 들어간 액터·위젯이 섞여 있다.
호출부도 `Get()`의 반환을 검사하지 않고 바로 `->`로 접근하는 곳이 많아, 반환값을 null로 바꾸는 순간 그대로 크래시가 나는 상태였다.

수정

  • 종료가 시작되면 null을 반환
    종료 중 게임플레이 이벤트는 전부 무의미하다. 구독/해제/브로드캐스트를 한 지점에서 끊는 편이, 구독자마다 종료 여부를 따로 검사하는 것보다 확실하다.
if (!World || World->bIsTearingDown)
{
    return nullptr;
}

 

  • 호출부 전수 조사하여 nullptr 검증이 빠진 곳을 검증하도록 변경

 

호시노 방패 돌진 어빌리티 서버 권한 이관, 방패 돌진 적중 후 어빌리티 종료되지 않는 현상 수정
문제
방패 돌진이 적에게 적중한 뒤에도 어빌리티가 종료되지 않았음


원인

진행/종료 판정이 클라이언트 타임라인(ChargeTimeline)과 서버 RPC(Server_StartCharge, Server_SetLaunchCapsuleTrace)에 흩어져 있어서, 서버가 Deactivate()를 불러도 클라이언트 인스턴스에 전파될 경로가 없었던 것


해결 방향
어빌리티 진행·종료의 판정 주체를 서버로 단일화하고, 클라이언트는 연출만 담당하도록 역할을 갈랐다.

  1. AMFAbility 서버 활성화 경로 추가
    bServerActivate 플래그를 추가하여 켜면 서버 인스턴스도 Activate()/Deactivate()를 거쳐 코어 워크를 실행한다.
    서버 CanActivate() 실패 시 ClientDeActivate()로 클라이언트를 롤백
    쿨타임 타이머 자체는 양쪽에 유지하여 서버에서 CanActivate() 검증
  2. AMFGASCharge 서버/클라 책임 분리
    ChargeTimeline 제거, 서버 bCharging 플래그 + Tick의 OnChargeTick()으로 대체
    서버 담당: BeginChargeOnServer / EndChargeOnServer(돌진 진행/종료), ApplyChargeVelocity(전진), TraceChargeHit(적·벽 판정 및 종료)
    클라 담당: SetupCharge / RestoreCharge(FOV 타임라인, 감도, 입력 컨텍스트)

 

1인칭 메시가 벽에 파묻히는 문제 수정
원인
1인칭 메시가 월드와 같은 깊이(depth)로 그려지기 때문. 캡슐이 지오메트리에 파고드는 관통 버그가 아니라 순수 렌더링 문제다.

수정
UE 5.5의 엔진 네이티브 First Person 렌더링을 써서 1인칭 메시의 정점을 카메라 쪽으로 압축해 깊이 버퍼에서 차지하는 범위를 좁혀, 월드와 교차하지 않게 만든다.

  • 카메라 위치를 원점으로 삼아 균일하게 축소하는 변환이다. 카메라에서 거리 `d`인 정점이 같은 방향의 `0.6d` 지점으로 옮겨진다.
    여기서 크기와 거리가 같은 비율로 줄기 때문에 화면상 크기는 변하지 않는다 — 보이는 크기는 `크기 ÷ 거리`인데 (0.6s) / (0.6d) = s / d로 상쇄
    무기가 카메라에서 30~80cm에 걸쳐 있었다면 압축 후 18~48cm가 된다. 벽이 60cm 앞에 있을 때 압축 전 총구(80cm)는 벽 뒤라 가려지지만, 압축 후(48cm)는 벽 앞이라 깊이 테스트에서 이긴다.

    절대 보장은 아니다. 엔진 주석도 "씬과 교차할 가능성을 줄인다"고 쓴다. 벽이 48cm보다 가까우면 여전히 뚫린다. 그래서 `FirstPersonScale`은 튜닝값이고, 낮출수록 방지가 강해지는 대신 근평면 잘림이 심해진다(부작용 1번)
    렌더 패스마다 뷰가 달라(메인컬러패스(플레이어카메라), 그림자뎁스패스(광원)) 메인 컬러 패스에만 변환이 적용되고 그림자뎁스패스에는 적용되지 않아 원래 위치로 기록되는데 셰이딩은 압축된 위치로 조회하니 어긋난다(부작용 2번)
Mesh->SetFirstPersonPrimitiveType(EFirstPersonPrimitiveType::FirstPerson);
FollowCamera->SetEnableFirstPersonScale(true);
FollowCamera->SetFirstPersonScale(0.6f);

 

  • 플래그와 카메라 설정은 둘 다 필요하다. 카메라 쪽이 꺼져 있으면 변환 행렬이 항등이라 플래그만 줘도 아무 일도 일어나지 않는다.
  • 머티리얼은 건드릴 필요 없다. 셰이더가 프리미티브 플래그만 보고 압축을 적용한다(`First Person Output` 노드는 정점별 블렌드가 필요할 때만 쓴다).

1P 메시가 팔·총·투척물로 흩어져 있어 하나라도 빠지면 그 부분만 벽에 파묻히므로, UMFRenderUtility 한 곳으로 모아 1인칭 전용 = First Person 렌더링 대상 불변식을 강제

사이드 이펙트
깊이 압축은 메시를 카메라 쪽으로 당기는 것이라 사이드 이펙트가 따라옴

  1. 근평면 잘림: 가장 가까운 부위(팔뿌리·개머리판)가 근평면을 넘어가 잘려 NearClipPlane을 10 → 6로 낮춰 해결
    근평면을 낮추면 원거리 깊이 정밀도를 소모한다. UE는 reverse-Z 깊이 버퍼를 쓰므로(FarPlane=0, NearPlane=1) 표준 Z보다는 덜 민감하지만 공짜는 아니다. 그래서 잘림이 사라지는 선에서 가능한 한 높은 값을 택했고(3에서 시작해 6까지 올림), 원거리 오브젝트에서 z-파이팅이 나타나지 않는 것을 확인했다.
    • 근평면과 원평면의 값을 뒤집어 근평면은 하나의 메시가 여러 개의 값을 사용할 확률이 높고, 원평면은 여러 개의 메시가 하나의 값을 사용할 확률이 높으므로 부동소수점 때문에 0에 가까울 수록 정밀도가 높기 때문에 Z 파이팅 확률을 상쇄
    • 부동소수점 지수가 하나 정해지면, 그 구간 안에서는 가수 24비트로 값을 나눕니다. 그래서 [1, 2)든 [0.5, 1)이든 [0.25, 0.5)든 표현 가능한 값의 개수가 똑같이 2²³개(약 838만) 입니다.
      그런데 구간의 폭은 0에 가까워질수록 절반씩 줄어듭니다.
      같은 개수를 절반 넓이에 밀어 넣으니 밀도가 2배씩 늘어납니다. 0에 가까워질수록 이게 계속 반복되므로 밀도가 기하급수적으로 촘촘해집니다.
  2. 무기가 검게 변함: 압축된 1P 메시가 3P 메시의 그림자 볼륨 안으로 끌려 들어가 잘못 그늘졌다. 그림자 맵은 원래 위치로 기록되는데 셰이딩은 압축된 위치로 조회하기 때문
    1P 메시를 전용 라이팅 채널(1)로 분리하고, 그 채널을 캐릭터에 내장한 뷰모델 라이트만 비추게 했다.
    엔진에 프리미티브 단위 그림자 미수신 스위치가 없어서 채널 격리가 유일한 수단이고, 채널을 끊으면 빛과 그림자가 함께 끊기므로 전용 라이트가 필연으로 따라온다. 라이트를 레벨이 아니라 카메라에 붙여 레벨별 작업을 없앴다.

검토한 대안들
클리핑을 없애는 방법

  • FOV/스케일만 줄여 벽에 안 닿게: 완화책이지 "무조건 보임"을 보장하지 못한다.
  • 반투명 + Disable Depth Test: 위에 그려지긴 하나 디퍼드 라이팅을 못 받아 툰 셰이딩이 무너진다.
    툰 셰이딩은 디퍼드로 처리하고, 반투명은 포워드로 처리하기 때문

그림자 처리

  • 3P 메시 bCastHiddenShadow를 끔 : 한 줄로 끝나고 무기가 환경 조명을 그대로 받아 오히려 자연스럽다. 대신 1인칭에서 자기 그림자를 잃고, 건물 안에서 무기가 어두워진다.
  • 무기가 검게 변하는 걸 SetCastShadow(false)로 고치려 함 : 건물 등에도 그림자를 받을 것이고, 압축 때문에 그림자가 실제와 맞지 않을 것, 3인칭 메시의 그림자를 잃게 됨

 

4주차

호스트(리슨서버)가 ADS하면 다른 클라이언트의 캐릭터까지 조준 애니메이션이 재생되는 현상 수정
원인
ADS 애니메이션 전파가 MFGameplayEventSubsystem의 전역 델리게이트로 처리되고 있었고, 모든 MFHandlingObject가 BeginPlay에서 이 이벤트를 구독했기 때문에, 호스트 장비가 조준하면 월드에 존재하는 모든 장비가 이벤트를 받아 각자 소유 캐릭터의 조준 애니메이션을 실행했기 때문

수정

  • 전역 브로드캐스트 → 소유 무기 직접 호출 구조로 변경
  • NotifyADS를 SetIsAiming으로 개편해 MFHOFireArm이 자기 소유 캐릭터의 AnimManagerComponent에만 직접 조준 상태를 설정
  • 전역 ADS 이벤트는 UI 갱신 용도로만 유지하되, IsLocallyControlled()인 경우에만 브로드캐스트하도록 제한

 

ADS 중 다른 장비로 교체하면 조준이 해제되지 않고 ADS 상태가 그대로 남아 있는 문제 해결
원인
총기 교체 시 AMFHOFireArm::Deactivate()가 호출되지만, 총기에 부착된 파츠(조준경 등)에는 비활성화가 전파되지 않아서 조준경이 ADS 종료 처리를 할 기회가 없었음

수정 내용

  • UMFFirearmPart에 가상 함수 Deactivate()를 추가하고, AMFHOFireArm::Deactivate()에서 부착된 모든 파츠에 대해 호출하도록 전파 구조를 만듦
  • UMFFPSight::Deactivate() 오버라이드에서 EndADS()를 호출해 총기 비활성화 시 조준을 강제 해제

 

이즈나 E스킬이 호스트(리슨서버) 한정 3회 이상 연속 사용 가능 이슈 수정

원인

  • 대시 카운트 관리 로직이 여러 곳에 흩어져 있었고, 권한(Authority) 체크 없이 실행되는 경로가 존재
  • SetDashCount가 호스트 환경에서는 서버·클라이언트 역할이 겹치며 카운트가 중복으로 갱신될 여지가 있었음
  • 충전 타이머 시작/정지 로직이 StartDashing, DeActivateCoreWork, OnEndCoolDown, ChargingTheDash 네 곳에 분산되어 있어, 쿨다운 종료 시 SetDashCount(MaxDashCount)로 카운트를 통째로 복구하는 등 카운트가 실제보다 많아지는 경로가 있었음


수정

  • 카운트와 충전 타이머 관리를 SetDashCount 한 곳으로 일원화
  • SetDashCount 진입 시 HasAuthority() 체크를 추가해 서버에서만 카운트가 변경
  • 카운트 변경 시점에 타이머를 함께 관리하도록 변경: 최대치 도달 시 타이머 정지, 미만이면서 타이머가 꺼져 있으면 충전 타이머 시작
  • 이에 따라 중복 로직이던 DeActivateCoreWork, OnEndCoolDown 오버라이드와 ChargingTheDash 내부의 타이머 정리 분기를 제거했다 (61줄 → 13줄 순감소)

충전형 어빌리티를 통합 및 리팩토링 진행할 예정

 

호시노 방패돌진이 계단에서 종료되는 현상 수정

원인
돌진 중 캡슐 트레이스에서 충돌면을 DetermineCollideSurfaceType(ImpactNormal)로만 판정했는데, 법선 각도만 보기 때문에 계단의 수직면이 벽으로 분류되어 돌진이 중단됨


수정 내용

  • FMFCollisionUtility에 캐릭터 기준 표면 판정 오버로드 추가 : 법선 기준으로 벽으로 나오더라도 충돌 지점 높이가 발 위치 기준 MaxStepHeight 이하이거나 법선이 GetWalkableFloorZ() 이상(걸을 수 있는 경사)이면 → Floor로 재분류
    즉, 걷기로 넘을 수 있는 단차/경사는 벽으로 취급하지 않음
  • 트레이스 시작점 개선 : 스칼라 전방 거리를 FVector로 변경해 캐릭터 회전 기준 3축 오프셋 조정 가능하도록 변경
    캡슐 트레이스 시작점 및 반지름, 높이 개선

 

대각 이동시 걷는 사운드 재생 간격이 빨라지는 현상 수정

원인
발소리 재생 타이밍은 입력 방향 벡터의 크기를 CurrentWalkAmount에 누적해 임계값에 도달하면 재생하는 방식인데, 대각 입력 시 X·Y 축이 각각 1이라 벡터 크기가 √2(≈1.41)가 되어 누적 속도가 직선 이동보다 약 41% 빨라진 것이 원인

해결
MoveIntensity를 1.0에 클램프하여 대각 이동도 직선 이동과 동일한 누적 속도를 갖게 되어 발소리 간격이 일정해짐

 

이즈나 대시 스킬 쿨타임이 UI에 갱신되지 않는 현상 수정

문제
기존에는 CurrentDashCount를 Replicated 프로퍼티로 복제만 하고 있어서, 값이 클라이언트에 도착해도 UI에 알려주는 통로가 없었고 쿨타임 시작/종료 시점도 전달되지 않음

해결

  • 리플리케이트 방식을 제거하고, 서버에서 대시 횟수가 바뀔 때마다 명시적으로 클라이언트 RPC로 통지하는 방식으로 변경
    • 대시 횟수는 해당 플레이어 어빌리티 및 UI에만 필요하므로 모든 클라이언트에 복제할 필요 없이 소유 클라이언트로의 Client RPC면 충분
    • 쿨타임 시작/종료는 상태가 아니라 이벤트라서, 스냅샷을 동기화하는 복제보다 발생 시점에 통지하는 RPC가 의미상 맞는 도구
  • 대시 횟수 변경 로직을 SetDashCount() 하나로 통일 : 사용 시 감소, 충전 시 증가, 쿨다운 종료 시 최대치 복구가 전부 이 함수를 거치며, 최대치 도달 시 충전 타이머 해제도 여기서 처리
  • Client RPC에서 남은 충전 시간, 이전 값, 현재 값을 전달하여 카운트를 설정하고 UI에 이벤트를 보내어 스킬 횟수 및 쿨타임이 갱신되도록 변경

 

달리기 관련 버그 수정

문제

  1. 점프 중 달리기 키를 떼면 자동으로 달리기가 되는 현상
  2. 착지 후 점프 키를 릴리즈하면 달리기가 안 되는 현상


원인

  1. 상태 복원 구조
    CharacterActionState(현재 상태)와 PrevCharacterActionState(직전 상태) 두 값으로 관리됩니다. 점프하면 상태가 Sprint → Jumping으로 바뀌면서 PrevCharacterActionState에 Sprint가 저장되고, 착지 시 ResetJumpState()가 이 직전 상태를 보고 "점프 전 상태로 되돌리는" 방식

    버그 발생 흐름
    달리는 중(Sprint) 점프 → PrevCharacterActionState = Sprint 저장
    공중에서 달리기 키를 뗌 → Sprint(false) 호출로 bSprinting = false가 되지만 현재 상태는 Jumping이라 상태 전이는 없음
    착지 → ResetJumpState()가 PrevCharacterActionState == Sprint만 보고 SetMovementState(Sprint) 호출하여 자동 달리기가 됨
  2. 점프 입력 처리 순서 문제로, 착지 후 점프 키 릴리즈 경로에서도 점프 상태 설정이 실행되어 점프 중이라 판단하고 달리기로 설정되지 않음


수정 내용

  1. Sprint로 전이 요청됐지만 bSprinting이 false면 Walk로 보정하며, SetMovementState()에서 상태 적용 전에 검증을 거치도록 변경
  2. 점프 키 press일 때만 점프 상태로 설정하도록 변경

 

3주차

시로코 사망 후 자폭 데미지 발생 버그 수정
원인
AMFGAAddStatus가 BeginPlay 시점에 상태 태그를 부여하고 있었고, BeginPlay는 어빌리티 액터가 스폰될 때 한 번만 호출되기 때문에, 캐릭터의 생명주기(사망/리스폰)와 무관하게 상태가 적용되어 사망 이후에도 자폭 상태가 남아 데미지가 들어감

  • 어빌리티는 캐싱하여 객체를 재사용하기 때문에 BeginPlay가 캐릭터 생명주기와 무관함


수정 내용

  • 상태 부여 시점을 캐릭터 스폰 시점으로 이동 : AMFGAAddStatus::BeginPlay 대신 OnSpawnedCharacter에서 새로 스폰된 캐릭터에 AddStatus를 호출하도록 변경
  • 캐시된 어빌리티가 등록되지 않는 현상 수정
    • 시로코의 어빌리티 중 어빌리티 태그가 None인 어빌리티가 해당 어빌리티 포함 2개였음
    • UMFAbilityComponent::FindAbilityContainer에서 None 어빌리티를 같다고 판단하여 다른 이미 등록된 어빌리티를 반환하여 등록하려는 어빌리티가 등록되지 않음
    • 유효하지 않은 GameplayTag가 들어오면 조기 nullptr 반환하도록 검사 추가
      추후 None인 어빌리티를 등록하려 하면 checkf로 어썰트 발생 예정


호시노 샷건이 벽을 뚫어 발사되는 현상 수정
원인

발사 원점 불일치

  • 조준(레이캐스트)은 카메라 시점 기준인데, 투사체는 총구 소켓(FireVFXSocketName) 위치에서 스폰되고 있었음
  • 캐릭터가 벽에 붙으면 총구가 벽 너머에 위치해 탄이 벽을 건너뛰고 발사됨


해결

  • 발사 원점 규칙 통일: 투사체는 총구 소켓이 아닌 카메라 위치(ViewLocation)에서 스폰, 총구 소켓은 총구 화염 등 연출 전용으로 역할 분리
  • AMFCharacter::GetPawnViewLocation() 오버라이드 : 눈 위치를 BaseEyeHeight가 아닌 실제 카메라 컴포넌트 위치로 반환, BP에서 카메라를 옮겨도 조준과 발사 원점이 일치
    AMFHOFireArm::SpawnShootableObject() 공용 함수 신설 : 일반 발사(Fire), 샷건 펠릿, 슬러그탄에 흩어져 있던 중복 스폰 코드를 통합 (회전 오버라이드는 TOptional로 처리)
    MFSORaycastMovement: 발사 원점이 눈 위치가 되면서 사수 자신의 부착 장비(방패 등)가 탄도를 막는 문제가 생겨, 인스티게이터에 부착된 액터들을 충돌 무시 목록에 추가

 

호시노 벽에 가까이 갔을 때 뚫려 보이는 현상 수정

원인

다른 캐릭터와 비교하여 호시노 1인칭 카메라의 X Location이 더 앞에 있어 벽과 가까워져 카메라 근평면 클리핑이 발생

 

해결

호시노 1인칭 카메라의 X Location를 다른 캐릭터와 통일

 

 

섬광 폭발 사운드 적용

ExplosionCue에 섬광 폭발 사운드를 등록한 SoundCue를 등록하여 폭발할 때 사운드가 재생되도록 적용

 

데미지 0 이하일 때 데미지 처리 안되도록 변경
문제
데미지 경감(퍼센트/고정량 감소) 적용 후 최종 데미지가 0 이하가 되어도 이후 처리(킬 관여 기록, 데미지 이벤트 브로드캐스트 등)가 그대로 수행되는 문제가 있었음

 

수정

MFDamageHandlerComponent::HandleDamageInternal 데미지 경감 계산 후 최종 데미지가 UE_SMALL_NUMBER 이하이면 조기 리턴하여 데미지 무효 처리 + 로그 출력

 

부수 수정

데미지 처리 대상은 MFStatisticInterface 구현 필수이므로 StatisticComponent 조회를 먼저하여 없을 경우 checkf로 명확하게 실패하도록 변경
기존의 깊은 중첩 구조를 조기 리턴 방식으로 정리하여 가독성 개선

 

이즈나 섬광 수리검 즉시 폭발 안 되는 현상 수정
폭발 딜레이를 1.0 > 0.0으로 수정

 

실명 종료 1초 전 점진적으로 회복하도록 변경
실명 FadeIn을 0.1초 이내로 단축

  • FlashbangCurve 커브 에셋을 수정해 섬광 피격 시 화면이 거의 즉시 완전 실명 상태에 도달하도록 변경

회복 연출 추가

  • 실명 종료 1초 전부터 시야가 회복되도록 별도 Float Curve로부터 머터리얼 파라미터 값 갱신
    Tick에서 회복 커브의 길이(GetTimeRange)를 기준으로 회복 시작 시점(ComputedDuration - 커브 길이)을 계산하고, 그 전에는 기존 FlashCurve, 이후에는 RecoveryFlashCurve를 샘플링하도록 분기

 

2주차

이즈나 점멸이 단차에 걸리는 이슈 수정
문제
이즈나 점멸(순간이동 대쉬) 사용 시 계단이나 낮은 단차에 캡슐 스윕이 막혀, 실제 이동 거리가 짧아지며 "걸리는" 느낌이 발생

시작~목표를 단일 스윕으로 검사해 낮은 턱조차 벽으로 판정했기 때문

해결

핵심 아이디어는 "순간이동 경로를 걷기와 동일한 기준으로 시뮬레이션한다"는 것

  • 구간 분할 전진
    시작~목표를 한 번에 스윕하지 않고, 캡슐 반지름 단위 구간으로 나눠 반복 전진
    단일 스윕은 경로 중간의 단차 하나만 있어도 전체가 막히지만, 구간별로 처리하면 각 구간마다 스텝업/스텝다운을 개별 시도할 수 있음
  • 지상 전진: 띄우고 → 전진하고 → 지면에 스냅
    매 구간마다 캡슐을 MaxStepHeight만큼 위로 띄운 상태에서 수평 스윕하여 바닥에 붙은 캡슐이 지면 접촉만으로 스윕이 막히는 것을 방지하고, 스텝 높이 이하의 턱은 자연스럽게 넘어감
    띄우는 동작 자체는 스윕하지 않게 하여 바닥에 미세하게 겹친 캡슐은 위로 떠나는 스윕조차 전진이 막히는 것을 방지
    전진 후 아래로 스윕해 지면에 다시 붙임
  • 스냅 하강 허용량에 경사 낙차 포함
    스냅 시 허용 하강량을 스텝 높이만이 아니라 스텝 높이 + (수평 이동량 × tan(걷기 가능 경사각))으로 계산
    스텝 높이만 허용하면 계단을 내려갈 때 구간 경계가 단 모서리에 걸리는 경우, 실제로는 걸어 내려갈 수 있는 낙차를 절벽으로 오판해 캐릭터가 허공에 멈추는 문제를 해결
  • 스냅 프로브는 반지름을 줄인 캡슐 사용
    하향 스냅 스윕은 캡슐 반지름을 5cm 줄여 수행하여 원래 크기로 스윕하면 방금 스친 단면이나 벽에 캡슐 옆면이 닿아 전진이 막히는 것을 방지
    그래도 막힌다면 이전 지면 높이(Z)를 유지하는 보수적 처리
  • 벽 판정 완화
    전진 스윕이 막혀도 즉시 벽으로 보지 않고, 접촉 지점에서 2cm 물러난 위치까지의 실제 전진량만 차감하고 다음 반복에서 더 높아진 지면 기준으로 재시도
    스텝업을 해도 전진량이 1cm 미만일 때만 진짜 벽으로 판정하고 정지
    물러나는 이유는 다음 스윕이 바로 충돌 판정되는 것을 막기 위함
  • 절벽과 공중 처리
    허용 하강량을 넘는 낙차(절벽)를 만나면 그 지점부터 Z를 유지한 채 허공에서 종료하고 자연 낙하
    점멸 시작 시 IsFalling()으로 공중 여부를 판별해 전달하며, 공중에서는 스텝업 없이 같은 Z로 수평 이동하고 벽에 막히면 직전 위치에서 정지

부수 수정

  • 기존 대시 방향에서 컨트롤 회전의 Pitch/Roll을 제거해 점멸 방향을 캐릭터 방향으로 고정

검토한 후보와 결론

  • 엔진 걷기 로직 재사용 : 실패시 복원 때문에 탈락
    한 프레임 안에서 CMC의 SafeMoveUpdatedComponent + StepUp + FindFloor를 반복 호출해 경로를 압축하는 방식. PhysWalking과 같은 함수로 판정하므로 걷기와 대시가 갈라질 수 없다(정의상 일치). 대가는 실제 컴포넌트를 움직이므로 오버랩/트리거 발화 억제, 리플리케이션 순간값, 실패 시 복원 등 부작용 관리가 필요하고 단위 검증이 어려움
  • 높이맵 조회 (X,Y → 지면 Z) : 탈락
    랜드스케이프 높이 조회나 사전 베이크 높이 그리드로 경로를 따라 Z만 보정하는 방식
    (X,Y)당 높이가 하나뿐이라 다층 구조를 표현할 수 없어 2층에서 대시했는데 1층 바닥 높이가 나오면 바닥을 뚫는 문제 우려
    벽 판정도 없어서 수평 스윕이 여전히 필요하고, 동적 지오메트리도 반영 못 함
  • 내비메시 투영 : 탈락
    ProjectPointToNavigation에 수직 QueryExtent를 좁게 주면 다층 구분(1층/2층/계단 스냅)은 가능
    쿼리 수 회 vs 스윕 수십 회로 성능은 조금 유리하지만 대시 보정은 발동 순간 1회 실행 및 쿨타임이 있어 병목이 아닌 곳의 최적화라 의미가 없다고 판단
    • 벽 근처 손해: 내비메시는 벽에서 에이전트 반지름만큼 침식돼 생성되어 벽에 붙는 대시가 한 발짝 앞에서 멈추고, 좁은 통로는 폴리곤이 아예 없어 비유효로 오판될 수 있음. 경계가 벽 때문인지 절벽 때문인지 내비메시만으로 구분할 수 없어 보정 불가 판단
    • 가장자리 오판: 복셀화(CellSize/CellHeight) 때문에 폴리곤 경계가 실제 가장자리와 지점마다 다른 방향·크기로 어긋날 수 있음
      가짜 절벽(지면인데 허공 판정), 가짜 지면(허공인데 스냅 성공)이 맵·격자 정렬마다 새 지점에서 재발
    • 층 경계 애매 지점: 계단 중간처럼 두 층 사이 높이에서는 QueryExtent를 어떻게 잡아도 실패 지점이 있음
      좁으면 폴리곤 틈에서 투영 실패, 넓으면 계단 밑 복도로 투영돼 계단을 뚫고 아래층으로 이동하기 때문에 폴리곤 인접 그래프 순회가 필요한데 그 복잡도가 현재 스윕 루프보다 큼
  • 대시 중 중력 적용 : 탈락
    단일 프레임 순간이동에는 경과 시간이 없어서 중력은 규칙으로 모델링해야 하는데, 무한 낙하 스냅(절벽에서 지면까지 붙임)은 "절벽 → Z 유지, 허공 종료 후 자연 낙하"라는 확정 규칙 때문에 부적합
  • Lyra식 루트 모션 소스 (GA_Hero_Dash) : 탈락
    Lyra의 대시는 순간이동이 아니라 AbilityTask_ApplyRootMotionConstantForce로 짧은 시간 CMC에 루트 모션 소스를 주입하는 방식
    이동이 걷기 물리 그대로라 보정 코드가 필요 없고, CMC 예측 통합으로 네트워킹도 공짜
    "단일 프레임 순간이동"과 "이동 중 피격 판정 없음" 규칙을 둘 다 위반, 질풍 대쉬는 돌진(제트)이 아니라 점멸(트레이서)로 정의돼 있으므로 채택 불가

 

캐릭터 부스팅 방지
캐릭터 위에는 설 수 없고, 얹히면 미끄러져 떨어지도록 다층 방어를 구성

  • AMFCharacter::CanBeBaseForCharacter : 다른 AMFCharacter를 무브먼트 베이스로 삼는 것을 거부
  • IsWalkable : 캐릭터 표면을 걸을 수 없는 면으로 판정해 착지 자체를 차단
  • JumpOff : 베이스가 캐릭터면 점프 대신 즉시 Falling으로 전환
  • HandleImpact : 정중앙에 얹히면 미끄러질 방향이 없어 균형 체류가 가능하므로, 45도 이내 윗면 접촉 + 수평 속도 부족 시 수평 속도를 부여해 떨어뜨림
    미끄러질 방향 우선순위: 수평 속도 방향 → 두 캐릭터 중심을 잇는 수평 방향 → 아래 캐릭터의 등 뒤 방향

 

슬라이딩 충돌 시 상대가 밀려나고 간헐적으로 멀리 날아가는 이슈
원인

  1. 캡슐이 겹치면 엔진의 관통 해소(ResolvePenetration)가 상대 캐릭터에 무브먼트가 적용됨
  2. 관통 해소로 밀린 위치 델타가 다음 프레임에 속도로 역산되면서, 위치 교정이 물리적 충격처럼 큰 속도로 전환돼 멀리 날아가는 현상이 발생
  3. 엔진의 bJustTeleported 가드는 이 경로를 전부 막지 못했고, 해소 당일 프레임뿐 아니라 다음 프레임에도 속도가 튀는 사례가 실측으로 확인

해결
ResolvePenetrationImpl에서 겹침 해소 이동을 슬라이딩한 캐릭터 무브먼트에 적용되도록, 밀림 방향의 반대로 상대를 스윕 없이 직접 이동시켜 이동을 상쇄

  • cm 단위 이동이라 벽에 박혀도 다음 틱의 관통 해소로 복구됨

OnMovementUpdated에 관통 해소 후 2프레임 속도 가드 추가

  • 가드 기간 중 한 프레임에 속도가 급증하면 역산된 속도로 보고 이전 속도로 되돌림

 

호시노가 스폰 직후 샷건으로 재교체까지 슬러그탄 사용 불가 버그 수정
원인
어빌리티 등록 주체가 두 곳

  • 컨트롤러 — 캐릭터 고유 스킬 등록
  • 장비 — 슬러그탄 같은 장비 어빌리티 등록

그런데 해제는 UnregisterAllAbility() 하나로 전부 일괄 삭제였는데, 컨트롤러가 InitAbility에서 캐릭터 스킬을 다시 등록하기 전에 모든 어빌리티를 등록 해제하면서, 장비가 등록해 둔 슬러그탄 어빌리티까지 함께 제거됨

이후 샷건으로 재교체하여 장비의 Activate()가 다시 호출되어야만 슬러그탄이 재등록되므로, 그 전까지는 사용이 불가능

수정
어빌리티 등록에 출처(Source) 개념을 도입해, 등록한 주체가 자기 것만 해제하도록 변경
RegisterAbility(AbilityClass, Source) — 등록 시 출처를 FAbilityContainer::Source에 기록
UnregisterAllAbility() → UnregisterAbilitiesBySource(Source) — 해당 출처가 등록한 어빌리티만 일괄 해제
이제 컨트롤러는 자신이 등록한 캐릭터 스킬만 정리하고, 장비가 등록한 어빌리티는 건드리지 않음

부수 수정
UnregisterAbility()에서 RemoveSingle 후 무효화된 포인터를 참조하던 문제 수정 => 제거 전에 태그 복사
캐시된 어빌리티를 다른 Source가 재등록하는 경우는 없다는 가정을 checkf로 명시

 

1주차

이즈나 대시가 벽/바닥을 뚫는 버그 수정
원인
기존 로직은 3단계로 도착 위치를 보정

  1. 도착 지점에서 캡슐 오버랩 검사
  2. 막혀 있으면 바닥 라인 트레이스로 위쪽 위치를 찾아 보정
  3. 그래도 막혀 있으면 시작점→도착점 스윕으로 후퇴

문제는 1번이 도착 지점만 검사한다는 것, 도착 지점 자체는 비어 있지만 경로 중간에 벽이 있는 경우(예: 얇은 벽 너머)는 막힌 걸로 판정되지 않아 벽을 그대로 통과, 또 2번 바닥 보정으로 구한 위치가 벽 안쪽인 케이스도 완전히 걸러내지 못함

수정
3단계 보정 로직을 버리고, 시작점에서 도착점까지 캡슐 스윕 한 번으로 단순화, 스윕은 경로 전체를 검사하므로 벽 너머로 넘어갈 수 없고, 막히면 충돌 직전 위치가 곧 가장 가까운 유효 위치
이 로직은 MovementUtility으로 분리해서, 앞으로 다른 순간이동류 스킬에서도 재사용할 수 있게 함

 

26년 6월

5주차

킬로그 피아식별 불가 이슈 수정

원인

킬로그를 표시할 때 킬러/희생자가 아군인지 적군인지 구분하지 않아 피아를 표현하지 못하는 문제 발생

수정

1. 로컬 플레이어 기준 피아 판정
UpdateKillLogDisplay에서 로컬 PlayerState와 킬러/희생자 팀을 UMFGameplayStatics::IsSameTeam으로 비교해 아군 여부를 판정


2. 킬로그 위젯을 전용 클래스로 교체 및 리팩토링
기존에는 GetWidgetFromName으로 위젯 내부 요소를 일일이 찾아 텍스트/이미지를 직접 세팅

이를 전용 위젯 UMFKillLogEntryWidget으로 바꾸고, 피아 정보를 BlueprintImplementableEvent로 전달하여 UMG에서 스스로 표시를 구성하도록 리팩토링

  • 문자열 의존 제거: GetWidgetFromName("Text_PlayerName_Attacker")처럼 위젯 이름을 C++에 하드코딩하던 방식은 UMG에서 요소 이름을 바꾸면 컴파일 에러도 없이 조용히 동작이 깨지는 현상 방지
  • 표현 로직 분리: 아군/적군 색상, 레이아웃 같은 시각 표현을 UMG와 위젯 안에서 처리하므로, C++ UI 컨트롤러는 "누가 누구를 죽였고 피아가 무엇인지"라는 데이터만 넘기면 된다. 디자인 변경 시 C++ 재컴파일이 필요 없음
  • 표시 규칙이 위젯에 캡슐화되어 있어, 다른 화면에서 킬로그 항목을 재사용하거나 표현을 확장하기 쉬워짐

 

4주차

호시노 방패 돌진이 즉시 멈추는 버그 수정
원인
돌진 중에는 매 틱마다 캐릭터 전방으로 캡슐 트레이스를 쏴서 벽이나 적과의 충돌을 판정

호시노가 들고 있는 방패의 콜리전이 WorldStatic이라, 벽 판정을 위해 WorldStatic 트레이스가 매번 방패 자신을 맞히고 벽에 부딪혔다고 판단해 돌진을 중단시키고 있었음

수정
1. 부착된 액터를 트레이스 무시 목록에 추가
OwnerCharacter->GetAttachedActors(ignoreActors, false, true);

2. 벽 판정을 공용 유틸리티로 교체

매직넘버로 직접 판정하던 부분을 표면 분류 공용 함수로 바꿔 프로젝트 전역과 기준을 통일

// before
const bool bIsWall = hitResult.Normal.Z < 0.85;

// after
const bool bHitWall =
    FMFCollisionUtility::DetermineCollideSurfaceType(hitResult.ImpactNormal) == ESurfaceType::Wall;


2주차

매치 BGM 사운드를 GameState에서 실행하도록 리팩토링
동기

  • 매치 흐름 BGM(카운트다운/오버타임/종료 사운드)은 기존에 MFTDMGameMode가 사운드 데이터를 소유하고, 페이즈마다 PlayerController를 순회하며 Multicast_PlaySound RPC를 일일이 호출하는 구조
  • 서버 전용 액터인 GameMode가 클라이언트 재생까지 직접 지휘하다 보니 PC 순회 보일러플레이트가 페이즈마다 반복

변경 내용

  • 사운드 재생 책임을 이미 전 클라이언트에 복제되는 GameState로 이전
    HandlePhaseChange()에서 HandleMatchFlowAudio(NewPhase)를 호출해, 페이즈 전이의 부수효과로 사운드를 재생하도록 정리
    MFPlayerController::Multicast_PlaySound RPC와 GameMode의 사운드 프로퍼티·PC 순회 코드를 전부 제거
    사운드 에셋 프로퍼티를 GameState로 이동하고, Finished 진입 시 재생 중인 사운드를 끄는 StopSoundsOnFinished(Wwise Stop 이벤트)를 추가

효과

  • 순수 삭제 우위(50줄 추가 / 118줄 삭제)로 RPC·순회 보일러플레이트 제거
  • 사운드 트리거가 페이즈 상태와 한 곳에 모여 응집도 향상, 트리거 지점이 HandlePhaseChange 단일 경로로 단순화

 

SoundComponent → GameplayCue 전환 리팩토링

동기

  • 액터마다 사운드 전용 컴포넌트(UMFSoundManagerComponent)를 생성·복제하는 구조가 비효율적
    무기·캐릭터·투척물 등 사운드를 내는 액터마다 컴포넌트가 붙어 메모리·복제 비용이 늘고, 재생 로직도 컴포넌트마다 중복
  • 사운드 재생을 GameplayCue 체계로 일원화하고, 폭발처럼 사운드와 VFX가 함께 나가는 연출을 하나의 Cue로 결합 재생할 수 있도록 정리

변경

  • UMFSoundManagerComponent 제거 : MFAbility / MFCharacter / MFHandlingObject / MFSOGrenade 등에서 생성하던 서브오브젝트 및 PlaySound 호출 일괄 삭제
  • GameplayCue 클래스 신규 추가
    UMFGCSound : Wwise 이벤트 재생
    UMFGCVFXSound : 사운드 + Niagara VFX를 함께 재생
    UMFGameplayCueComponent에 일회성 재생 API 추가
    발소리 데이터화 : 기존 if/else 분기를 CharacterActionSoundCues 맵(ActionState → Cue)으로 전환해 디자이너가 데이터로 관리

네트워크 처리

  • 발신 예측: 로컬 제어 폰은 즉시 재생 후 서버 경유로 전체 전파
    Server → Multicast / Client → Server → Multicast
  • ShouldSkipReplicatedSound로 이미 로컬에서 재생한 폰은 멀티캐스트 재생을 skip
    착지음은 서버·클라 양쪽 호출되므로 IsLocallyControlled() 가드로 중복 제거

효과

  • 사운드 재생 경로가 GameplayCue 하나로 통합되어 호출 규약이 단순해짐
    액터마다 붙던 사운드 컴포넌트가 사라져 메모리·복제 부담 감소
    사운드·VFX 연출을 큐 단위로 묶어 관리 가능

26년 5월

4주차

AMFAbilityCondition이 리플리케이트되지 않도록 변경

기존에는 AMFAbilityCondition과 그 하위 클래스(AMFACCharge)가 액터 리플리케이션을 통해 활성화 조건 상태를 클라이언트에 동기화하는데, 방식은 Condition 액터 자체를 리플리케이트해야 하고, AMFACCharge에서는 Tick으로 GameplayEffect 스택을 폴링하는 비효율을 제거하기 위함

GameplayEffect Stack을 UMFGameplayEffectManagerComponent에서 Client RPC로 직접 동기화하도록 변경하여, Condition 액터의 리플리케이션 부담을 제거

Tick 폴링 → 온디맨드 조회: 0.3초마다 스택을 확인하는 대신, 필요할 때 직접 조회하므로 불필요한 연산 제거
액터 리플리케이션 → Client RPC: Condition 액터 전체를 리플리케이트하는 대신, 실제 필요한 데이터(GameplayEffect Stack)만 RPC로 전달하여 네트워크 비용 절감
EffectStacks 캡슐화: 직접 접근을 차단하고 변경 경로를 통일하여, 스택 변경 시 클라이언트 동기화가 누락되는 실수를 방지

 

2주차

리스폰 무적 부여 방식을 GameplayEffect로 전환

기존 리스폰 무적 로직은 RespawnComponent 내부에서 타이머와 델리게이트를 플레이어 ID별로 직접 관리하는 방식으로 구현됨

GameplayEffect와 기능이 대부분 유사했고, 중복 적용 방지 분기로 인한 복잡도 때문에 리팩토링 진행

 

UMFInvincibilityGameplayEffect 클래스를 새로 만들어 무적 적용

  • Apply() : bRespawnInvincible = true 설정 + LifeState 변경 감지 바인딩
  • Release() : bRespawnInvincible = false 설정 + 델리게이트 핸들 정리

RespawnComponent에서는 리스폰 캐릭터에 GameplayEffect 적용을 요청만 하면 되므로 간단해짐

GameplayEffect 하나가 자신의 적용/해제를 스스로 책임지는 구조가 되니, 앞으로 새로운 종류의 무적(스킬, 아이템 등)을 추가할 때도 같은 패턴을 따르면 됨

 

1주차

어빌리티 Tick을 기본적으로 비활성화하도록 변경

Tick이 필요한 어빌리티에서만 활성화하여 오버헤드 줄임

 

26년 4월

5주차

스킬이 비활성 상태일 때 횟수가 화면에 표시되도록 변경


횟수가 0이 되어 어빌리티가 비활성화된 이후, 횟수가 다시 증가해도 UI가 갱신되지 않던 문제

InitAbility 시점에 EquipmentManager의 Equipments 복제가 아직 도착하지 않은 클라이언트에서는 TryBindDeActiveEquipmentDelegate()가 실패하고, OnUpdatedEquipments로 지연 바인딩되는 흐름이 있는데, 이 지연 흐름에서는 OnInitAbility가 발동되지 않아 UI가 초기 횟수를 받아가지 못함

OnUpdatedEquipments 콜백에서 정상 바인딩 후 PostInitAbility를 호출하여 UI는 어떤 경로로든 1회 OnInitAbility를 수신하도록 변경

 

게임 시작/스폰 시 초기 어빌리티 횟수가 UI에 반영되지 않던 문제

UI가 "어빌리티가 초기화되었으니 횟수를 다시 한 번 동기화해라"는 신호를 받을 수 있도록 신규 이벤트 추가
AMFAbility::InitAbility() 마지막에 SendInitGameplayEvent() 호출, AMFAbility::PostInitAbility() 파생 어빌리티가 자신의 늦은 초기화 시점에 다시 한 번 이벤트를 보낼 수 있도록 함


클라이언트에서 수리검 충돌 시 정지 안 되는 현상 수정

이전 커밋에서 수리검 충돌 시 메시가 박힌 채 정지하도록 했으나, AMFSOProjectileMovement::NotifyHit은 서버에서만 실행되므로 StopMovementImmediately()도 서버에서만 호출되었다. 결과적으로 서버에서는 투사체가 정지하지만 다른 클라이언트에서는 정지-동기화 누락 현상이 발생

UMFProjectileMovementComponent::StopMovementImmediately를 NetMulticast RPC를 통해 모든 클라에서 정지되도록 변경

 

스킬 알림 UI 시스템(NotifyUIController) 구현

스킬마다 다른 알림 위젯을 데이터 자산으로 매핑하고, 게임플레이 이벤트를 `OnNotifySkill` / `OnEndNotifySkill` 한 쌍으로 통일하여 향후 다른 스킬에도 동일한 패턴으로 알림 UI를 붙일 수 있도록 확장성을 확보

OnNotifySkill이 들어왔을 때 NotifySkillWidgetDataAsset에서 태그에 해당하는 위젯 클래스를 조회하여 CreateWidget 후 NamedSlot에 컨텐츠로 설정
OnEndNotifySkill이 들어왔을 때 현재 표시 중인 태그와 일치하는 경우에만 슬롯 컨텐츠를 nullptr로 비움

 

수리검 충돌 지연 효과 적용

이즈나 수리검이 충돌한 위치에 잠시 박혀 있다가 GameplayEffect가 발동되도록 기획이 변경됨에 따라, 폭발 컴포넌트에 지연 시간을 도입하고, 투사체가 충돌 시 즉시 정지하여 메시가 박힌 채로 보이도록 처리

 

4주차

이즈나 실명 수리검 변경 적용

이즈나의 섬광 수리검 기획 변경에 따라, 실명(Flash) 효과의 지속 시간이 거리 + 시야각 조합으로 동적으로 계산되도록 변경

  • GameplayCue / GameplayEffect 파이프라인에 `Instigator` 정보가 전파되도록 구조를 확장
  • 지속시간 계산 : 거리로 DurationByDistance FloatCurve에서 기본 지속 시간을 구하고, 캐릭터 정면 기준 Instigator까지의 시야각(deg)으로 WeightByViewAngle FloatCurve에서 가중치를 구한 뒤 두 값을 곱해 최종 지속시간 산출
  • 뒤를 바라봐도 섬광이 적용되도록 기존 CanApply에서 수행하던 시야각 기반 차단 로직(FOV 코사인 임계값 비교) 제거

EquipmentHandle 리플리케이트 순서 불일치 수정
기존 장비 장착 어빌리티는 서버에서 RegisterEquipment 결과로 받은 FEquipmentHandle을 자체 Replicated 프로퍼티로 보관하고, 클라이언트에서는 리플리케이트된 해당 핸들로 EquipmentManager의 장비를 조회하여 OnDeActive 델리게이트를 바인딩
그러나 어빌리티의 EquipmentHandle 복제와 UMFEquipmentManager::Equipments 배열 복제는 서로 독립적이며, 도착 순서가 보장되지 않아 EquipmentHandle이 먼저 도착하는 경우 매니저의 Equipments에는 아직 해당 장비가 없어 핸들 → 장비 조회가 실패
또한 외부에서 EquipmentHandle을 저장하고 관리하여 캡슐화가 되지 않고 있음

 

외부적으로 FEquipmentHandle을 사용하지 않도록 변경하고 요청은 어빌리티가 이미 알고 있는 EquipmentClass로 일원화
클라이언트에서 장비가 아직 도착하지 않았을 때를 대비해, EquipmentManager에 장비 배열 갱신 알림을 추가하고 어빌리티가 이 알림을 기다려 지연 바인딩

이즈나 수리검 투사체 충돌 규칙 변경 적용

이즈나 수리검(섬광/연막) 투사체의 충돌 동작 기획이 변경됨에 따라, 전용 콜리전 프로파일을 추가하고 투사체 기본 동작을 조정

  • 수리검은 캐릭터에 직접 데미지를 주지 않고 통과하도록 변경
  • 생성자에서 `bDestroyOnProjectileHit = false`를 명시적으로 설정.
    수리검류 투사체가 단일 히트로 즉시 파괴되지 않도록 기본값 보장
    섬광/연막 수리검은 명중 후에도 효과 부여/지면 정착 등의 추가 라이프사이클을 거치므로 자동 파괴 비활성이 필요

3주차

이즈나 수리검 스킬 사용 방법 기획 변경 적용

이즈나의 멀티 수리검 스킬 어빌리티 사용 방식이 기획 변경됨에 따라, 어빌리티가 키 재 입력으로 취소될 때 별도의 쿨타임을 적용할 수 있도록 어빌리티 시스템을 확장하고, 캐릭터가 특정 상태 태그(예: CC) 보유 시 어빌리티가 자동으로 취소되도록 하는 일반화된 메커니즘을 추가

 

무적 캐릭터 점멸 구현

리스폰 직후 부여되는 무적 상태를 시각적으로 표현하기 위해 캐릭터 메시 및 장착 무기에 점멸(Blinking) Overlay Material을 적용하는 기능을 구현

기존 무적 시스템에 GameplayCue 기반의 시각 피드백을 결합하고, 장착 장비 교체 시에도 점멸이 끊기지 않도록 처리

  • 신규 GameplayCue를 구현하여 OnActivated에서 UMaterialInstanceDynamic을 생성하여 캐릭터 3인칭 메시 및 현재 장착 장비에 Overlay Material 적용하고, Tick에서 sin(ElapsedTime * BlinkSpeed) * 0.5 + 0.5 공식으로 Opacity 파라미터를 조절하여 부드러운 펄스 효과 구현
  • 장비 Overlay 일괄 적용 인터페이스 신규 인터페이스를 도입하여 AMFHandlingObject에 신규 인터페이스 인스턴스 배열을 보유하고, RegisterOverlayTarget / UnregisterOverlayTarget / ApplyOverlayMaterial 추가하여 등록된 모든 비주얼 메시에 일괄 적용
    서브웨폰 비주얼(3인칭)도 RegisterToOwnerEquipment / UnregisterFromOwnerEquipment로 등록·해제하여 보조 무기까지 점멸 적용 범위에 포함

GameplayCue 멀티캐스트 지원

  • AddGameplayCue_Multicast / RemoveGameplayCue_Multicast API 추가
  • ActiveMulticastCueClasses 배열을 PushModel 기반 복제(REPNOTIFY_OnChanged)로 처리하여 후속 접속/리레플리케이션 상황에서도 큐가 복원되도록 구성

 

서버에서 게임 시작 전에 UI의 게임 시간이 초기화되지 않는 현상 수정

게임모드의 GameTimingSettings의 OnRep_ 콜백을 통해 HandleTimingChange가 호출되는 구조인데, 서버에서는 자기 자신이 변경한 Replicated 변수에 대해 OnRep이 호출되지 않아서 OnRemainTimeChange GameplayEvent가 브로드캐스트되지 않음
또한 기존 HandleTimingChange의 switch문은 WaitingToStart와 Finished를 동일 분기로 묶어 단순히 로그만 출력하고 있어, 초기 잔여 시간을 UI에 푸시할 경로 자체가 존재하지 않음

 

GameMode::StartPlay()에서 GameTimingSettings를 세팅한 직후 HandleTimingChange()를 직접 호출하도록 추가하여, OnRep 누락 경로를 보완
HandleTimingChange()에서 WaitingToStart 페이즈에 별도 분기를 만들어 OnRemainTimeChange로 GameDurationTime을 브로드캐스트하도록 수정하여 UI가 게임 시작 전부터 정확한 초기 잔여 시간을 표시할 수 있게 됨

 

클라이언트에서 게임 시작 카운트다운 UI가 표시되지 않는 현상 수정

클라이언트에서 UI NativeConstruct() 시점에 GameState가 아직 네트워크에서 수신되지 않아 nullptr인 경우, 델리게이트 바인딩이 되지 않아 카운트다운 UI가 동작하지 않음

 

AMFGameState::PostNetInit()에서 클라이언트가 GameState를 네트워크에서 처음 수신 및 초기화를 완료한 시점에 OnInitializedGameState GameplayEvent를 발행

위젯 생성 시 GameState가 이미 있으면 즉시 초기화하고, 없으면 OnInitializedGameState를 구독해 GameState가 준비된 후 초기화

 

캐릭터 선택하지 않고 게임 시작했을 때 스폰되지 않는 현상 수정

게임이 시작되면 모든 캐릭터의 LifeState를 Alive_Grace로 전환이 되어 직후 기본 캐릭터로 자동으로 스폰될 때 LifeState가 이미 Alive_Grace이기 때문에 스폰 실패 판정

Alive_Grace 전환 조건에 플레이어 컨트롤러가 캐릭터를 Possess했는지를 추가하여 캐릭터 없을 때 LifeState가 전환되는 것을 막아서 해결

 

2주차

리스폰 대기 동안 체력, 스킬, 탄창 UI 안보이도록 변경

OnRespawnedPlayerCharacter와 OnDeadPlayer를 DECLARE_MULTICAST_DELEGATE에서 DECLARE_DYNAMIC_MULTICAST_DELEGATE로 변경하고, UPROPERTY(BlueprintAssignable)도 추가해 BP에서 직접 바인딩 가능하도록 변경

HUDUIController에서 해당 UI들을 Hidden 처리하는 이벤트를 OnDeadPlayer에 바인드하여 리스폰 대기하는 동안 안보이도록 변경

HUDUIController에서 해당 UI들을 Non-Hit Testable 처리하는 이벤트를 OnRespawnedPlayerCharacter에 바인드하여 리스폰이 됐을 때 보이도록 변경

 

1주차

리스폰 UI 구현

서버에서 플레이어 사망이 감지되면 Client_NotifyDeadPlayerCharacter RPC로 킬러 이름을 클라이언트에 전달
클라이언트는 GameplayEventSubsystem::OnDeadPlayer 델리게이트를 브로드캐스트

새로 만든 UMFRespawnUIController가 이 델리게이트를 구독해 UI를 표시하고, UMFTimerUserWidget의 StartTimer()를 호출해 리스폰 카운트다운을 시작

  • 리스폰 대기 시간은 UMFRespawnComponent에서 가져와 GameState에 리플리케이트하도록 변경하여 클라이언트가 사용할 수 있도록 구현
  • KillerName과 타이머의 남은 시간을 위젯 BP에서 표시

정확성을 위해 타이머가 완료될 때가 아니라 리스폰 Notify Client RPC를 통해 브로드캐스트되는 OnRespawnedPlayerCharacter 델리게이트로 UI를 숨기고 타이머를 종료

 

UI 타이머 공용화

UMFAbilityUserWidget에 쿨타임 로직(Tick 감소, 상태 관리, BP 프로퍼티)이 모두 박혀 있었고, 다른 UI에서도 같은 기능이 필요해지면서 중복 작성이 불가피한 상황이 됐다.

UMFTimerUserWidget을 새로 만들어 CoolTime, RemainCoolTime, NativeTick 감소 로직, StartCooldown() / EndCooldown()를 모두 여기로 이전

UMFTimerUserWidget을 합성하여 OnStartCooldown / OnEndCooldown에서 타이머 함수를 호출하는 것으로 단순화

 

26년 3월

4주차

캐릭터 변경으로 무적 영향 없도록 변경

캐릭터 변경할 때 리스폰 로직을 실행하면서 리스폰이 아닌 상태(LifeState가 Invalid, Dead가 아닐 때)는 무적을 설정하지 않도록 변경

 

무적 해제 구현

타이머 만료 및 LifeState가 Normal로 변경될 때 무적 해제

PlayerState의 FGuid로 무적 타이머와 LifeState 변경 델리게이트를 저장하고, 무적이 취소될 때 해당 PlayerState FGuid의 무적 타이머와 LifeState 변경 델리게이트를 모두 제거

람다로 타이머가 만료되거나 LfieState가 변경될 때 캐릭터의 DamageHandlerComponent에 무적 요청

 

플레이어를 처치한 상대방을 붉은색 Outer Glow 실루엣으로 강조

상하좌우 4방향 이웃 픽셀의 Custom Stencil 값을 샘플링해 합산한 뒤, 현재 픽셀의 스텐실 값을 빼는 방식으로 경계 마스크를 생성하고, 마스크가 1인 픽셀에 GlowColor 적용하는 포스트 프로세스 머터리얼 구현

죽었을 때 죽인 캐릭터의 CustomDepthStencilValue를 1로 설정 및 활성화, 카메라 컴포넌트에 Blendable로 머터리얼을 등록

캐릭터가 파괴될 때 죽인 캐릭터의 CustomDepth를 초기화하여 리스폰됐을 때 보이지 않도록 구현

 

3주차

죽었을 때 죽인 캐릭터를 카메라가 주시하도록 구현

  1. 죽었을 때 틱 타이머에 바인딩하여 죽인 캐릭터를 주시하는 회전값을 FindLookAtRotation으로 찾음
  2. RInterpTo를 사용하여 현재 회전값으로부터 보간하여 회전
  3. 틱 타이머에 다시 바인딩

틱 타이머에 바인딩하는 이유는 죽기 전까지 틱에서 불필요한 if문 방지와 죽은 후에는 몇 초 후 리스폰되어 기존 캐릭터가 Destroy되기 때문

 

죽었을 때 InputBlock, 리스폰될 때 InputBlock 해제

플레이어 컨트롤러에서 플레이어 캐릭터가 죽음/리스폰 시점에 Client RPC를 호출하여 InputManager에서 Block/Block 해제

 

리스폰될 때 캐릭터가 Destroy 되도록 변경, 캐릭터 Destroy를 플레이어 컨트롤러로 이동

플레이어 컨트롤러에서 캐릭터 리스폰이 되면서 이전 캐릭터를 Destroy

리스폰 컴포넌트에서 캐릭터를 스폰하고, 컨트롤러에서 캐릭터들을 관리하여 스폰과 소멸의 책임을 PlayerController 혹은 RespawnComponent에서 관리

 

리스폰됐을 때 HP UI가 업데이트되지 않는 현상 수정

리스폰됐을 때 UI에 델리게이트를 전달하지 않아 UI가 업데이트되지 않음

플레이어컨트롤러에 캐릭터가 없었다가 SetPawn되는 시점을 InitializePlayerCharacter로 정의하고, 이 때 델리게이트를 브로드캐스트하고, UI에서 해당 델리게이트를 바인딩하여 업데이트

  • 리스폰되는 시점에 브로드캐스트하려 했으나, Client RPC가 호출되는 시점에 아직 플레이어컨트롤러에 Pawn이 리플리케이트되지 않아 InitializePlayerCharacter 시점을 새로 정의

 

2주차

캐릭터가 죽었을 때부터 리스폰 타이머가 시작하도록 변경

리스폰 함수를 캐릭터 Destroy 델리게이트에 바인딩하는 것을 Dead 델리게이트에 바인딩하도록 변경

 

캐릭터 스폰할 때 탄창 업데이트 안되는 현상 수정

기존에는 UI에서 GeyPlayerPawn 함수로 컨트롤러->캐릭터->EquipmentComponent->Equipment 순서로 참조하여 탄창을 가져오는 로직

클라에서 캐릭터가 스폰되면서 Posses되기 전에 장비 장착이 되기 때문에 캐릭터를 못 가져오는 현상 발생

EquipmentComponent에서 Equip 델리게이트를 호출할 때 장비를 전달하여 UI에서 장비를 직접 참조하여 탄창을 가져오도록 변경

 

InGame 매치 상태가 됐을 때 Grace LifeState가 되도록 구현

PlayerState가 변경 GameState 델리게이트에 바인딩하여 InGame이 됐을 때 LifeState를 Grace로 초기화하여 정상적으로 LifeState가 진행되도록 구현

 

1주차

시작할 때 캐릭터 선택을 안한 플레이어는 기본 캐릭터로 스폰되도록 구현

InGame 매치가 될 때 게임모드가 플레이어 배열을 리스폰 컴포넌트에 전달하여 캐릭터를 스폰하지 않은 플레이어는 기본 캐릭터로 스폰하도록 변경

 

26년 2월

3주차

캐릭터 변경했을 때 카메라 방향으로 약간 움직인 위치에서 스폰되는 현상 수정

캐릭터를 스폰할 때 캐릭터가 지형에 통과되는 상황을 방지하기 위해 ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn로 스폰하는데, 기존 캐릭터와의 충돌 때문에 점점 위치가 카메라 방향으로 움직여서 스폰되는 현상이 발생

새로운 캐릭터를 스폰하기 전에 기존 캐릭터의 충돌을 끈 후 스폰하여 현상 방지

 

MatchState가 Lobby일 때 캐릭터 변경 UI가 Hide되는 현상 수정

LifeState가 Normal일 때 UI가 Hide되는데, MatchState가 Lobby일 때에도 LifeState가 Grace에서 타이머가 발동하여 Normal로 전환되기 때문

MatchState가 Lobby일 때 LifeState가 Grace에서 변경되지 않도록 구현

 

캐릭터를 직접 변경할 때 계속 스폰되는 현상 수정

죽었을 때 실행되는 Delay 스폰 로직에서 LifeState가 Dead가 아닐 때만 호출하도록 검사 추가

캐릭터를 변경할 수 있을 때에는 죽지 않는다는 가정으로 캐릭터를 변경할 때 Delay가 아닌 즉시 스폰이 이루어지기 때문에 중복 호출 방지

 

하드코딩된 리스폰 DelayTime을 변수화하여 모드마다 다양한 시간을 설정할 수 있도록 변경

 

2주차

플레이어 캐릭터를 리스폰하는 로직을 컴포넌트로 이동

리스폰 시스템을 사용하지 않는다고 명시하거나 별도의 리스폰 시스템을 사용하는 모드를 제외한 모드는 리스폰 시스템이 적용되기 때문에 TDM 게임모드에 있던 리스폰 로직을 다른 게임모드도 사용할 수 있도록 컴포넌트로 분리하여 역할 분리 및 코드 중복 방지

 

캐릭터를 런타임에 바꿀 수 있도록 시스템 변경

기존에는 캐릭터와 플레이어컨트롤러가 쌍으로 구성돼야 하며, 플레이어컨트롤러에 캐릭터와 관련된 데이터가 고정되어 런타임에 캐릭터를 변경할 수 없었음

따라서 플레이어컨트롤러를 여러 캐릭터가 사용할 수 있도록 구체적인 사항들을 제거하여 범용적으로 설계하고, 캐릭터를 선택했을 때 캐릭터 타입을 바탕으로 에셋에서 캐릭터 관련 데이터를 가져와 캐릭터를 설정할 수 있도록 변경

  • 캐릭터가 변경될 때 기존 어빌리티를 모두 해제하고, 새로운 어빌리티를 등록

Match 상태와 Life 상태를 정의하고, 가능한 Match 상태와 Life 상태일 때만 UI를 킬 수 있도록 OwningPlayerController를 통해 검사

매치 상태나 Life 상태가 변경될 때 릭터를 변경하지 못하도록 캐릭터 UI가 사라지도록 구현

 

1주차

어빌리티 액터의 Relevancy를 Owner와 관련하도록 설정

어빌리티는 서버와 autonomous Proxy만 알고 있어야 하기 때문에 리플리케이트 되는 클라이언트를 autonomous Proxy로 제한

 

26년 1월

4주차

서버 RPC를 위해 DamageType을 DataAsset으로 변경

기존 UObject로 구현된 DamageType은 데이터 입력 편의를 위해 DefaultToInstanced, EditInlineNew 메타데이터를 설정했으나 해당 인스턴스들을 서버가 모르는 문제 발생

DataAsset으로 구현하여 서버에서 이미 로드된 DamageType을 사용하여 패킷 비용을 줄임

데미지에 필요한 동적인 데이터는 구조체로 분리하여 따로 전달

 

25년 12월

2주차

데미지 타입 및 처리 리팩토링

언리얼 엔진의 기본 UDamageType 클래스 기반 데미지 시스템을 커스텀 UMFNewDamageType 시스템으로 전면 리팩토링

기존에는 TSubclassOf<UDamageType>과 FDamageEvent를 사용하여 데미지를 처리했으나, 이를 인스턴스 기반의 유연한 구조로 변경

 

기존 시스템의 문제점

  • UDamageType은 CDO(Class Default Object) 기반으로 동작하여 런타임에 동적인 데미지 값 설정이 제한적
  • FDamageEvent, FPointDamageEvent, FRadialDamageEvent 등 언리얼 기본 이벤트 구조에 의존하여 커스텀 확장이 어려움
  • 데미지 계산 시 BaseDamage를 함수 파라미터로 전달해야 해서 데미지 타입과 데미지 값이 분리되어 관리됨

주요 변경 사항

  • DefaultToInstanced와 EditInlineNew 지정자를 사용하여 에디터에서 인라인 인스턴스 생성이 가능하도록 선언
  • BaseDamage 파라미터가 제거되고, 데미지 타입 객체 내부에 데미지 값이 포함되는 구조로 변경
  • FDamageEvent 의존성을 제거하고, FDamageEvent에서 필요했던 HitBoneName을 ApplyPointDamage API에서 DamageType에 설정하도록 변경

 

1주차

외부에서 장비 포인터를 직접 참조하는 방식을 FEquipmentHandle로 장비 컴포넌트에 요청하는 방법으로 리팩토링

장비 컴포넌트에서 장비 Raw 포인터를 반환하고 있어 장비 컴포넌트가 장비를 완전히 제어하지 않고 있었음

따라서 외부에서 FEquipmentHandle를 갖고 있도록 구현하고, 장비 컴포넌트에 FEquipmentHandle을 전달하여 해당 장비를 컴포넌트가 제어하도록 변경

 

25년 11월

4주차

시로코 캐릭터는 자폭 데미지를 입지 않도록 구현

캐릭터의 상태를 관리하는 StatusComponent 기초 설계

캐릭터의 상태는 FGameplayTag로 나타냄

추후 CC 등에 활용될 수 있음

 

Status를 추가하는 범용 어빌리티를 설계 후, Status_SelfExplosionDamageImmunity 상태를 추가

Status_SelfExplosionDamageImmunity를 가진 캐릭터가 DamageHandlerComponent에서 데미지 처리 요청이 왔을 때 스스로의 스킬에 의한 것이라면 데미지 처리를 종료하도록 구현

 

 

수류탄의 폭발 및 섬광 기능을 ExplosionComponent로 통합 리팩토링

폭발이 수류탄에 국한됐다가 드론 미사일에도 사용해야 하므로 폭발 기능을 담당하는 액터 컴포넌트 ExplosionComponent로 통합하기로 결정

 

섬광탄에 맞았을 때 이속 감소 및 포스트 프로세스에 섬광 머터리얼을 적용해야 하므로 컴포넌트의 GameplayEffect가 유효할 경우 폭발에 맞은 액터에 GameplayEffect를 적용

GameplayEffect에서 이속 감소를 적용하고, 포스트 프로세스에 섬광 머터리얼을 적용하는 GameplayCue를 실행하도록 구현

 

수류탄의 경우 충격 신관에 의해 폭발할 경우 데미지가 반감되고, 거리가 멀어질 수록 데미지가 줄어듦

충돌돼서 폭발됐을 경우의 플래그를 인자로 전달받아 기본 데미지에 충격 신관 데미지 비율을 곱하여 계산

거리에 따른 데미지 계산은 데미지 계산을 담당하는 UMFExposionDamageType 클래스를 DamageHandlerComponent에 전달한 뒤 UMFExposionDamageType에서 계산한 거리로 UCurveFloat에서 얻은 데미지 비율로 곱하여 계산하도록 구현

  • 에디터에서 UCurveFloat와 UMFExposionDamageType을 생성하고 ExplosionComponent에 주입하여 코드 수정 없이 다양한 데미지 계산이 가능

 

캐릭터 Die 시점에 각 컴포넌트나 인터페이스의 함수를 직접 호출하는 것을 델리게이트를 사용하여 리팩토링

캐릭터 OnDie 함수에서 각각의 컴포넌트나 인터페이스의 함수를 호출하여 함수가 비대해지고, 결합도가 높아지기 때문에 리팩토링 진행

OnDie 시점에 델리게이트를 호출하고 캐릭터의 OnDie 시점을 필요하다면 적절한 시점에 델리게이트 바인딩하여 결합도를 낮춤

 

UI에서 바인딩하는 델리게이트를 HUD에서 GameplayEventSubsystem으로 이동

아래의 이유로 리팩토링을 진행

  • NativeConstruct, NativeOnInitialized 시점에 HUD가 초기화되는 것이 보장되지 않음
  • UserWidget, UIController에서 HUD의 델리게이트를 직접 참조하는 것은 하위 모듈에서 상위 모듈을 참조하는 것은 적절하지 않음

 

3주차

시로코 드론 미사일 폭발 시스템 구현

ExplosionComponent에서 폭발을 담당하도록 구현

  • 폭발 거리를 바탕으로 현재 오너 위치에서 구 모양으로 MultiSweep하여 Block되는 액터들에 데미지를 처리

 

나이아가라 이펙트 적용

  • 벽, 땅 등에 사격했을 때 구멍, 연기 효과 적용
    총알이 라인트레이스 판정했을 때 HitActor가 StaticMesh일 때 나이아가라 시스템 스폰
  • 헤드샷/바디샷 피격 이펙트 구분하여 적용
    총알 라인트레이스 판정했을 때 BodyType에 따라 구분하여 나이아가라 시스템 스폰
  • 시로코 드론 미사일 폭발 이펙트 적용
    Projectile이 Explode될 때 나이아가라 시스템 스폰

 

2주차

반응성 연막탄 렌더링 최적화

연막이 생성될 때 GPU Time 600ms -> 20ms

런타임에 연기 생성 및 구멍을 만들기 위해서는 모든 복셀의 Vanish RenderTarget을 그려서 복셀을 베이킹

기존에는 PostInitializeComponents 시점에 모든 복셀마다 RenderTarget을 그려서 드로우콜이 많이 발생하여 몇 초간 화면이 멈춤

연기가 1초 동안 퍼져나가기 때문에 해당 시간 동안 여러 Tick에 걸쳐 소멸 RenderTarget을 그리도록 변경

 

폭발 GPU Time 200 ms -> 20ms

기존에는 폭발에 영향을 받은 복셀마다 복셀의 중점과 반지름을 Material에 전달하여 구멍 RenderTarget을 그려 드로우콜이 많이 발생

폭발원에 해당하는 점의 위치와 폭발 거리를 통해 연막에 미치는 거리를 Material에 전달하여 구멍 RenderTarget을 한 번만 그려 한 번의 드로우콜로 폭발 구멍을 렌더링

 

25년 10월

5주차

서버에서 캐릭터가 리스폰될 때 무기가 장착되지 않는 현상 수정

EquipmentComponent에서 장착된 무기를 장착하라고 요청이 들어왔을 때 장착하고, 기존 장비와 같기 때문에 장비를 Activate하지 않아 발생

무기를 장착할 때 현재 장착된 장비와 같다면 요청을 종료하도록 변경

 

스킬을 사용한 상태에서 리스폰될 때 스킬이 활성화돼있는 현상 수정

리스폰되면서 스킬(어빌리티)가 초기화될 때 어빌리티를 비활성화하도록 변경

 

팀 인디케이터 구현

플레이어 캐릭터에 팀 인디케이터 Sphere 스태틱 메시 컴포넌트를 추가

캐릭터가 장애물에 가려져도 인디케이터가 보이기 위해 Disable Depth Test를 체크한 머터리얼을 적용

컴포넌트에서 아군 플레이어 캐릭터의 인디케이터가 아니라면 DestoryComponent

 

  • WidgetComponent의 Screen 모드는 각 클라이언트의 로컬 화면에만 렌더링되는데, 서버(호스트)는 렌더링되지 않기 때문에 사용하지 않음
  • HUD의 위젯은 NativeTick 시점에 Target 액터 위 위치를 업데이트하는 것이 1 프레임 지연되기 때문에 사용하지 않음

 

4주차

연막에 위치한 적 표시 UI 구현

연막탄을 던진 플레이어에 한해서 연막에 1초 이상 오버랩된 플레이어 캐릭터 수를 표시하는 UI 구현

연막 액터의 Instigator가 LocalPlayerController(연막탄을 던진 플레이어)일 때 OnBeginOverlap 시점에 다른 팀이 오버랩 될 경우 1초 타이머 실행 후 OverlapCount 증가, OnEndOverlap 시점에 Overlap된 액터가 있었다면 OverlapCount 감소

OverlapCount가 변경될 때마다 델리게이트를 BroadCast

어빌리티 위젯에서는 UIAbilityDataAsset에서 어빌리티 태그의 AdditionalWidget이 있다면 해당 슬롯에 추가

UMG 블프 노드에서는 델리게이트에 바인딩하고, 함수가 호출됐을 때 OverlapCount를 텍스트로 표시

 

클라에서 장비를 장착하는 어빌리티가 활성화되는 중에 다시 스킬을 눌렀을 때 주무기 조작이 안되는 현상 수정

스킬 키를 누르거나, 장비가 비활성화될 때 해당 어빌리티가 비활성화되고, 어빌리티가 비활성화되면 주무기를 장착함

장비 컴포넌트에서 현재 장비가 리플리케이트될 때 기존 장비를 Deactivate시키고, 현재 장착된 장비를 Activate시킴

클라에 DeAcitvate는 동기화되지만 Activate는 동기화되지 않음

동일한 장비로 두 번 장착하는 요청이 들어왔을 때 두 번째 요청 처리의 서버에서 동일한 장비를 DeActivate시키고, Activate시키는데, 변수의 수정은 없으므로 리플리케이트 콜백 함수는 호출되지 않아 DeActivate만 동기화되어 조작이 되지 않았음

따라서 기존 장비와 현재 장비가 동일할 때는 장비의 비활성화, 활성화를 하지 않도록 구현하고, 어빌리티에서는 이미 비활성화됐을 때 비활성화 처리를 또 동작하지 않도록 변경

 

2주차

uasset 확장자 파일 LFS 필터 적용

 

DamageCauser가 유효하지 않아도 데미지를 입도록 변경

자폭 같은 플레이를 허용하도록 객체가 죽거나 파괴됐을 때도 데미지를 입을 수 있도록 검증 코드 제거

 

1주차

이즈나 다용도 수리검 구현

장비를 장착하고 마우스 왼쪽 클릭시 실명 수리검, 오른쪽 클릭시 연막 수리검을 발사하도록 구현

ProjectileMovementComponent::ProjectileGravityScale을 0으로 설정하여 직선으로 발사되도록 설정

실명은 섬광탄으로, 연막은 반응성 연막탄으로 기존 시스템을 재활용

 

GameplayCue 시스템 설계

게임플레이 상태에 영향을 미치지 않는 이펙트(섬광 등)에 사용될 GameplayCue 시스템을 설계

GameplayCueComponent로 GameplayCue를 관리

  • GameplayCueComponent를 통해 GameplayCue를 적용할 때 로컬에서 실행돼야 하므로 해당 캐릭터가 LocallyControll일 경우 GameplayCue를 적용, 그렇지 않고 서버 액터일 경우 클라 RPC를 통해 GameplayCue를 적용
  • GameplayCueComponent에서 RPC를 실행했을 때 어썰트 발생하는 현상 수정
    리플리케이트를 설정하지 않고, CreateDefaultSubObject로 생성하지 않았기 때문에 IsSupportedForNetworking 함수가 false를 반환하여 어썰트 발생
    플레이어 캐릭터만 컴포넌트가 필요하므로 리플리케이트를 설정하여 해결

 

Throw 장비에서 TriggerType에 따라 발사하는 Projectile를 다르게 설정할 수 있도록 변경

TMap<ETriggerType, TSubclassOf<AMFProjectile>> 자료구조로 변경하고, 장비에 Trigger 이벤트가 들어왔을 때 Trigger 타입에 따른 Projectile 클래스를 스폰하도록 변경

 

클라이언트에서 수류탄이 흔들리면서 이동하는 현상 수정

클라이언트의 ProjectileMovement의 Velocity를 설정하지 않고, 액터의 ReplicateMovement를 True로 설정하여 서버의 위치를 동기화하면서 위치가 리플리케이트될 때마다만 액터의 위치를 설정하기 때문에 매 프레임 위치를 변경하지 않기 때문에 자연스럽게 이동하지 않고 흔들리면서 이동하게 됨

 

패킷 전달 횟수를 줄이기 위해 ReplicateMovement + 보간 방법 보다는 Velocity를 각 클라이언트에 동기화하는 방식을 사용하여 해결

 

25년 9월

5주차

수류탄이 방패에 닿을 시 Destory되는 현상 수정

Projectile이 Overlap될 때 Destroy된 코드가 있어서 발생

해당 코드는 필요없으므로 삭제

 

Projectile 콜리전 프리셋을 만들고 Block되게 설정, 오브젝트마다 필요하면 Overlap, Ignore를 커스텀하게 설정

수류탄이 충돌됐을 때 튕기지 않아 ProjectileMovement::bShouldBounce를 True로 설정

 

 

플레이어 캐릭터 리스폰 시 리플리케이트 타이밍 이슈로 스킬 UI를 생성하지 않는 현상 수정

클라에 컨트롤러의 Pawn보다 어빌리티가 더 빨리 리플리케이트되어 어빌리티 UI에서 컨트롤러의 폰을 찾을 수 없어 발생

UI에서 어빌리티 태그를 Pending하여 어빌리티가 리플리케이트될 때, Pawn이 리플리케이트될 때 모두 Pending된 어빌리티 태그를 처리하도록 변경

 

4주차

기존 float Delay 값을 이용한 타이머 기반의 수류탄 발사 로직을 AnimNotify 기반으로 변경

기존 문제점

  1. 부정확한 타이밍 : 애니메이션 재생 속도가 변경되거나, 다른 애니메이션과의 블렌딩 과정에서 미세한 시간 차이가 발생할 경우, 캐릭터의 손에서 수류탄이 떠나는 시점과 실제 발사 시점이 어긋나는 문제가 발생할 수 있음
  2. 어려운 유지보수 : 발사 타이밍을 조절하기 위해선 항상 애니메이션을 확인하고 코드의 float 값을 수정해야 ㅎ마

애니메이션 몽타주에서 해당 프레임에 SendGameplayEvent_AnimNotify를 추가

장비에서 Throw GameplayEvent를 받을 때 수류탄 발사 로직 실행

로컬에서만 애니메이션이 실행되므로 로컬에서만 이벤트를 받을 수 있도록 구현

  • 정확성 향상 : 애니메이션의 특정 프레임에서 발사 로직이 실행되므로, 캐릭터의 모션과 발사 타이밍이 완벽하게 일치
  • 유지보수 향상 : 이제 발사 타이밍을 조절해야 할 때 코드를 수정할 필요 없이, 애니메이션 에디터에서 노티파이의 위치만 옮기면 되므로 훨씬 직관적이고 빠른 수정이 가능
  • 코드 가독성 증가 : 타이머와 관련된 복잡성이 사라지고, 각 함수의 역할이 명확해져 코드의 가독성 상승

 

OwnerCharacter를 캐싱하여 발생한 크래시 수정

캐릭터는 죽었을 때 Destroy된 후 리스폰될 때 생성됨

 

어빌리티는 Controller에서 소유하여 캐릭터를 캐싱할 경우 댕글링이 발생하므로 캐릭터가 필요할 때마다 가져오도록 수정

총과 같이 캐릭터가 소유하는 오브젝트는 보통 BeginPlay에서 캐싱하는데, 게임이 이미 시작됐으므로 클라이언트는 Owner가 리플리케이트되는 것보다 BeginPlay가 먼저 호출되므로 Owner가 nullptr인 상황이 발생하여 캐릭터를 필요할 때마다 가져오도록 수정

 

넉백 시스템 구현

타겟을 특정 방향, 거리, 각도로 밀어내는 넉백 시스템을 구현

포물선 운동 공식을 사용하여 LaunchCharacter에 필요한 벡터 계산

  • 포물선 운동에서 유도된 R = v0^2 * sin(2θ) / g에서 v0 = sqrt(R * g / sin(2θ))를 통해 벡터 계산
  • 거리와 각도가 주어졌을 때 주어진 수평 거리만큼 넉백돼야 하므로 위 수식을 사용

 

3주차

와카모 불여우 추적 놓치지 않아 패시브 구현

합연산 퍼센트인 AdditivePercent를 EGameplayModifyOperation에 추가

StatisticBaseValue의 수치 퍼센트만큼 증가

 

StatisticGameplayEffect Release 구현

StatisticGameplayEffect::Apply에서 Duration이 유효할 때 CurrentValue에 대한 변화량을 저장한 후 Release에서 해당 StatisticSet에 변화량만큼 감소하여 복구

 

아야네 화력지원 RPC 횟수 최적화

장비 장착, 드론 스폰, possess를 각각 클라에서 RPC로 요청하여 총 6회 RPC 실행

서버 어빌리티에서 장비 장착, 드론 스폰, possess를 요청하여 총 2회 RPC로 최적화

 

25년 8월

5주차

와카모 불여우 추적 스킬의 추적 모드 구현

 

근접 공격 보정 시스템 구현

캐릭터를 중심으로 일정 거리 이내의 Damageable 트레이스 채널로 오버랩되는 액터들을 구하고 근접 공격 조건에 부합하는지 체크

  • 일정 시야각 이내인지 : AttackerForwardVector와 AttackerToTargetVector를 내적한 값이 시야각의 cos 값보다 큰 경우
  • 죽지 않은 상태
  • 같은 팀이 아닌 액터
  • LOS가 만족하는지 : 액터의 중앙, 좌, 우 세 점을 카메라로부터 라인트레이스하여 블락되지 않는 점이 2개 이상인지

보정으로 타겟을 구했다면 플레이어가 타겟을 바라보도록 회전

  • 타임라인을 사용하여 타겟을 바라보는 FRotator까지 보간하여 회전 설정

 

4주차

와카모 불여우 추적 스킬의 투척 모드 구현

 

3주차

AbilityTask, GameplayEvent 시스템 구현

어빌리티에서 각 Task를 재사용하고 유지보수를 위해 AbilityGameplayTask 시스템을 도입

static 함수를 통해 AbilityTask를 생성하고, 프로젝트의 커스텀 AbilityComponent를 사용

 

AbilityComponent에서 TMap<FGameplayTag, FGameplayEventDelegate> 자료구조 관리

각 객체에서 AbilityComponent의 GameplayTag 이벤트에 등록한 후 이벤트가 발생했을 때 유틸리티 함수로 AbilityComponent에 전달하여 등록된 객체까지 전달되도록 구현

 

WaitGameplayEvent 어빌리티 태스크로 어빌리티 컴포넌트에 등록, 해제를 할 수 있도록 RAII 기법 적용

Activate 시점에 어빌리티 컴포넌트에 델리게이트를 등록하고, DeActivate 시점에 어빌리티 컴포넌트에 델리게이트를 제거하고, 태스크 객체를 Destroy

 

Grenade 클래스에 있는 Throw 관련 로직을 ThrowEquipment로 이동 및 리팩토링

 

2주차

아야네 화력지원 드론 이동 반경 제한

스폰 위치와의 거리가 제한 거리 이상일 때 StartLocation + (ActorLocation - StartLocation).GetSafeNoraml() * MaxMoveDistance로 위치 설정하여 움직이지 못하는 것처럼 구현

 

아야네 화력지원 어빌리티에서 드론을 Possess하여 드론을 조종할 수 있도록 구현

FamiliarComponent에서 드론을 스폰하고, 클라에 드론이 리플리케이트될 때 델리게이트를 호출하도록 구현하여 타이밍 최적화

드론 리플리케이트 델리게이트가 호출됐을 때 PlayerController의 서버 RPC를 호출하여 드론을 Possess

드론에 MFInputComponent를 추가하여 독립적인 드론 Input 시스템 동작

어빌리티에서 드론을 Possess 하기 전 플레이어 캐릭터를 저장하고, 어빌리티가 비활성화될 때 저장한 플레이어 캐릭터를 Possess하여 복구

 

명령 패턴을 활용하여 Input 시스템 리팩토링

기존에는 각 InputActon마다 직접 함수를 바인딩하여 Input이 추가될 때마다 코드를 작성해야 하고, 비슷한 코드가 증가하는 문제 발생

InputConfig에서 InputAction을 GameplayTag에 바인딩하고, InputComponent에서 InputCommand를 GameplayTag에 바인딩하여 같은 GameplayTag의 InputAction이 입력됐을 때 InputCommand를 실행

 

연막탄이 수류탄에 반응하지 않는 버그 수정

수류탄 폭발에 영향을 받는 Voxel을 통해 Hole 머터리얼 파라미터를 설정까지만 구현돼있고 Draw Render Target이 없어서 적용되지 않음

수류탄 폭발에 영향을 받는 Voxel마다 Draw Render Target을 호출

 

아야네 화력지원이 끝날 때 드론 파괴되도록 변경

FamiliarComponent에서 소환수를 관리하고 있으므로 드론을 파괴하도록 FamiliarComponent의 서버 RPC를 호출

 

25년 7월

4주차

연막탄 Flood Fill 알고리즘 최적화

재귀->BFS, 미리 필요한 값을 캐싱하여 26ms->6ms로 개선

 

3주차

아야네 쉴드 전개 드론 및 쉴드 구현

시작 위치에서 타겟 위치까지 타임라인으로 보간하여 드론 3m 수직 상승을 구현

3m 수직 상승 후 드론이 쉴드를 생성

쉴드는 DamageHandlerComponent로 무적을 설정하여 모든 데미지를 흡수

 

등록된 StatisticSet의 데이터로 초기화할 수 있도록 변경

액터마다 다른 체력을 적용하기 위함

StatisticSet을 Blueprintable로 선언하고 한정된 데이터(ex. HP)를 블루프린트에서 입력할 수 있도록 선언

블루프린트에서 입력한 데이터를 바탕으로 Statistic을 초기화하도록 구현

 

연막이 수류탄 폭발에 의해 지워지도록 구현

폭발될 때 폭발의 범위에 있는 액터에 인터페이스로 폭발 함수 호출

폭발에 영향받았을 때 폭발 위치와 범위에 영향받은 Voxel 위치에 Hole 머터리얼 파라미터를 변경하여 연기가 사라지도록 구현

연기 액터의 틱마다 DeltaTime만큼 Hole 머터리얼의 Radius와 Density를 줄여 연기 복원

 

연막탄 Voxel에 VFX 적용

Voxel마다 Render Target을 사용하여 런타임에 머터리얼을 변경할 수 있도록 구현

임시 연기 머터리얼 사용

 

연막탄과 연기를 생성하는 액터 구현

연막탄이 던져지고, 연막탄이 터질 때 연기를 생성하는 오브젝트를 스폰

 

반응성 연막탄에 사용될 연기 생성을 위한 Flood Fill 알고리즘 구현

연막탄이 터진 지점을 연기원으로 설정하고, 해당 Voxel에서 인접한 Voxel로 퍼져나가며 볼륨을 채움

  • 연기원 위치에는 조건 없이 무조건 Voxel이 생성

재귀로 6방향을 탐색하며 연기 생성 조건을 만족하는지 체크

  • 최대 스모크 부피 제한
  • 그리드가 장애물에 충돌되는 경우 퍼지지 않아 현실적으로 벽을 통과하지 않고 장애물을 감싸듯 채워짐을 보장

디버그 용도로 연기 Voxel마다 Cube StaticMeshComponent를 추가

 

2주차

아야네 화력 지원 스킬의 드론 소환 및 카메라 드론 시점 전환까지 구현

Input으로 어빌리티가 활성화될 때 태블릿 장비 장착 및 드론 소환 어빌리티 실행

드론이 소환되어 리플리케이트 됐을 때 플레이어 컨트롤러의 ViewTarget을 드론으로 설정

스킬 지속시간이 종료됐을 때 ViewTarget을 플레이어 캐릭터로 변경하고, 소환한 드론 제거 및 어빌리티 종료

 

소환수 관리 리팩토링

어빌리티에서 소환수를 스폰하고 있었는데 소환수에 대한 관리가 어려워 컴포넌트로 이동하고, 어빌리티는 컴포넌트에 스폰 요청

컴포넌트에서 소환수를 스폰하고, 스폰환 소환수를 저장하고, 소환수를 Destroy하도록 변경

 

아야네 체력&탄약 보충을 즉발로 변경

기획 변경에 따라 스킬 사용에 따른 중간 메커니즘 없이 바로 GameplayEffect가 적용될 수 있도록 변경

 

캐릭터 선택 순서 저장 구현

캐릭터 선택 레벨과 플레이 레벨의 게임 모드가 다르므로 캐릭터 선택 순서를 캐릭터 선택 게임 모드에서 서버 게임 인스턴스에 저장

  • 플레이어마다 UnqieuNetId가 다르므로 UnqieuNetId 키로 TMap에 캐릭터 선택 데이터를 저장

플레이어의 UniqueNetId를 통해 캐릭터 선택 순서를 가져오도록 함수 생성

 

팀원의 목록을 표시하기 위해 GameState에서 같은 팀 캐릭터를 반환하는 함수 구현

GameState의 PlayerArray에서 전달 받은 PlayerState와 TeamIndex가 같은 PlayerState의 Controller의 캐릭터를 반환

 

1주차

플레이어 정보 UI를 Profile로 분리

HUDUIController에 있던 플레이어 정보 위젯들(썸네일, 베리어, 체력, 이름)을 Profile 위젯으로 분리

Profile 위젯에서 썸네일, 베리어, 체력, 이름 위젯이 각각 있을 때 해당 업데이트를 처리

위젯을 분리하여 HUD 뿐만 아니라 아야네 태블릿 UI에도 재사용하기 위함

 

아야네 체력&탄약 스킬 구현

Input이 Press 됐을 때 태블릿 장착 어빌리티 실행

Input이 트리거 됐을 때 태블릿 장비에서 체력&탄약 지원 어빌리티 실행

  • Input Action의 Hold Time Thresold를 0.1로 설정하여 클라이언트의 장착 리플리케이트 지연에도 Trigger를 받을 수 있도록 구현

어빌리티에서 1초 타이머를 설정하고 1초 후에 체력, 탄약을 증가시키는 GameplayEffect 적용

  • 1초 전에 장비에서 Release Input Trigger가 됐을 때 체력&탄약 지원 어빌리티를 취소하여 GameplayEffect가 적용되지 않도록 구현

 

어빌리티에 Input Start/Trigger/Complete 이벤트 전달하는 시스템 구현

더 이상 Input을 했을 때 어빌리티가 계속 활성화되지 않도록 수정

Input을 받았을 때 Input TriggerEvent(Start/Trigger/Released)를 어빌리티 컴포넌트를 통해 어빌리티에 전달

어빌리티가 각 Input 함수에 따라 로직을 재정의할 수 있도록 설계

 

DamageHandlerComponent에 데미지 처리 요청을 보내는 로직을 통일

기존에는 UGameplayStatics::ApplyDamage를 호출하여 액터마다 TakeDamage를 오버라이드하여 DamageHandlerComponent 함수를 호출했어야 함

우리 프로젝트 프레임워크에 맞게 DamageHandlerComponent 함수를 호출하는 UMFGameplayStatics::ApplyDamage를 구현

25년 6월

4주차

무적, Dead 처리 로직을 캐릭터에서 DamageHandlerComponent로 이동

데미지 처리 로직이 분산된 것을 역할에 맞게 DamageHandlerComponent로 이동

무적, Dead를 캐릭터 뿐만 아니라 DamageHandlerComponent를 소유한 액터도 처리할 수 있도록 변경

 

클라에서 어빌리티 UI에 WidgetSwitcher가 보이지 않는 현상 수정

다른 어빌리티가 등록/해제되면서 어빌리티 UI를 정렬하는데, 이 때 ClearChildren -> AddChild를 수행

AddChild가 되면서 NativeConstruct가 호출되는데, NativeConstruct에서 WidgetSwitcher를 Collapsed로 설정하고 있었기 때문에 발생

WidgetSwitcher는 UI가 초기화될 때 Collapsed로 설정해야 하므로 NativeConstruct가 아닌 NativeOnInitialized에서 설정하도록 변경

 

클라에서 어빌리티 UI가 추가/삭제되지 않는 현상 수정

서버에서 어빌리티가 등록됐을 때 호출하는 Notify 함수가 클라 RPC로 선언되지 않아서 HUD가 없기 때문에 발생

클라 RPC로 선언하여 패킷을 보내도록 수정

 

아야네 원격지원 설치 어빌리티 구현

어빌리티를 실행하면 설치 프리뷰 액터를 스폰하거나 스폰이 이미 됐다면 Activate하여 화면에 보임

한 번 더 어빌리티를 Input하면 설치 프리뷰 액터 위치에 설치할 액터를 스폰하고 설치 프리뷰 액터를 DeActivate하여 화면에 보이지 않음

 

설치 프리뷰 액터 움직임 구현

에임이 2m 이내의 지형을 바라봐야 프리뷰 액터가 표시되도록 구현

프리뷰 액터가 지형에 위치해도 에임에 따라 이동이 가능해야 하기 때문에 콜리전을 사용하지 않고 라인트레이스 기반의 커스텀 충돌을 구현

  1. ViewLocation에서 ViewRotation 방향으로 라인트레이스를 추적
  2. 라인 트레이스에 충돌한 오브젝트가 WorldStatic이고, 거리가 2m일 때 위치 계산 수행
    위 조건을 만족하지 않는다면 프리뷰 액터가 보이지 않도록 구현
  3. HitResult의 ImpactNormal(평면의 법선벡터)를 활용하여 충돌된 오브젝트의 형태를 검사
  4. 천장과 벽에 충돌됐을 때는 충돌된 위치 + ImpactNormal * BoundExtent로 위치 설정(지형과 프리뷰 액터가 겹치기 때문에 바운딩 박스만큼 조절)
    바닥에 충돌됐을 때는 바닥 높이의 캐릭터 위치에서 에임 방향으로 2m 떨어진 위치로 설정
    위에서 설정한 위치로 라인트레이스를 한번 더 추적하여 벽과 충돌할 경우 위치를 변경하지 않음(에임이 2m 이내로 고정하고 캐릭터를 벽 쪽으로 이동할 경우 프리뷰 액터가 벽을 뚫는 현상 방지)

 

3주차

아야네 원격 지원 바리케이드 구현

액터의 기능들(피격 시 파괴, 떨어질 때 캐릭터가 맞으면 즉사) 블루프린트 그래프에서 구현

 

아야네 원격 지원 체력/탄약 디스펜서 구현

액터의 기능들(피격 시 파괴, 지속 시간 지난 후 파괴) 블루프린트 그래프에서 구현

설치 프리뷰 액터를 대비하기 위해 디스펜서의 장판(GameplayEffectProvider)을 Visibility를 BeginPlay에 true로 설정

 

GameplayEffectProvider에서 GameplayEffect를 여러 개, 여러 번 적용할 수 있도록 변경

 

DamageHandlerComponent 관련 리팩토링

액터도 데미지 시스템을 사용할 수 있도록 캐릭터에 있는 로직을 DamageHandlerComponent로 이동

컴포넌트에서 DamageType 객체에 데미지 계산을 요청하여 DamageType에 따라 데미지를 계산

 

시작할 때 플레이어 체력 UI가 동기화되지 않는 현상 수정

StatisticSet의 초기화 델리게이트에 바인딩하기 전에 브로드캐스트되어 받지 못함

따라서 UI에서 NativeConstruct 시점에 업데이트하도록 변경

 

어빌리티 UI의 색상을 블루프린트 그래프에서 지정할 수 있도록 변경

프로그래머의 개입 없이 UI 디자이너가 수정할 수 있게 하기 위함

어빌리티가 사용 가능/불가능 상태를 반환하는 함수를 블루프린트에서 호출할 수 있도록 제공

Hex String을 Linear Color로 변환하는 함수를 블루프린트에서 호출할 수 있도록 제공

블루프린트 그래프에서 어빌리티 사용 가능 여부에 따라 Hex String으로 어빌리티 아이콘 색상을 설정할 수 있도록 제공

 

2주차

동일 장비가 여러 번 등록되지 않도록 수정

같은 클래스의 장비가 2번 이상 등록되지 않도록 현재 등록된 장비들을 검사 후 등록된 장비의 클래스면 등록하지 않도록 변경

불필요한 스폰 및 메모리 감소

 

어빌리티 위젯이 계속 추가되는 현상 수정

어빌리티를 UnRegister했을 때 UI에 전달하여 해당 어빌리티 위젯을 삭제하도록 변경

어빌리티 UI에서 위젯을 생성할 때 해당 어빌리티가 이미 생성됐으면 생성되지 않도록 변경

 

어빌리티 등록을 해제할 때 어빌리티를 Destory하지 않고 재등록할 때 캐싱한 어빌리티를 등록하도록 변경

등록한 어빌리티를 캐싱하여 재등록할 때 스폰 오버헤드를 줄이기 위함

 

장비를 장착/해제할 때 장비 어빌리티를 등록/해제

장비와 관련있거나 장비에 의존하는 어빌리티를 장비에서 등록/해제 요청

캐릭터가 아닌 장비에 결합도를 부여하여 장비에 따라 어빌리티를 달리 가질 수 있도록 하기 위함

 

1주차

장비 장착 어빌리티의 사용 가능 횟수를 UI에 전달하여 표시

장비 사용 가능 횟수를 장비 장착 어빌리티에 전달하고, UI에 전달하여 표시

클라에서 장비 사용 가능 횟수를 알기 위해 어빌리티에서 관련 장비를 리플리케이트로 동기화

 

어빌리티 UI를 Input KeyName에 따라 등록

어빌리티의 GameplayTag를 통해 플레이어 Input에 바인딩된 KeyName을 찾고 KeyName이 등록된 어빌리티만 UI 생성

KeyName에 의해 정렬하여 HorizontalBox의 Child 순서를 조정

 

어빌리티를 로컬에서 실행하도록 변경

빠른 어빌리티 실행을 위해 서버에서 어빌리티 실행하던 것을 로컬에서 실행하도록 변경

어빌리티에 따라 필요할 경우 서버 RPC를 통해 동기화 수행

 

어빌리티를 서버에서 스폰하도록 변경

어빌리티를 서버와 클라 모두에서 스폰하여 동기화가 되지 않기 때문에 서버에서 스폰하고 리플리케이트를 통해 클라에서 스폰하도록 변경

 

플레이어가 로그인될 때 선택한 캐릭터에 따라 캐릭터 클래스를 DefaultPawnClass를 변경하여 캐릭터 변경

캐릭터를 선택하고 준비하면 서버로 RPC를 전달하여 해당 캐릭터 클래스를 FUniqueNetIdRepl에 따라 서버의 GameInstance에 저장

게임 모드에서 플레이어가 로그인할 때 GameInstance에서 FUniqueNetIdRepl에 따라 DefaultPawnClass를 설정하여 구현

 

25년 5월

5주차

시작하자마자 수류탄이 보이는 현상 수정

장비가 등록될 때 장착하기 전에는 비활성화 상태로 전환하여 작동하지 않도록 수정

 

컨트롤러에서 어빌리티 핸들이 관리되지 않도록 수정

컨트롤러에서는 어빌리티 태그만으로 어빌리티 컴포넌트에 요청만 하고, 어빌리티 존재 여부나 실행 여부를 모름

어빌리티 컴포넌트에서는 어빌리티 태그에 따른 어빌리티가 있는지에 따라 어빌리티를 실행

 

어빌리티 리플리케이트를 위해 TMap<AbilityHandle, Ability>를 TArray<AbilityContainer>로 변경

 

시작하자마자 수류탄이 보이는 현상 수정

장비를 등록하자마자 DeActivate하여 보이지 않도록 수정

 

총 ADS시 수류탄 교체하면 ADS가 유지되는 현상 수정

총이 DeActivate될 때 파츠들도 DeActivate되도록 수정

 

장비 장착 어빌리티가 Activate될 때마다 장비를 스폰하는 것을 초기화할 때 스폰하도록 변경

어빌리티에 의한 장비는 런타임에 해제될 일이 없기 때문에 어빌리티가 초기화될 때 스폰하여 계속 사용하도록 변경

EquipmentManager에 장비 스폰을 요청하도록 변경

 

4주차

수류탄 횟수를 지정하지 않을 경우 버퍼 언더플로우 발생하는 현상 수정

FMath::Max를 사용하여 0보다 작은 값을 설정하지 않도록 수정

 

빠르게 수류탄을 던질 경우 CookingCheckTimer에 의해 한 번 더 던지는 현상 수정

수류탄을 던지는 함수에서 쿠킹 딜레이 타이머를 클리어 안해줘서 함수가 두 번 호출되어 발생

수류탄을 던지는 함수에서 쿠킹 딜레이 타이머를 클리어하여 해결

 

장비의 횟수 타입 구분하도록 수정

InValid(사용하지 않음), Available(일반적인 횟수), Infinite(무한)으로 Type을 구분하여 장비에서 반환

UI에서 해당 Type에 맞게 표시하도록 변경 

 

수류탄을 던질 수 있는 횟수를 반환하는 함수를 재정의하여 UI에 표시하도록 수정

 

클라에서 수류탄을 던졌을 때 서버 혹은 다른 클라에 수류탄이 보이지 않는 현상 수정

수류탄 오브젝트를 서버에서 스폰하고, 리플리케이트되도록 변경

 

클라에서 수류탄을 모두 사용했을 때 기본 무기로 교체되지 않는 현상 수정

클라에서 서버 RPC를 사용하여 서버에 장비가 DeActivate 되도록 수정

 

장비 교체했을 때 클라에서 기존 장비가 보이고, 교체된 장비가 보이지 않는 현상 수정

장비가 Activate될 때 3인칭 오브젝트를 Activate하여 수정

장비가 DeActivate될 때 3인칭 오브젝트를 DeActivate하여 수정

 

클라에서 수류탄이 교체되지 않는 현상 수정

서버에서 장비가 교체될 때 클라에 동기화하지 않아 발생

서버에서 현재 장착된 장비를 리플리케이트하고, 클라에서 리플리케이트 됐을 때 해당 장비를 장착

 

장비 장착 어빌리티로 장착된 장비가 DeActive될 때 기본 무기를 장착하도록 구현

장비가 DeActivate될 때 어빌리티에서 델리게이트를 받아 EquipmentManager에 기본 무기 장착 요청

 

수류탄 장비 리팩토링

기존에는 수류탄 장비에서 쿠킹 타이머가 종료됐을 때 실행하는 함수와 던지는 함수가 분리되어 복잡

쿠킹 타이머가 종료됐을 때 실행하는 함수를 던지는 함수로 통합하고, 쿠킹 타이머가 유효할 때는 쿠킹 타이머의 남아있는 시간을 수류탄 폭파 타이머로 설정하여 중복 코드 제거

 

3주차

장비를 장착하는 어빌리티가 장비가 DeActive될 때 어빌리티가 DeActive되도록 구현

 

던질 수 있는 장비(수류탄 등)에 ThrowCount 구현

Throw가 될 때 ThrowCount을 감소시키고, AddThrowCountCoolTime마다 ThrowCount가 증가

ThrowCount가 0이 될 때 장비가 DeActive 됨

 

HandlingObject에서 TriggerType에 따른 함수 실행

기존에는 각 장비에서 TriggerType에 따른 로직을 직접 실행하여 코드 중복이 발생

HandlingObject에서 TriggerType에 따른 함수를 호출하고, 각 장비에서 해당 함수를 오버라이드

 

섬광탄과 수류탄 장비 코드가 동일해서 수류탄 계열 장비 클래스로 통합

 

섬광탄과 수류탄 Projectile 관련 중복 코드를 Grenade 클래스로 통합

Grenade는 수류탄 계열을 의미하고, FragmentationGrenade가 일반 수류탄을 의미

 

장비가 Active 상태에서만 PullTrigger가 되도록 수정

장비에서 Active 상태일 때를 체크하는 것이 아닌 EquipmentComponent에서 장비의 Active 상태를 체크

 

GameplayEffectComponent::ApplyEffect를 Server RPC로 변경

GameplayEffect가 서버에서만 적용될 수 있으므로 클라에서 GameplayEffect 적용을 허용하기 위함

 

중력 스텟 구현

Jump 관련 StatisticSet에 GravityScale을 추가

GravityScale이 리플리케이트되도록 설정하여 서버와 모든 클라이언트가 적용

StatisticSet::GravityScale 값이 변경될 때 Character에서 CharacterMovement::GravityScale 값 적용

 

배리어 구현

배리어 스텟이 유효할 경우 HP 대신 배리어가 차감

UI에 배리어 스텟을 업데이트해주는 로직 구현

 

UMFStatusUserWidget::GetStatusValue을 블루프린트에서 호출 가능하도록 변경

블루프린트 UserWidget 혹은 클래스를 추가하지 않아도 스텟 UI를 업데이트할 수 있도록 변경

 

StatisticSet의 BaseValue와 CurrentValue 역할 재정의

BaseValue : 처음 초기화한 수치, 변경 불가능한 역할에서 기본적으로 변경할 수 있고, StatisticGameplayEffect에 의한 일시적인 영향을 제외한 값을 나타냄

CurrentValue : 사용되는 실제 수치(일시적인 값을 나타낼 수 있음)이고, StatisticGameplayEffect에 의해서만 수정됨

StatisticGameplayEffect : Duration이 있을 경우 CurrentValue를 수정하고, Duration이 없을 경우 BaseValue를 수정

 

2주차

발 아래로 총을 쏘면 자신이 데미지를 입는 현상 수정

트레이스를 시도할 때 캐릭터를 IgonredActor로 등록하여 트레이싱되지 않도록 해결

 

어빌리티 UI 구현

HUDUIController에서 AbilityUIController를 소유

AbilityUIController에서 등록된 어빌리티에 따라 AbilityUserWidget을 동적으로 생성

AbilityUserWidget에서 UWidgetSwitcher를 사용하여 어빌리티 상태에 따라 표시되는 위젯을 교체하도록 변경

 

어빌리티를 GameplayTag 기반으로 수정

GameplayTag를 키로 어빌리티를 저장

Input에 바인딩되는 함수를 하드코딩하는 것이 아니라 GameplayTag를 전달하여 어빌리티를 실행하도록 수정

 

25년 4월

4주차

StatisticGameplayEffect로 Statistic 변경할 수 있도록 구현

GameplayEffectManagerComponent에서 Effect의 조건 검사를 호출한 뒤 통과되면 GameplayEffect::Apply 실행

서버에서 Statistic 수정을 적용하고 각 클라이언트에 리플리케이트하여 일관성을 보장하도록 구현

StatisticGameplayEffect에서는 현재 캐릭터의 Statistic BaseValue를 가져와 Modfier를 수행한 결과 값을 해당 Statistic에 설정

ModifierOperation Enum 값에 따라 계산을 수행(Add만 구현하고, 필요할 때 추가 구현)

Statistic에 값을 설정하기 전 후에 OnPreBaseValueChanged, OnPostBaseValueChanged를 호출하여 StatisticSet에서 값을 변경하거나 상황에 맞게 로직을 수행할 수 있게 구현

 

StatisticGameplayEffect 블루프린트 에디터에서 Statistic 프로퍼티를 선택할 수 있도록 Slate UI 구현

프로퍼티 Iterator가 매크로로 선언된 변수를 인식하지 못해 직접 Statistic 변수를 선언하도록 변경

PropertyEditorModule에서 StatisticPropertyDetail을 추가한 뒤 StatisticWidget(SListView, SComboButton)을 추가

  • 콤보 버튼을 클릭했을 때 Statistic 프로퍼티들을 표시하도록 구현

Statistic 프로퍼티를 선택했을 때 StatisticGameplayEffect의 Statistic 프로퍼티에 선택한 프로퍼티가 저장되도록 구현

 

3주차

어빌리티 쿨타임 구현

쿨타임 타이머가 실행할 때 어빌리티의 실행하지 못하도록 설정

어빌리티가 Deactive될 때 쿨타임 타이머가 실행

 

드론의 미사일 발사 어빌리티를 플레이어가 아닌 드론 컨트롤러가 갖고 있도록 리팩토링

드론을 스폰하는 어빌리티와 미사일 발사 어빌리티를 플레이어가 갖고 있어 기존에는 스폰 어빌리티에서 미사일 발사 어빌리티로 교체되는 구조였음

따라서 드론 스폰 어빌리티가 Destroy되기 때문에 어빌리티의 상태를 관리하기 어려움

그래서 어빌리티 교체하는 시스템을 제거하고 드론 미사일 발사 어빌리티를 드론 컨트롤러가 갖고 있도록 수정

플레이어는 드론을 스폰하고 드론 컨트롤러에게 어빌리티를 실행하도록 요청

어빌리티 객체를 유지하여 상태를 관리할 수 있고, 각 어빌리티를 기능의 주체자가 소유하여 코드가 단순해짐

 

HUD 총기 정보 UI 구현

HUDUIController에서 MagazineCount, TotalMagazineCount Type의 StatusUserWidget을 소유하여 각 장전 탄창 수, 보유 탄창 수를 반환할 수 있도록 함

HUDUIController에서 Fire, Reload 등 델리게이트가 호출될 때 MagazineCount, TotalMagazineCount에게 탄창 수를 반환 받아 TextBlock을 수정 

 

AMFHOFireArm의 RPC가 제대로 호출되지 않는 현상 수정

AMFHOFireArm를 스폰할 때 Owner를 설정하지 않았는데, Owner를 설정하지 않아서 Connection을 알 수 없어 RPC가 제대로 호출되지 않음

스폰할 때 FActorSpawnParameters를 통해 Owner 설정

 

CurrentActivatedEquipment가 리플리케이트 되지 않는 현상 수정

CurrentActivatedEquipment가 리플리케이트 액터가 아니었기 때문에 CurrentActivatedEquipment를 리플리케이트 액터로 변경하여 수정

 

HUD Player HP UI 구현

HUDUIController에서 StatusUserWidget을 상속 받는 HP 블루프린트 Widget을 소유

HUDUIController에서 HP 변경 이벤트를 받으면 StatusUserWidget에게 반환된 HP 퍼센트에 맞게 HP Widget의 블록들의 색상을 설정

 

StatusUserWidget 구현

StatusUserWidget의 StatusType을 블루프린트 에디터에서 지정하면 해당 Enum에 맞는 스텟을 StatusUserWidget에서 반환

 

2주차

UI 프레임워크 및 룰 설계

 

MFBaseUserWidget : UI의 기본 기능 및, UserWidget의 기본 기능을 구현

MFXXUserWidget : 콘텍스트에 독립적인 UI 요소(ex. HP바, 스텟 표시, 아이템 슬롯 등)

 

MFBaseUIController : Controller의 기본 기능을 구현

MFXXUIController

  • 각 콘텐츠를 담당하는 UI 관리자(ex. HUD, Invetory 등)
  • MFUserWidget을 소유하고 관리할 수 있음

  • UserWidget은 AddToViewport 불가능 : MFBaseUserWidget::AddToViewport를 막기 위해 NameHiding 사용
    • UserWidget 포인터를 사용한다면 막지 못하는 한계는 있음
  • UI는 게임플레이 로직을 수정하지 않음
    • 수정을 요청하는 함수를 호출할 수는 있지만 Setter 함수나 직접적으로 수정해서는 안됨
  • 블루프린트가 바이너리이므로 코드가 많이 복잡하지 않는 이상 블루프린트 그래프를 사용하지 않음

'언리얼 > ProjectMF' 카테고리의 다른 글

[ProjectMF] 반응성 연막탄  (3) 2025.12.02
ProjectMF 설치 프리뷰 액터 구현  (0) 2025.06.30

실용주의 팀

이 책에서 개인이 더 나은 프로그래머가 되게끔 도와주는 실용주의 기법들을 모든 팀에서 적용 가능하고, 그 이점이 몇 배로 더 커짐

 

작고 안정적인 팀을 유지하라

실용주의 팀은 10~12명 이하여야 하고, 구성원이 추가되거나 빠지는 일은 드물어야 함, 모두가 서로 잘 알고 신뢰하며, 의존해야 함

 

품질은 팀의 문제

부지런한 개발자라도 품질에 무심한 팀에 배치된다면 자질구레하게 문제를 고치는 데 필요한 열정을 유지하긴 어려울 것

개발자가 이런 수정을 하느라 시간을 쏟는 것을 팀이 적극적으로 방해하고 나선다면 문제는 더 커짐

팀 전체가 사소한 결점을 아무도 고치지 않고 놔두어서는 안되고, 반드시 품질에 책임을 져야 함

 

프로젝트 개발 속에서 전체 환경의 변화에 계속 신경 쓰기란 어려운 법

팀원들은 누군가가 문제를 처리하겠거니 생각하거나, 사용자가 요청한 변경 사항을 팀 리더가 이미 동의했겠거니 생각하거나, 사용자가 요청한 변경 사항을 팀 리더가 이미 동의했겠거니 하고 여김

프로젝트가 심각하게 변화하는 것에는 둔감할 수도 있음

모든 사람이 적극적으로 환경(범위의 확장, 일정 단축, 추가 기능, 새로운 환경 등) 변화를 감시하도록 권장

 

실현하려면 계획하라

성공을 원하는 팀이라면 마찬가지로 자신들의 지식과 기술에 투자하는 것을 고려해야 함

팀을 개선하고 혁신하고 싶다면 계획을 세워야 함

할 일을 기능 개발로만 모두 채우지 말고 다음과 같은 일들도 하라

  • 구형 시스템 유지 보수
  • 프로세스 회고와 개선
  • 새로운 기술 탐험
    후보 기술로 프로토타입을 만들고 신중하게 조사하라
    새로운 것을 시도해 보고 결과를 분석하는 업무를 일정표에 추가하라
  • 학습 및 기술 갈고 닦기
    많은 기술이 팀 전체로 퍼졌을 때 더 효과적
    점심을 먹으며 이야기할 수도 있고, 스터디 시간을 잡을 수도 있음

 

같은 팀에 속한 개발자끼리 서로 대화해야 하는 것은 당연

팀이라는 것 역시 더 큰 조직의 일부이므로 나머지 세상과 명확하게 의사소통해야 하는 존재

외부 사람들에게 무뚝뚝하고 과묵해 보이는 팀은 최악, 회의의 체계가 없고 침묵만 가득, 이메일과 프로젝트 문서는 엉망진창, 문서마다 생김새도 제각각이고, 서로 다른 용어를 사용

훌륭한 프로젝트팀은 모든 사람이 좋아할 만한 잘 준비된 퍼포먼스를 기대하여 회의를 기대

 

프로젝트 이름을 지으면 정체성 확립의 기반을 얻고 기억할 만한 뭔가를 얻게될 것

 

중복된 일은 노력을 무위로 돌릴 뿐 아니라 결국 유지 보수를 악몽으로 만들 수도 있음

즉각적이고 매끄러운 의사소통은 이런 문제를 피하는 핵심

  • 질문하기, 진행 상황이나 문제, 통찰 및 새롭게 알게 된 점을 공유
  • 동료가 뭘 하고 있는지 알고 있기가 쉽고, 거추장스러운 단계가 적음

팀원에게 질문하고 거의 즉각적으로 답을 받을 수 있어야 함

만약 질문을 하거나 상황을 공유하기 위해 일주일 남은 팀 회의 시간까지 기다려야 한다면 엄청 껄끄러운 커뮤니케이션

 

모든 기능을 갖춘 팀을 조직하라

팀은 프로젝트의 여러 분야에서 많은 기술을 섭렵하고 다양한 과제를 해결해야 함

요구 사항을 이해하고, 아키텍처를 설계하고, 프론트엔드와 서버 코드를 쓰고, 테스트를 돌리는 모든 일을 해내야 함

오만 가지 역할과 직함은 관문과 인수인계가 추가되어 업무가 중단되는 문제가 있음

 

예광탄을 사용하여 처음에는 작고 제한적일지라도 시스템의 끝에서 끝까지 전체에 걸쳐 있는 단일 기능을 개발할 것을 추천

작업에 필요한 모든 기술을 팀 안에 모두 갖추어야 하지만 기능의 조그만 부분을 아주 빠르게 개발할 수 있음

팀이 얼마나 잘 소통하고 결과물을 만들어 내는지 즉각적인 피드백을 받을 수도 있음

 

일관성과 정확성을 모두 보장하는 확실한 방법은 팀이 하는 모든 일을 자동화하는 것

에디터나 IDE가 자동으로 맞춰 주는데 왜 코드 스타일에 신경 쓰는가?

지속적 빌드가 테스트를 자동으로 실행하는데 왜 수동으로 테스트를 돌리는가?

왜 손으로 배포하는가, 자동화하면 매번 반복적으로 확실하게 배포해 줄 텐데?

 

팀은 개인들로 이루어져 있기 때문에 자신의 방식대로 빛나게 하라

팀원들을 지원하기에, 프로젝트가 가치를 만들어 내기에 딱 좋을 만큼의 구조를 제공하라

 

코코넛만으로는 부족하다

눈에 잘 띄는 결과물을 만드는 데만 투자하면서 기반이 되는 작업이 끝나 있기를 소망하는 문제에 빠지기 쉬움

스크럼을 사용하는 팀이 있는데 일일 스탠드업 미팅을 일주일에 한 번씩만 하고, 반복 주기는 4주 단위였는데 6~8주로 늘어지는 경우가 잦음, 그런데도 애자일 일정 관리 도구를 사용하니 아무런 문제가 없다고 생각했음

피상적인 결과물에만 투자하고 있었고, 스탠드업이나 반복 주기는 이름만 가져다 쓰고 있는 상태

이 팀은 프로젝트를 성공시키지 못함

 

유행하는 것이 아니라 실제로 잘 맞는 것을 사용하라

특정한 개발 방법이나 프레임워크, 테스트 기법을 굳이 사용하는 이유가 무엇인가?
지금 하는 일에 잘 맞아서인가?, 자신에게 잘 맞아서인가? 성공 사례에서 사용했기 때문에 도입한 것인가?

다른 팀, 회사에서 성공한 정책과 프로세스를 도입하기 전에 맥락(시장, 제약 조건, 전문성, 조직 크기, 경영진, 문화, 사용자 등)을 고려해야 함

 

소프트웨어 개발 방법론의 목표는 사람들이 함께 일하는 것을 돕는 것

작은 팀이나 조직에서 아이디어를 시험해보고 잘 맞는 것 같은 좋은 부분만 유지하고, 나머지는 낭비나 비용일 뿐이므로 버리면 됨

어떤 특정 방법론에서 나와 팀에 맞는 좋은 부분만 가져다가 적절히 조정하여 사용해야 함

 

사용자에게 필요할 때 제공하라

진짜 목표는 작동하는 소프트웨어를 제공함으로써 사용자가 즉각적으로 새로운 일을 할 수 있게 되는 것

지금으로부터 몇 주, 몇 달, 몇 년 후가 아니라 지속적 배포가 이상적이므로 올바른 방향을 바라보는 것이 중요

이런 지속적 개발을 도입하려면 매우 견고한 기반 구조가 필요

  • 피처 브랜치가 아니라 메인 브랜치에서 개발해야 함
  • 사용자에게 선택적으로 시범적 기능을 공개할 때는 기능 스위치 같은 기법을 활용
    • 기능 스위치 기법은 코드를 다시 배포하지 않아도 기능을 끄고 킬 수 있도록 만드는 기법

기반 구조가 갖춰지고 나면 작업을 어떻게 진행할지 결정해야 함

초심자라면 프로젝트 관리는 스크럼으로 시작하고, 익스트림 프로그래밍의 기술 실천 방법을 추가로 도입할 수 있음

  • 익스트림 프로그래밍은 좋다고 알려진 개발 관행들을 극단적인 수준으로 밀어붙이는 방법
    코드 리뷰가 좋다면 항상 둘이 페어 프로그래밍하고, 테스트가 좋다면 코드보다 테스트를 먼저 작성하자는 식

더 경험이 많고 단련된 팀이라면 칸반이나 린 기법을 살펴볼 수 있음

  • 칸반은 작업 흐름을 시각화하고 진행 중인 작업을 제한하는 방법
    Todo > InProgress > Review > Done 별로 작업 카드 배치
    각 단계에서 동시 처리 작업 수를 제한해 병목과 멀티태스킹 줄임
    작업이 막히지 않도록 모니터링
    작업 규칙을 명확히 함
    피드백 루프 적용 / 점진적 개선
  • 린은 낭비를 제거하고 가치 흐름을 최적화하는 방식
    미사용 기능, 불필요한 대기, 결함, 과도한 작업 등 제거
    짧은 반복과 피드백으로 빠르게 학습
    정보가 충분할 때 결정
    빠른 전달로 피드백 확보
    팀에 권한 위임
    품질을 처음부터 설계에 반영
    부분이 아닌 시스템 전체를 최적화

이런 접근 방법들을 곧이곧대로 받아들이지는 말고 조사하고 시도해 보되, 지나치지 않도록 주의하라

특정 방법론에 과도하게 투자하면 다른 대안을 보지 못하게 될 수도 있음

 

실용주의 시작 도구

일상적인 작업은 모두 자동화해야 함

수작업은 일관성을 운에 맡기고, 반복 가능성도 보장받지 못하기 때문

 

버전 관리 시스템으로 빌드, 테스트, 릴리스를 운용하라

프로젝트를 빌드하는데 필요한 모든 것을 버전 관리하에 두어야 함

  • 빌드 장비를 일시적으로 쓰고 없앨 수 있음
  • 배포 설정도 버전 관리 시스템 안에 있으므로 릴리스를 자동으로 처리

일찍 테스트하고, 자주 테스트하라. 자동으로 테스트하라

모든 테스트가 끝날 때까지는 코딩이 끝난 게 아니다

많은 개발자들이 무의식적으로 코드가 어디에서 깨지는지 파악하고서는 약한 지점을 피해 다니면서 살살 테스트하려 함

코드를 작성하자마자 테스트해야 함, 하지 않는다면 버그가 나중에 크게 돌아올 수 있음

테스트를 통과했다는 것은 코드가 완성되었다는 말에 높은 수준의 확신을 줌

 

빌드 과정에는 단위 테스트, 통합 테스트, 유효성 평가 및 검증, 성능 테스트가 들어가야 함

  • 단위 테스트
    하나의 모듈을 테스트하는 코드
    다음 단계로 넘어가기 전 사용하는 모듈 단위 테스트가 반드시 통과해야 함
    모든 모듈이 시스템 전체에 걸쳐 어떻게 사용되고 상호 작용하는지 테스트해야 함
  • 통합 테스트
    프로젝트를 구성하는 주요 서브시스템이 다른 부분과 제대로 작동하는지 보여줌
    계약이 제대로 되어있고, 테스트가 잘 되어 있다면 어떤 통합 문제든 쉽게 발견할 수 있음, 그렇지 않다면 통합 과정은 버그를 키우는 부분이 될 것
  • 유효성 평가 및 검증
    사용자들이 정말 필요로 하는 것인가?
    시스템의 기능적 요구 사항을 충족하는가?
    최종 사용자의 접근 방식에 대해, 개발자의 테스트 데이터와 어떻게 다른지에 대해 관심을 기울여라
  • 성능 테스트
    실 조건에서 성능 요구 사항들을 준수하는지 자문해보라

버그를 심어 놓고 테스트를 테스트하라

어떤 버그를 감지해내는 테스트를 작성한 후에 버그가 의도적으로 생기도록 한 다음 테스트가 실패하는지 확인하라
실제로 버그가 생겼을 때 테스트가 잡아낼 것이라고 확신할 수 있음

 

코드 커버리지만 올리지 말고 상태 조합을 테스트하라

우연히 코드의 모든 줄이 실행될지라도 그게 전부가 아님, 정말로 중요한 것은 상태의 개수

// 0~999 정수 두 개를 받는 함수
int test(int a, int b)
{
	return a / (a + b);
}

이 함수는 이론상 백만 가지의 논리적 상태를 갖고, a와 b 모두 0 일 때 제대로 작동하지 않음

따라서 프로그램의 모든 가능한 상태를 확인해야 할 것

불행히도 일반적으로 매우 어려운 문제

 

버그는 한 번만 잡아라

버그를 찾았더라면 테스터가 그 버그를 발견해서는 안됨

버그는 다시 발생할 수 있기 때문에 무조건, 예외 없이 해당 버그를 확인할 수 있게 자동화 테스트를 수정해야 함

 

수작업 절차를 사용하지 말라

사람들은 컴퓨터처럼 같은 일을 반복할 수 없을 뿐더러 그런 것을 기대해서도 안됨

쉘 스크립트나 프로그램은 동일한 명령을 매번 똑같은 순서로 수행하고, 버전 관리 시스템에 들어 있을 것이므로 시간이 지남에 따라 그 빌드, 릴리스 절차가 어떻게 변했는지도 조사할 수 있음

 

빌드 전체가 자동화되어 있지 않다면 임의의 클라우드 서버에서 프로젝트를 빌드할 수 없을 것

수작업 단계가 끼어 있다면 자동 배포를 할 수 없을 것

 

사용자를 기쁘게 하라

사용자의 기대를 충족시킬 수 있을지 고민

  • 모든 팀 구성원이 사용자가 기대하는 바를 완전히 이해해야 함
  • 결정을 내릴 때면 어떤 선택이 사용자의 기대에 더 가깝게 가는 길인지 생각
  • 기대를 염두에 두고 사용자 요구 사항을 비판적으로 분석
    요구 사항 문서의 형태를 띠었지만 실제로는 비전문가의 구현 계획
    요구 사항을 바꾸면 프로젝트가 목표에 더 가까워진다는 것을 보여줄 수 있다면 주저하지 말고 바꾸자고 제안
  • 프로젝트를 진행하면서도 계속 사용자의 기대에 대하여 생각

도메인에 대한 지식이 늘어남에 따라 근본적인 사업 문제를 해결하기 위해 맡지 않은 다른 부분에 대해서도 더 좋은 제안을 할 수 있게 됨

사업의 여러 부분을 함께 엮어낼 방법을 개개의 부서에서는 알아차리기 힘들기 때문에 조직의 여러 측면을 경험한 개발자가 더 잘 찾아낼 수도 있음

 

고객이 문제를 풀 때 적극적으로 도와줄 수 있는 관계를 구축하라

 

오만과 편견

실용주의 프로그래머는 책임을 회피하지 않고 도전을 수용하고 전문성을 널리 알려지는 것을 기뻐함

사람들이 코드에 붙은 여러분의 이름을 보고 튼튼하고 잘 작성됐으며 제대로 테스트됐을 뿐 아니라 훌륭히 문서화됐을 것이라고 기대하도록 만들자

 

경계심 때문에 여러분의 코드를 참견하는 사람으로부터 방어하려고 해서는 안됨, 다른 사람의 코드를 존중해야 함

개발자 사이에 황금률(남에게 대접 받고자 하는 대로 너희도 남에게 대접하라)과 상호 존중이 꼭 필요

 

익명성은 적당주의, 실수, 태만, 나쁜 코드의 온상이 될 수 있음

 

먼저 해를 끼치지 말라

이 코드의 사용자를 위험으로부터 보호하기 위해 최선을 다했는가?를 고민하라

  • 간단한 베이비 모니터지만 보안 패치가 계속 적용되도록 했는가?
  • 자동 중앙 난방 조절기가 혹시 고장 나더라도 고객이 수동으로 조절할 수 있도록 했는가?
  • 오직 필요한 데이터만 저장하는가?
  • 개인 정보는 암호화하는가?

내가 사용자라면 만족스러울까?를 고민하라

요구 사항의 구렁텅이

자신이 뭘 원하는지 정확히 아는 사람은 아무도 없다.

무엇을 다루든 정확한 명세란 것은 거의 불가능하기 때문에 프로그래머들은 사람들이 원하는 바를 깨닫도록 돕는 것

 

신입 개발자들이 자주 범하는 실수는 요청 사항을 받았을 때 바로 해결책을 구현하는 것

경험상 최초의 요청 사항은 궁극적인 요구 사항이 아님

"5만원 이상인 모든 주문은 배송비가 무료입니다." 요구 사항을 받았을 때 떠오르는 점

  • 어떤 종류의 배송이 무료인가? 당일 배송, 일반 배송, 국제 배송?
  • 5만원에 현재 고객이 선택한 배송 방법의 배송비도 포함인가?
  • 5만원에 세금도 포함인가?
  • 5만원이 모두 종이책이어야 하나, 아니면 전자책을 포함해도 되나?
  • 나중에 5만원 기준이 바뀔까?

좋은 개발자라면 협상 능력을 키울 수 있음

  • 나 : 총 5만원에 대해 궁금한 점이 있습니다. 여기에 우리가 원래 부과하는 배송비도 포함입니까?
  • 의뢰인 : 네. 고객이 지불하는 금액 전체를 말하는 겁니다.
  • 나 : 그런데 일부 시스템이 이상하게 사용하려고 할 것 같은데요.
  • 의뢰인 : 어떻게요?
  • 나 : 예를 들어 2만 5천원짜리 책을 하나 사고, 가장 비싼 당일 배송을 고른다고 가정하면 당일 배송 비용은 3만원이니까 총 5만 5천원이 되는데요. 그러면 결제금액이 5만원이 넘으므로 배송이 무료로 바뀝니다. 따라서 고객은 2만 5천원만 내면 되고 배송비 없이 2만 5천원짜리 책을 당일 배송으로 받을 수 있습니다.

프로그래머의 역할은 의뢰인의 요청을 해석해서 그로 인한 영향을 다시 알려주는 것

 

요구 사항은 피드백을 반복하며 알게 됨

프로그래머는 요구 사항에 대한 피드백을 주고 의뢰인은 피드백을 바탕으로 자기 생각을 더 가다듬음

 

가끔은 익숙하지 않은 분야거나 등등의 이유로 구체적으로 피드백을 주기가 어려울 때는 "말씀하신 게 이런 것입니까?" 부류의 피드백에 의존한다

모형이나 프로토타입을 만들어서 의뢰인이 직접 다루어 볼 수 있도록 함

  • 모형이나 프로토타입이 바꾸기 쉬워서 의뢰인과 대화하는 도중에도 계속 바꿀 수 있다면 이상적

실용주의 프로그래머는 프로젝트 전체를 요구 사항 수집 과정으로 보아야 함

그래서 우리는 짧은 주기로 반복하는 것을 선호하고, 반복 주기가 끝날 때마다 의뢰인에게 피드백을 받음

그러면 궤도에서 벗어나지 않을 수 있고 잘못된 방향으로 가더라도 잃어버리는 시간을 최소화할 수 있음

 

정책은 메타데이터다

"직원의 기록은 해당 직원의 관리자와 인사팀만 열람할 수 있습니다." 이 요구 사항에는 사업 정책이 포함됨

요구 사항이 인사팀에서만 직원 기록을 열람할 수 있다는 식으로 되어 있다면 개발자는 인사팀인지 확인하는 코드를 작성하고, 요구 사항이 권한이 있는 사용자만이 직원 기록에 접근할 수 있다면 일종의 접근 관리 시스템을 설계하고 구현할 것이므로 정책이 바뀔 때 시스템의 메타데이터만 업데이트하면 됨

이런 식으로 요구 사항을 수집하면 자연스럽게 메타데이터를 지원하는 잘 분리된 시스템이 만들어질 것

 

도구가 성공하려면 사람 손에 적응할 수 있어야 하므로 프로토타입이나 예광탄을 이용한 빠른 피드백을 통해 수정해야 함

 

문서는 구현 과정에서 안내 역할을 하는 이정표일 뿐

 

엄청 상세하게 만든 문서가 틀린 이유

  • 의뢰인이 원하는 것을 처음에 정확히 모름
  • 의뢰인이 문서를 제대로 읽지 않음

요구 사항 문서는 개발자를 위해서 쓰는 것이므로 의뢰인이 이해하기 어려운 부분도 많고 정보와 세부 요소들을 담고 있기 때문에 의뢰인이 읽기 힘듦

요구 사항을 간단하게 작성하면 개발자들이 명확하지 않은 점을 물어보도록 유도하여 의뢰인과 개발자 간의 피드백 과정이 더 활발해짐

좋은 요구 사항은 추상적이어야 하고, 필요한 사항을 정확히 반영하는 가장 간단한 표현이 최고

의미론적 불변식, 구체적인 작업 방식을 정책으로 문서화해야 함

요구 사항은 아키텍처가 아니고, 설계가 아니고, 인터페이스가 아니고 필요를 표현하는 것

 

요구 사항이 계속 증가되는 것을 막기 위해 반복 주기를 거치며 의뢰인과 정기적으로 피드백을 주고받는다면 의뢰인이 기능 추가의 영향을 받을 것

 

프로젝트 용어 사전을 사용하라

프로젝트 용어 사전을 만들고 관리하여 일관성을 확보

 

불가능한 퍼즐 풀기

절대적 제약 조건은 아무리 불쾌하거나 어리석어 보여도 꼭 따라야 함

그럴싸해 보이는 제약 조건은 제약 조건이 아닐수도 있고, 많은 소프트웨어 문제가 이런 속임수 같은 것일지도 모름

생각의 틀을 벗어나라는 말과 같이 유효하지 않은 제약을 인식하고 무시하라는 의미

 

풀리지 않는 문제와 마주쳤다면 생각해 볼 수 있는 모든 해결 경로 후보를 나열하고, 목록을 하나씩 점검하면서 왜 그 경로를 따라갈 수 없는지 설명하고 증명해보라

제약을 범주별로 나누고 우선순위를 매겨라, 제일 구속이 심한 제약부터 파악해내고 나머지 제약을 그 안에서 맞춰보자

 

정말 해결 방법이 안떠오를 때는 다른 행동을 하거나, 놀거나, 내일로 미뤄라, 다른 행동을 하고 있을 때 갑자기 떠오를 수 있음

다음으로 좋은 방법은 문제에 대해 다른 사람에게 이야기하여 깨달음을 얻는 방법

  • 왜 이 문제를 풀고 있는가?
  • 문제를 풀어서 얻는 것이 무엇인가?
  • 풀려고 하는 문제가 특수한 경우에 해당되는가? 특수한 경우를 없앨 수는 없나?
  • 관련된 문제 중에 여러분이 풀 수 있는 더 간단한 문제는 없나?

유레카의 순간을 경험하려면 해답에 도움이 될 수 있는 경험을 많이 해야 함

 

함께 일하기

한 사람이 코드를 입력하는 동안 한 명 혹은 여러 명의 팀 동료가 조언하고 고민하며 문제를 함께 푸는 방식은 끝없는 회의나 제안서, 문서보다 훨씬 좋음

  • 문제를 푸는 다른 기법과 접근 방법을 공유
  • 구현을 담당하는 개발자는 문법이나 코딩 스타일 같은 낮은 수준의 세부 사항에 집중해야만 함
    다른 개발자는 문제를 더 높은 수준에서 넓은 범위를 보며 고민할 수 있음
  • 다른 사람이 지켜보고 있으면 꼼수를 덜 쓰게 되고 소프트웨어의 품질이 좋아짐

코딩을 하는 와중에 질문하고 토론 하는 것

 

시스템을 설계하는 조직은 조직의 의사소통 구조를 그대로 본뜬 설계를 만들기 마련

서로 이야기하지 않는 팀에서는 많이 분리된 시스템을 만들거나 클라이언트/서버나 프론트엔드/백엔드로 나뉨

만들고 싶은 구조에 맞춰 팀을 조직하는 방법도 있음

 

셋 이상의 후원자, 의뢰인, 테스터 등 여러 사람들이 동시에 진행하는 방법도 있음

 

함께 개발할 때 팁

  • 누가 똑똑한지 겨루는 것이 아님
  • 소규모로 시작
  • 코드만 비판하고 사람을 비판하지 말라
    "넌 틀렸어"보다는 "이 부분을 한 번 볼까요?"가 훨씬 듣기 좋음
  • 다른 사람의 관점을 듣고 이해하려고 노력
  • 자주 회고하고 시도하거나 개선할 점을 찾아라

한 가지 방법으로만 개발했다면 다른 방식을 실험하는 것도 좋지만 각자 개발 방식이 있으므로 너무 무턱대고 접근하지는 말라

 

애자일의 핵심

애자일은 명사가 아니다. 애자일은 무언가를 하는 방식이다

애자일 선언

  • 공정과 도구보다 개인과 상호작용
  • 포괄적인 문서보다 작동하는 소프트웨어
  • 계약 협상보다 고객과의 협력
  • 계획을 따르기보다 변화에 대응하기

애자일 선언은 고정불변의 문서보다는 만들어 가는 과정에 대한 제안

애자일(기민함)은 변화에 대응하는 것, 일을 시작한 이후의 미지의 것에 대응하는 것이 전부

개발할 때 따라야 할 단 한 가지 계획은 없고 피드백을 수집하고 그에 대응하라는 것 뿐

 

  1. 여러분이 어디에 있는지 알아내라
  2. 도달하고 싶은 곳을 향하여 의미 있는 발걸음을 가능한 작게 옮겨라
  3. 어디에 도착했는지 평가하고, 망가뜨린 것이 있으면 고쳐라

위 과정을 모든 일이 끝날 때까지 반복

 

피드백 과정을 효율적으로 만들려면 변경하는 일이 쉬워야 하는데, 그렇지 않으면 망가진 채로 유지하고 싶은 유혹에 빠질 것

대부분 설계 내용을 컴퓨터가 실행할 수 있는 문장으로 바꾸는 일만 하면 된다고 생각하면 프로젝트가 실패하는 가장 큰 원인

이런 태도 때문에 많은 시스템이 너저분해지고 비효율적이 되고, 구조가 망가지고 유지 보수가 힘들어짐

 

테스트는 버그를 찾는 작업이 아니라 코드에 대한 설계 측면에서, API나 결합 측면에서 등 다양한 피드백을 받는 작업

 

운전을 안전하게 잘하는 사람은 언제나 자기 상황을 검토하고, 잠재적인 문제들을 점검하며 예상하지 못한 일이 생길 때에도 잘 대처하는데, 코딩도 동일하여 대부분은 반복적이지만 정신을 늘 기민하게 유지하면 재앙을 막을 수 있음

 

파충류의 뇌에 귀 기울이기

프로그래머로서 경험이 늘어갈수록 잘되는/안되는 방법, 오류 형태별로 가능한 원인 등 암묵적인 지식이 쌓임

어떤 작업을 앞두고 의심이 계속 남아 있거나 꺼림칙하다면 그 느낌을 따르고, 어떤 것이 문제라고 정확하게 짚지는 못하더라도 시간을 좀 주면 의심이 실체가 있고 대응책을 생각할 수 있는 무엇으로 구체화될 수 있음

 

어떤 날은 코딩이 엄청 안되는 날이 있을텐데 지금 하는 작업이 필요 이상으로 힘들다면 구조나 설계가 틀렸을 수도 있고 엉뚱한 문제를 붙들고 있거나, 버그가 많은 코드를 만들 수도 있음

 

여러분 내면의 파충류에게 귀 기울여라

일단 하고 있는 일을 멈추고 뇌가 정리할 수 있도록 약간의 시간과 공간을 확보하자

언젠가는 해결책이 떠오르는 순간이 찾아올 것

 

위 방법이 잘 안된다면 문제를 표면으로 끄집어내서 그림으로 그리고, 동료나 다른 사람 등에게 설명하라

여전히 막혀있다면 프로토타이핑을 통해 문제가 없는 것을 확인해야 함

  • 프로토타이핑은 원래 실패하는 것이라고 상기시켜라
    실패하지 않더라도 프로토타이핑은 버리는 것이라고 상기시켜라
    프로토타이핑은 손해 볼 것이 없음
  • 하고 싶은 것을 한 문장으로 표현해 보라
  • 코딩을 시작하라

다른 사람이 작성한 코드가 이상해 보인다면 적어 놓고, 작업하면서 패턴을 찾아보라

만약 그런 식으로 코드를 작성해야만 했던 원인을 찾아낼 수 있다면 코드를 이해하는 일이 훨씬 더 쉬워질 수 있음
이 과정에서 새로운 것을 배울 수도 있음

 

우연에 맡기는 프로그래밍

잘못된 결론을 내리지 않도록 언제나 주의해야 함

우연에 맡기는 프로그래밍, 행운과 우연한 성공에 의존하는 프로그래밍을 하지 않아야 하고 의도적으로 프로그래밍해야 함

 

왜 코드가 망가졌는지 모르는 이유는 코드가 왜 잘 돌아가는지도 몰랐기 때문

 

우연한 행운과 주도면밀한 계획을 착각하기 쉬운 경우

  • 단순히 코드가 지금 작성된 방식이 그렇기 때문에 생기는 우연한 일들이 있는데, 이런 우연에 기대다 보면 결국 문서화되지 않은 에러나 예외적인 경우의 동작에 의존하게 됨
    • 함수가 잘못된 데이터를 가지고 호출했을 때, 함수가 예상하지 못한 데이터에 특정한 방식으로 반응을 하지만 함수의 의도와는 다를 때 해당 함수는 생각하지 못한 경우이므로 이 함수를 고치면 이전에 작성된 함수를 사용하는 코드에서는 작동을 멈출지도 모름
      가장 극단적인 경우에는 그렇게 설계되지 않았음에도 원하는 효과를 내는 것처럼 보일 수도 있음

paint();
invalidate();
validate();
revalidate();
repaint();
paintImmediately();
  • 잘못된 순서로 호출하거나 잘못된 맥락에서 호출하는 것도 이와 관련된 문제
    이런 식으로 호출하도록 설계되지 않았으므로 지금은 작동하는 것처럼 보여도 우연일 뿐
    잘 작동하는 데 수정해야 할 이유
    • 제대로 돌아가는게 아닐지도 모름
    • 의존하는 조건이 우연인 경우도 있음
      화면 해상도가 다른 경우나 CPU 코어가 더 많은 경우 등 다른 상황에서는 이상하게 작동할지도 모름
    • 문서화되지 않은 동작은 라이브러리의 다음 릴리스에서 변경될 수도 있음
    • 불필요한 추가 호출은 코드를 더 느리게 만듦
    • 추가로 호출한 함수에 새로운 버그가 생길 수도 있음
    다른 사람이 호출할 코드를 작성하고 있다면 모듈화를 잘하는 것, 잘 문서화된 적은 수의 인터페이스 아래에 구현을 숨기는 것 같은 기본 원칙들이 모두 도움이 됨
    다른 함수를 호출할 때도 문서화된 동작에만 의존하라, 어떤 이유로든 그럴 수 없다면 추측을 문서로 상세히 남겨라
  • 데이터들의 시간대에 대한 해석과 정책이 서로 달라 언제나 틀렸는데, 한 시간만 틀려서 개발자들이 1시간을 더하거나 빼서 답을 계산하는 데 익숙해짐, 그 다음 함수에서 값이 시간을 되돌림
    하지만 그저 우연이었고, 시간을 다루는 적절한 모델이 없었기 때문에 +1, -1 코드가 퍼져 결국 제대로 맞는 값이 없었고 프로젝트는 폐기됨
  • 테스트가 내 자리에서는 통과했지만 서버에서는 통과하지 못하는 이유는 두 환경의 차이일 수도 있지만 우연일 수 있음
    잘되는 듯한 답을 찾는 것과 올바른 답을 찾는 것은 다름
    가정하지 말라, 증명하라
    • GUI 프로그램에서 쓰려고 작성하는 모듈을 GUI가 있는 환경에서만 돌아가도록 만들 필요가 있을까?
    • 사용자의 언어가 언제나 한국어나 영어일 것이라고 가정하고 있지 않은가?
    • 언제나 사용자가 글을 읽을 수 있다고 생각하는가?
    • 확실한 것이 아닌데도 의존하는 것은 무엇이 있을까?
    • 현재 디렉터리에 쓸 수 있다는 것에 의존하고 있지 않은가?
    • 환경 변수나 설정 파일에 대해 의존하고 있는가?
    • 서버의 시간이 얼마나 정확해야 하는가?
    • 네트워크의 속도가 어느 정도 이상이라는 것에 의존하고 있지 않은가?
    • 검색한 코드가 동일한 상황이라고 확신하는가? 아니면 의미는 신경 쓰지 않고 따라하는 코드를 만들고 있나?

우연에 맡기는 프로그래밍을 하지 말라

우연은 요구 사항을 만들어내는 단계부터 테스트 단계까지 모든 단계에서 우리를 오도할 수 있음

테스트가 특히 가짜 원인과 우연한 결과로 가득찬 단계

가정을 문서화하는 경우는 드물며 개발자마다 가정이 다를 때도 많음

확고한 사실에 근거하지 않은 가정은 재앙의 근원이 됨

 

개발 시간을 줄이고 싶고, 개발 초기에 오류를 고치고 싶고, 오류를 더 적게 만들고 싶다면 의도적으로 프로그래밍해야 함

  • 지금 무엇을 하고 있는지 알아야 함
  • 더 경험이 적은 프로그래머에게 코드를 상세히 설명할 수 있어야 함
  • 완전히 파악하지 못하면 우연의 함정에 빠질 가능성이 높음, 왜 동작하는지 잘 모르면 왜 실패하는지도 모름
  • 계획을 세우고 계획을 바탕으로 진행하라
  • 가정에 의존하지 말고 신뢰할 수 있는 것에만 기대라, 신뢰할 수 없다면 최악의 상황을 가정하라
  • 가정을 기록에 남겨라, 가정을 명확하게 하는 데 도움이 되고 다른 사람과 소통에도 도움이 됨
  • 코드 뿐만 아니라 가정도 테스트해봐야 함
    가정을 테스트할 수 있는 어썰트를 작성하라
    가정이 맞았다면 코드를 더 이해하기 쉽게 만든 것이고, 가정이 틀렸다면 어썰트로 일찍 발견할 수 있음
  • 노력을 기울일 대상의 우선순위를 정하라
    중요한 부분이 가장 어려운 부분이기도 한 경우가 많음
    기본이나 기반 구조가 제대로 되어 있지 않다면 여러 기능들이 의미 없음
  • 언제나 리팩토링할 자세가 되어 있고, 리팩토링을 미루지 말라
    프로젝트 일정에 영향을 줄지도 모르지만, 하지 않을 경우의 비용보다 일정이 늦어지는 비용이 적어야 함

알고리즘의 속도

반복문이나 재귀 코드를 작성하면 수행 시간과 메모리 양을 주어진 환경에서 말이 되는지 가볍게 확인해보라

훨씬 상세한 분석을 해야 하는 경우는 O 표기법 사용

  • 단순 반복문 : O(n)
  • 이중 반복문 : O(m x n), O(n^2)
  • 이진 탐색 : O(logn)
  • 분할 정복 : O(nlogn)
  • 조합 : 순열을 사용하므로 수행 시간이 매우 빠르게 증가, 여행하는 외판원 문제, 상자에 물건을 최적으로 넣는 문제, 집합을 분할해서 각 부분 집합의 원소 합을 모두 같게 만드는 문제 등, 한정된 문제 도메인에서 이런 알고리즘의 수행 시간을 줄이기 위해 휴리스틱을 동원하기도 함

사용하는 알고리즘의 차수를 추정하라

입력값으로 얼마나 큰 숫자가 올 수 있는지 생각해야 함

입력의 최대값이 정해져 있다면 수행 시간을 추정할 수 있으며, 외부 요인에 따라 달라진다면 많은 입력이 들어왔을 때 수행 시간이나 메모리 소모에 어떤 영향을 미칠지 생각해야 함

 

O(n^2) 알고리즘이 있다면 분할 정복을 사용하여 O(nlogn)으로 줄일 수 없는지 시도해보라

코드의 실행 시간이나 사용 메모리가 확실하지 않다면 직접 실행해보고, 영향을 줄 것 같은 요소라면 바꾸어 가면서 실행해 본 다음 결과를 그래프로 그리면 대략적인 답이 나옴

 

여러분의 추정을 테스트 하라

입력값 n이 작을 경우 단순한 O(n^2) 코드가 복잡한 O(nlogn) 코드보다 상수 시간 때문에 더 좋은 성능을 낼 수도 있음

입력 데이터 집합이 작을 때는 수행 시간이 선형적으로 늘어나다가도, 수백만 개라면 스레싱이 발생하면서 수행 시간이 폭증할 수도 있음

임의로 생성한 데이터로 테스트 결과보다 정렬된 입력으로 테스트 결과가 수행 시간이 낮을 수도 있음

 

정확하게 시간을 재는 일이 어렵다면 코드 프로파일러를 사용하여 각 단계의 실행 횟수와 입력 크기별 실행 횟수를 그래프로 그려 보라

 

가장 빠른 알고리즘이 언제나 가장 좋은 알고리즘은 아님

  • 입력 데이터가 작다면 단순한 삽입 정렬도 퀵 정렬과 비슷한 성능을 내지만 삽입 정렬을 작성하고 디버깅하는 시간이 퀵 정렬보다 적음

선택한 알고리즘이 코드 작성과 데이터 준비 비용이 많이 드는지 고려해야 함

 

알고리즘을 개선하느라 시간을 투자하기 전에 그 알고리즘이 정말 병목인지 먼저 확인하는 것이 좋음

 

리팩토링

프로그램이 발전함에 따라 점점 초기에 내린 결정을 다시 고려하고 코드의 일부분을 다시 작성할 일이 생김

 

리팩토링 정의

  • 체계적으로 진행
  • 밖으로 드러나는 동작은 바뀌지 않음, 기능을 추가하는 작업이 아님

무질서하게 대규모로 코드를 다시 쓰는 것이 아니라 위험하지 않은 작은 단계들을 밟고, 정확한 목적을 가지고 정밀하게 접근하는 활동으로, 코드를 바꾸기 쉽게 유지하는 것

동작이 바뀌지 않는다는 것을 보장하려면 코드의 동작을 검증하는 좋은 자동화된 단위 테스트가 필요

 

리팩토링을 해야 하는 상황

  • 중복
  • 직교적이지 않은 설계
  • 더 이상 유효하지 않은 지식
  • 성능 개선

일찍 리팩토링하고 자주 리팩토링하라

일정의 압박은 리팩토링을 하지 않는 단골 핑계이지만 지금 리팩토링을 하지 않으면 일이 더 진척되었을 때, 신경 써야 할 의존성이 더 많아졌을 때 문제를 고쳐야 하므로 훨씬 더 많은 시간을 투자해야 하기 때문에 설득력이 떨어짐

다른 사람에게 설명할 때는 병이나 종양에 비유하면 좋음

 

코드의 부수적인 피해도 시간이 흐르면서 매우 심각해질 수 있음

대부분의 일처럼 리팩토링도 문제가 작을 때 하는 것이 더 쉬움

코드 한 부분 때문에 리팩토링만 하는 일주일이 필요하다면 완전 재작성이고, 이 정도로 많은 시간이 필요하다면 즉시 해결할 수 없는 것도 당연하지만 그 대신 일정에 리팩토링할 시간을 확실히 포함시키고, 그 코드를 사용하는 사람들이 코드가 조만간 재작성될 것이라는 사실과 재작성이 그들의 코드에 미칠 영향을 인지하도록 해야 함

 

리팩토링의 본질은 재설계

새로운 사실이 밝혀지거나, 문제에 대한 이해가 더 깊어지거나, 요구 사항이 바뀐다면 언제라도 재설계의 대상이 될 수 있음

하지만 거대한 규모의 코드를 닥치는 대로 헤집어 놓으면 나중에는 리팩토링 전보다 더 안 좋을 수도 있음

분명히 리팩토링은 천천히, 신중하게, 조심스럽게 진행해야 하는 작업

 

리팩토링 방법

  • 리팩토링과 기능 추가를 동시에 하지 말라
  • 리팩토링을 시작하기 전 TC가 먼저 있는지 확인
    할 수 있는 한 자주 테스트를 돌려봐서 바꾼 것 때문에 무언가 망가졌을 경우 빨리 알 수 있음
  • 단계를 작게 나누어서 신중하게 작업하라
    클래스 멤버를 다른 클래스로 옮기기, 함수 하나 쪼개기, 변수명 하나 바꾸기 같이 작은 단위로 작업해야 함
    리팩토링에서는 국지적인 변경들이 많이 모여서 커다란 규모의 변화를 낳는 일이 자주 발생
    단계를 작게 나누고, 한 단계가 끝날 때마다 테스트를 돌린다면 기나긴 디버깅 작업을 피할 수 있음
  • 리팩토링만으로 부족해서 외부에서 보이는 동작이나 API를 바꿔야 한다면, 일부러 리팩토링 대상 코드에 의존하는 코드들을 컴파일을 실패하여 고쳐야 하는 부분이 어디인지 파악 가능

테스트로 코딩하기

테스트는 버그를 찾기 위한 것이 아니다

DB 조회 코드를 작성한다고 가정

어떤 프레임워크는 DB 연결을 자동으로 처리해서 테스트를 수행할 때 테스트 데이터베이스로 연결해주기도 하지만 그런 프레임워크를 사용하지 않는다면 데이터베이스 인스턴스를 매개변수로 전달받아야 함

따라서 테스트 데이터를 작성하려면 어떤 필드를 쓸지 알아야 하기 때문에 필드 이름을 매개 변수로 전달받아야 함

테스트에 대해 생각하는 것으로 시작했는데 코드 한 줄 쓰지 않고도 두 가지를 발견하고 함수의 매개변수들을 변경함

 

테스트가 코드의 첫 번째 사용자다

테스트는 코딩을 인도하는 필수 피드백

다른 코드와 긴밀하게 결합된 함수는 실행하기도 전에 온갖 사전 설정을 해야 하기 때문에 테스트하기 좋게 만들면 결합도도 낮아짐

테스트하려면 그것을 이해해야만 함

경계 조건과 오류 조건을 지원하다가 분기 처리 등으로 코드가 복잡해질 수 있는데, 코딩을 시작하기 전에 경계 조건과 오류 조건 등에서 어떻게 동작해야 하는지를 먼저 생각해 본다면 함수를 단순하게 만드는 패턴을 찾을 수도 있음

 

TDD(Test-Driven Development)

기본 주기

  1. 추가하고 싶은 작은 기능을 하나 결정
  2. 그 기능이 구현됐을 때 통과하게 될 테스트를 하나 작성
  3. 테스트를 실행, 다른 테스트는 통과하고 방금 추가한 테스트 하나만 실패해야 함
  4. 실패하는 테스트를 통과시킬 수 있는 최소한의 코드만 작성, 모든 테스트가 통과하는지 확인
  5. 코드를 리팩토링, 방금 작성한 테스트나 함수를 개선할 수 있는 부분이 없는지 살펴봄, 개선한 후에도 테스트가 계속 통과되는지 확인

TDD의 핵심은 반복 주기가 몇 분 정도로 매우 짧아야 하고, 끊임없이 테스트 작성과 통과하게 만들기를 반복

TDD 작업 방식을 따르면 코드에 언제나 테스트가 있을 수 밖에 없고, 언제나 테스트에 대해 생각하게 됨

 

TDD의 단점

  • 늘 테스트 커버리지 100%를 달성하기 위해 과도하게 많은 시간을 투자
  • 많은 수의 중복 테스트가 생김
    클래스를 처음으로 작성하기 전에 단순히 클래스 이름만 참조해서 실패하는 테스트를 만들고, 테스트가 실패하고 나면 그제야 빈 클래스 정의를 작성하고 테스트가 통과함
    다음 테스트에 당연히 클래스를 참조할 것이므로 첫 번째 테스트는 불필요해지고 나중에 클래스 이름을 바꿀 때 바꿔야 하는 곳만 많아질 뿐
  • 밑에서부터 시작하여 위로 올라가는 방식으로 설계를 함

상향식이나 하향식이 아니라 끝에서 끝까지 만들어라

하향식 : 문제를 코드로 표현할 수 있을 만큼 작게 만들어 구현하는 방법

상향식 : 바닥에서 시작하여 문제에 가까운 추상적 개념을 만들고, 그 위에 더 높은 수준의 추상화 계층을 쌓아 올려 문제가 해결해주는 추상화가 될 때까지 계속하는 방법

상향식과 하향식 모두 개발을 처음 시작할 때 무엇을 하고 있는지 모른다는 것이 문제

  • 하향식 설계는 전체 요구 사항을 시작할 때 다 알고 있다고 가정하지만 사실은 알 수 없음
  • 상향식 설계는 추상화 계층을 쌓다 보면 결국에는 하나의 최상위 해결 계층에 도착할 것이라고 가정하지만, 목표를 모르므로 각 계층의 기능을 결정할 수 없음

한쪽 끝과 다른 쪽 끝을 잇는 조그만 기능 조각들을 만들고, 그 과정에서 문제에 대하여 배우고, 코드를 채워나가면서 배운 것을 적용

 

TDD를 실천하되 중간에 멈추어 큰 그림을 살피는 것을 잊지 말라

작은 단계들을 밟는 접근 방법이 목표를 무시한 채 쉬운 문제들만 해결하도록 유도되어 잘못된 방향이 될 수 있음

테스트는 개발을 이끌어 나가는데 도움이 되지만 나아갈 때마다 목적지를 떠올리지 않으면 계속 같은 자리만 빙빙 돌 수 있음

 

컴포넌트 기반 개발에서 맨 처음부터 테스트가 가능하도록 만들고, 코드들을 서로 연결하기 전에 코드를 하나하나 철저하게 테스트해야만 함

각 모듈의 동작을 검증하기 위해 다른 것들로부터 분리시켜놓고 테스트를 수행하면 모듈이 어떻게 행동할지 더 잘 알게 됨

A라는 모듈이 DataFeed와 LinearRegression이라는 다른 모듈을 사용할 때, 테스트 순서

  1. DataFeed의 계약을 완전히 테스트
  2. LinearRegression의 계약을 완전히 테스트
  3. A의 계약을 테스트

만약 DataFeed와 LinearRegression은 테스트 통과하는데 A는 통과하지 못한다면 문제가 A에 있거나 하위 컴포넌트를 사용하는 방식에 있다고 거의 확신할 수 있음

이 기법은 디버깅 비용을 줄여주는 방법으로, A의 하위 컴포넌트를 다시 검사하는 데 시간을 허비하지 않고 빠르게 문제의 근원일 가능성이 높은 A의 내부에 집중할 수 있음

계약을 잘 지키는지 확인하는 테스트를 강조함으로써 프로젝트에서 이후에 벌어질지 모를 재앙을 피하려고 노력하는 것

 

단위 테스트는 인위적인 환경을 구축한 다음, 테스트할 모듈의 함수들을 호출하여 반환된 결과들을 알고 있는 값과 비교해 보거나 똑같은 테스트를 이전에 돌렸을 때 나온 값과 비교하여 올바른지 검사

동일한 테스트를 코드 수정 후 다시 돌려보는 것을 회귀(Regression) 테스트라고 함

 

테스트 할 수 있도록 설계하라

만든 테스트를 그냥 버리지 말고 기존 단위 테스트에 포함시켜라

 

아무리 테스트를 잘 갖추었어도 모든 버그를 발견할 수는 없음

따라서 배포한 후에도 테스트할 일이 자주 생기기 때문에 내부 상태를 디버거 없이 다양한 형태로 볼 수 있는 방법을 제공할 수도 있음

  • 논리 경로를 추론하거나 로그를 자동으로 파싱하기 위해 로그 메시지는 반드시 규칙적이고 일관된 형식이어야 함
  • 특정 커맨드를 통해 해당 로직을 실행하거나 진단 제어 창이 열리게 만드는 것이 유용

철저하게 테스트 계획을 세우는 것이 좋음

테스트를 먼저하는 것(TDD 등)이 대부분의 상황에서 최상의 선택이지만 때에 따라 테스트를 먼저 쓰기가 어렵거나 의미가 없을 수도 있으므로 코드와 테스트를 함께 진행하여 코드를 조금 작성하고 테스트를 작성하는 과정을 반복하는 방법을 사용

 

제대로 된 테스트 문화를 가졌다면 모든 테스트가 언제나 통과해야 함

 

테스트 코드를 다른 코드와 마찬가지로 결합도를 낮추고 깨끗하고 견고하게 유지하라

 

GUI 시스템의 위젯 위치나 서버 로그에 찍힌 현재 시간, 에러 메시지의 문구처럼 신뢰할 수 없는 것에 의존하지 말라
이런 종류의 것을 테스트하면 테스트가 더 잘 깨지게 됨

  • GUI 시스템의 위젯 위치는 화면 해상도, OS의 폰트 크기 세팅, UI 라이브러리의 마이너 업데이트, 혹은 단순한 패딩 스타일 수정만으로도 위젯의 절대적인 좌표나 크기는 쉽게 바뀜
  • 서버 로그에 찍힌 현재 시간은 "현재 시간이 2026-05-31인지 검증하라"라는 테스트 코드는 작성한 당일에는 통과하겠지만, 내일이 되면 무조건 실패합니다. 혹은 서버의 타임존(Timezone) 설정이 UTC냐 KST냐에 따라서도 결과가 깨짐
  • 에러 메시지의 문구는 에러 메시지는 UX 개선이나 다국어 지원을 위해 개발 과정에서 아주 빈번하게 수정되는 영역

 

테스트도 프로그래밍의 일부임을 명심하라

 

프로퍼티 기반 테스트

함수를 작성할 때 단위 테스트도 작성하기를 추천

본인이 코드를 쓰고 테스트를 작성한다면 잘못된 가정이 코드와 테스트에 들어갈 수도 있으므로 코드와 테스트를 서로 다른 사람이 작성하는 방법이 좋음

대안으로는 코드에서 프로퍼티를 찾아내서 테스트 자동화에 사용하는 프로퍼티 기반 테스트를 사용

 

프로퍼티 기반 테스트로 가정을 검증하라

인위적인 예로 리스트 정렬 기능의 테스트

  • 정렬이 됐는지, 리스트의 길이가 동일한지
from hypothesis import given
import hypothesis.strategies as some

@given(some.lists(some.integers()))
def test_list_size_is_invariant_acorss_sorting(a_list):
	original_length = len(a_list)
	a_list.sort()
	assert len(a_list) == original_length
	
@given(some.lists(some.text()))
def test_sorted_result_is_ordered(a_list):
	a_list.sort()
	for i in range(len(a_list) - 1):
		assert a_list[i] <= a_list[i + 1]

 

잘못된 가정 찾기

class Warehouse:
	def __init__(self, stock):
		self.stock = stock
		
	def in_stock(self, item_name):
		return (item_name in self.stock) and (self.stock(item_name] > 0)
	
	def take_from_stock(self, item_name, quantity):
		if quantity <= self.stock[item_name]:
			self.stock[item_name] -= quantity
		else:
			raise Exception("{}를(을) 재고보다 많이 팔았음",format(item_name))
			
			
	def stock_count(self, item_name):
		return self.stock[item_name]
		
def order(warehouse, item, quantity):
	if warehouse.in_stock(item):
		warehouse.take_from_stock(item, quantity)
		return ( "완료", item, quantity)
	else:
		return ( "재고 없음", item, quantity)
		
def test_stock_level_plus_quantity_eqauls_original_stock_level(item, quantity):
	wh = Warehouse({"신발" : 10, "모자" : 2, "우산" : 0})
	initial_stock_level = wh.stock_count(item)
	(status, item, quantity) = order(wh, item, quantity
	if status == "완료":
		assert wh.stock_count(item) + quantity == initial_stock_level

warehouse.take_from_stock 안에서 창고에서 모자를 세 개 꺼내려고 했는데 재고가 둘 밖에 없어서 함수가 터짐

프로퍼티 기반 테스트가 잘못된 가정을 찾았는데, 원래 in_stock 함수는 단순히 주어진 물품의 재고가 하나라도 있는지만 확인했는데 주문을 처리할 만큼 충분한 재고가 있는지 확인해야 함

def in_stock(self, item_name, quantity):
	return (item_name in self.stock) and (self.stock(item_name] >= quantity)

 

프로퍼티 기반 테스트가 강력한 까닭은 입력을 생성하는 규칙과 출력을 검증하는 어썰트만 설정한 채 제멋대로 작동하도록 놔두기 때문에 생각하지 못한 곳에서 버그를 발견할 수 있음

 

프로퍼티 기반 테스트가 실패했다면 어떤 매개 변수를 사용했는지 알아낸 다음 그 값을 이용하여 별도의 단위 테스트를 정식으로 추가하는 것이 좋음

  • 첫 번째는 프로퍼티 기반 테스트의 여러 가지 다른 수행 결과와 상관없이 문제가 발생하는 상황에 집중할 수 있게 해줌
  • 단위 테스트가 회귀 테스트 역할을 하여 다음 번에 실행했을 때 똑같은 값을 전달하는 보장이 없기 때문에 문제를 일으켰던 값을 사용하는 단위 테스트를 만들어두면 버그가 해결됨을 보장할 수 있음

프로퍼티 기반 테스트는 불변식과 계약이라는 관점으로 바라보게 하므로 경계 조건이 줄어들고, 데이터의 일관성을 해치는 함수는 더 도드라짐

  • 개발자가 직접 예시를 짜다 보면 0, 음수, 빈 문자열, Null 같은 극단적인 값(경계 조건, Edge Case)을 깜빡하고 빼먹는 경우가 많습니다.
    하지만 프로퍼티 기반 테스트 프레임워크는 수백, 수천 개의 무작위 데이터를 자동으로 생성해서 함수에 전달
  • 프로그램에서 가장 위험한 버그 중 하나는 "어떤 함수를 거쳤더니 데이터가 이상하게 오염되는 현상"입니다. 즉, 데이터의 일관성이 깨지는 것
    무작위로 생성된 수많은 데이터를 함수에 통과시키다 보면, 특정 상황에서 불변식을 장렬하게 깨뜨리는(일관성을 망치는) 함수가 걸려들게 됩니다.
    PBT 프레임워크는 단순히 에러났다고 끝내는 게 아니라, 축소라는 기능을 제공합니다. 에러를 일으킨 거대한 입력값을 점점 줄여나가며 "정확히 이 최소한의 조건에서 데이터 일관성이 깨진다"는 것을 보여줌
    결과적으로 사이드 이펙트가 심하거나, 상태를 엉망으로 만드는 함수가 도드라지게 됨

UE에서 PBT를 사용하는 방법

  • 외부 C++ PBT 라이브러리 연동 (추천)
    가장 완성도 높은 방법은 Modern C++용 오픈소스 PBT 라이브러리를 언리얼 엔진의 서드파티 모듈(Third-Party Module)로 포함시켜 사용
    추천 라이브러리: RapidCheck (C++11/14/17 지원, 가볍고 Unreal과 연동하기 좋음)
    PBT의 핵심인 축소(Shrinking) 기능(실패한 무작위 입력값을 최소한의 에러 케이스로 줄여주는 기능)을 직접 만들지 않고 그대로 쓸 수 있음
  • UE 기본 FStreamableManager 또는 FRandomStream 활용 (자체 구현)
    외부 라이브러리 도입이 부담스럽다면, 언리얼이 제공하는 결정론적 난수 생성기인 FRandomStream을 활용해 RunTest 내부에서 루프를 돌리는 방식으로 Pseudo PBT를 구현할 수 있음

바깥에서는 안전에 주의하라

코드가 완성됐을 때 다음으로 해야 하는 일은 잘못될 수 있는 경우를 찾아보고, 각 경우에 대한 단위 테스트를 추가하는 것

잘못된 매개 변수를 넘기거나 리소스를 흘리거나 모자라는 경우를 생각해야 함

 

기본 보안 원칙

  • 공격 표면을 최소화하라
    공격 표면은 데이터를 입력하거나 데이터를 추출하거나 서비스를 실행시킬 수 있는 모든 접근 지점을 합한 것
    • 코드의 복잡성은 공격 매개체를 유발
      복잡한 코드는 예상 외의 버그가 일어날 확률을 높이고 공격 표면을 넓힘
      단순한 코드는 이해하기도 쉽고 잠재적 취약점을 발견하기도 쉬움
    • 입력 데이터는 공격 매개체
      외부의 데이터를 DB, 렌더링, 다른 처리 로직에 전달하기 전에 언제나 나쁜 내용을 제거하라
    • 인증이 없는 서비스는 공격 매개체
      별도로 처리하거나 제한을 두지 않으면 최소한 Dos 공격이 가능해짐
      데이터 인증 없이 누구나 접근할 수 있는 클라우드 저장소에 데이터를 저장한다면 유출될 수 있음
    • 인증을 요구하는 서비스도 공격 매개체
      인증 받은 사용자의 수를 언제나 최소로 유지하라
      쓰이지 않거나, 오래되고 유효하지 않은 사용자나 서비스를 정리하라
    • 출력 데이터는 공격 매개체
      시스템에 비밀번호를 등록하려고 했더니 다른 사용자가 사용 중인 비밀번호라는 오류 메시지를 출력하는 전설이 있음
      출력 데이터에 정보를 누설하지 말라
      응답에 들어 있는 데이터가 사용자의 권한에 적절한지 확인하라
    • 디버깅 정보는 공격 매개체
      테스트가 노출하는 정보나 실행 시점 예외 정보를 잘 보호하라
  • 최소 권한 원칙
    최소 권한만을 꼭 필요한 시간만큼만 부여하여 위험을 줄여야 함
  • 안전한 기본값
    가장 안전한 값이 가장 사용자 친화적인 값이나 편리한 값은 아닐 수 있지만, 각 사용자가 보안과 편리함 사이에서 고르도록 하는 편이 나음
    예를 들어 비밀번호를 입력할 때의 기본값은 입력한 글자를 별표로 바꾸어 비밀번호를 숨기는 것
  • 민감 정보를 암호화하라
    개인 식별 정보나 금융 데이터, 비밀번호, 다른 인증 정보를 일반 텍스트로 남기지 말라
    데이터가 유출되더라도 암호화가 안전장치 역할을 할 수 있어야 함
    키나 암호는 빌드나 배포 프로세스 내에서 설정 파일이나 환경 변수로 관리
  • 보안 업데이트를 적용하라
    패치의 부작용으로 프로그램의 일부가 망가질 수 있으나, 알려진 공격 방법으로 뚫릴 수 있음

암호화에 있어서 첫 번째 규칙이자 가장 중요한 규칙은 절대 직접 만들지 말라는 것

 

이름 짓기

이름은 의도와 믿음을 드러내는 것

이름을 짓기 위해서는 무언가를 만들 때마다 이것을 왜 만드는지 생각해야 한다는 뜻

문제 풀이 사고방식에서 벗어나 더 큰 그림을 보고, 변수나 함수의 역할을 생각해야 함

 

주문에 할인을 적용하는 메서드

public void deductPercent(double amount)

deductPercent는 함수가 하는 일이 담겨 있지만 의도는 담겨 있지 않음

amount는 할인 금액인지 할인율인지 알 수 없음

 

public void applyDiscount(Percentage discount)

메서드에 의도가 명확하게 드러나고, 매개변수의 타입도 변경하여 백분율을 명시

 

이름을 지을 때는 표현하고 싶은 것을 더 명확하게 다듬기 위해 끊임없이 노력해야 함
명확하게 다듬는 작업이 코드를 작성할 때 코드를 더 잘 이해할 수 있도록 도울 것

 

C 언어에서 i, j, k는 전통적으로 반복문에서 증가하는 변수로 쓰이고, s는 문자열을 의미하고, 그 밖에 여러 관습이 있음

관습을 어긴다면 문제가 되거나 틀릴 수 있음

그 분야의 문화를 존중하라

 

모든 프로젝트에는 팀 내에서 특별한 의미가 있는 용어들을 비롯하여 고유의 어휘들이 있음

반드시 팀의 모든 사람들이 각 단어의 뜻을 알고 일관성 있게 사용해야 함

 

이름을 잘 지어라. 필요하면 이름을 바꿔라

코드는 리팩토링되고 사용 방식은 바뀌고 의미는 미묘하게 달라지므로 이름을 계속 바꾸지 않으면 잘못된 이름을 사용하는 코드가 됨

의도를 제대로 표현하지 못하거나 오해를 부를 수 있거나 헷갈리는 이름을 발견했다면 고쳐야 함

잘못된 이름을 바꿀 수 없는 상황이라면 ETC 위반이므로 ETC 문제를 고치고 나서 잘못된 이름을 바꿔라

잘못된 포인터 사용 결과

메모리 누수(Leak) : delete를 하지 않아 힙에 그대로 남아 있음

댕글링(Dangling) 포인터 : 이미 해제된 주소를 가리키는 포인터

와일드(Wild) 포인터 : 값이 초기화되지 않아 엉뚱한 주소를 가리키는 포인터

 

가비지 컬렉션 시스템

더 이상 사용하지 않는 오브젝트를 자동으로 감지해 메모리를 해제하는 시스템

모든 오브젝트 정보를 모아둔 저장소를 사용해 사용되지 않는 메모리 추적

 

일반적으로 마크-스윕(Mark-Sweep) 방식을 사용

  1. 저장소에서 최초 검색을 시작하는 루트 오브젝트를 표기
  2. 루트 오브젝트가 참조하는 객체를 찾아 사용한다면 마크(Mark)
  3. 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고 이를 계속 반복
  4. 이제 저장소에 마크된 객체와 마크되지 않은 객체로 나뉨
  5. 가비지 컬렉터가 저장소에서 마크되지 않은 객체(가비지)들의 메모리를 회수(Sweep)

 

월드에 스폰된 액터는 엔진에 관리하는 월드 오브젝트 목록에 의해 참조되기 때문에 루트 세트에 추가하지 않아도 가비지 컬렉션 대상에서 제외됨

Destroy를 호출하거나 월드에서 제거하면 가비지 컬렉션 대상이 됨

 

언리얼에서는 지정된 주기(GCCycle, 기본값 60초)마다 시스템 동작

ForceGarbageCollection 함수로 가비지 컬렉션 시스템을 바로 동작시킬 수는 있음

백그라운드에서 진행하는 작업이 부하가 있어 병렬 처리, 클러스터링 등 기능 탑재

 

클러스터링

프로젝트에서 켜거나 끌 수 있음

관련된 오브젝트들을 하나의 가비지 컬렉션 클러스터에 묶어 오브젝트 각각이 아닌 클러스터 하나만 검사함

일반적으로 클러스터를 사용하면 가비지 컬렉션 퍼포먼스가 향상되지만 클러스터가 너무 클 경우 같은 프레임에 클러스터의 개별 오브젝트 전부를 삭제 준비하므로 버벅일 수 있음

 

GUObjectArray : 관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역변수

Garbage 플래그 : 참조가 없어 회수 예정인 오브젝트를 나타내는 플래그

RootSet 플래그 : 참조를 파악하기 위해 시작하는 시드 오브젝트를 나타내는 플래그, 참조가 없어도 회수되지 않음

 

시스템이 Garbage 플래그로 설정된 오브젝트를 파악하고 메모리를 안전하게 회수

오브젝트를 삭제하기 위해 레퍼런스 정보를 없애면 가비지 컬렉터가 자동으로 메모리 회수

 

메모리가 회수되지 않아야 하는 오브젝트는 AddToRoot 함수로 RootSet 플래그를 설정

RemoveFromRoot 함수로 루트셋 플래그를 제거

컨텐츠 제작에는 권장하지 않음

 

UPROPERTY 매크로가 붙은 프로퍼티는 Token Stream에 등록되고, GC 탐색 시 TokenStream을 순회하여 ClearUnreachable하기 때문에 회수되지 않음

만약 UPROPERTY를 사용할 수 없다면 AddReferencedObject 함수로 참조 설정

오브젝트 포인터는 가급적 UPROPERTY로 선언하여 가비지 컬렉터가 자동으로 관리하도록 함

언리얼 오브젝트를 강제로 지우려 하지 말고 참조를 끊는 방법 사용

Actor를 소멸시키기 위해 Destory 함수 사용, 내부 동작은 가비지 컬렉터와 동일

 

일반 c++ 클래스가 언리얼 오브젝트로 관리해야 하는 경우 FGCObejct 상속, AddReferencedObjects, GetReferenceName 함수 구현해야 함
AddReferencedObjects에서 관리할 언리얼 오브젝트를 추가

class UNREALMEMORY_API FStudentManager : public FGCObject
{
public:
    virtual void AddReferencedObjects(FReferenceCollector& Collector) override;

    virtual FString GetReferenceName() const override
    {
        return TEXT("FStudentManager");
    }
};

 

언리얼 오브젝트는 nullptr 체크만 하면 댕글링 포인터가 발생할 수 있으므로 댕글링 포인터 문제를 탐지하기 위해 nullptr 체크 뿐만 아니라 플래그 체크를 통해 GC에 의해 삭제 예정인지를 체크하는 IsValid 함수 제공

IsValidLowLevel은 메모리가 정상적인 방법과 과정으로 할당됐는지 체크

 

GC 내부 레이어 및 로직

루트셋에서 시작해서 참조를 따라가며 도달한 모든 오브젝트는 Reachable, 나머지는 Unreachable

 

오브젝트 배열을 각 스레드에 분할하여 순회하여 순회 속도 향상

무작위로 동적할당된 오브젝트 직접 참조 대신 모아져 있는 ObjectItem 배열을 사용하여 캐시 미스 최소화 

ParallelFor(NumThreads, [ObjectsToSerializeArrays, &ClustersToDissolveList, &KeepClusterRefsList, FastKeepFlags, KeepFlags, NumberOfObjectsPerThread, NumThreads, MaxNumberOfObjects](int32 ThreadIndex)
{
	int32 FirstObjectIndex = ThreadIndex * NumberOfObjectsPerThread + GUObjectArray.GetFirstGCIndex();
	int32 NumObjects = (ThreadIndex < (NumThreads - 1)) ? NumberOfObjectsPerThread : (MaxNumberOfObjects - (NumThreads - 1) * NumberOfObjectsPerThread);
	int32 LastObjectIndex = FMath::Min(GUObjectArray.GetObjectArrayNum() - 1, FirstObjectIndex + NumObjects - 1);
	int32 ObjectCountDuringMarkPhase = 0;
	TArray<UObject*>& LocalObjectsToSerialize = ObjectsToSerializeArrays[ThreadIndex]->ObjectsToSerialize;

	for (int32 ObjectIndex = FirstObjectIndex; ObjectIndex <= LastObjectIndex; ++ObjectIndex)
	{
		FUObjectItem* ObjectItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ObjectIndex];

 

FUObjectAllocator::AllocateUObject, FreeUObject 함수에서 오브젝트 할당 및 해제를 하고 있으므로 해당 함수에서 성능 모니터링 코드를 삽입할 수 있음

 

할당 성능 최적화 등의 목적으로 Allocator 교체 방법

class FMyPoolAllocator : public FMalloc
{
    void* Malloc(SIZE_T Count, uint32 Alignment) override
    {
        ...
    }
    void Free(void* Original) override { /* 풀 반환 */ }
};

// 엔진 초기화 시
GMalloc = new FMyPoolAllocator();

 

특정 타입 객체 별도 관리하는 방법

  • 비UObject
class FMyObjectManager : public FGCObject
{
    TArray<UMySpecialObject*> ManagedObjects;

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        // 이 배열의 모든 오브젝트를 GC에 Reachable로 알림
        Collector.AddReferencedObjects(ManagedObjects);
        
        UE_LOG(LogTemp, Log, TEXT("GC Tracing %d managed objects"), ManagedObjects.Num());
    }

    FString GetReferencerName() const override
    {
        return TEXT("FMyObjectManager"); // 디버그 식별자
    }
};
  • UObject
UCLASS()
class UMyActor : public AActor
{
    // UPROPERTY 없는 포인터도 GC에 알리는 방법
    TMap<int32, TArray<UObject*>> DynamicRefs; // UPROPERTY 불가 구조

    static void AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
    {
        UMyActor* This = CastChecked<UMyActor>(InThis);
        
        // 부모 클래스 먼저 호출 (필수)
        Super::AddReferencedObjects(InThis, Collector);
        
        // 커스텀 레퍼런스 등록
        for (auto& Pair : This->DynamicRefs)
        {
            Collector.AddReferencedObject(Pair.Value);
        }
    }
};

 

GC 로직 내의 델리게이트

// GarbageCollection.cpp 내 CollectGarbageInternal() 실행 순서

// ① GC 시작 직전 (스트리밍 플러시, 전처리 등)
FCoreUObjectDelegates::GetPreGarbageCollectDelegate().Broadcast();

// ② Reachability 분석 완료 후 (Unreachable 목록 확정 시점)
FCoreUObjectDelegates::PostReachabilityAnalysis.Broadcast();

// ③ BeginDestroy 라우팅 직전 (Unhash 직전)
FCoreUObjectDelegates::PreGarbageCollectConditionalBeginDestroy.Broadcast();

// ④ GC 완료 후 (검증, 통계 등)
FCoreUObjectDelegates::GetPostGarbageCollect().Broadcast();

//모니터링 코드 삽입 예시
// 게임 모듈 초기화 시 등록
void FMyModule::StartupModule()
{
    // ① GC 시작 시간 기록
    FCoreUObjectDelegates::GetPreGarbageCollectDelegate().AddLambda([]()
    {
        GCStartTimestamp = FPlatformTime::Seconds();
        UE_LOG(LogTemp, Log, TEXT("[GC] Started"));
    });

    // ② Reachability 분석 후 — Unreachable 오브젝트 수 확인
    FCoreUObjectDelegates::PostReachabilityAnalysis.AddLambda([]()
    {
        // GUnreachableObjects는 이 시점에 확정됨
        UE_LOG(LogTemp, Log, TEXT("[GC] Unreachable objects: %d"), GUnreachableObjects.Num());
    });

    // ④ GC 종료 후 총 시간 측정
    FCoreUObjectDelegates::GetPostGarbageCollect().AddLambda([]()
    {
        double Elapsed = FPlatformTime::Seconds() - GCStartTimestamp;
        UE_LOG(LogTemp, Log, TEXT("[GC] Finished in %.3f ms"), Elapsed * 1000.0);
    });
}

 

UObject GC 소멸시 호출되는 3단계 가상 함수

class UMyObject : public UObject
{
    // ① Sweep 단계 — 비동기 리소스 해제 시작
    virtual void BeginDestroy() override
    {
        // 렌더 스레드 펜스 시작, GPU 리소스 해제 요청 등
        RenderFence.BeginFence();
        Super::BeginDestroy();
        
        UE_LOG(LogTemp, Log, TEXT("BeginDestroy: %s"), *GetName());
    }

    // ② 비동기 작업 완료 여부 — 이게 true 반환해야 FinishDestroy 호출됨
    virtual bool IsReadyForFinishDestroy() override
    {
        return RenderFence.IsFenceComplete(); // 렌더 완료 대기
    }

    // ③ 실제 소멸 — 모든 정리 작업
    virtual void FinishDestroy() override
    {
        // 리소스 해제 완료
        Super::FinishDestroy();
        
        UE_LOG(LogTemp, Log, TEXT("FinishDestroy: %s"), *GetName());
    }
};

 

내장 성능 모니터링 시스템

GarbageCollection.h PERF_DETAILED_PER_CLASS_GC_STATS을 활성화하면 GarbageCollection.cpp LogClassCountInfo 함수에서 아래 목록들이 자동으로 로그에 찍힘

// GClassToPurgeCountMap       — 클래스별 회수 오브젝트 수
// GClassToRegularObjectRefsMap — 클래스별 일반 레퍼런스 수
// GClassToDisregardedObjectRefsMap — 클래스별 Permanent Pool 레퍼런스 수
// GClassToCyclesMap           — 클래스별 GC 소요 사이클
  • GarbageCollection.cpp  UpdateDetailedStats에서 커스텀 통계 코드 삽입 가능

GC 멀티스레드 레이어

STW(Stop The World)

  • 게임 스레드에서 SetGCIsWaiting가 호출되면 워커 스레드 LockAsync에서 GCUnlockedEvent->Wait()으로 블로킹
  • GC가 실행되는 동안 레퍼런스 변경이 일어나지 않도록 모든 작업을 멈춤

FPlatformMisc::MemoryBarrier() : 컴파일러 최적화를 통해 코드의 순서가 변경되는 것을 방지하기 위해 MemoryBarrier 이전의 코드를 모두 수행하는 것을 보장하기 위함

Type Erasure(타입 절제/소거)는 서로 다른 구체적인 타입을 공통된 인터페이스 뒤로 숨겨, 런타임에 다형성을 제공하면서도 템플릿의 유연함을 유지하는 설계 패턴

  • std::function이나 std::any가 이 패턴의 대표적인 구현체

 

사용 이유

일반적인 상속 기반 다형성은 클래스 계층 구조에 묶여야 한다는 단점이 있고, 템플릿 기반 다형성(컴파일 타임)은 컨테이너에 서로 다른 타입을 담을 수 없다는 단점이 있습니다.

  • 상속: Shape를 상속받지 않은 클래스는 Shape* 포인터에 담을 수 없음
  • 템플릿: vector<T>는 한 가지 타입만 담을 수 있음

Type Erasure는 이 두 장점을 결합하여 "특정 기능을 수행할 수만 있다면 어떤 타입이든 담을 수 있는" 컨테이너를 만들게 해줍니다.

 

핵심 구조

Type Erasure는 보통 세 가지 구성 요소로 이루어집니다.

  1. Interface (외부 노출): 사용자가 직접 다루는 래퍼 클래스입니다. 내부의 구체적인 타입을 감춥니다.
  2. Concept (추상 베이스): 내부에서 사용될 가상 함수 인터페이스입니다.
  3. Model (템플릿 구현체): 구체적인 타입 T를 저장하고, Concept의 가상 함수를 T의 방식대로 구현합니다.

출력 가능한 기능을 가진 모든 타입을 담는 래퍼를 만드는 예시

#include <iostream>
#include <memory>
#include <vector>
#include <string>

class Object {
public:
    // 1. Interface: 어떤 타입이든 생성자로 받을 수 있음
    template <typename T>
    Object(T value) : self(std::make_unique<Model<T>>(std::move(value))) {}

    void print() const { self->print_(); }

private:
    // 2. Concept: 내부 추상 인터페이스
    struct Concept {
        virtual ~Concept() = default;
        virtual void print_() const = 0;
    };

    // 3. Model: 실제 타입을 저장하는 템플릿 구현체
    template <typename T>
    struct Model : Concept {
        Model(T value) : data(std::move(value)) {}
        void print_() const override { std::cout << data << std::endl; }
        T data;
    };

    std::unique_ptr<Concept> self;
};

int main() {
    // 서로 아무 관계 없는 타입들을 하나의 컨테이너에 담음
    std::vector<Object> vec;
    vec.emplace_back(10);
    vec.emplace_back(std::string("Hello Type Erasure"));

    for (const auto& obj : vec) {
        obj.print();
    }
}

 

장단점

장점

  • 기존 클래스 코드를 수정하거나 특정 인터페이스를 상속받게 강제할 필요가 없음
  • std::vector<Object>처럼 서로 다른 타입을 하나의 컨테이너에 관리할 수 있음
  • 결합도 감소: 구체적인 타입 간의 의존성이 사라집니다.

단점

  • 성능 비용: 내부적으로 가상 함수 호출(V-Table 참조)과 동적 메모리 할당(Heap Allocation)이 발생하므로, 순수 템플릿보다는 느립니다.
  • 복잡도: 보일러플레이트 코드가 늘어나 코드를 이해하기 다소 어려울 수 있음

'C++' 카테고리의 다른 글

Named Constructor Idiom  (0) 2026.05.06
CRTP(Curiously Recurring Template Pattern)  (0) 2026.05.06
C++ Template  (0) 2025.11.12
C++ std::span  (0) 2025.11.01
std::tie  (0) 2025.09.11

+ Recent posts

목차