제네릭, 오버로딩, 오브젝트 풀, 설계 & 객체지향 원칙, SOLID 원칙

이준호·2024년 1월 3일
0

📌 제네릭

제네릭은 마치 마법상자처럼 다양한 타입을 담을 수 있는 기능이다. 제네릭을 사용하면 코드의 재사용성과 유연성을 높일 수 있다. 타입에 대한 제한이 없이 여러 종류의 데이터를 한꺼번에 처리할 수 있어 개발을 더욱 효율적으로 만들어준다. 이러한 제네릭은 마치 마법처럼 다양한 상황에서 사용할 수 있는 강력한 도구이다.

제네릭은 데이터 타입을 미리 지정하지 않고 다양한 타입을 지원하는 코드를 작성할 수 있게 해준다. 제너릭을 사용하면 코드의 재사용성과 유지 보수성을 향상시킬 수 있다.

예를 들어, List<T>클래스는 제너릭을 사용하여 다양한 데이터 타입의 리스트를 생성할 수 있다. T는 사용자가 지정한 타입에 따라서 다양한 형태의 리스트를 만들 수 있다. 아래는 제너릭을 사용한 List<T>클래스의 예시이다.

List<int> numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);

List<string> names = new List<string>();
names.Add("John");
names.Add("Jane");
names.Add("Mike");

위의 예시에서 List<int>는 int의 리스트를, List<string>은 string의 리스트를 생성한다.
제네릭을 사용하면 동일한 코드를 다양한 데이터 타입에 대해 재사용할 수 있다.

제너릭을 사용하여 사용자 정의 클래스나 메소드를 작성할 수도 있다.
아래는 제너릭을 사용한 메소드의 예시이다.

public T GetMax<T>(T a, T b)
{
    if (a.CompareTo(b) > 0)
    {
        return a;
    }
    else
    {
        return b;
    }
}

int maxInt = GetMax<int>(3, 5);
string maxString = GetMax<string>("apple", "banana");

위의 예시에서 GetMax<T>메소드는 제너릭으로 작성되어 있다. 사용자가 지정한 데이터 타입에 따라서 최댓값을 반환하는 메소드이다. GetMax<int>는 정수형 데이터에 대한 최댓값을, GetMax<string>은 문자열 데이터에 대한 최댓값을 반환한다.

제네릭의 제한

하지만, 사실 대부분의 경우에서는 아무거나 막 넣을 수 있도록 허락하지 않는다. 더하기를 한다고 하는데 클래스를 넣어버리고 이러면 곤란할거다. 그래서 제너릭에서는 제한을 걸 수 있도록 한다.
제너릭에서 제한을 거는 부분은 where 절을 사용하여 구현할 수 있다. where 절을 사용하면 제네릭 타입 매개변수에 대한 제약 조건을 지정할 수 있다.

예를 들어, TMonster 클래스를 상속받은 클래스여야 한다는 제약 조건을 걸고 싶다면 다음과 같이 작성할 수 있다

public class Example<T> where T : Monster
{
    // ...
}

위의 예시에서 Example<T> 클래스는 TMonster클래스 또는 Monster클래스를 상속받은 클래스여야 한다. 이렇게 하면 TMonster클래스로 제한되며, Monster클래스의 멤버에 접근할 수 있다.












📌 오버로딩

C#에서 오버로딩은 같은 이름의 메소드를 여러 개 정의하는 것을 의미한다. 오버로딩을 사용하면 동일한 기능을 수행하는 메소드를 다양한 매개변수 조합으로 호출할 수 있다.

아래는 C#에서 오버로딩을 사용한 예시이다.

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }

    public double Add(double a, double b)
    {
        return a + b;
    }

    public int Add(int a, int b, int c)
    {
        return a + b + c;
    }
}

// 사용 예시
Calculator calculator = new Calculator();
int sum1 = calculator.Add(2, 3); // int Add(int a, int b) 메소드 호출
double sum2 = calculator.Add(2.5, 3.7); // double Add(double a, double b) 메소드 호출
int sum3 = calculator.Add(2, 3, 4); // int Add(int a, int b, int c) 메소드 호출

// 생성자 오버로딩
new Student(); // 기본생성자
new Student("송지원"); // 이름 등에 세팅을 해줄 수 있는 생성자

Random.Range(0, 5) : 0, 1, 2, 3, 4 int Random(int minValue, int maxValue)
Random.Range(0f, 5f) : 0.1, 2.4 3.9 4.99 float Random(float minValue, float maxValue)

