OHJINSU BLOG
  1. About
  2. Blog

© 2025 오진수. All rights reserved.
이 블로그는 Makernote로 만들어졌습니다.

profile image오진수 · 프로그래밍 · 

게임 개발 디자인 패턴: 분리와 역전


이미지 출처: ThisisEngineering on Unsplash


게임 개발 할 때 속도와 설계를 모두 잡을 수 있는 디자인 패턴을 소개하고자 합니다. 분리(Seperation)역전(Invert) 디자인 패턴은 사실 제가 짜깁기한 이름입니다. 분리는 관심사 분리(Seperation of Concerns)에서 따온 말이고, 역전은 제어 역전(Inversion of Control)에서 나온 말이죠. 소프트웨어를 다룬다면 익숙한 용어일 겁니다.


여기서 이야기하는 분리와 역전은 무엇이냐 하면. 즉, 기본적으로 main 함수에서 순차적으로 비즈니스 로직을 써내려 나가되, 코드가 지나치게 복잡해졌을 때 일부 코드를 추출해서 관심사를 분리하고, 의존 관계를 고려하여 제어를 역전시키는 개발 방법을 의미합니다.


이건 사실 고정적인 형식을 가진 디자인 패턴이 아니고 코드를 작성하는 과정 중에 지켜야 할 지침과도 같습니다.


main 함수에서 순차적으로 비즈니스 로직을 써내려 가야 하는 이유는 가장 핵심적인 기능만 빠르게 개발하기 위해서입니다. 처음부터 설계를 따지고 들어가면 실제로 사용하지도 않을 객체들 사이에 자질구레한 의존 관계까지 집착하기 십상입니다. 이렇게 낭비하는 시간에 더해서 어떤 설계란 흔히 interface 같은 보일러플레이트 코드를 필요로 하는 것이라서 실제 코드 작성 시간은 더 길어지고 맙니다.


단순한 프로그램이라면 코딩을 시작하는 위치는 main 함수겠지만 유니티라면 GameObject를 생성해 MonoBehaviour Script를 붙이고 거기서 시작하는 것이 일반적입니다. 분리와 역전 패턴은 기존 개발 방법과 그리 달라 보이지 않습니다. 차이점은 오직 필요가 생길 때까지 또 다른 MonoBehaviour를 추가하지 않는다는 점에 있습니다.


가끔은 유니티 같은 게임 엔진 자체가 객체 지향 프로그래밍을 권장하기 때문에 하나의 객체에서 제어 흐름을 관리하는 방법을 잊게 만드는 것 같습니다. 마치 React에서 useState를 지원하기 때문에 사용할 필요가 없을 때도 useState를 남발하는 습관처럼 말입니다.


유니티에서도 비즈니스 로직을 추가하기 위해서 다짜고짜 새로운 MonoBehaviour 스크립트를 생성하는 방법은 "기본 컨셉"으로 제시되곤 하지만 그리 바람직하지 않아 보입니다. 관심사에 따라 분리해서 사용하는 객체는 매우 유용하지만 적절한 경우에 적절한 이유를 가지고 사용해야 합니다. 자칫 잘못하면 프로젝트 규모가 커지면서 오히려 객체 사이 의존 관계가 꼬이기 쉽고 제어 흐름을 놓칠 위험이 커집니다.


가장 좋은 방법은 지나치게 머리를 쓰기보다 일단 손을 움직이면서 본능적으로 프로그래밍하는 방식이라고 생각합니다.


다음은 단순한 맵 생성 및 유닛 배치 코드를 작성하는 과정입니다.


public class GameManager : MonoBehaviour {
  void Start() {
    var json = System.IO.File.ReadAllText(...);

    var data = JsonConvert.DeserializeObject<GameData>(json);
  }
}


우선 데이터를 불러옵니다.


public class GameManager : MonoBehaviour {
  public GameObject tilePrefab;

  private List<GameObject> _tiles;

  void Start() {
    var json = System.IO.File.ReadAllText(...);

    var data = JsonConvert.DeserializeObject<GameData>(json);

    foreach (var tile in data.tiles) {
      var instance = instantiate(tilePrefab);
      
      instance.transform.position = tile.position;
  
      _tiles.Add(instance);
    }
  }
}


그리고 불러온 데이터를 바탕으로 타일 맵을 생성해 줍니다.


public class GameManager : MonoBehaviour {
  public GameObject tilePrefab;

  private List<GameObject> _tiles;

  public GameObject unitPrefab;

  private List<GameObject> _units;

