내일배움캠프 28일차 TIL
📌 SOLID란?
객체지향 프로그래밍에서 유지보수, 확장성, 유연성을 높이기 위해 지켜야 할 다섯 가지 원칙의 약어.
SRP (Single Responsibility Principle) 단일 책임 원칙
"클래스는 하나의 변경 이유만 가져야 한다"
즉, 하나의 책임(역할) 만 가져야 한다는 뜻. 하나의 클래스는 한 가지 일만 잘해야 한다!
단일 책임 원칙이 왜 중요할까?
- 여러 책임을 한 클래스가 맡으면, 하나의 기능을 바꾸는 것만으로도 클래스 전체가 영향을 받음
- 버그 발생 시 원인을 찾기 어렵고, 유지보수가 힘들어짐
비유로 이해해보기
택배 상자가 물건도 담고, 주소 출력도 하고, 배달도 한다면?
하나 바꾸면 다 망가짐!
→ 각 역할을 "상품 상자", "송장 프린터", "배달 기사"로 나누면 훨씬 유연하다!
코드 예시
플레이어가 게임에 처음 입장하면
- 플레이어 정보를 생성하고
- 파일에 저장하고
- 환영 메시지를 화면에 띄운다
❌ SRP 위반 코드 (하나의 스크립트가 모든 책임을 가짐)
using UnityEngine;
using System.IO;
public class PlayerManager : MonoBehaviour
{
public void Start()
{
CreatePlayer("Player1");
}
void CreatePlayer(string playerName)
{
Debug.Log($"플레이어 {playerName} 생성됨");
// 파일 저장
File.WriteAllText(Application.persistentDataPath + "/player.txt", playerName);
// UI 메시지 출력
GameObject.Find("MessageText").GetComponent<TMPro.TextMeshProUGUI>().text = $"환영합니다, {playerName}!";
}
}
⚠ 문제점
- 이 PlayerManager 클래스가 (플레이어 생성, 파일 저장, UI 메시지 출력) 3가지 역할을 모두 수행
- UI나 저장 방식이 바뀌면 이 클래스도 바꿔야 함 → 변경 이유가 여러 개
✅ SRP 적용 코드 (역할을 나눔)
1. Player.cs – 데이터를 표현하는 클래스
public class Player
{
public string Name;
public Player(string name)
{
Name = name;
}
}
2. PlayerCreator.cs – 플레이어 생성 책임
using UnityEngine;
public class PlayerCreator : MonoBehaviour
{
public Player CreatePlayer(string name)
{
Debug.Log($"플레이어 {name} 생성됨");
return new Player(name);
}
}
3. PlayerSaver.cs – 파일 저장 책임
using System.IO;
using UnityEngine;
public class PlayerSaver : MonoBehaviour
{
public void Save(Player player)
{
File.WriteAllText(Application.persistentDataPath + "/player.txt", player.Name);
}
}
4. UIManager.cs – UI 출력 책임
using UnityEngine;
using TMPro;
public class UIManager : MonoBehaviour
{
public TextMeshProUGUI messageText;
public void ShowWelcomeMessage(string playerName)
{
messageText.text = $"환영합니다, {playerName}!";
}
}
5. GameManager.cs – 기능들을 조합하는 상위 클래스
using UnityEngine;
public class GameManager : MonoBehaviour
{
public PlayerCreator playerCreator;
public PlayerSaver playerSaver;
public UIManager uiManager;
void Start()
{
var player = playerCreator.CreatePlayer("Player1");
playerSaver.Save(player);
uiManager.ShowWelcomeMessage(player.Name);
}
}
| 항목 | SRP 위반 | SRP 적용 |
| 변경 이유 | 파일 저장 방식이 바뀌면 UI 코드도 영향받음 | 저장만 수정하면 PlayerSaver만 수정하면 됨 |
| 재사용성 | PlayerManager 재사용 어려움 | 각 컴포넌트는 독립적으로 재사용 가능 |
| 테스트 | 통합 테스트만 가능 | 유닛 테스트가 쉬움 |
정리
- 하나의 클래스가 "AND"로 연결된 기능을 하고 있다면 SRP 위반일 수 있음
- 단일 책임으로 나누면 코드가 늘어나는 것처럼 보여도, 버그 수정·유지보수는 훨씬 쉬움
- 각 책임은 나중에 테스트, 재사용, 교체하기 쉬운 단위가 됨
- Unity에서 MonoBehaviour를 남용하면 쉽게 SRP를 어기게 됨
- 기능별 스크립트 분리는 처음엔 번거롭지만, 나중에 유지보수에서 큰 이득
- SRP는 “대규모 프로젝트”일수록 진가를 발휘!
OCP (Open/Closed Principle) 개방 폐쇄 원칙
“확장에는 열려 있고, 변경에는 닫혀 있어야 한다”
즉, 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다는 원칙.
개방 폐쇄 원칙이 왜 중요할까?
- 기존 코드를 건드리면 버그가 생길 위험이 큼
- 이미 안정된 기능은 그대로 두고, 새로운 기능만 확장할 수 있어야 유지보수가 쉬움
비유로 이해하기
편의점 계산대가 모든 결제 수단을 if문으로 처리한다면?
새로운 결제 수단이 나올 때마다 기존 코드를 계속 고쳐야 함 → 리스크 증가
→ 💡 "결제 수단"이라는 추상 인터페이스를 만들고, 각 결제 방법은 확장해서 구현만 하면 됨!
코드 예시
플레이어가 적을 공격할 때 다양한 무기 타입(칼, 활, 마법 등) 을 사용할 수 있음
❌ OCP 위반 코드 (무기 타입 추가할 때마다 기존 코드 수정)
public class PlayerAttack : MonoBehaviour
{
public string weaponType;
public void Attack()
{
if (weaponType == "Sword")
{
Debug.Log("칼로 공격!");
}
else if (weaponType == "Bow")
{
Debug.Log("활로 공격!");
}
else if (weaponType == "Magic")
{
Debug.Log("마법 공격!");
}
}
}
⚠ 문제점
- 무기 타입이 늘어날 때마다 기존 if-else를 계속 수정해야 함
- 변경에 닫혀있지 않음 → OCP 위반
✅ OCP 적용 코드: 다형성을 통한 확장
1. IWeaponAttack.cs – 무기 인터페이스 정의
public interface IWeaponAttack
{
void Attack();
}
2. 무기 클래스들 – 각각 독립 구현
public class SwordAttack : IWeaponAttack
{
public void Attack()
{
Debug.Log("칼로 공격!");
}
}
public class BowAttack : IWeaponAttack
{
public void Attack()
{
Debug.Log("활로 공격!");
}
}
public class MagicAttack : IWeaponAttack
{
public void Attack()
{
Debug.Log("마법 공격!");
}
}
3. PlayerAttack.cs – 변경 없이 확장 가능
using UnityEngine;
public class PlayerAttack : MonoBehaviour
{
public IWeaponAttack weapon;
public void PerformAttack()
{
weapon.Attack();
}
}
4. 사용 예
void Start()
{
PlayerAttack player = GetComponent<PlayerAttack>();
player.weapon = new SwordAttack(); // 나중에 MagicAttack으로 교체 가능
player.PerformAttack();
}
LSP (Liskov Substitution Principle) 리스코프 치환 원칙
“자식 클래스는 부모 클래스로 교체할 수 있어야 한다”
즉, 부모 클래스를 사용하는 코드에서 자식 클래스로 바꿨을 때도 아무 문제 없이 동작해야 한다는 원칙
리스코프 치환이 왜 중요할까?
- 상속을 잘못 사용하면 오히려 유연성이 떨어지고 버그가 생김
- "is-a" 관계를 신중히 판단하지 않으면 대체가 안 되는 자식 클래스가 만들어짐
비유로 이해하기
“컵은 물, 주스, 커피를 담을 수 있다.”
그런데 "컵"을 상속한 "종이컵"이 끓는 물을 담지 못한다면?
컵 = 종이컵이 성립하지 않음 → 리스코프 위반!
코드 예시
여러 종류의 적(Enemy)이 있는데,
모든 적은 이동한다(move) 라는 공통 동작을 가짐
❌ LSP 위반 예시
public class Enemy
{
public virtual void Move()
{
Debug.Log("적이 이동 중");
}
}
public class FlyingEnemy : Enemy
{
public override void Move()
{
Debug.Log("적이 하늘을 날아 이동 중");
}
}
public class StaticEnemy : Enemy
{
public override void Move()
{
throw new System.NotSupportedException("이 적은 움직이지 않음");
}
}
⚠ 문제점
- StaticEnemy는 Enemy를 상속했지만 Move를 제대로 구현하지 않음
- 부모 클래스 Enemy를 사용하는 코드에서 StaticEnemy로 교체하면 예외가 발생
이런 상황이 벌어짐
void MoveAllEnemies(List<Enemy> enemies)
{
foreach (var e in enemies)
{
e.Move(); // ❌ StaticEnemy에서 예외 발생
}
}
→ 이건 LSP 위반: 부모(Enemy)를 자식으로 바꿨더니 동작이 깨짐!
✅ LSP를 지킨 예시: 책임을 분리
인터페이스로 역할 분리
public interface IMovable
{
void Move();
}
public class Enemy : MonoBehaviour
{
public string Name;
}
public class MovingEnemy : Enemy, IMovable
{
public void Move()
{
Debug.Log($"{Name} 이동 중");
}
}
public class StaticEnemy : Enemy
{
// 이동 기능 없음
}
이렇게 사용 가능
void MoveAllEnemies(List<IMovable> movables)
{
foreach (var e in movables)
{
e.Move(); // ✅ 움직일 수 있는 적만 이동!
}
}
| 기준 | LSP 위반 | LSP 준수 |
| 설계 | 모든 적이 Move()를 가짐 | "움직일 수 있는 적"만 IMovable 구현 |
| 확장성 | 자식 클래스마다 NotImplementedException 주의 | 타입 안전하게 설계 가능 |
| 유지보수 | 예외 처리 복잡 | 역할 분리가 깔끔함 |
Unity 적용 팁
- Unity에서는 MonoBehaviour 상속 구조가 많아서 부적절한 상속 남용이 자주 발생
- “공통 부모”보다 “역할 기반 인터페이스”가 더 유연할 때가 많다!
- AI, 무기, 이동, 상호작용 같은 기능은 구현 가능한 인터페이스로 분리하면 유리
ISP (Interface Segregation Principle) 인터페이스 분리 원칙
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다”
즉, 큰 인터페이스 하나를 강제로 쓰게 하지 말고, 작고 역할에 맞는 인터페이스 여러 개로 쪼개라는 원칙
인터페이스 분리 원칙은 왜 중요할까?
- 불필요한 기능이 포함된 인터페이스는 클래스를 복잡하게 만들고
- 쓰지도 않을 기능을 구현하느라 불필요한 코드가 생기고
- 코드 변경 시 불필요한 연쇄적인 영향이 생김
비유로 이해하기
전동 드릴 하나에 못 박기, 페인트칠, 절단 기능이 다 있다고 해보자.
나는 못만 박고 싶은데 왜 절단 기능까지 알아야 하지?
→ 필요한 기능만 제공해라!
코드 예시
ICharacter라는 인터페이스에 다음 기능이 있다고 가정
- Move()
- Attack()
- CastSpell()
- Heal()
❌ ISP 위반
public interface ICharacter
{
void Move();
void Attack();
void CastSpell();
void Heal();
}
이 인터페이스를 전사(Warrior) 와 마법사(Mage) 가 모두 구현해야 한다면?
public class Warrior : ICharacter
{
public void Move() => Debug.Log("이동!");
public void Attack() => Debug.Log("근접 공격!");
public void CastSpell() => throw new System.NotImplementedException(); // ❌
public void Heal() => throw new System.NotImplementedException(); // ❌
}
⚠ 문제점
- Warrior는 마법을 못 쓰는데 CastSpell()을 반드시 구현해야 함
- 쓰지 않는 기능에 의존하고 있음 → ISP 위반
✅ ISP를 지킨 코드: 인터페이스 분리
1. 인터페이스들을 역할로 나누기
public interface IMovable
{
void Move();
}
public interface IAttacker
{
void Attack();
}
public interface ISpellCaster
{
void CastSpell();
}
public interface IHealer
{
void Heal();
}
2. 각 클래스는 필요한 인터페이스만 구현
public class Warrior : MonoBehaviour, IMovable, IAttacker
{
public void Move() => Debug.Log("이동!");
public void Attack() => Debug.Log("칼로 공격!");
}
public class Mage : MonoBehaviour, IMovable, ISpellCaster
{
public void Move() => Debug.Log("이동!");
public void CastSpell() => Debug.Log("파이어볼!");
}
3. 사용할 때도 필요한 인터페이스만 참조
void DoMove(IMovable character)
{
character.Move();
}
void DoAttack(IAttacker attacker)
{
attacker.Attack();
}
| 기준 | ISP 위반 | ISP 준수 |
| 인터페이스 크기 | 하나의 거대한 인터페이스 | 작고 역할별로 나뉜 인터페이스 |
| 구현 클래스 | 필요 없는 메서드도 구현해야 함 | 필요한 기능만 구현하면 됨 |
| 유연성 | 낮음 | 높음 |
| 유지보수 | 변경 범위 큼 | 변경이 최소화됨 |
Unity 적용 팁
- MonoBehaviour는 모든 기능을 넣기 쉬워서 자주 ISP를 어긴다.
- 움직임, 공격, 마법, 회복 등은 별도의 컴포넌트 또는 인터페이스로 분리하는 게 좋다.
- 이렇게 하면 플레이어, NPC, 몬스터, 오브젝트 등 다양한 캐릭터에 재사용할 수 있다.
DIP (Dependency Inversion Principle) 의존성 역전 원칙
고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상(인터페이스)에 의존해야 한다.”
- "고수준" 모듈: 중요한 비즈니스 로직(예: 게임 진행 제어)
- "저수준" 모듈: 구체적인 구현 클래스(예: 데이터 저장, 사운드 재생)
왜 중요할까?
- DIP를 지키지 않으면 한 클래스가 다른 클래스를 너무 많이 알고 있어서
- 구현을 바꾸거나 테스트하기 어려워짐
- 결합도는 높고, 유연성은 낮아짐
비유로 이해하기
마트 계산대(고수준 모듈)가 "현금 결제 시스템(저수준 모듈)"만 알면 카드 결제, 앱 결제는 못 씀
대신 "결제 인터페이스"에 의존하면 어떤 결제 방식이든 가능!
예시 코드
플레이어가 아이템을 저장할 수 있는 시스템이 있다.
처음엔 그냥 PlayerPrefs로 저장했다가
나중에는 파일, 서버, 암호화된 저장소로 바꾸고 싶을 수 있음.
❌ DIP 위반 예시
public class InventorySystem : MonoBehaviour
{
public void SaveItem(string itemName)
{
PlayerPrefs.SetString("SavedItem", itemName); // 👈 PlayerPrefs에 직접 의존
Debug.Log("아이템 저장됨");
}
}
⚠ 문제점
- 저장 방식이 바뀌면 InventorySystem을 직접 수정해야 함
- 테스트도 어려움 (PlayerPrefs는 테스트 환경에서 문제 발생할 수 있음)
✅ DIP 준수 예시: 추상화 도입
1. 인터페이스 정의
public interface IDataSaver
{
void Save(string key, string value);
}
2. 구현체 분리
public class PlayerPrefsSaver : IDataSaver
{
public void Save(string key, string value)
{
PlayerPrefs.SetString(key, value);
}
}
public class FileSaver : IDataSaver
{
public void Save(string key, string value)
{
System.IO.File.WriteAllText($"{key}.txt", value);
}
}
3. 고수준 모듈에서 인터페이스에만 의존
public class InventorySystem : MonoBehaviour
{
private IDataSaver dataSaver;
public void Init(IDataSaver saver)
{
dataSaver = saver;
}
public void SaveItem(string itemName)
{
dataSaver.Save("SavedItem", itemName);
Debug.Log("아이템 저장됨");
}
}
4. 초기화 시 구체 객체 주입
void Start()
{
var inventory = GetComponent<InventorySystem>();
IDataSaver saver = new PlayerPrefsSaver(); // 나중에 FileSaver로 교체 가능
inventory.Init(saver);
}
| 기준 | DIP 위반 | DIP 준수 |
| 의존 구조 | 고수준 모듈이 직접 구현체 사용 | 고수준 모듈이 인터페이스에 의존 |
| 유연성 | 변경 시 전체 코드 수정 필요 | 구현 교체만 하면 됨 |
| 테스트 | 어려움 | 테스트 더블(목 등)로 손쉽게 테스트 가능 |
| 유지보수 | 어렵고 위험 | 모듈 교체가 쉬움 |
Unity 적용 팁
- Unity는 보통 new를 잘 안 쓰고 컴포넌트를 붙이기 때문에 의존성 주입(DI) 은 직접 구성하거나, ScriptableObject 또는 Service Locator로 우회할 수 있다.
- 유닛 테스트나 Mock 구현을 할 때 DIP가 매우 유용함
📌 SOLID 정리 다시 보기
| 원칙 | 핵심 의미 | Unity 예시 |
| SRP | 하나의 책임만 가져야 한다 | GameManager가 게임 시작/종료만 담당 |
| OCP | 확장엔 열려 있고 수정엔 닫혀야 한다 | Strategy 패턴으로 무기 시스템 교체 |
| LSP | 부모를 자식으로 바꿔도 문제 없어야 한다 | 이동 불가능한 적은 IMovable 구현 X |
| ISP | 인터페이스는 작게 나눠라 | Move/Attack/Spell 인터페이스 분리 |
| DIP | 추상화에 의존하라 | SaveSystem → IDataSaver 사용 |

'TIL' 카테고리의 다른 글
| [Unity] 델리게이트 (Delegate) (1) | 2025.05.20 |
|---|---|
| [Unity] Raycast (0) | 2025.05.19 |
| Unity 협업 중 프리팹 충돌 및 .meta 파일 관련 트러블 슈팅 (1) | 2025.05.15 |
| 방 종류 추가와 문 생성 수정 (0) | 2025.05.12 |
| 방 생성 및 문 이동 시스템 구현 (1) | 2025.05.09 |