[25.04.17] TIL( 개인 프로젝트 완성 및 SOLID 원칙의 D )

설민우·2025년 4월 17일

내일배움캠프 - Unity

목록 보기
24/85

도전 기능 구현 완료

TextRPG의 모든 도전 내용까지 구현을 완료하였고, 최종적으로 Main 브렌치에 병합을 완료하였습니다.
또한 기존의 프로젝트 구성에서 조금 변경점이 있었습니다

구현 및 변경 내역

1. 기존 State 방식의 게임루프에서 Handler를 루프하는 방식으로 변경

interface IGameStateHandler
{
    void Handle(GameLoop context); // 또는 필요한 매개변수를 더 넣어도 됨
}
class GameLoop
{
    // 플레이어 변수
    public  Player myPlayer;
    //상태 변수
    GameState state = GameState.None;
    int waitTime = 100;

    //게임 매니져
    public Shop shop = new Shop();
    public Inventory inventory = new Inventory();
    public Database database = new Database();
    public SaveManager saveManager = new SaveManager();

    //던전관련 변수
    public DungeonResultData dungeonResultData { get; set; } = new DungeonResultData();
    static bool? isRestore = null;

    public void Run()
    {
        Init();
        InitializeStateHandlers();

        while (true)
        {
            if (stateHandlers.TryGetValue(state, out var handler))
            {
                handler.Handle(this);
            }
            else
            {
                Thread.Sleep(waitTime);
            }
        }
    }

    private Dictionary<GameState, IGameStateHandler> stateHandlers;

    private void InitializeStateHandlers()
    {
        stateHandlers = new Dictionary<GameState, IGameStateHandler>
    {
    { GameState.SetChar, new SetCharHandler() },
    { GameState.Town, new TownStateHandler() },
    { GameState.CheckStat, new CheckStatHandler() },
    { GameState.Inventory, new ShowInventoryHandler() },
    { GameState.Equip, new EquipInventoryHandler() },
    { GameState.Shop, new ShowShopHandler() },
    { GameState.Buy, new BuyShopHandler() },
    { GameState.Sell, new SellShopHandler() },
    { GameState.Dungeon, new ShowDungeonHandler() },
    { GameState.DungeonResult, new DungeonResultHandler() },
    { GameState.Restore, new GoRestoreHandler() },

    };
    }
}
  • 인터페이스로 핸들러를 구현해두고 GameManager에서 Run() 작동시키면 사전에 초기화 해둔 Handler를 돌면서 State에 맞는 Handler를 작동시키도록 했습니다
  • 과거의 State를 switch로 구분하는 것 보다 간결한 표현이 가능하고, 추가도 딕셔너리에 Hnadler만 추가해주면 됩니다.
  • 외부로부터 데이터가 필요하다면 자신을 호출한 GameLoop의 데이터를 참조하여 해결합니다.

2. 저장 시스템 구현

public interface ISave<T>
{
    void Save(T data);
    T Load();
}
    public class ItemSaveData
    {
        public int Id { get; set; }
        public bool IsEquipped { get; set; }
    }

    public class InventorySave : ISave<Dictionary<int, Item>>
    {
        private const string SavePath = "inventory_save.json";

        public void Save(Dictionary<int, Item> inventory)
        {
            var dto = ConvertToDTO(inventory);
            var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(SavePath, json);
        }

        public Dictionary<int, Item> Load()
        {
            if (!File.Exists(SavePath)) return null;

            var json = File.ReadAllText(SavePath);
            var dto = JsonSerializer.Deserialize<InventorySaveData>(json);
            return ConvertToInventory(dto);
        }

        private InventorySaveData ConvertToDTO(Dictionary<int, Item> inventory)
        {
            var dto = new InventorySaveData();

            foreach (var value in inventory)
            {
                dto.Items.Add(new ItemSaveData
                {
                    Id = value.Key,
                    IsEquipped = value.Value._isEquip
                });
            }

            return dto;
        }

        private Dictionary<int, Item> ConvertToInventory(InventorySaveData data)
        {
            // 1) 기존 인벤토리/장착 컬렉션 초기화
            var invenDict = new Dictionary<int, Item>();
            GameManager.gameLoop.inventory.GetEquipDict().Clear();

            // 2) 각 DTO로부터 Item 인스턴스 생성
            foreach (var itemDto in data.Items)
            {
                var item = Item.Create(itemDto.Id);
                if (item == null)
                    continue;

                invenDict[itemDto.Id] = item;
                GameManager.gameLoop.shop.SetItemBuy(item._id);

                if (itemDto.IsEquipped)
                {
                    item.TogleEquipState();
                    GameManager.gameLoop.inventory.GetEquipDict()[item._itemType] = item;
                    GameManager.gameLoop.inventory.ActiveItemEffect(item);
                }
            }
            return invenDict;
        }
    }
  • 저장 할 수 있음을 나타내는 인터페이스를 추가하였습니다
  • 이를 기반으로 Json 파일을 읽어와 데이터를 저장 / 로드 합니다
  • 저장의 경우는 ConvertToDTO() 함수를 통해 딕셔너리 값을 Data Transfer Object(데이터 전송 객체)로 변환 합니다.
  • 로드의 경우는 ConvertToInventory() 함수를 통해 dto 값을 딕셔너리로 변환 합니다.

3. Main 과 GameLoop 분리

  • 기존에 Main 함수에 모두 연결하여 Static 함수 및 변수가 연발되던것을 수정 했습니다
  • Main함수에서 GameLoop를 가지고 있도록 변경 했습니다