  void Start() {
    var json = System.IO.File.ReadAllText(...);

    var data = JsonConvert.DeserializeObject<GameData>(json);

    foreach (var tile in data.tiles) {
      var instance = instantiate(tilePrefab);
      
      instance.transform.position = tile.position;
  
      _tiles.Add(instance);
    }

    foreach (var unit in data.units) {
      var instance = instantiate(unitPrefab);
      
      instance.transform.position = unit.position;
  
      _units.Add(instance);
    }
  }
}


다음으로는 타일을 생성한 것과 마찬가지로 유닛을 배치해 줍니다.


public class GameManager : MonoBehaviour {
  public GameObject tilePrefab;

  private List<GameObject> _tiles;

  public GameObject unitPrefab;

  private List<GameObject> _units;

  private GameObject _selectedUnit;

  void Start() {
    var json = System.IO.File.ReadAllText(...);

    var data = JsonConvert.DeserializeObject<GameData>(json);

    foreach (var tile in data.tiles) {
      var instance = instantiate(tilePrefab);
      
      instance.transform.position = tile.position;
  
      _tiles.Add(instance);
    }

    foreach (var unit in data.units) {
      var instance = instantiate(unitPrefab);
      
      instance.transform.position = unit.position;
  
      _units.Add(instance);
    }
  }

  void Update() {
      if (Input.GetMouseButtonDown(0))
      {
          Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
          
          RaycastHit hit;

          if (Physics.Raycast(ray, out hit))
          {
            foreach (var unit in units) {
              if (hit.collider != null && hit.collider.gameObject == unit.gameObject)
              {
                _selectedUnit = unit;

                break;
              }
            }
        }
      }
  }
}


너무 단순해 보여서 마우스 클릭으로 유닛을 선택하는 로직까지 넣었습니다.


일단 이렇게 순차적으로 쓰다 보면 어느샌가 GameManager 클래스 코드는 300줄이 넘어가기 시작합니다.


이럴 때 관심사의 분리를 해주는 것입니다.


public class GameManager : MonoBehaviour {
  private DataLoader _dataLoader = new DataLoader();

  public MapManager mapManager;

  public UnitManager unitManager;

  void Start() {
    var data = _dataLoader.Load();

    mapManager.AddTiles(data.tiles);

    unitManager.AddUnits(data.units);
  }

  void Update() {
      var pointedObject = PlayerController.RaycastFromMousePosition();

      unitManager.SelectUnit(pointedObject);

      Debug.Log(unitManager.SelectedUnit);
  }
}


여기서 중요한 점은 코드를 추출해서 만든 다른 클래스가 꼭 MonoBehaviour를 상속할 필요는 없다는 사실입니다. MonoBehaviour가 필요한 경우는 다음과 같습니다.


  1. MonoBehaviour가 제공하는 메서드(Instantiate, StartCoroutine) 등을 사용해야 하는 경우.

  2. 자체적으로 Update()OnDestroy() 같은 라이프사이클 이벤트를 구독할 필요가 있는 경우.

  3. SerializedField를 통해서 주입받아야 하는 의존성 객체를 가지고 있는 경우.


위의 예에서는 오직 MapManagerUnitManagerMonoBehaviour 메서드를 사용할 필요가 있었으므로 MonoBehaviour를 상속시키고 SerializedField를 통해 의존성을 주입받았습니다.


여기서 개발자는 GameManager에 있는 MapManager 의존성을 제거하고 싶은 유혹이 들 수 있습니다. GameManager.Start 함수에서 MapManager.AddTiles를 호출하는 것이 아니라, Monobehaviour를 상속한 MapManagerStart 함수에서 직접 데이터를 불러오고 스스로 타일 배치를 할 수도 있거든요. 뭔가 더 깔끔한 방식처럼 보입니다.


public class MapManager {
  private DataLoader _dataLoader = new DataLoader();

  void Start() {
    var data = _dataLoader.Load();

    AddTiles(data.tiles);
  }
}


충분히 가능한 방식이지만, 제어 흐름을 잃을 가능성을 염두에 두지 않으면 안됩니다. GameManagerMapManager.AddTiles를 호출할 때는 순차적으로 데이터를 불러오고 타일을 생성하고 그 다음 유닛을 생성할 수 있었지만, 지금은 어떤 코드가 먼저 실행될지 알 수 없게 되었습니다. 오히려 이 순서를 맞추어 주려면 다른 코드가 더 필요하죠.

댓글 0

프로그래밍 카테고리 다른 글