위의 예시에서는 Calculator 클래스에서 Add 메소드를 오버로딩하여 다양한 매개변수 조합으로 호출할 수 있다. 첫 번째 Add 메소드는 int 타입의 매개변수 두 개를 받아 더한 결과를 반환하고, 두 번째 Add 메소드는 double 타입의 매개변수 두 개를 받아 더한 결과를 반환한다. 마지막으로 세 번째 Add 메소드는 int 타입의 매개변수 세 개를 받아 더한 결과를 반환한다.

오버로딩을 사용하면 매개변수의 타입, 개수, 순서를 다양하게 조합하여 메소드를 호출할 수 있어 코드의 가독성과 편의성을 높일 수 있다.

오버라이드(virtual - override) 와 오버로딩을 혼동해서는 안된다.












📌 오브젝트 풀

프리팹 ➔ 인스턴스화하고 꺼놓음 ➔ 오브젝트풀 안 ➔ 활성화➔ 실제 게임오브젝트

오브젝트 풀은 미리 생성된 게임 오브젝트들을 관리하는 방식으로,
게임 실행 중에 오브젝트를 동적으로 생성하고 삭제하는 것보다 효율적이다.

오브젝트 풀 활용 이유

  • 성능 향상 : 오브젝트 생성 및 삭제는 비용(메모리 할당 / 해제 및 이로 인한 GC)이 많이 들기 때문에, 미리 생성된 오브젝트를 재활용하여 성능을 향상시킨다.

  • 메모리 관리 : 오브젝트 풀을 사용하면 게임 실행 중에 오브젝트를 반복적으로 생성하고 삭제하는 것보다 메모리를 효율적으로 관리할 수 있다.

오브젝트 풀의 활용 사례

  • 총알 : 총알은 자주 생성되고 삭제되는 게임 오브젝트 중 하나이다. 총알을 매번 생성하고 삭제하는 대신, 총알 오브젝트 풀을 사용하여 재활용할 수 있다.

  • 적 캐릭터 : 적 캐릭터는 게임에서 자주 등장하는 오브젝트이다. 많은 수의 적 캐릭터를 생성하고 제거하는 대신, 적 캐릭터 오브젝트 풀을 사용하여 효율적으로 관리할 수 있다.

오브젝트 풀은 게임 개발에서 유용한 도구로서, 성능 향상과 메모리 관리를 개선하고 일관성을 유지하는 데 도움이 된다.

미리 생성해두는 오브젝트 풀 (국룰)

밑의 코드는 Prewarmed 오브젝트 풀을 구현한 예시이다.

public class ObjectPool : MonoBehaviour
{
    public GameObject prefab;
    public int poolSize;

    private List<GameObject> objectPool;

    private void Start()
    {
        objectPool = new List<GameObject>();

        for (int i = 0; i < poolSize; i++)
        {
            GameObject obj = Instantiate(prefab, transform);
            obj.SetActive(false);
            objectPool.Add(obj);
        }
    }

    public GameObject GetObjectFromPool()
    {
        foreach (GameObject obj in objectPool)
        {
            if (!obj.activeInHierarchy)
            {
                obj.SetActive(true);
                return obj;
            }
        }

        return null;
    }
}

Prefab 변수에 생성할 오브젝트의 프리팹을 지정하고, spoolSize 변수에 풀을 생성할 오브젝트의 개수를 설정한다. Start() 함수에서는 지정한 개수만큼 오브젝트를 생성하고 비활성화 상태로 풀에 추가한다.
GetObjectFromPool() 함수는 풀에서 비활성화된 오브젝트를 찾아 활성화 후 반환한다.

제한이 있는 오브젝트 풀

다음은 파티클이나 이펙트와 같이 제한된 오브젝트 풀에서 가장 오래된 게임 오브젝트를 비활성화하고 새로운 오브젝트를 활성화하는 방식을 구현한 예시이다.

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    public GameObject prefab;
    public int maxPoolSize;

    private Queue<GameObject> objectPool;

    private void Start()
    {
        objectPool = new Queue<GameObject>();
    }

    public GameObject GetObjectFromPool()
    {
        GameObject obj;

        if (objectPool.Count < maxPoolSize)
        {
            obj = Instantiate(prefab, transform);
            objectPool.Enqueue(obj);
        }
        else
        {
            obj = objectPool.Dequeue();
            obj.SetActive(false);
            obj.transform.position = transform.position; // 위치 재설정이 필요할 경우
            obj.SetActive(true);
            objectPool.Enqueue(obj);
        }

        return obj;
    }
}