4. 싱글턴 패턴 사용 해제

  • 기존 대부분의 참조를 싱글턴 패턴을 이용하던것을 GameManager 단일로 참조하도록 변경 했습니다.

< 오늘의 문시해알 >

발생했던 문제들

기존에 작성했던 코드들이 대부분 Main 함수에 직접 연결해 있어서 모두 Static 으로 선언된 문제가 있었고, 다른 한편으로는 많은 Manager들이 싱글턴으로 인스턴스 생성된 문제가 있었습니다.

  • 위와 같은 구조는 의존 관계가 좋지 못하고 캡슐화가 잘 되어있지 않다고 판단했습니다
  • 이미 구조단에서 제작을 잘못하여 완전히 뒤집어 엎는 과정은 어려워 보였지만 당장에 눈에 많이 거슬리는 부분들만 제거해 보고 싶었습니다.

해결 방법

    internal class GameManager
    {
       public static GameLoop gameLoop;
        static void Main(string[] args)
        {
            gameLoop = new GameLoop();
            gameLoop.Run();
        }
    }
  • 먼저 위의 코드처럼 GameManger(Main 함수) 와 GameLoop를 분리했습니다
  • 그 다음 GameLoop를 모두 GameManager 스크립트에 두는 것이 아니라 StateHandler 인터페이스를 구현하여 적합한 위치(Shop, Inventory, Dungeon)의 스크립트로 이동시켜 주었습니다.
  • 그 후 Static -> private로 변경해 주었습니다.
  • 마지막으로 개별적으로 싱글턴을 만들었던 부분들을 GameLoop 에서 Public으로 가지고 있고, 이를 GameManager를 통해 참조하도록 변경하였습니다.

알게된 것

SOLID 원칙을 잘 지키면서 개발을 진행해보고자 했으나, 실상 잘 되지 않았습니다. 특히나 의존 관계를 설정하는 부분이 아직 미숙하여 더 노력이 필요합니다. 아래의 나용은 SOLID 원칙에서의 의존 관계 역전의 원칙에 대한 설명입니다.


의존관계 역전 원칙 (DIP: Dependency Inversion Principle)

개요

의존관계 역전 원칙(Dependency Inversion Principle)은 객체 지향 설계의 SOLID 원칙 중 하나로, 상위 모듈이 하위 모듈에 의존하지 않도록 설계를 유도하는 원칙입니다.

고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항이 추상화에 의존해야 한다.

핵심 개념

  • 고수준 모듈: 애플리케이션의 정책이나 비즈니스 로직을 담고 있는 상위 계층
  • 저수준 모듈: 구체적인 구현(데이터베이스, 파일, 네트워크 등)을 담당하는 하위 계층
  • 문제점: 고수준 모듈이 저수준 모듈에 직접 의존하면 변경에 매우 취약해진다.
  • 해결책: 인터페이스(추상화)를 도입하여 고수준 모듈과 저수준 모듈 모두 이 추상화에 의존하게 한다.

예시 (C#)

잘못된 설계

public class FileLogger {
    public void Log(string message) {
        // 파일에 로그 기록
    }
}

public class OrderService {
    private FileLogger logger = new FileLogger();

    public void PlaceOrder() {
        // 주문 처리 로직
        logger.Log("주문이 처리되었습니다.");
    }
}
  • OrderService가 FileLogger라는 구체적인 클래스에 직접 의존 → DIP 위반

DIP를 따른 설계

public interface ILogger {
    void Log(string message);
}

public class FileLogger : ILogger {
    public void Log(string message) {
        // 파일에 로그 기록
    }
}

public class OrderService {
    private readonly ILogger logger;

    public OrderService(ILogger logger) {
        this.logger = logger;
    }

    public void PlaceOrder() {
        // 주문 처리 로직
        logger.Log("주문이 처리되었습니다.");
    }
}

public class ConsoleLogger : ILogger {
    public void Log(string message) {
        Console.WriteLine(message);
    }
}

// 사용하는 코드
var logger = new ConsoleLogger();
var orderService = new OrderService(logger);
orderService.PlaceOrder();
  • OrderService는 ILogger라는 추상화에만 의존
  • FileLogger는 ILogger의 구현체
  • 덕분에 FileLogger → ConsoleLogger 등으로 유연하게 교체 가능

장점

  • 유연성: 구현 변경 시 고수준 모듈 수정이 불필요
  • 테스트 용이성: 테스트용 가짜 객체(Mock, Stub)를 주입하여 테스트 가능
  • 유지보수성 향상: 각 모듈이 추상화에 의존하므로 결합도가 낮아짐

결론

< 최종적으로 이해한 과정 >

1. 인터페이스부터 정의한다.

→ 어떤 동작(기능)을 추상화해서 이름만 정해두는 역할.

2. 그 인터페이스를 기반으로 실제 클래스를 구현한다.

→ 예: FileLogger, ConsoleLogger 등

3. 고수준 모듈(비즈니스 로직을 수행하는 클래스)은 인터페이스에만 의존하게 만든다.

→ 직접 구현체(FileLogger)에 의존하지 않는다.

4. 나중에 구현체를 갈아끼우기 쉽게 만든다.

→ 코드 수정 없이도 기능을 교체하거나 테스트용 가짜 객체를 넣을 수 있다.

의존관계 역전 원칙은 소프트웨어의 변경에 강한 구조를 만들기 위해 매우 중요한 설계 원칙입니다. 추상화에 의존하도록 설계함으로써, 모듈 간의 결합도를 낮추고, 확장성과 테스트 용이성을 높일 수 있습니다.

profile
클라이언트 개발자를 지망하고 있습니다.

0개의 댓글