| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- System Programming
- Unity
- multi-thread
- BOJ
- 독서
- Network Programming
- Toy Project
- Data Structure
- PS
- C++
- c#
- git
- Online Judge
- Today
- Total
I'm FanJae.
[20260511] C# ( 객체지향 설계 5대 원칙 SOLID ) 본문
1. 객체지향 5대 설계 원칙 (SOLID)
(1) 정의
- SOLID란 객체지향 프로그래밍에서 유지보수성과 확장성을 높이기 위한 5가지 설계 원칙을 의미한다.
- 각각의 원칙은 클래스, 인터페이스, 의존성 등을 설계할 때 변경에 강하고 이해하기 쉬운 구조를 만들기 위한 기준이 된다.
- SOLID는 하나의 독립된 기술이 아닌, 좋은 객체지향 설계를 위한 종합적인 설계에 가깝다.
※ 즉, 각각의 원칙은 서로 얽혀있다.
(2) 필요한 이유
- 프로젝트 규모가 커질수록 코드 구조가 복잡해질 수 있다.
- 기능을 추가하거나 수정할 때 기존 코드가 함께 영향을 받을 수 있다.
- 여러 사람이 협업하는 환경에서는 코드의 책임과 의존 관계가 명확하지 않아 유지보수가 어렵다.
- SOLID는 위와 같은 문제를 줄이고, 변경에 더 유연한 코드를 작성하기 위해 사용한다.
(3) 장점
- 유지 보수가 쉬워진다.
- 기능 확장이 쉬워진다.
- 버그 발생 가능성을 줄일 수 있다. (아예 없앨 수 있는건 아니다.)
- 코드 재사용성이 높아진다.
- 협업 시 코드의 의도를 파악하기 쉬워진다.
(4) 주의점
- SOLID는 모든 상황에서 완벽하게 지켜야 하는 규칙이 아니다. (이 규칙을 모두 지키는게 생각보다 매우 어렵다.)
- 원칙을 지나치게 적용하면 오히려 코드 구조가 복잡해질 수 있다.
- 즉, 현재 코드에서 변경이 어디 발생할 수 있는지 판단하고 적절히 적용하는 것이 중요하다.
(5) 정리
- SOLID는 객체지향 설계를 더 유연하고 유지보수하기 쉽게 만들기 위한 원칙이다.
- 5가지 원칙은 서로 완전히 독립된 개념이 아닌, 좋은 설계를 만들기 위해 서로 연결되어 있다.
- SOLID에 너무 얽매이기 보다 프로젝트 상황에 맞게 적절히 적용해야 한다.
2. 5대 설계 원칙에 대한 상세 설명
2-1. SRP(Single Responsibility Principle, 단일 책임 원칙)
(1) 정의
- SRP란 하나의 클래스는 하나의 책임만 가져야 한다는 원칙이다.
- 즉, ‘클래스를 변경해야 하는 이유는 하나여야 한다’는 뜻이다.
- 여기서 책임이란 단순히 메서드 하나를 지칭하는 것이 아닌, 같은 이유로 변경되는 기능끼리 묶여야 한다는 의미이다.
(2) 필요한 이유
- 클래스가 여러 책임을 가지게 되면, 서로 관련 없는 변경 사항이 하나의 클래스에 모인다.
- 그 결과 특정 기능만 수정하려고 해도 다른 기능에 영향을 줄 가능성이 크다.
- 프로젝트 규모가 커질수록 하나의 클래스가 UI, 저장, 전투, 이동 등 여러 책임을 동시에 가지면 유지보수가 어려워진다.
- SRP는 책임을 분리하여 변경 영향을 최소화하기 위해서 필요하다.
(3) 올바르지 않은 사례
① BadPlayer 설계
public class BadPlayer
{
public string name = "기사";
public int hp = 100;
public void Move()
{
// 플레이어 이동
}
public void Attack()
{
// 플레이어 공격
}
public void ShowUI()
{
// 플레이어 UI 표시
}
public void Save()
{
// 플레이어 데이터 저장
}
}
- 위 예제에서 BadPlayer 클래스가 너무 많은 책임을 가지고 있다.
- Move() 와 Attack() 은 플레이어의 게임 플레이 행동으로 묶을 여지가 있다.
※ 설계하기에 따라서는 이들도 나눌 수 있다. (개발자의 설계 근거에 따라 다르다.)
- 하지만 ShowUI() 와 Save() 는 플레이어가 가지고 있기엔 과한 책임이다.
② 왜 과한 책임인가?
- Player 클래스는 플레이어의 상태와 행동을 표시하는 것에 있다.
이름
체력
이동
공격
피격
- 위와 같은 상태와 행동은 플레이어라는 게임 객체 자체와 직접적인 관련이 있다.
- 하지만 ShowUI() 는 플레이어가 아닌 UI 시스템의 책임에 가깝다.
- 캐릭터의 체력바 표현 방식은 텍스트로 한다. (기존)
- 캐릭터의 체력바 표현 방식은 이미지로 한다. (신규)
- 만약 체력바 표현 방식이 텍스트에서 이미지로 변경되었을 때, 그 수정이 Player나 Enemy 같은 캐릭터 클래스 내부에서 발생한다면 캐릭터 클래스가 UI 변경의 영향을 직접 받게 된다.
- 즉, 캐릭터의 상태나 행동이 바뀐 것이 아님에도 캐릭터 클래스를 수정해야 하는 것이다.
- 비슷한 사례로 Save()도 저장 시스템의 책임에 가깝다.
- 캐릭터 정보를 로컬 파일에 저장한다. (기존)
- 캐릭터 정보를 DB 또는 서버에 저장한다. (신규)
- 저장 방식이 바뀔 때마다 캐릭터 클래스를 수정해야 한다면, 캐릭터 클래스가 저장 책임까지 가지고 있는 상태다. 캐릭터는 자신의 상태와 행동을 담당하고, 저장은 별도의 저장 시스템이 담당하는 편이 책임이 더 명확하다.
③ 발생 할 수 있는 문제점
- BadPlayer가 저장 방식을 직접 알고 있으면, 저장 방식이 바뀔 때마다 플레이어 관련 클래스들을 수정해야 할 수 있다.
- 예를 들어, 여러 종류의 플레이어가 각각 Save() 를 가지고 있다고 가정한다.
public class Warrior
{
public void Save()
{
// 파일 저장
}
}
public class Mage
{
public void Save()
{
// 파일 저장
}
}
public class Archer
{
public void Save()
{
// 파일 저장
}
}
- 각 캐릭터 클래스가 저장 방식에 직접 의존하고 있으면, 저장 정책이 바뀔때마다 일일이 여러 클래스를 모두 수정해야 한다.
(4) 책임을 기준으로 Player를 분리하는 방법 (SRP의 적용)
public class Player
{
public string Name { get; private set; } = "기사";
public int Hp { get; private set; } = 100;
public void Move()
{
// 플레이어 이동
}
public void Attack()
{
// 플레이어 공격
}
}
public class PlayerUI
{
public void Show(Player player)
{
// 플레이어 UI 출력
}
}
public class PlayerRepository
{
public void Save(Player player)
{
// 플레이어 데이터 저장
}
}
- 위와 같이 분리하면, 각 클래스의 책임은 명확해진다.
- Player : 플레이어의 상태와 행동을 담당한다.
- PlayerUI : 플레이어 정보를 화면에 표시하는 책임을 담당한다.
- PlayerRepository : 플레이어 데이터를 저장하는 책임을 담당한다.
(5) 설계 방식 변경에 따른 이점
- 파일의 저장 방식이 변경되면 기존 코드에서는 BadPlayer 안의 Save() 를 수정해야 한다.
public class BadPlayer
{
public void Save()
{
// 파일 저장에서 DB 저장으로 변경
}
}
- 이 경우, BadPlayer 자체를 수정하므로, 플레이어의 다른 기능도 영향을 받을 수 있다.
public class PlayerRepository
{
public void Save(Player player)
{
// 파일 저장에서 DB 저장으로 변경
}
}
- 이렇게 구현하면, 저장 정책이 바뀌어도 플레이어의 이동이나 공격 등에 영향을 주지 않는다.
(6) 정리
- SRP는 하나의 클래스가 하나의 책임만 가지도록 설계하는 원칙이다.
- 핵심은 메서드 개수를 줄이는 게 아니라, 변경 이유를 하나로 제한하는 것이다.
- 서로 다른 이유로 변경되는 기능은 다른 클래스로 분리하는 것이 좋다.
- SRP를 적용하면, 변경 영향 범위가 줄어들고, 테스트와 유지보수가 쉬워진다.
- 다만 책임을 너무 잘게 나누면 클래스 수가 과도하게 늘어날 수 있으므로, 현재 프로젝트 규모와 변경 가능성을 기준으로 적절히 나눠야 한다.
2-2. OCP(Open Closed Principle, 개방 폐쇄 원칙
(1) 정의
- OCP란 확장에는 열려 있고, 수정에는 닫혀 있어야 한다는 원칙이다.
- 즉, 새로운 기능이 추가될 때 기존 코드를 직접 수정하기보다, 새로운 코드를 추가하는 방식으로 확장할 수 있어야 한다.
- 여기서 수정에 닫혀 있다는 말은 코드를 수정해서는 안된다라는 뜻이 아니다.
- 이미 동작하고 검증된 코드는 가능한 건드리지 않고, 새로운 요구사항은 확장을 통해 처리한다는 의미이다.
(2) 필요한 이유
- 새로운 기능이 추가될 때마다 기존 코드를 계속 수정하면, 기존 기능이 깨질 가능성이 커진다.
- if, else if , switch 문이 계속 늘어나면, 코드가 복잡해지고 유지보수가 어려워진다.
- 특정 클래스가 여러 타입을 직접 검사하고 처리하면, 새로운 타입이 추가될 때마다 해당 클래스를 수정해야 한다.
- OCP는 기존 코드를 수정하지 않고도 기능을 확장할 수 있는 구조를 만들기 위해 필요하다.
(3) 올바르지 않은 사례
① object를 사용한 캐릭터 공격 처리
public class Warrior
{
public void Attack()
{
Console.WriteLine("전사가 공격합니다.");
}
}
public class Mage
{
public void Attack()
{
Console.WriteLine("마법사가 공격합니다.");
}
}
public class BattleSystem
{
public void CharacterAttack(object character)
{
if (character is Warrior)
{
((Warrior)character).Attack();
}
else if (character is Mage)
{
((Mage)character).Attack();
}
}
}
- C#에서 object는 모든 클래스가 상속받는 최상위 클래스이다.
- 따라서 어떤 타입의 객체든 object 타입으로 받을 수 있다.
① 발생할 수 있는 문제점 1. 새로운 타입이 추가될 때마다 기존 코드의 수정이 필요하다.
public class Archer
{
public void Attack()
{
Console.WriteLine("궁수가 공격합니다.");
}
}
public void CharacterAttack(object character)
{
if (character is Warrior)
{
((Warrior)character).Attack();
}
else if (character is Mage)
{
((Mage)character).Attack();
}
else if (character is Archer)
{
((Archer)character).Attack();
}
}
- 새로운 캐릭터가 추가될 때마다 기존 CharacterAttack() 메서드를 계속 수정해야 한다.
② 실수로 인하여 기존 코드가 깨질 가능성이 크다.(=망가질 가능성이 크다.)
if (character is Warrior)
{
((Warrior)character).Attack();
}
else if (character is Mage)
{
((Mage)character).Attack();
}
else if (character is Mage) // Logical Error
{
((Archer)character).Attack();
}
- 이와 같은 실수가 발생할 수 있다.
③ 코드가 특정 클래스에 강하게 의존하게 된다.
- BattleSystem 이 Warrior , Mage , Archer 같은 구체적인 클래스를 알게 된다.
- 캐릭터의 종류가 늘어나면, BattleSystem 이 알아야 하는 클래스도 늘고, 이렇게 되면 두 클래스 간의 결합도가 높아지게 된다.
(4) 공통 기능에 대한 인터페이스 분리 (OCP의 적용)
public interface ICharacter
{
void Attack();
}
- 캐릭터에 대한 Attack() 를 구현한다.
public class Warrior : ICharacter
{
public void Attack()
{
Console.WriteLine("전사가 공격합니다.");
}
}
public class Mage : ICharacter
{
public void Attack()
{
Console.WriteLine("마법사가 공격합니다.");
}
}
public class Archer : ICharacter
{
public void Attack()
{
Console.WriteLine("궁수가 공격합니다.");
}
}
- 이와 같이 Override 하면, BattleSystem에서 캐릭터 타입을 검사할 필요가 사라진다.
public class BattleSystem
{
public void AttackCharacter(ICharacter character)
{
character.Attack();
}
}
- character 객체는 자신이 바라보고 있는 실제 객체 정보에 따라서 Attack()을 호출하게 된다.
(5) 설계 방식 변경에 따른 이점
public class Assassin : ICharacter
{
public void Attack()
{
Console.WriteLine("암살자가 공격합니다.");
}
}
ICharacter assassin = new Assassin();
battleSystem.AttackCharacter(assassin);
- 새로운 직업이 추가되더라도 battleSystem의 코드는 변경할 필요가 사라진다.
(6) 정리
- OCP는 확장에는 열려 있고, 수정에는 닫혀야 있다는 원칙이다.
- 새로운 기능이 추가될 때 기존 코드를 계속 수정해야 한다면 OCP를 위반할 가능성이 높다.
- if , else-if , switch 로 타입을 계속 검사하는 구조는 새로운 타입이 추가될 때 마다 수정이 필요할 수 있다.
- 인터페이스 또는 추상 클래스를 사용하면, 새로운 클래스를 추가하는 방식으로 기능을 확장할 수 있다.
- 핵심은 새로운 요구사항이 생겼을 때 기존의 안정적인 코드는 최대한 유지하고, 새로운 코드 추가로 해결이 가능하다.
2-3. LSP(Liskov Substitution Principle, 리스코프 치환 원칙)
(1) 정의
- LSP란 자식 클래스는 부모 클래스를 대체할 수 있어야 한다는 원칙이다.
- 즉, 부모 타입이 사용되는 곳에 자식 객체를 넣어도 프로그램의 논리가 깨지면 안 된다는 뜻이다.
- 쉽게 말하면, 부모라고 믿고 사용했는데 자식 객체 때문에 이상하게 동작하면 안 된다.
- 따라서 부모 클래스가 약속한 행동을 자식 클래스도 지켜야 한다.
(2) 필요한 이유
- 상속을 사용할 때 부모 클래스의 기능을 자식 클래스가 제대로 지키지 않으면 코드의 신뢰성이 떨어진다.
- 부모 타입으로 객체를 다루는 코드는 ‘이 객체는 부모가 제공한 기능을 정상적으로 수행할 것’이라고 기대한다.
- 그런데 특정 자식 클래스가 부모의 동작을 수행하지 못하면 예외 처리나 조건문이 늘어나게 된다.
- LSP는 상속 구조에서 부모와 자식의 관계가 논리적으로 올바른지 판단하기 위해 필요하다.
(3) 올바르지 않은 사례
① Bird 설계
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("새가 날아갑니다.");
}
}
public class Eagle : Bird
{
public override void Fly()
{
Console.WriteLine("독수리가 높이 날아갑니다.");
}
}
public class Penguin : Bird
{
public override void Fly()
{
Console.WriteLine("펭귄은 날 수 없습니다.");
}
}
- 위 코드에서 Bird는 Fly() 기능을 가지고 있다.
- 따라서 Bird 타입을 사용하는 코드는 모든 Bird 가 날 수 있다고 기대한다.
public class BirdManager
{
public static void BirdFly(Bird bird)
{
bird.Fly();
}
}
Bird eagle = new Eagle(); // 독수리 : 날 수 있다. 가능.
Bird penguin = new Penguin(); // 펭귄 : 날 수 없다. 불가능
BirdManager.BirdFly(eagle);
BirdManager.BirdFly(penguin); // 기능 동작 불가
- 독수리는 날 수 있다.
- 하지만 펭귄은 날 수 없다. (새는 맞지만 날 수 없으니까..)