이 코드는 오브젝트 풀이 maxPoolSize에 도달했을 때, 큐에서 가장 먼저 들어간 오브젝트 (즉, 가장 오래전에 생성된 오브젝트)를 재활용한다. Queue 구조를 사용하여 가장 먼저 들어간 오브젝트를 쉽게 관리할 수 있다. 오브젝트가 필요할 때마다 큐에서 꺼내 재사용하고, 다시 큐의 끝에 추가한다.

동적 오브젝트 풀 (국룰)

밑의 코드는 동적 오브젝트 풀을 구현한 예시이다.

public class ObjectPool : MonoBehaviour
{
    public GameObject prefab;
    public int initialPoolSize; // 100
    public int maxPoolSize; // 10000

    private List<GameObject> objectPool;

    private void Start()
    {
        objectPool = new List<GameObject>();

        for (int i = 0; i < initialPoolSize; i++)
        {
            GameObject obj = Instantiate(prefab, transform);
            obj.SetActive(false);
            objectPool.Add(obj);
        }
    }

    public GameObject GetObjectFromPool()
    {
        foreach (GameObject obj in objectPool)
        {
            if (!obj.activeInHierarchy)
            {
                obj.SetActive(true);
                return obj;
            }
        }

        if (objectPool.Count < maxPoolSize)
        {
            GameObject obj = Instantiate(prefab, transform);
            objectPool.Add(obj); 
            obj.SetActive(true);
            return obj;
        }
        else
        {
            // 풀에 더 이상 생성할 수 없음
            return null;
        }
    }
}

위의 코드는 동적 오브젝트 풀을 구현한 예시이다. prefab 변수에 생성할 오브젝트의 프리팹을 지정하고, InitiaPoolSize 변수에 초기에 생성할 오브젝트의 개수를, maxPoolSize 변수에 풀에 생성할 오브젝트의 최대 개수를 설정한다. Start() 함수에서는 초기 개수만큼 오브젝트를 생성하고 비활성화 상태로 풀에 추가한다. GetObjectFromPool() 함수는 풀에서 비활성화된 오브젝트를 찾아 활성화 후 반환하며, 풀에 추가할 여유 공간이 없을 경우에는 추가적으로 오브젝트를 생성하여 반환한다.

유니티 내장 ObjectPool

유니티 2021이후 버전에서는 유니티 내장 ObjectPool이 제공되며, 생성자가 다소 험악하게 생겼지만, 여태 배운 것을 총 복습하는 느낌으로 진행할 수 있다.

using UnityEngine;
using UnityEngine.Pool;

public class BulletPoolExample : MonoBehaviour
{
    public GameObject bulletPrefab;

    private ObjectPool<GameObject> bulletPool;

    private void Start()
    {
        bulletPool = new ObjectPool<GameObject>(
            createFunc: () => Instantiate(bulletPrefab), // 오브젝트 생성 방법
            actionOnGet: (obj) => obj.SetActive(true),   // 오브젝트를 풀에서 가져올 때 실행할 액션
            actionOnRelease: (obj) => obj.SetActive(false), // 오브젝트를 풀에 반환할 때 실행할 액션
            actionOnDestroy: (obj) => Destroy(obj),     // 오브젝트를 파괴할 때 실행할 액션
            defaultCapacity: 10,                        // 초기 용량
            maxSize: 20                                 // 최대 용량
        );
    }

    public GameObject GetBullet()
    {
        return bulletPool.Get();
    }

    public void ReturnBullet(GameObject bullet)
    {
        bulletPool.Release(bullet);
    }
}

오브젝트풀은 무조건 해야하는게 절대 아니다

  • 오브젝트의 생성과 파괴가 잦다.

  • 오브젝트를 많이 생성해야 한다 (최소 30)

  • 오브젝트의 생성관리를 체계적으로 하고싶다.












📌 소프트웨어 설계와 객체지향 원칙

게임은 폭포수식 개발이 정말 어렵다.

변경이 없는 게임을 만드는 것은 불가능하다(엎는 것이 일상).
앱/웹도 변경이 없게 만드는 것은 어렵지만, 게임은 특히 더 어렵다.

재미에 대한 이렇다 할 공식이 없기 때문이다. (아래의 장표도는 재미의 4요소)


아곤, 미미크리, 일링크스, 알레아는 놀이의 4요소로, 게임에서의 재미를 분석하는 데 많이 활용한다.

그렇기 때문에 '설계'에 대한 중요성이 게임개발에선 커진다.
왜냐하면 설계라는 것은 결국 변경에 대해 유연해지는 것이디 때문이다.

