게임 로고 이미지

SOLID원칙은 객체 지향 프로그래밍에서 코드의 구조를 개선하기위한 다섯가지 원칙입니다. 이 원칙을 참고하지 않고 코드를 작성한다면 스파게티 코드가 될 확률이 매우 높습니다. 또한 생산성도 낮고, 관리도 어려운 코드가 만들어 질 수 있습니다. 게임 특성상 기존 콘텐츠를 수정하거나 변경해야 하는 경우가 많은데 이런 경우 이 원칙을 참고해서 코드를 작성하지 않으면 많은 어려움을 겪습니다. 예를 들어서 기존 퀘스트에는 없던 몬스터 처치 임무를 추가한다고 했을 때 몬스터 처치 로직이 한 곳이 아닌 여러 곳일 경우 어려움을 겪는 경우가 있습니다. 이러한 상황을 막기 위한 것이 이 원칙입니다.


[단일 책임 원칙(Single Responsibility Principle – SRP)]

각 클래스는 단 하나의 책임만 가져야 한다는 원칙입니다.

장점

  • 역할이 확실하기 때문에 코드의 가독성이 증가합니다.
  • 클래스와 클래스 간 커플링이 줄어듭니다.
  • 커플링이 줄어들면서 클래스가 변경 및 수정 될 경우 다른 클래스에게 주는 영향력이 감소하여 확장성이 증가하게 됩니다.
  • 단위 테스트가 가능하게 됩니다.

스킬 시스템 예시 코드

using UnityEngine;

public class Skill
{
    public string skillName;

    public Skill(string name)
    {
        skillName = name;
    }

    public void Activate()
    {
        Debug.Log($"{skillName} activated!");
        // 스킬 활성화에 관련된 로직 구현...
    }
}

public class SkillManager : MonoBehaviour
{
    public Skill[] skills;

    void Start()
    {
        // 스킬 초기화
        skills = new Skill[]
        {
            new Skill("Fireball"),
            new Skill("Healing Wave"),
        };
    }

    public void UseSkill(int skillIndex)
    {
        if (skillIndex >= 0 && skillIndex < skills.Length)
        {
            skills[skillIndex].Activate();
        }
        else
        {
            Debug.LogError("Invalid skill index!");
        }
    }
}

public class Player : MonoBehaviour
{
    public SkillManager skillManager;

    void Start()
    {
        skillManager = new SkillManager();
        skillManager.Start();
    }

    void Update()
    {
        // 특정 키 입력에 따라 스킬 사용
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            skillManager.UseSkill(0);
        }
        else if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            skillManager.UseSkill(1);
        }
    }
}

Skill 클래스는 하나의 스킬을 초기화하고 활성화하는 역할을 합니다. 그리고 SkillManager는 스킬 전체를 관리하는 클래스로 각 스킬에 접근하여 실제로 사용하는 역할을 합니다.

Player클래스는 SkillManager를 포함하여 입력에 따라 스킬을 처리하도록 구현돼 있습니다.

단일 책임 원칙의 관점으로 위 코드를 보면 각 클래스마다 역할이 분명하다는 것을 알 수 있습니다. Skill은 하나의 스킬을 관리하고 SkillManager는 여러 스킬을 관리합니다. 3개의 클래스가 합쳐져 있다고 생각하면 원칙을 지키는 것이 얼마나 중요한지 바로 깨닫게 되실 겁니다.

[개방 폐쇄 원칙 (Open/Closed Principle – OCP)]

모든 코드는 확장에는 열려 있어야 하며 변경에는 닫혀 있어야 하는 원칙입니다. 즉, 기존의 코드를 수정하지 않고 새로운 기능을 추가할 수 있어야 한다는 것 입니다.

장점

  • 기존 코드의 변경을 최소화 하기 때문에 변경에 유연한 대처가 가능합니다.
  • 코드의 안정성이 향상 됩니다.
    • 기존 코드를 변경하지 않는다는 것은 이미 검증된 코드를 건드릴 필요가 없다는 것이며 새로 추가된 코드의 검증만 마치면 된다는 뜻도 됩니다.
  • 모듈 간의 결합도 감소
    • 서로 간 간섭이 적기 때문에 모듈화가 가능해집니다. 이는 중복된 기능을 재사용해서 사용할 수 있다는 뜻이기도 합니다. 결과적으로 중복된 코드가 줄어드는 효과가 있습니다.
  • 코드 리팩토링이 쉬워집니다.
    • 리팩토링 이란 기존 코드를 다듬는 작업을 말합니다. 코드들 간의 결합성이 낮다 보니 특정 부분을 리팩토링 하는데 어려움이 없습니다.

적들의 공격 실행 예시 코드

using UnityEngine;

// 공격 인터페이스
public interface IAttack
{
    void Attack();
}

// 적의 기본 동작 클래스
public class Enemy : MonoBehaviour, IAttack
{
    public void Attack()
    {
        Debug.Log("적이 일반 공격을 합니다.");
    }
}

