Post

2026-04-21 TIL (41일차)

2026-04-21 TIL (41일차)

Unreal

아이템 시스템 구현 설계: 상속과 인터페이스의 활용


• 상속 vs 인터페이스

  • 상속 “부모의 유산을 물려받는 것”
    • 부모 클래스(Base Class)가 가진 속성(변수)과 기능(함수)을 자식 클래스가 그대로 물려받아 사용하는 개념입니다. (예: AActor를 상속받으면 트랜스폼, 충돌, 렌더링 기능을 물려받음)
  • 인터페이스: “반드시 지켜야 할 약속(규격)을 정하는 것”
    • 구체적인 기능 구현은 없고, “이 인터페이스를 상속받는다면 반드시 A, B, C 함수를 구현해야 한다”라는 설계도(함수 원형)만 제공합니다.


• 인터페이스를 활용한 설계 및 장점

모든 아이템의 공통적인 특징은 “플레이어와 오버랩(충돌)했을 때 특정한 상호작용을 한다”는 것입니다. 이를 인터페이스로 설계합니다.

구현 순서

  1. 인터페이스 정의: UInterface를 생성하고, OnItemOverlap()과 같은 상호작용 함수의 원형(가상 함수)만 정의합니다.
  2. 클래스 적용: 아이템 역할을 할 클래스들이 이 인터페이스를 상속(public IItemInterface)받아, 각자 자신의 특징에 맞게 함수를 재정의(Override)하여 구현합니다.

인터페이스 사용의 핵심 장점

  • 인터페이스를 안 쓴다면? (의존성 증가): 플레이어가 어떤 물체와 충돌했을 때, 그 물체가 코인인지, 지뢰인지, 포션인지 일일이 캐스팅(Casting)해서 타입을 확인하고, 각 클래스에 맞는 전용 함수를 각각 불러줘야 합니다. 아이템 종류가 100개면 if-else문이 100개가 필요합니다.
  • 인터페이스를 쓴다면? (의존성 감소): 플레이어는 충돌한 물체가 무슨 아이템인지 알 필요가 전혀 없습니다. 그저 “너 IItemInterface를 가지고 있어? 그럼 네가 가진 상호작용 함수를 실행해!”라고 명령(Execute_OnItemOverlap)만 내리면 끝납니다. 새로운 아이템이 수백 개 추가되어도 플레이어 코드는 단 한 줄도 수정할 필요가 없습니다.