좋은 소프트웨어 설계는?

아래와 같은 주장이 있다고 생각해보자.

좋은 디자인이란 변경을 할 때 마치 프로그램 전체가 그 변경을 예상하고 만들어진 것처럼 느껴지는 것을 의미한다. 코드의 조금의 파장도 남기지 않고 완벽하게 들어맞는 몇 가지 함수 호출만으로 작업을 해결할 수 있다.

매력적으로 들리지만 사실 거의 실현가능하지 않다.
하지만, 그래도 아래를 목표로 잡아볼 수 있다.

변경 사항이 기존 코드들을 방해하지 않도록 코드를 작성한다.

응집도와 결합도

높은 응집도(Cohesion)와 낮은 결합도(Coupling)은 좋은 소프트웨어의 특징이다.
응집도는 모듈 내부에 존재하는 구성 요소들 사이의 밀접한 정도를 나타내며,
결합도는 모듈과 모듈 사이의 관계에서 관련 정도를 나타낸다.

스프트웨어 설계 원칙 - SOLID 원칙

SOLID 원칙은 객체 지향 프로그래밍 설계 원칙으로, 유지 보수 가능하고 확장 가능한 소프트웨어를 만들기 위한 원칙이다.

S : 단일 책임 원칙(Single Responsibility Principle) :

하나의 클래스는 하나의 책임만 가져야 한다. 각 클래스는 한 가지 목적을 위해 존재하고, 변경의 이유는 하나여야 한다.

예시 1

유니티의 컴포넌트들의 사례

  • Transform : sacle, rotation, position을 저장하고 이와 관련한 처리를 수행하는 클래스

  • Rigidbody : 물리 시뮬레이션을 위해 사용되는 클래스

  • MeshFilter : 3D 모델의 참조를 위해 존재하는 클래스

예시 2

public class UnrefactoredPlayer : MonoBehaviour
{
 [SerializeField] private string inputAxisName;
 [SerializeField] private float positionMultiplier;
 private float yPosition;
 private AudioSource bounceSfx;
 private void Start()
 {
	 bounceSfx = GetComponent<AudioSource>();
 }
 private void Update()
 {
	 float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
	 yPosition = Mathf.Clamp(yPosition + delta, -1, 1);
	 transform.position = new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
 }
 private void OnTriggerEnter(Collider other)
 {
	 bounceSfx.Play();
 }
}

// RequireComponent == 이 컴포넌트는 아래 세개의 컴포넌트들을 무조건 필수로 가지고 있어야 합니다.
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), 
typeof(PlayerMovement))] // 대괄호를 애트리뷰트(attribute)라고 함
public class Player : MonoBehaviour
{
 [SerializeField] private PlayerAudio playerAudio;
 [SerializeField] private PlayerInput playerInput;
 [SerializeField] private PlayerMovement playerMovement;
 private void Start()
 {
 playerAudio = GetComponent<PlayerAudio>();
 playerInput = GetComponent<PlayerInput>();
 playerMovement = GetComponent<PlayerMovement>();
 }
}
public class PlayerAudio : MonoBehaviour
{}
public class PlayerInput : MonoBehaviour
{}
public class PlayerMovement : MonoBehaviour
{}

중용의 미학
단일 책임 원칙을 수행하기 위해서 클래스의 구조를 과도하게 정리하는 것은 위험하다.
유니티에서는 세 가지 기준에 의해 단일책임원칙을 적용하라고 제안한다.
기준 : 가독성 / 확장성 / 재사용성


우리가 코드를 객체지향적으로 리팩토링 하는 이유가 무엇일까? 개발자가 편하기 위해서.






O : 개방 - 폐쇄 원칙 (Open - Closed Principle)

확장에는 열려 있고, 수정에는 닫혀 있어야 한다. 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 설계되어야 한다
[추상클래스 혹은 인터페이스를 사용하면 쉽다]

예시 1)

public class AreaCalculator 
{
 public float GetRectangleArea(Rectangle rectangle)
 {
 return rectangle.width * rectangle.height;
 }
 public float GetCircleArea(Circle circle)
 {
 return circle.radius * circle.radius * Mathf.PI;
 }
}
public class Rectangle
{
 public float width;
 public float height;
}
public class Circle
{
 public float radius;
}

public abstract class Shape
{
 public abstract float CalculateArea();
}

public class Rectangle : Shape
{
 public float width;
 public float height;
 public override float CalculateArea()
 {
   return width * height;
 }
}
public class Circle : Shape
{
 public float radius;
 public override float CalculateArea()
 {
   return radius * radius * Mathf.PI; 
 }
}

