SOLID 원칙 정의
SOLID 원칙은 객체지향 설계에서 지켜야 하는 5가지 원칙이다.
- 단일 책임 원칙 SRP(Single Responsibility Principle)
- 개방 폐쇄 원칙 OCP(Open Closed Priciple)
- 리스코프 치환 원칙 LSP(Listov Substitution Priciple)
- 인터페이스 분리 원칙 ISP(Interface Segregation Principle)
- 의존 역전 원칙 DIP(Dependency Inversion Principle)
솔리드 원칙 공부 이유
내가 매번 프로젝트 진행하면서 코드량이 많아지고 코드의 중복이 생기고 코드의 가독성이 나쁘다고 느끼는 이유가 바로 이 원칙을 지키지 않아서가 아닐까 싶다.
알아본 바로는 솔리드 원칙을 지키지 않고 코딩을 하면 스파게티 코드가 될 확률이 높아지는 등 여러 방면에서 부작용이 있다고 한다. 때 마침 유니티 알쓸유잡에서 솔리드 원칙 5가지에 대한 강의 영상을 찾게 되어 내가 이해한 내용을 기록하기로 하였다.
1. 단일 책임 원칙
정의
- 하나의 클래스는 하나의 `목적과 책임을` 위해 생성한다.
- 그리고 변경의 이유는 하나여야 한다.
- 단일 책임 원칙을 수행하기 위해서 클래스의 구조를 과도하게 정리하는 것은 위험하다.
단일 책임 원칙 잘 지킨 예시
플레이어 오브젝트에 플레이어가 이동하는 코드와 데이터를 저장하는 코드가 적용되어야 한다고 가정할 때, 단일 책임 원칙을 잘 지킨 예시로는 이동 관련 코드를 PlayerMovement.cs에 작성하고, 데이터 저장 관련 코드를 PlayerDataSaver.cs에 작성하는 방법이 있다.
public class PlayerMovement : MonoBehaviour
{
public float speed = 5f;
// 이동만 처리
void Update()
{
MovePlayer();
}
void MovePlayer()
{
float moveHorizontal = Input.GetAxis("Horizontal");
float moveVertical = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);
transform.Translate(movement * speed * Time.deltaTime);
}
}
public class PlayerDataSaver : MonoBehaviour
{
public Transform playerTransform;
// 데이터 저장만 처리
void Update()
{
if (Input.GetKeyDown(KeyCode.S))
{
SavePlayerData();
}
}
void SavePlayerData()
{
PlayerPrefs.SetFloat("PlayerX", playerTransform.position.x);
PlayerPrefs.SetFloat("PlayerY", playerTransform.position.y);
PlayerPrefs.SetFloat("PlayerZ", playerTransform.position.z);
Debug.Log("Player Data Saved");
}
}
단일 책임 원칙 잘 안 지킨 예시
아래 코드는 PlayerController가 이동과 데이터 저장 `두 가지 책임`을 가지고 있습니다. `이동과 관련된 코드와 데이터 저장 코드를 분리하여` 각각의 클래스로 만들면 코드의 유지보수성이 높아집니다. (위 예시가 개선 된 예 이다)
public class PlayerController : MonoBehaviour
{
public float speed = 5f;
// Update 함수에서 이동 및 데이터 저장 모두 수행
void Update()
{
// 플레이어 이동 처리
float moveHorizontal = Input.GetAxis("Horizontal");
float moveVertical = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);
transform.Translate(movement * speed * Time.deltaTime);
// 데이터 저장 처리
if (Input.GetKeyDown(KeyCode.S))
{
SavePlayerData();
}
}
// 데이터를 저장하는 함수
void SavePlayerData()
{
PlayerPrefs.SetFloat("PlayerX", transform.position.x);
PlayerPrefs.SetFloat("PlayerY", transform.position.y);
PlayerPrefs.SetFloat("PlayerZ", transform.position.z);
Debug.Log("Player Data Saved");
}
}
2. 개방폐쇄원칙
정의
- 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 원칙
- 이 원칙은 객체 지향 설계에서 매우 중요한 개념으로, 기존의 코드를 수정하지 않고 새로운 기능을 추가할 수 있도록 설계해야 한다는 뜻입니다.
개방 폐쇄 원칙을 잘 지킨 코드
각각의 무기 클래스가 IWeapon 인터페이스를 구현
using UnityEngine;
// 무기 인터페이스 정의
public interface IWeapon
{
void Attack();
}
// 각각의 무기 클래스가 IWeapon 인터페이스를 구현
public class Sword : IWeapon
{
public void Attack()
{
Debug.Log("Attack with Sword!");
}
}
public class Bow : IWeapon
{
public void Attack()
{
Debug.Log("Attack with Bow!");
}
}
public class Magic : IWeapon
{
public void Attack()
{
Debug.Log("Attack with Magic!");
}
}
Player 클래스는 무기의 종류가 늘어나도 코드 수정 없이 SetWeapon(IWeapon weapon) 메서드를 통해 새로운 무기를 사용할 수 있습니다.
// 플레이어 클래스는 IWeapon을 사용하여 공격
public class Player : MonoBehaviour
{
private IWeapon currentWeapon;
// 무기를 설정하는 함수
public void SetWeapon(IWeapon weapon)
{
currentWeapon = weapon;
}
public void Attack()
{
currentWeapon.Attack();
}
}
새로운 무기(예: Gun)를 추가하려면 IWeapon을 구현하는 새로운 클래스만 만들면 됩니다. 즉, 기존 코드를 수정할 필요가 없습니다.
public class Gun : IWeapon
{
public void Attack()
{
Debug.Log("Attack with Gun!");
}
}
무기 사용
public class GameManager : MonoBehaviour
{
public Player player; // 유니티에서 플레이어 오브젝트를 할당
void Start()
{
// 플레이어에게 무기를 설정
IWeapon sword = new Sword();
IWeapon bow = new Bow();
IWeapon magic = new Magic();
// 검을 무기로 설정
player.SetWeapon(sword);
// 플레이어가 공격
player.Attack();
// 활로 무기 변경
player.SetWeapon(bow);
player.Attack();
// 마법으로 무기 변경
player.SetWeapon(magic);
player.Attack();
}
}
그런데 가만 보면 위 코드는 의존성 역전 법칙도 같이 준수하고 있습니다 Player 클래스는 구체적인 구현체(Sword, Bow, Magic)에 의존하지 않고, 추상적인 인터페이스(IWeapon)에 의존하고 있기 때문! => DIP 준수
개방 폐쇄 원칙을 지키지 않은 경우
무기가 추가 될 때마다 기존 코드를 수정해야 합니다.
using UnityEngine;
public class Player : MonoBehaviour
{
public void Attack(string weaponType)
{
if (weaponType == "Sword")
{
Debug.Log("Attack with Sword!");
}
else if (weaponType == "Bow")
{
Debug.Log("Attack with Bow!");
}
else if (weaponType == "Magic")
{
Debug.Log("Attack with Magic!");
}
}
}
3. 리스코프 치환 원칙
정의
리스코프 치환 원칙은 하위 클래스가 상위 클래스의 역할을 완전히 할 수 있어야 한다는 것입니다. 즉, 어떤 객체를 그 객체의 부모 클래스로도 사용할 수 있어야 한다는 의미입니다.
예시 1) 이론
예를 들어, '차'라는 클래스를 만들고, 그 안에 '전기차'와 '휘발유차'라는 두 가지 종류를 만들었다고 하자. 이때, '차'의 기능(예: 운전하기, 연료 주입하기 등)은 '전기차'와 '휘발유차'가 모두 잘 수행해야 한다.
즉, '전기차'를 '차'라고 생각하고 사용해도 문제가 없어야 한다는 거죠. 만약 '전기차'가 '연료 주입하기' 기능을 제대로 지원하지 않는다면, 리스코프 치환 원칙을 위반한 것이다.
결국, 리스코프 치환 원칙은 코드가 더 안전하고 예측 가능하게 작동하도록 도와준다.
예시 2) 리스코프 치환 원칙 미 준수 시 대체 방법으로 인터페이스 활용
아래 그림을 보면 탈것 이라는 기반(부모) 클래스가 있다 속도와 방향을 필드로 선언하였고 상,하,좌,우 에 대한 메서드를 가지고 있다. 그리고 자동차와 트럭 클래스가 부모 클래스를 상속 받고 있다는 가정을 할 때 전혀 문제가 없다 자동차와 트럭은 속도와 방향은 물론이며 상,하,좌,우로 움직은데 모두 문제가 되지 않는다 .

