디자인패턴을 공부할 때 항상 SOLID 원칙은 중요하게 다뤄지며, 이를 활용하면 코드의 유지보수성과 가독성이 크게 향상된다.
SOLID는 다섯가지 원칙의 영문명 앞글자를 딴 것이다.
Single responsibility 단일 책임 원칙
Open closed 개방 폐쇄 원칙
Liskov substitution 리스코프 치환
Interface segregation 인터페이스 분리 원칙
Dependency inversion 의존 역전 원칙
각각의 원칙에 대해 설명하겠다.
모든 클래스는 단 하나의 책임만 가지며, 클래스가 제공하는 모든 기능은 이 책임을 위한 것이다.
각각의 클래스는 그 책임을 완전하게 캡슐화해야 한다.
캡술화
객체지향 프로그래밍의 4대 요소(추상화, 상속, 다형성, 캡슐화) 중 하나로 클래스 내부에 서로 연관된 속성과 기능을 하나의 캡슐로 만들어서 데이터를 외부로부터 감추고 보호하는 것을 의미한다.
public, protected, private와 같은 접근제어자를 활용하고 getter와 setter 등을 활용하는 것이 캡슐화를 위한 대표적인 방법이다.
단일책임원칙을 따르면 클래스의 행동이 제한되어 역할이 확실해지므로 가독성이 향상되고 이런 작은 클래스를 쉽게 상속받을 수 있는 데다 다양한 부분에서 재사용할 수 있게 모듈화된다.
클래스의 가독성, 확장성, 재사용성을 확보할 수 있는 것이다.
using UnityEngine;
public class Player : MonoBehaviour
{
public float moveSpeed = 5f;
private int score = 0;
void Update()
{
Move();
HandleScore();
}
void Move()
{
float moveX = Input.GetAxis("Horizontal");
float moveY = Input.GetAxis("Vertical");
Vector3 move = new Vector3(moveX, moveY, 0);
transform.position += move * moveSpeed * Time.deltaTime;
}
void HandleScore()
{
// 점수 관련 로직
score += 10;
Debug.Log("Score: " + score);
}
}
위는 단일책임원칙을 따르지 않는 코드이다. Player 클래스는 이동과 점수측정이라는 두가지 책임을 갖고있는데, 점수를 매기는 로직이 변경되거나 플레이어가 움직이는 방식이 바뀌면 Player 클래스를 수정해야되므로 유지보수성이 떨어진다.
그러면 단일책임원칙을 따라서 PlayerMovement와 PlayerScore클래스를 별도로 만들어서 전자는 오직 플레이어의 움직임만 담당하고, 후자는 점수 관리를 책임지게 하고 Player 클래스에서 이 둘을 사용하면 어떨까?
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField] private PlayerMovement playerMovement;
[SerializeField] private PlayerScore playerScore;
void Start()
{
// PlayerMovement와 PlayerScore 컴포넌트를 가져옴
playerMovement = GetComponent<PlayerMovement>();
playerScore = GetComponent<PlayerScore>();
}
void Update()
{
// 플레이어의 움직임은 PlayerMovement가 처리하도록 위임
playerMovement.Move();
}
// 외부에서 점수를 추가하는 메서드를 제공할 수 있음
public void AddScore(int points)
{
playerScore.AddScore(points);
}
}
public class GameManager : MonoBehaviour { ... }
public class PlayerScore : MonoBehaviour { ... }
Player 클래스가 더이상 이동과 점수관리 로직을 책임지지 않는다. 플레이어의 움직임과 점수 관리가 서로 영향을 주지 않으며, 각각을 독립적으로 수정할 수 있기 때문에 유지 보수성과 확장성이 높아진다.
점수 로직을 바꾸거나 플레이어의 움직임 방식을 변경할 때 Player 클래스는 변경되지 않고, 각 기능은 독립된 클래스를 수정함으로써 관리할 수 있다. 이 경우, Player클래스는 PlayerMovement와 PlayerScore클래스를 불러오고 관리해야하는 책임을 갖게 되는 것이다.
확장에 대해 열려있고 수정에 대해서는 닫혀야하는 원칙이다.
요구 사항이 바뀌면 새로운 동작을 추가하여 모듈의 일을 확장해야하며(확장에 대해 열려있다), 기존에 잘작동하는 코드를 수정할 필요가 없이 모듈의 기능을 확장하고 변경할 수 있어야한다. (수정에 대해 닫혀있다) 이를 잘 지켜 개발된 것이 DLL과 같은 라이브러리이다.
using UnityEngine;
public class Player : MonoBehaviour
{
public void Attack(string attackType)
{
if (attackType == "Sword")
{
// 칼 공격
Debug.Log("Sword Attack!");
}
else if (attackType == "Bow")
{
// 활 공격
Debug.Log("Bow Attack!");
}
// 새로운 공격 방식이 추가될 때마다 이곳을 수정해야 함
}
}
현재 플레이어는 칼과 활로 공격을 할 수 있다. 만약에 마법공격을 추가해야한다면 Player 클래스의 Attack() 메서드를 수정해야한다. 이는 수정에 대해 닫혀있는 방식이 아니다.
public interface IPlayerAttack
{
void Attack();
}
public class SwordAttack : IPlayerAttack
{
public void Attack()
{
Debug.Log("Sword Attack!");
}
}
public class BowAttack : IPlayerAttack
{
public void Attack()
{
Debug.Log("Bow Attack!");
}
}
public class Player : MonoBehaviour
{
private IPlayerAttack currentAttack;
public void SetAttackType(IPlayerAttack attack)
{
currentAttack = attack;
}
public void Attack()
{
if (currentAttack != null)
{
currentAttack.Attack();
}
else
{
Debug.Log("No attack type set!");
}
}
}
IPlayerAttack이라는 인터페이스를 만들어 이를 구현함으로써 SwordAttack과 BowAttack을 만들었다. 마법 공격을 추가할 때에도 이와 같이 IPlayerAttack를 MagicAttack로써 구현하면 Player 클래스의 수정없이 공격 방식을 확장할 수 있게된다. 새로운 공격 방식이 추가될 때 기존의 공격 방식에는 영향을 미치지 않는다.
상속으로 파생된 클래스는 기본 클래스(부모)를 완벽히 대체할 수 있어야한다. 부모 클래스가 예상하는 동작이나 규칙을 따르며, 그 방향성을 지켜야한다는 것이다.
만일 자식 클래스가 부모 클래스의 행동을 완전히 변경하거나 무효화 혹은 제외하는 등 예외를 발생시킨다면, 이는 코드상으로는 정상적으로 작동하더라도 리스코프 치환법칙을 위반하게된다.
public class Creature
{
void Move()
{
Debug.Log("The Creature is moving");
}
void Attack()
{
Debug.Log("The Creature is attacking");
}
}
public class Player : Creature
{
public void Move()
{
// 플레이어는 걷는다.
Debug.Log("The player is walking.");
}
void Attack()
{
Debug.Log("The player is attacking");
}
}
public class Plant : Creature
{
public void Move()
{
// 식물은 움직이지 않는다.
throw new System.NotSupportedException("Plants can't move.");
}
}
위 코드는 부모 클래스인 Creature가 기대하는 Move() 메서드의 동작을 자식 클래스들이 일관되게 따르지 않는다. Creature 타입을 사용하는 코드에서 Player는 몰라도 Plant로 완벽히 교체하여 사용할 수 없게 되어 리스코프 치환 원칙을 위반한 것이 되는 것이다.
인터페이스를 활용해 IMovable, IAttackable을 구현하는 방식으로 기능을 나누게 되면 이 문제는 잘 해결할 수 있다.
public interface IMoveable
{
void Move();
}
public interface IAttackable
{
void Attack();
}
public class Player : IMoveable, IAttackable
{
public void Move()
{
// 플레이어는 걷는다.
Debug.Log("The player is walking.");
}
void Attack()
{
// 플레이어가 공격한다.
Debug.Log("The player is attacking");
}
}
public class Plant : IAttackable
{
// 식물은 움직이지 않으므로 그냥 IMovable 인터페이스를 상속하지 않으면 된다.
void Attack()
{
Debug.Log("The plant is attacking");
}
}
클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않도록 큰 기능을 포괄하는 인터페이스가 아닌 구체적이고 작은 단위의 인터페이스로 구성하는 원칙이다. 간단히 말하면 하나의 큰 인터페이스보다 여러 개의 작은 인터페이스를 사용하라는 것이다.
public interface ICharacter
{
void Move();
void Jump();
void Attack();
void Heal();
}
public class Warrior : ICharacter
{
public void Move()
{
Debug.Log("Warrior is moving.");
}
public void Jump()
{
Debug.Log("Warrior is jumping.");
}
public void Attack()
{
Debug.Log("Warrior is attacking.");
}
public void Heal()
{
Debug.Log("Warrior cannot heal."); // 전사는 치유 마법을 못쓴다
}
}
public class Mage : ICharacter { ... }
Warrior 클래스는 Heal() 메서드를 구현해야 하지만, 실제로는 그 기능이 필요하지 않다.
이로 인해 클래스가 불필요한 메서드에 의존하게 되며, 코드가 복잡해질 수 있다.
public interface IMovable
{
void Move();
void Jump();
}
public interface IAttackable
{
void Attack();
}
public interface IHealable
{
void Heal();
}
public class Warrior: IMovable, IAttackable { ... }
public class Mage : IMovable, IAttackable, IHealable { ... }
모듈을 분리하는 형식으로 높은 수준의 클래스가 낮은 수준의 클래스의 것을 직접 가져오면 안되고 추상화에 의존해야한다. 클래스가 다른 클래스와의 직접적인 의존성이 있으면 안된다. 이는 두 클래스 간의 종속성과 결합성이 더 심화되게된다. 이는 프로그램에 잠재적인 위험요소가 된다.
public class Player
{
public void Move()
{
Debug.Log("The player is moving.");
}
}
public class Game
{
private Player player;
public Game()
{
player = new Player(); // Game 클래스가 Player 클래스에 직접 의존
}
public void Start()
{
player.Move();
}
}
위 코드를 보면 Game 클래스가 Player 클래스의 Move()를 직접적으로 호출하고 있다. 이 경우는 명령을 던져야하는 높은 수준 클래스 Game이 낮은 수준 클래스인 Player에게 의존한다.
이렇게 되면 Player 클래스를 다른 클래스로 교체하거나 변경하기 어려워지고, 테스트 시에도 문제가 발생할 수있다.
public interface IMoveable
{
void Move();
}
public class Player : IMoveable
{
public void Move()
{
Debug.Log("The player is moving.");
}
}
// 추가적인 캐릭터 클래스 예시
public class Enemy : IMoveable
{
public void Move()
{
Debug.Log("The enemy is moving.");
}
}
public class Game
{
private IMoveable moveableCharacter;
public Game(IMoveable character)
{
moveableCharacter = character; // Game 클래스가 IMoveable 인터페이스에 의존
}
public void Start()
{
moveableCharacter.Move();
}
}
Player와 Enemy 클래스가 각각 IMoveable 인터페이스를 구현하여 이동 동작을 정의한다.
이를 통해 Game 클래스는 IMoveable 인터페이스에 의존하게 되어, 고수준 모듈이 저수준 모듈에 의존하지 않게 된다. Player 또는 Enemy 클래스와 같은 구체적인 클래스에 의존하지 않는 것이다. Player에서 Enemy 클래스로의 교체 또한 쉬워진다.
Single responsibility 단일 책임 원칙
클래스는 한가지 작업만 수행
Open closed 개방 폐쇄 원칙
기존의 작동방식을 바꾸지않고 클래스 기능을 확장
Liskov substitution 리스코프 치환
하위 클래스는 기본 클래스를 대체할 수 있어야 하며 기본 클래스 방향 유지
Interface segregation 인터페이스 분리 원칙
인터페이스를 작게 유지하여 클라이언트는 필요한 것만 가져다가 구현
Dependency inversion 의존 역전 원칙
하나의 구체적인 클래스에 다른 클래스가 직접 의존하지 않고 추상화에 의존