public class AreaCalculator 
{
 public float GetArea(Shape shape)
 {
   return shape.CalculateArea();
 }
}

예시 2)

public interface IEnemy
{
    void Attack();
}

public class Zombie : IEnemy
{
    public void Attack()
    {
        // 좀비의 공격 로직
    }
}

public class Robot : IEnemy
{
    public void Attack()
    {
        // 로봇의 공격 로직
    }
}

public class Dinosaur : IEnemy
{
	
}

public class EnemyManager
{
    private List<IEnemy> enemies = new List<IEnemy>();

    public void AddEnemy(IEnemy enemy)
    {
        enemies.Add(enemy);
    }

    public void AllEnemiesAttack()
    {
        foreach(var enemy in enemies)
        {
            enemy.Attack();
        }
    }
}





L : 리스코프 치환 원칙 (Liskov Substiution Principle)

하위 클래스는 상위 크래스의 기능을 완전히 대체할 수 있어야 한다. 이는 상속 관계에서 어떤 클래스든지 자신의 부모 클래스로 취급될 수 있어야 함을 의미한다.

예시

public class Vehicle
{
 public float speed = 100;
 public Vector3 direction;
 public void GoForward()
 {
 ...
 }
 public void Reverse()
 {
 ...
 }
 public void TurnRight()
 {
 ...
 }
 public void TurnLeft()
 {
 ...
 }
}

리스코프 치환 원칙을 어기는 사례

  • 자식 클래스를 만들면서 피처를 제거하는 경우

  • 자식 클래스의 일부를 예외로 처리하는 경우

  • 구현은 있으나 구현이 공백인 경우

public class Penguin : Bird{
	public override Fly(){
				// (1) Do nothing
				// (2) Exception 금지
	}
}

리스코프 치환 원칙 지키는 방법

  • 추상화 간단하게 유지

  • 하위 클래스에서 public 멤버 추가 지양

  • 현실의 분류에 몰두 X

  • 컴포지션 적극적 활용






I : 인터페이스 분리 원칙 (Interface Segregation Principle)

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다. 여러 개의 인터페이스로 분리함으로써 클라이언트는 필요한 기능에만 의존할 수 있다.

예시

public interface IUnitStats // 나쁜 예시
{
    public float Health { get; set; }
    public int Defense { get; set; }
    public void Die();
    public void TakeDamage();
    public void RestoreHealth();
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }
    public void GoForward();
    public void Reverse();
    public void TurnLeft();
    public void TurnRight();
    public int Strength { get; set; }
    public int Dexterity { get; set; }
    public int Endurance { get; set; }
}

public interface IMovable // 좋은 예시
{
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }
    public void GoForward();
    public void Reverse();
    public void TurnLeft();
    public void TurnRight();
}
public interface IDamageable
{
    public float Health { get; set; }
    public int Defense { get; set; }
    public void Die();
    public void TakeDamage();
    public void RestoreHealth();
}
public interface IUnitStats
{
    public int Strength { get; set; }
    public int Dexterity { get; set; }
    public int Endurance { get; set; }
}





D : 의존 역전 원칙 (Dependency Inversion Principle)

의존 관계는 추상화에 의존해야 하며, 구체화는 의존해서는 안 된다. 즉, 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 되며, 추상화에 의존해야 한다.

예시

public class Switch : MonoBehaviour
{
    public Door door;
    public bool isActivated;
    public void Toggle()
    {
        if (isActivated)
        {
            isActivated = false;
            door.Close();
        }
        else
        {
            isActivated = true;
            door.Open();
        }
    }
}
public class Door : MonoBehaviour
{
    public void Open()
    {
        Debug.Log("The door is open.");
    }
    public void Close()
    {
        Debug.Log("The door is closed.");
    }
}

public class Switch : MonoBehaviour
{
    public ISwitchable client;
    public void Toggle()
    {
        if (client.IsActive)
        {
            client.Deactivate();
        }
        else
        {
            client.Activate();
        }
    }
}

public interface ISwitchable
{
    public bool IsActive { get; }
    public void Activate();
    public void Deactivate();
}

public class Door : MonoBehaviour, ISwitchable
{
    private bool isActive;
    public bool IsActive => isActive;
    public void Activate()
    {
        isActive = true;
        Debug.Log("The door is open.");
    }
    public void Deactivate()
    {
        isActive = false;
        Debug.Log("The door is closed.");
    }
}
profile
No Easy Day

0개의 댓글