// 새로운 종류의 적 - 화염 적
public class FireEnemy : MonoBehaviour, IAttack
{
    public void Attack()
    {
        Debug.Log("화염 적이 불을 뿜습니다.");
    }
}

// 공격을 처리하는 매니저
public class AttackManager : MonoBehaviour
{
    public void ExecuteAttack(IAttack attacker)
    {
        attacker. Attack();
    }
}

public class GameManager : MonoBehaviour
{
    void Start()
    {
        AttackManager attackManager = new AttackManager();

        // 적 생성 및 공격 실행
        Enemy basicEnemy = new Enemy();
        attackManager.ExecuteAttack(basicEnemy);

        FireEnemy fireEnemy = new FireEnemy();
        attackManager.ExecuteAttack(fireEnemy);

        // 새로운 적이 추가되더라도 AttackManager의 변경이 필요 없음
    }
}

이 코드는 새로운 종류의 적이 추가 될 때마다 IAttack 인터페이스를 구현하면 됩니다. 즉, AttackManager를 수정하지 않고도 새로운 적의 공격을 추가할 수 있다는 뜻입니다. 핵심은 Interface를 사용하여 개방 폐쇄 원칙을 지킨 것 입니다.

[리스코프 치환 원칙 (Liskov Substitution Principle – LSP)]

상속관계에서 하위 클래스는 상위 클래스를 대체할 수 있어야 한다는 것 입니다.

장점

  • 코드의 가독성이 향상됩니다.
    • 만약 하위 클래스가 상위 클래스를 대체하지 못한다면 일관성이 없는 코드가 작성 되고 다른 사람이 봤을 경우 이해하기 어려운 코드가 됩니다.
  • 대체가 가능해 지면서 안정성과 확장성이 증가 합니다.
    • 상속을 통하여 새로운 무기를 추가 할 경우 모든 무기는 상위 클래스를 대체할 수 있기 때문 입니다. 이 부분은 글로만 봐서는 이해가 어려우니 바로 예시 코드를 작성 하겠습니다.

무기 클래스 예시 코드

using UnityEngine;

public class Weapon
{
    public virtual void Attack()
    {
        Debug.Log("기본 무기로 공격합니다.");
    }
}

public class FireWeapon : Weapon
{
    public override void Attack()
    {
        Debug.Log("화염 무기로 공격합니다.");
    }
}

public class Player
{
    private Weapon currentWeapon;

    public void ChangeWeapon(Weapon newWeapon)
    {
        currentWeapon = newWeapon;
    }

    // 플레이어의 공격 메서드
    public void PerformAttack()
    {
        if (currentWeapon != null)
        {
            currentWeapon.Attack();
        }
        else
        {
            Debug.LogWarning("무기가 설정되지 않았습니다.");
        }
    }
}

public class GameManager : MonoBehaviour
{
    void Start()
    {
        Player player = new Player();
        Weapon baseWeapon = new Weapon();
        FireWeapon fireWeapon = new FireWeapon();

        // 초기에는 기본 무기 사용
        player.ChangeWeapon(baseWeapon);
        player.PerformAttack();

        // 새로운 무기로 교체
        player.ChangeWeapon(fireWeapon);
        player.PerformAttack();
    }
}

Weapon의 하위 클래스인 FireWeapon는 Attack함수를 정의 함으로 대체가 가능해집니다. 이로써 무기를 언제든 교체할 수 있는 기능을 만들 수 있습니다.

[인터페이스 분리 원칙 (Interface Segregation Principle – ISP)]

사용하지 않는 인터페이스는 배제해야 한다는 것 입니다. 즉, 어떠한 기능을 사용할 때 사용하지 않는 기능에 의존하면 안된다는 뜻 입니다.

장점

  • 의존성을 줄여 클래스간 결합도가 감소합니다.
    • 결합도의 감소로 인해 재사용성과 유지보수성이 증가합니다.
  • 인터페이스의 명확성 증가
    • 필요한 것만 사용한다는 것은 인터페이스를 작은 단위로 나눈다는 뜻이므로 명확성이 향상 됩니다.

캐릭터의 동작 예시 코드

using UnityEngine;

public interface IAttackable
{
    void Attack();
}

public interface IMovable
{
    void Move();
}

// 플레이어 클래스
public class Player : MonoBehaviour, IAttackable, IMovable
{
    public void Attack()
    {
        Debug.Log("플레이어가 공격합니다.");
    }

    public void Move()
    {
        Debug.Log("플레이어가 이동합니다.");
    }
}

// 몬스터 클래스
public class Monster : MonoBehaviour, IAttackable
{
    public void Attack()
    {
        Debug.Log("몬스터가 공격합니다.");
    }
}

// 게임 메인 루프
public class GameManager : MonoBehaviour
{
    void Start()
    {
        // 플레이어와 몬스터 생성
        Player player = new Player();
        Monster monster = new Monster();

        // 플레이어와 몬스터에게 각자의 동작을 실행
        player.Attack();
        player.Move();

        monster.Attack();
    }
}