이번엔 차와 트럭이 아닌 차와 기차가 기반 클래스를 상속 받는 경우이다 앞서 설명한 예시와 달리 기차는 앞 뒤로만 움직일 수 있기 때문에 좌 우와 같은 불필요한 메서드가 생긴다. 다시 말해 기반 클래스의 모든 메서드를 사용하지 않는 (무력화) 상황은 리스코프 치환원칙에 위배 된다. 그럼 어떻게 해결해야 할까?

이럴 떈 인터페이스를 사용해야 한다.
앞,뒤 를 묶는 인터페이스와 좌,우를 묶는 인터페이스로 나누고 필요한 인터페이스를 상속 받는 구조로 개선할 수 있다

예시 3) 리스코프 치환 원칙 준수 및 다형성
Enemy는 기본적으로 데미지를 받는 행동을 구현한 기반 클래스입니다. 이 클래스는 서브 클래스에서 상속받아 확장할 수 있지만, 기본적인 동작(데미지를 받는 것)은 변경하지 않아야 합니다.
public class Enemy : MonoBehaviour
{
public virtual void TakeDamage(int damage)
{
Debug.Log("Enemy takes damage: " + damage);
}
}
Zombie는 Enemy를 상속받아 데미지를 덜 받는 좀비를 구현합니다. 기반 클래스에서 제공하는 데미지를 받는 기능을 확장하여 좀비가 피해를 절반만 받도록 했지만, 여전히 기본적으로 데미지를 받는 동작은 유지됩니다. 이는 리스코프 치환 원칙을 준수한 예입니다.
public class Zombie : Enemy
{
public override void TakeDamage(int damage)
{
Debug.Log("Zombie takes reduced damage: " + (damage / 2));
}
}
Robot은 Enemy의 서브 클래스로, 데미지를 받지 않는 로봇을 구현합니다. 로봇은 데미지를 전혀 받지 않지만, 여전히 TakeDamage 메서드를 구현하여 기반 클래스의 계약을 준수합니다. 이 역시 LSP를 지키고 있습니다.
public class Robot : Enemy
{
public override void TakeDamage(int damage)
{
Debug.Log("Robot takes no damage because it's armored.");
}
}
클라이언트 코드에서는 Enemy 타입의 변수를 사용해 좀비와 로봇을 처리합니다. 두 객체 모두 Enemy를 상속받아 처리되고 있으며, 각각의 TakeDamage 메서드가 올바르게 호출됩니다. 이는 서브 클래스가 기반 클래스와 상호교환 가능하다는 LSP 원칙을 잘 따르고 있는 예입니다.
Enemy zombie = new Zombie(); 여기서 zombie 변수는 Enemy타입이지만 실제로는 Zmbie() 객체를 참조함
객체 지향 프로그래밍에서 부모 클래스 타입의 변수가 자식 클래스의 인스턴스를 참조할 수 있는 이유는 다형성(Polymorphism) 덕분입니다. 이를 통해 부모 클래스와 자식 클래스 간의 상호 교환이 가능해집니다.
public class Game : MonoBehaviour
{
private void Start()
{
Enemy zombie = new Zombie();
Enemy robot = new Robot();
zombie.TakeDamage(10);
robot.TakeDamage(10);
}
}
예시 4) 리스코프 치환 원칙 미준수
리스코프 치환 원칙(LSP)을 준수하지 않는 예시에서는 서브 클래스가 기반 클래스의 동작을 변경하거나 깨트리는 경우가 발생합니다. 이러한 경우, 서브 클래스는 기반 클래스와 교체될 수 없으며, 예상치 못한 행동이 발생하게 됩니다.
public class Enemy : MonoBehaviour
{
public virtual void TakeDamage(int damage)
{
Debug.Log("Enemy takes damage: " + damage);
}
}
이 예시에서는 Zombie 클래스가 TakeDamage 메서드를 재정의하면서 추가적인 제약을 추가했습니다. 여기서 damage가 0 이하인 경우 예외를 던지도록 구현되었습니다. 그러나 Enemy 클래스는 그러한 제한을 두지 않기 때문에 클라이언트는 Enemy 타입으로 처리되는 객체가 항상 데미지를 받을 수 있을 것이라 기대합니다. Zombie가 그 기대를 깨면서 리스코프 치환 원칙을 위반하게 됩니다.
public class Zombie : Enemy
{
public override void TakeDamage(int damage)
{
if (damage > 0)
{
Debug.Log("Zombie takes damage: " + damage);
}
else
{
throw new ArgumentException("Damage cannot be negative");
}
}
}
Ghost 클래스는 Enemy를 상속받지만 TakeDamage 메서드를 아예 구현하지 않고 예외를 던집니다. 기반 클래스는 데미지를 처리할 수 있다고 기대하지만, 이 서브 클래스는 그 기능을 아예 지원하지 않으므로 리스코프 치환 원칙을 위반하게 됩니다. 클라이언트는 Enemy 타입을 기대하고 TakeDamage 메서드를 호출했으나 예외가 발생하여 프로그램이 중단될 수 있습니다.
public class Ghost : Enemy
{
public override void TakeDamage(int damage)
{
// Ghosts are immune to damage, but instead we cause an error
throw new NotImplementedException("Ghosts cannot take damage!");
}
}
Game 클래스에서 Enemy 타입을 사용하여 zombie와 ghost를 처리합니다. Zombie의 경우, 데미지가 0 이하인 경우 예외가 발생할 수 있고, Ghost는 아예 데미지를 받을 수 없어 예외가 발생합니다. 이와 같은 동작은 기반 클래스의 예상된 동작을 깨트리기 때문에 리스코프 치환 원칙을 위반하는 예입니다.
public class Game : MonoBehaviour
{
private void Start()
{
Enemy zombie = new Zombie();
Enemy ghost = new Ghost();
zombie.TakeDamage(10); // 정상적으로 작동
ghost.TakeDamage(10); // 예외 발생으로 인해 프로그램 중단
}
}
리스코프 치환 원칙을 위반하는 문제점 요약
- 기반 클래스의 계약 위반: 서브 클래스가 기반 클래스에서 기대하는 계약(예: TakeDamage 메서드가 정상적으로 동작)을 지키지 않으면 문제가 발생합니다.
- 클라이언트 코드의 신뢰도 저하: 클라이언트는 기반 클래스의 행동을 신뢰할 수 없게 되며, 예상치 못한 예외나 오류로 인해 프로그램이 중단될 수 있습니다.
- 예외 발생: 서브 클래스가 기반 클래스의 기대와 다른 동작을 하여 예외를 던지면, 안전한 객체 치환이 불가능해집니다.
- 이러한 방식으로 리스코프 치환 원칙을 준수하지 않는 경우, 코드가 불안정해지며 유지보수와 확장이 어려워질 수 있습니다.
4. 인터페이스 분리 원칙
유닛을 만든다고 가정할 때 관련 기능을 인터페이스 하나에 때려넣기 식으로 작성하면 안 된다.

비슷한 기능들끼리 묶어서 인터페이스를 관리 해야 하며 이것이 인터페이스 분리원칙 이다.

마지막으로 의존성 역전 법칙인데 완전히 이해하지 못한 부분이 있어서 시간 될 때 공부하고 내용 추가 하는 걸로.. TO DO 250121