날 수 없다.
※ 즉, Bird 타입으로 사용했을 때 기대한 Fly() 동작을 수행하지 못한다.
① 발생할 수 있는 문제점 1. 부모 타입의 기대를 자식 클래스가 깨트린다.
public class Penguin : Bird
{
public override void Fly()
{
Console.WriteLine("펭귄은 날 수 없습니다.");
}
}
- 펭귄은 Bird를 상속 받았지만, Bird의 행동 약속을 지키지 못한다.
- 이에 따라서, 펭귄을 따로 처리하기 위해 예외 처리가 필요할 수 있다.
(4) 부모 클래스에서 분리하는 방식의 설계 (LSP를 적용한 설계)
① 적용 사례1
public interface IFlyable
{
void Fly();
}
- 날 수 있는 새와 날지 못하는 새를 분리하기 위해 별도의 인터페이스로 분리한다.
public class Eagle : Bird, IFlyable
{
public Eagle()
{
Name = "독수리";
}
public void Fly()
{
Console.WriteLine("독수리가 높이 날아갑니다.");
}
}
public class Penguin : Bird
{
public Penguin()
{
Name = "펭귄";
}
public void Swim()
{
Console.WriteLine("펭귄이 헤엄칩니다.");
}
}
- 이렇게 날 수 있는 경우에만 IFlyable 을 구현하게 만들어 준다.
- 이렇게 하면 FlyBird() 에는 애초에 날 수 있는 객체만 들어올 수 있다.
② 적용 사례2
public abstract class Character
{
public abstract void Move();
public abstract void Attack();
}
public class Warrior : Character
{
public override void Move()
{
Console.WriteLine("전사가 뛰어서 이동합니다.");
}
public override void Attack()
{
Console.WriteLine("전사가 검으로 공격합니다.");
}
}
public class Mage : Character
{
public override void Move()
{
Console.WriteLine("마법사가 순간 이동합니다.");
}
public override void Attack()
{
Console.WriteLine("마법사가 화염 공격을 합니다.");
}
}
- 모든 캐릭터가 이동과 공격을 할 수 있다. 라는 전제가 있다면, LSP를 만족할 수 있다.
- 즉, 부모 클래스가 약속한 행동을 지킬 수 있다면, 반드시 분리가 필요한 것은 아니다.
(5) 설계 방식 변경에 따른 이점
- LSP를 지키면 부모 타입을 사용하는 코드가 자식 클래스의 구체적인 타입을 몰라도 된다.
public class Battle
{
public static void Execute(Character character)
{
character.Move();
character.Attack();
}
}
- 여기서 Battle 타입(Warrior, Mage)에 대한 검사를 할 필요가 없다.
if (character is Warrior)
{
// 전사 처리
}
else if (character is Mage)
{
// 마법사 처리
}
- 위와 같은 타입 처리를 하지 않아도, 구현이 되었음을 믿고 사용할 수 있다.
(6) 정리
- LSP는 자식 클래스가 부모 클래스를 대체할 수 있어야 한다는 원칙이다.
- 부모 타입이 사용되는 곳에 자식 객체를 넣어도 기존 동작이 깨지면 안 된다.
- 상속은 단순히 현실 세계의 분류만 보고 결정하면 안 된다.
- 부모 클래스가 제공하는 행동을 자식 클래스가 제대로 수행할 수 있는지 확인해야 한다.
- Penguin : Bird처럼 현실적으로는 맞아 보여도, Bird가 Fly()를 약속한다면 펭귄은 그 약속을 지킬 수 없다.
- 이런 경우에는 IFlyable처럼 행동 단위로 인터페이스를 분리하는 것이 더 적절하다.
- 핵심은 부모 타입을 믿고 사용해도 자식 객체가 그 기대를 깨뜨리지 않아야 한다는 것이다.
2-4. ISP(Interface Segregation Principle, 인터페이스 분리 원칙)
(1) 정의
- ISP란 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하면 안 된다는 원칙이다.
- 즉, 어떤 클래스가 필요하지 않은 기능까지 억지로 구현하도록 만들면 안 된다는 의미이다.
- 하나의 큰 인터페이스를 만들기보다는, 역할에 맞는 작은 인터페이스 여러 개로 분리하는 것이 좋다.
- 단, 무조건 잘게 쪼개라는 뜻은 아니다.
- 핵심은 사용하지 않는 기능을 강제하지 않기 위해 인터페이스를 분리하는 것이다.
(2) 필요한 이유
- 인터페이스가 너무 크면 클래스가 필요 없는 기능까지 구현해야 한다.
- 사용하지 않는 메서드를 억지로 구현하면 빈 메서드나 예외 처리 코드가 생길 수 있다.
- 인터페이스가 변경될 때, 해당 기능을 사용하지 않는 클래스까지 영향을 받을 수 있다.
- ISP는 클래스가 자신에게 필요한 기능에만 의존하도록 만들기 위해 필요하다.
(3) 올바르지 않은 사례
public interface ICharacter
{
void Attack();
void Heal();
void Fly();
}
- 위 인터페이스는 공격, 회복, 비행 기능을 모두 가지고 있다.
- 하지만 모든 캐릭터가 이 기능을 전부 사용할 수 있는 것은 아니다.
public class Warrior : ICharacter
{
public void Attack()
{
Console.WriteLine("전사가 검으로 공격합니다.");
}
public void Heal()
{
// 전사는 회복 기능이 없음
}
public void Fly()
{
// 전사는 날 수 없음
}
}
- 이에 따라서 필요 없는 기능까지 억지로 구현해야 한다.
① 발생할 수 있는 문제점 1. 사용하지 않는 기능을 강제로 구현해야 한다.
public class Warrior : ICharacter
{
public void Fly()
{
// 전사는 날 수 없음
}
}
- Warrior는 공격만 필요하지만, 사용하지 못하는 Fly() 를 구현해야 한다.
② 발생할 수 있는 문제점 2. 빈 메서드나 예외 코드가 늘어난다.
public class Warrior : ICharacter
{
public void Fly()
{
Console.WriteLine("전사는 날 수 없습니다.");
}
}
- 이런 메서드는 클래스의 의도를 흐릿하게 만든다.
- 또, 코드를 읽는 사람 입장에서 이 클래스가 ‘날 수 있는지’를 확인해 봐야 할 수 있다.
(4) 인터페이스 분리를 이용한 설계 (ISP를 적용한 설계)
public interface IAttack
{
void Attack();
}
public interface IHeal
{
void Heal();
}
public interface IFly
{
void Fly();
}
- 이렇게 나누면 각 클래스는 자신에게 필요한 인터페이스만 구현하면 된다.
public class Warrior : IAttack
{
public void Attack()
{
Console.WriteLine("전사가 검으로 공격합니다.");
}
}
public class Mage : IAttack, IHeal
{
public void Attack()
{
Console.WriteLine("마법사가 공격합니다.");
}
public void Heal()
{
Console.WriteLine("마법사가 회복 마법을 사용합니다.");
}
}
public class Bird : IFly
{
public void Fly()
{
Console.WriteLine("새가 날아갑니다.");
}
}
public class Dragon : IAttack, IFly
{
public void Attack()
{
Console.WriteLine("드래곤이 화염 공격을 합니다.");
}
public void Fly()
{
Console.WriteLine("드래곤이 날아갑니다.");
}
}
- 이렇게 하면 각 클래스는 자신에게 필요한 기능만 구현해 주면 된다.
- Warrior : 공격 가능
- Mage : 공격, 회복 가능
- Bird : 비행 가능
- Dragon : 공격, 비행 가능
(5) 설계 방식 변경에 따른 이점
public interface ICharacter
{
void Attack();
void Heal();
void Fly();
}
- ISP 적용 전에는 모든 클래스가 ICharacter에서 불필요한 메서드까지 구현해야 했다.
- 하지만, ISP를 적용하면 필요한 기능만 선택해서 구현할 수 있다.
(6) 주의할 점
- ISP는 인터페이스를 무조건 작게 쪼개라는 뜻은 아니다.
- 항상 함께 사용되는 기능은 하나의 인터페이스로 묶어도 된다.
public interface ILeftMovable
{
void LeftMove();
}
public interface IRightMovable
{
void RightMove();
}
- 이런 식으로 쪼갤 필요는 없다.
(7) 정리
- ISP는 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하면 안 된다는 원칙이다.
- 큰 인터페이스 하나에 여러 기능을 몰아넣으면, 클래스가 필요 없는 기능까지 구현해야 할 수 있다.
- 사용하지 않는 메서드를 억지로 구현하면 빈 메서드나 예외 코드가 생길 수 있다.
- 기능별로 인터페이스를 분리하면 클래스는 자신에게 필요한 기능만 구현할 수 있다.
- 단, 인터페이스를 무조건 잘게 쪼개는 것이 목적은 아니다.
- 핵심은 ‘사용하지 않는 기능을 강제하지 않는 것’이다.
2-5. DIP(Dependency Inversion Principle, 의존성 역전 원칙)
(1) 정의
- 즉, 상위 모듈이 하위 모듈의 구체적인 구현에 직접 의존하지 않고, 추상화된 인터페이스를 통해 연결되어야 한다는 의미이다.
- 쉽게 말하면, 직접 만들고 직접 사용하는 구조를 피하고, 인터페이스를 통해 연결하자는 것이다.
- DIP는 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙이다.
(2) 필요한 이유
- 클래스가 구체적인 구현체를 직접 생성하고 사용하면 결합도가 높아진다.
- 결합도가 높아지면 특정 기능을 교체할 때 기존 코드를 수정해야 한다.
- 테스트할 때도 실제 구현체에 의존하게 되어 테스트 대역 객체를 사용하기 어려워진다.
- DIP는 구현체 교체가 쉬운 구조를 만들고, 모듈 간 결합도를 낮추기 위해 필요하다.
(3) 올바르지 않은 사례
public class Sword
{
public void Use()
{
Console.WriteLine("검으로 공격합니다.");
}
}
public class Warrior
{
private Sword sword = new Sword();
public void Attack()
{
sword.Use();
}
}
private Sword sword = new Sword();
- 위 코드에서 Warrior 는 Sword를 직접 생성하고 사용한다.
- 즉, Warrior는 Sword라는 구체적인 클래스에 직접 의존한다.
① 발생할 수 있는 문제점 1. 무기가 바뀌면 Warrior를 수정해야 한다.
private Bow bow = new Bow();
- 전사가 활을 사용한다면 Warrior 코드를 수정해야 한다.
- 즉, 무기 종류가 바뀌는 것만으로 전사 클래스가 변경되는 것이다.
② 발생할 수 있는 문제점 2. 새로운 무기의 추가가 어려워진다.
public class Axe
{
public void Use()
{
Console.WriteLine("도끼로 공격합니다.");
}
}
- 새로운 무기가 추가될 때마다 `Warrior` 내부 코드를 수정해야 한다.
- 따라서 무기가 늘어갈 수록 `Warrior` 가 알아야 하는 클래스도 늘어난다.
(4) 공통 동작을 분리하는 설계 방법(DIP를 적용한 설계)
public interface IWeapon
{
void Use();
}
public class Sword : IWeapon
{
public void Use()
{
Console.WriteLine("검으로 공격합니다.");
}
}
public class Bow : IWeapon
{
public void Use()
{
Console.WriteLine("화살로 공격합니다.");
}
}
public class Axe : IWeapon
{
public void Use()
{
Console.WriteLine("도끼로 공격합니다.");
}
}
- 이렇게 나눠두면 Warrior는 구체적인 무기 클래스를 알 필요가 없어진다.
public class Warrior
{
private IWeapon weapon;
public Warrior(IWeapon weapon)
{
this.weapon = weapon;
}
public void Attack()
{
weapon.Use(); // 내가 보는 무기가 무엇인지 상관없이 쓸 수 있다.
}
}
- Warrior는 Sword, Bow , Axe를 직접 의존하지 않아도 된다.
- IWeapon 이라는 추상화에 의존하게 되는 것이다.
(5) 의존성 주입(Dependency Injection)과의 관계
public Warrior(IWeapon weapon)
{
this.weapon = weapon;
}
- 위 코드는 생성자를 통해 외부에서 필요한 의존성을 전달 받는다.
- 이를 생성자 주입(Constructor Injection) 이라고 한다.
private Sword sword = new Sword();
- 기존 방식은 Warrior가 직접 Sword를 생성했다.
Warrior warrior = new Warrior(new Sword());
- 하지만 이렇게 하면, Warrior는 무기를 직접 만들지 않고, 전달 받은 무기만 사용한다.
(6) 설계 방식 변경에 따른 이점
public class Warrior
{
private Sword sword = new Sword();
public void Attack()
{
sword.Use();
}
}
- 이 경우 무기를 바꾸려면 Warrior의 수정이 필요하다.
- DIP 적용 후에는 Warrior가 IWeapon에만 의존하여 변경할 필요가 없다.
public class Warrior
{
private IWeapon weapon;
public Warrior(IWeapon weapon)
{
this.weapon = weapon;
}
public void Attack() // 무기가 바뀌어도 use를 부르는건 같다
{
weapon.Use();
}
}
(7) 정리
- DIP는 구체적인 클래스가 아닌 추상적인 인터페이스에 의존해야 한다는 원칙이다.
- 상위 모듈과 하위 모듈이 직접 연결되면 결합도가 높아진다.
- 결합도가 높으면 구현체를 교체하거나 테스트하기 어려워진다.
- 인터페이스를 사이에 두면 상위 모듈은 구체적인 구현체를 몰라도 된다.
- DI는 DIP를 구현하는 대표적인 방법이다.
- 핵심은 직접 생성하고 직접 사용하는 구조를 줄이고, 추상화를 통해 연결하는 것이다.
'Unity > Unity 초격차캠프' 카테고리의 다른 글
| [20260512] C# ( List ) (0) | 2026.05.12 |
|---|---|
| [20260512] C# ( 제네릭 프로그래밍 ) (0) | 2026.05.12 |
| [20260508] C# (Upcasting, Downcasting, is / as) (0) | 2026.05.08 |
| [20260507] C# ( namespace ) (0) | 2026.05.07 |
| [20260507] C# ( 추상 클래스 & 인터페이스의 차이점 ) (0) | 2026.05.07 |