공격과 이동을 하는 기능을 각각 IAttackable, IMovable로 분리하여 필요한 동작만을 받아서 사용합니다.

만약 원칙을 지키지 않고 공격과 이동이 합쳐진 인터페이스를 정의할 경우 사용 하지 않는 기능을 의존하기에 가독성도 떨어지며 행위의 구분도 어려워 지게 될 것 입니다.

[의존 역전 원칙(Dependency Inversion Principle – DIP)]

높은 수준의 모듈이 저 수준의 모듈에 의존해서는 안되며, 두 모듈은 구체적인 구현이 아닌 추상화에 의존해야 한다는 원칙 입니다. 예를 들어서 높은 수준의 모듈인 게임 매니저가 플레이어 클래스에 의존해서는 안된다는 의미입니다.

장점

  • 추상화에 의존하기에 두 모듈간 결합도가 낮아집니다.
  • 테스트용 객체를 따로 만들 수 있기 때문에 테스트가 쉬워집니다.
    • 모든 객체는 추상화에 의존하기 때문에 추상화한 객체를 상속받아 테스트용을 만들면 되기 때문입니다.
  • 코드의 이식 성 증가
    • 하위 모듈에 의존하지 않기에 추상화된 클래스를 다른 프로젝트에 이식하면 새로운 하위 클래스를 쉽게 만들 수 있기 때문 입니다.

로깅 예시 코드

using UnityEngine;

// 로깅을 위한 인터페이스
public interface ILogger
{
    void Log(string message);
}

// 간단한 콘솔 로거
public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Debug.Log($"[Console] {message}");
    }
}

// 파일 로깅을 위한 클래스
public class FileLogger : ILogger
{
    public void Log(string message)
    {
        // 파일에 로그 작성하는 동작 구현...
        Debug.Log($"[File] {message}");
    }
}

// 게임에서 로깅을 사용하는 클래스
public class GameManager
{
    private readonly ILogger logger;

    // 생성자를 통해 로깅 모듈 주입
    public GameManager(ILogger logger)
    {
        this.logger = logger;
    }

    // 게임 이벤트에 따른 로깅 동작
    public void PlayerDied()
    {
        logger.Log("Player died.");
    }
}

// 게임 메인 루프
public class MainLoop : MonoBehaviour
{
    void Start()
    {
        // 콘솔 로깅 사용
        ILogger consoleLogger = new ConsoleLogger();
        GameManager gameWithConsoleLogger = new GameManager(consoleLogger);
        gameWithConsoleLogger.PlayerDied();

        // 파일 로깅 사용
        ILogger fileLogger = new FileLogger();
        GameManager gameWithFileLogger = new GameManager(fileLogger);
        gameWithFileLogger.PlayerDied();
    }
}

로깅이란 로그를 남기는 작업을 말합니다. GameManager는 구체적인 구현에 의존하지 않고 ILogger 인터페이스에 의존합니다. 이에 따라 ILogger를 구현한 어떤 클래스라도 GameManager에 주입하여 사용이 가능 합니다. 이런 식으로 의존성을 주입하여 GameManager가 로깅 시스템에 대해 알필요가 없어지며, 다양한 로깅 시스템으로 교체가 가능해 집니다.

[SOLID 원칙의 단점]

여기까지 읽으셨다면 단점은 전혀 없고 5가지 원칙 모두를 지킬 경우 완벽한 코드가 될 것이라고 생각하실 겁니다. 하지만 5가지를 모두 지키기가 어렵다는 단점이 존재 합니다. 게임 프로그래밍 특성 상 변수가 많기에 그만큼 예외 코드도 늘어나게 됩니다. 이때마다 모든 원칙을 지키려고 하면 생각하는 시간이 너무 길어져 생산성이 떨어지는 결과를 낳게 됩니다.

[SOLID의 단점을 보완하려면?]

핵심 클래스들은 최대한 5가지 원칙을 준수 하되 나머지 클래스는 상황에 따라 판단하는 것입니다. 예를 들어서 게임 매니저는 프로젝트의 핵심이므로 원칙들을 잘 지켜서 작성하게 되면 하위 클래스들이 조금씩 어긋나도 기본 클래스가 잘 설계돼 있으니 어느정도 견고한 코드가 만들어 집니다. 하지만 반대로 아무리 하위 클래스가 잘 설계 돼 있어도 상위 클래스가 견고하지 않다면 시간은 더 들고 결과는 더욱 안 좋아 집니다.

[마무리]

SOLID 원칙은 유명한 원칙이고 객체 지향 프로그래밍에 없어서는 안되는 것이지만 사고의 유연함을 갖지 않으면 독이 되기도 합니다. 처음 부터 모든 원칙을 지킨다는 강박 관념 보다는 제가 소개한 이론과 예시 코드를 보면서 하나씩 적용해 간다는 느낌으로 연습하시다 보면 몸에 익혀져 자연스럽게 코드에 녹아 들게 될 것 입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다