• Unreal 인터페이스 헤더 분석

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
```cpp
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "ItemInterface.generated.h"

UINTERFACE(MinimalAPI)
class UItemInterface : public UInterface
{
    GENERATED_BODY()
};

/**
 * 
 */
class WEEK3_ASSIGNMENTS_API IItemInterface
{
    GENERATED_BODY()

    // Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
    
};

왜 클래스가 2개로 나뉘어져 있을까?

이러한 구조는 언리얼 엔진의 리플렉션 시스템(가비지 컬렉션, 블루프린트 연동 등)이 C++의 순수 다중 상속을 지원하지 않기 때문입니다. 이를 해결하기 위해 언리얼은 두 개의 클래스를 조합하여 인터페이스를 흉내냅니다.

1. UItemInterface (U-클래스)

  • 역할: 언리얼 엔진의 런타임 및 리플렉션 시스템(UObject 시스템)에 “이러한 인터페이스가 존재한다”고 등록하는 껍데기(타입 정보용) 클래스입니다.
  • 수정 여부: 절대 수정하지 않습니다. 엔진 내부에서 타입 확인(예: IsA(), Implements<>())이나 가비지 컬렉션을 위해 보이지 않게 사용됩니다.
  • 특징: 블루프린트에서 이 인터페이스를 타입으로 참조하거나 적용할 때, 엔진은 내부적으로 이 U 클래스의 정보를 사용합니다.

2. IItemInterface (I-클래스)

  • 역할: C++에서 실제로 다중 상속을 받고, 우리가 구현할 가상 함수(기능)들을 정의하는 실제 인터페이스 클래스입니다.
  • 수정 여부: 이곳에 코드를 작성해야 합니다. 사용할 가상 함수나 블루프린트 네이티브 이벤트 등을 이 클래스 내부 public: 아래에 선언합니다.
  • 특징: 다른 액터나 UObject가 이 인터페이스를 상속받을 때는 반드시 U가 아닌 IItemInterface를 상속받아야 합니다.

요약 및 활용 팁

  1. 절대 규칙: 위에 있는 UItemInterface는 건드리지 말고 그대로 둡니다.
  2. 함수 선언: 아래에 있는 IItemInterface 내부에 virtual void DoSomething() = 0; 혹은 UFUNCTION(BlueprintNativeEvent) 등을 선언합니다.
  3. 상속 방법: 새로운 클래스(예: 포션 아이템)에서 이 인터페이스를 사용할 때는 public IItemInterface를 다중 상속받아 사용합니다.
1
2
3
4
5
6
7
8
// 예시: 액터에 인터페이스 상속받기
UCLASS()
class AHealthPotion : public AActor, public IItemInterface // 반드시 'I'를 상속
{
    GENERATED_BODY()
    
    // ... IItemInterface의 함수 구현 ...
};


• 아이템 클래스 계층 구조 설계

1
2
3
// 최상위 부모 (BaseItem)
// AActor를 상속받아 게임 세상에 배치될 수 있게 하고, IItemInterface로 아이템 규격을 장착함
class ABaseItem : public AActor, public IItemInterface
  • [최상위 부모] ABaseItem: 모든 아이템의 뼈대. 스태틱 메쉬, 콜리전 박스, 인터페이스 오버라이드 뼈대 등 공통 컴포넌트만 가집니다.
    • [중간 부모] ACoinItem: ABaseItem을 상속. 점수를 올려주는 기능의 뼈대를 구현합니다.
      • [최종 자식] ASmallCoinItem: 10점짜리 작은 코인 (실제 배치용)
      • [최종 자식] ABigCoinItem: 50점짜리 큰 코인 (실제 배치용)
    • [중간 부모] AMineItem: 데미지를 주는 지뢰 아이템의 뼈대.
    • [중간 부모] AHealingItem: 체력을 회복시켜주는 포션 아이템의 뼈대.


• Base, Coin 등 부모 클래스에서 구체적인 값을 지정하지 않는 이유

ABaseItem이나 ACoinItem 같은 부모 클래스에 특정 타입(이름)이나 구체적인 점수(수치)를 하드코딩(고정)하지 않습니다. 그 이유는 객체 지향 프로그래밍의 핵심인 ‘추상화(Abstraction)’‘재사용성’ 때문입니다.

1) 상속의 경직성 방지 (유연성 확보)

만약 ACoinItem 클래스 내부에서 점수를 ‘10점’, 크기를 ‘작음’으로 완전히 박아버린다면, 이를 상속받는 ABigCoinItem은 부모가 강제로 설정한 10점을 지우고 50점으로 덮어써야 하는 불필요한 연산 비용과 구조적 오류가 발생합니다.

2) 추상적인 뼈대 역할 (템플릿)

ABaseItem이나 ACoinItem은 실세계에 존재하는 구체적인 물건이라기보다는, “코인이라는 것은 점수라는 변수를 가지고 있고, 획득하면 소리가 나야 해”라는 개념(템플릿)을 정의하는 곳입니다. 실질적인 값(10점, 50점, 빨간색, 파란색 등)은 이 뼈대를 상속받아 파생된 가장 마지막 자식 클래스(또는 블루프린트)에서 설정해 주는 것이 올바른 구조입니다.

3) 데이터 주도 설계 지원

C++ 기반 부모 클래스에서 변수(예: int32 ItemScore;)만 선언해 두고 값을 고정하지 않으면, 기획자나 디자이너가 언리얼 에디터(블루프린트)에서 자식 클래스를 수십 개 복제해 나가며 자유롭게 수치만 바꿔 새로운 아이템들을 무한히 찍어낼 수 있습니다. (결과적으로 프로그래머가 C++ 코드를 매번 수정하고 컴파일할 필요가 없어집니다.)




물리




알고리즘


• 약수 구하기

$O(N)$ 탐색

가장 직관적인 방법입니다. 1부터 $N$까지 모든 수를 돌면서 나누어 떨어지는지 확인합니다.

  • 사용 조건: $N$이 매우 작을 때 (보통 $N \le 10,000$)
  • 단점: $N$이 1억(100,000,000) 이상 넘어가면 시간 초과가 발생할 확률이높습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <vector>

using namespace std;

vector<int> getDivisorsBasic(int n) {
    vector<int> divisors;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            divisors.push_back(i);
        }
    }
    return divisors;
}


최적화 방식: $O(\sqrt{N})$ 탐색

코딩 테스트에서 특정 수의 약수를 구해야 한다면, 시간 복잡도를 $O(N)$에서 $O(\sqrt{N})$으로 줄이는 이 방식이 사실상의 표준(Standard)입니다.

핵심 원리: 약수의 대칭성 (Pair)

약수는 항상 으로 존재합니다. 어떤 수 $N$의 약수 $i$를 찾았다면, $N / i$ 역시 반드시 $N$의 약수가 됩니다.

  • 예: $N = 16$일 때
    • $1 \times 16$
    • $2 \times 8$
    • $4 \times 4$ (중간 지점)
    • $8 \times 2$ (반복)
    • $16 \times 1$ (반복)

이처럼 모든 약수 쌍은 $\sqrt{N}$을 기점으로 대칭을 이룹니다. 따라서 $1$부터 $\sqrt{N}$까지만 확인하면 모든 약수를 찾을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <vector>
#include <algorithm> // sort 활용
#include <cmath>     // sqrt 활용 (선택 사항)

using namespace std;

vector<int> getDivisors(int n) {
    vector<int> divisors;

    // 1. 1부터 루트 n까지만 탐색
    // i * i <= n 은 sqrt(n) 호출보다 정수 연산이
    for (int i = 1; i * i <= n; i++) {
        if (n % i == 0) {
            divisors.push_back(i); // 약수 i 추가

            // 2. 짝꿍 약수(n / i) 추가 (중복 방지)
            // i가 4이고 n이 16인 경우(제곱근일 때) 중복을 방지
            if (i != n / i) {
                divisors.push_back(n / i);
            }
        }
    }

    // 3. 약수를 오름차순으로 보고 싶다면 정렬 필수
    // 짝꿍 약수를 뒤에서부터 넣었기 때문에 배열이 정렬되어 있지 않습니다.
    sort(divisors.begin(), divisors.end());

    return divisors;
}


번외: 최대공약수구하기 (유클리드 호제법)

코딩 테스트의 약수 문제에서 재귀가 등장한다면, 단일 숫자의 모든 약수를 구하는 것이 아니라 두 수의 최대공약수를 구하는 상황일 확률이 있습니다.

단일 숫자의 약수를 나열할 때는 $O(\sqrt{N})$ for문 탐색이 표준이지만, 두 수의 공통된 가장 큰 약수(최대공약수)를 구할 때는 유클리드 호제법이라는 재귀 알고리즘을 사용합니다.

  • 핵심 원리: $A$를 $B$로 나눈 나머지를 $R$이라고 할 때, $A$와 $B$의 최대공약수는 $B$와 $R$의 최대공약수와 같다는 수학적 법칙을 이용합니다. 이를 나머지가 $0$이 될 때까지 재귀적으로 반복합니다.
  • 시간 복잡도: $O(\log(\min(A, B)))$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

using namespace std;

// 최대공약수(GCD)를 구하는 유클리드 호제법 (재귀 함수)
int getGCD(int a, int b) {
    // 나머지가 0이 되는 순간의 나누는 수(a)가 최대공약수입니다.
    if (b == 0) {
        return a;
    }
    // a를 b로 나눈 나머지를 다시 함수의 인자로 넘겨 재귀 호출합니다.
    return getGCD(b, a % b);
}

// (참고) 최소공배수(LCM) 구하는 공식
// 두 수의 곱을 최대공약수로 나누면 최소공배수가 됩니다.
int getLCM(int a, int b) {
    return (a * b) / getGCD(a, b);
}




This post is licensed under CC BY 4.0 by the author.