9. SOLID - LSP
1. SOLID 원칙
1. SOLID
OOP, 객체 지향 프로그래밍 설계의 기본 원칙
SOLID | Eng. | Kor. |
SRP | Single Responsibility Principle | 단일 책임 원칙 |
OCP | Open-Closed Principle | 개방-폐쇄 원칙 |
LSP | Liskov Substitution Principle | 리스코프 치환 원칙 |
ISP | Interface Segregation Principle | 인터페이스 분리 원칙 |
DIP | Dependency Inversion Principle | 의존성 역전 원칙 |
2. 리스코프 치환 원칙(LSP, Liskov Substitution Principle)
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
"서브 타입은 언제나 자신의 기반 타입(Base Type)으로 대체할 수 있어야 한다."
상위 타입의 객체가, 하위 타입의 객체로 변환되어도, 상위 타입은 정상적으로 동작해야 한다.
즉, 부모 클래스나 추상클래스, 인터페이스가, 자식 클래스로 변경되어도 동일하게 작동해야 하며, 부모 클래스의 행동을 자식 클래스가 변경해서는 안 됩니다.
간단한 예시
상위 클래스 : 사람{기능(근접공격)}
하위 클래스 : 전사{기능(근접공격)}
하위 클래스 : 법사{기능(근접공격) + 기능(마법공격)}
상위 클래스 : 사람{기능(근접공격)} 기능을 사용하는 로직에서
하위 클래스 : 전사{기능(근접공격)}, 하위 클래스 : 법사{기능(근접공격)} 으로 대체되어도 정상적으로 동작한다.
하지만, 상위 클래스 : 사람{기능(근접공격) + 기능(마법공격)} 이였다면,
하위 클래스 : 전사{기능(근접공격)} 으로 대체될 수 없다.
기능(마법공격)이 없기 때문에, 오류를 발생시킨다.
이를 리스코프 치환 법칙을 위배한 구조라고 한다.
3. 참새와 팽귄 예시
1. 가정
참새와 팽귄은 모두 새 입니다.
(공통) 참새와 팽귄 모두 "먹이를 먹고", "걷기"를 할 수 있습니다.
(차이) 하지만참새는 "날 수 있고",팽귄은 "날 수 없습니다."
2. LSP를 적용한 코드 구조 설계
- 부모 클래스(Bird)는 공통 속성을 정의하고, 자식 클래스(Sparrow, Penguin)에서 구체적인 동작을 정의
- 부모 클래스(Bird)의 행동이 자식 클래스에서 일관되게 동작하도록 보장 → 공통 기능 구현
- Bird 클래스의 Eat() 메서드는 모든 새에서 일관되게 동작
- 자식 클래스에서 부모 클래스의 동작을 무너뜨리지 않음→ 개별 기능은 인터페이스나 추상 클래스를 통해 다형성을 적용
- IFlyable 인터페이스를 통해 날 수 있는 새만의 행동을 명확히 구분
- 기대 효과 →새로운 새 소스 추가 시 기존 코드를 수정할 필요 없음
3. ✅ LSP가 지켜진 코드
namespace LSP_Demo01
{
using System;
namespace LSPExample
{
// ✅ 상위 클래스: 새(Bird)
public abstract class Bird
{
public abstract void Eat();
}
// ✅ 인터페이스: 날 수 있는 새 기능
public interface IFlyable
{
void Fly();
}
// ✅ 하위 클래스: 참새(Sparrow) → 날 수 있는 새
public class Sparrow : Bird, IFlyable
{
public override void Eat()
{
Console.WriteLine("참새가 먹이를 먹는다.");
}
public void Fly() // 🚨 날 수 있는 새만의 행동 처리
{
Console.WriteLine("참새가 날아간다.");
}
}
// ✅ 하위 클래스: 펭귄(Penguin) → 날 수 없는 새
public class Penguin : Bird
{
public override void Eat()
{
Console.WriteLine("펭귄이 물고기를 먹는다.");
}
}
// ✅ 다형성을 통한 일관된 행동 처리
public class BirdHandler
{
// 모든 새의 공통 행동 처리
public void HandleBird(Bird bird)
{
bird.Eat(); // LSP 원칙에 따라 공통된 행동 처리 가능
}
// 🚨 날 수 있는 새만의 행동 처리
public void HandleFlyingBird(IFlyable bird)
{
bird.Fly();
}
}
class Program
{
static void Main()
{
BirdHandler handler = new BirdHandler();
// 참새는 Bird이면서 Flyable을 구현 → 다형성 적용 가능
Sparrow sparrow = new Sparrow();
handler.HandleBird(sparrow); // 참새는 Bird이므로 처리 가능
handler.HandleFlyingBird(sparrow); // 🚨 참새는 Flyable이므로 날 수 있음
// 펭귄은 Bird이지만 Flyable이 아님 → 날 수 있는 행동을 허용하지 않음
Penguin penguin = new Penguin();
handler.HandleBird(penguin); // 펭귄은 Bird이므로 먹이 먹는 행동만 허용됨
//handler.HandleFlyingBird(penguin); // ❌ 컴파일 오류 발생, 빌드 불가, 오류 방지 → LSP 원칙 준수
// LSP 원칙 준수하지 않았다면, 펭귄이 Fly() 메서드를 호출할 수 있음 → 런타임 오류 발생 가능
}
}
}
}
4. 참새와 팽귄 예시 첨삭
✅ 상위 클래스의 기능이 사용되는 곳에, 하위 클래스로 치환되어도 유지되어야 한다.
1) 치환 구조의 정상 동작 예시
Bird 객체의 Eat( ); 기능이 동작하는 곳에, Sparrow, Penguin이 치환되어도 정상 동작한다.
(프로그램 로직의 특정 실행이 상위 클래스 Bird가 가진 Eat( ) 기능을 사용하고 있다면,
Bird 를 상속받은 하위 클래스인 Sparrow, Penguin 으로 대체되어도 정상 동작되도록 구현되어야 한다.)
*하지만 Fly( )기능은 Sparrow 만이 가진, 공통되지 않은 기능이기에, 치환 대상에 해당되지 않는다.
2) 예시의 구조
BirdHandler.HandleBird(Bird bird); 로직은, [Bird], [Sparrow], [Penguin] 어떤 객체로 치환되어도 정상 동작한다.
>> [Bird 상위 객체]가 사용된 위치에, [Sparrow, Penguin 하위 객체]로 치환 가능하다.
*BirdHandler. HandleFlyingBird (IFlyable bird); 로직은, [Sparrow] 객체에서만 동작해야 한다.
>> fly( ) 기능이 사용되는 곳은, 애초에 치환 대상일 수 없다.
>> Bird 객체가, 공통되지 않은 fly( ) 기능을 포함한 상태가 리스코프 치환 법칙에 위배된다.
3) 리스코프 치환 법칙을 준수하지 않은 구조
만약 Bird 라는 상위 클래스에 Eat( ), Fly( ) 두 기능이 모두 있었다고 가정하고 구현한다면,
Penguin 이 날 수 있는 기능을 갖게되고, 프로그램 구조상 Bird, Sparrow, Penguin 어떤 객체로 치.환.은. 가.능.하.다.
*하지만, 이는 개발자가 의도한 Penguin 객체의 정상 동작일 수 없다.
>> Bird 객체가 fly( )라는 공통되지 않은 기능을 포함하면, 리스코프 치환 법칙에 위배되는 구조가 된다.
4) 리스코프 치환 법칙
*리스코프 치환 법칙의 목적은, 상위 객체가 사용되는 곳이 하위 객체로 치환 가능한 구조를 구현하는 것이지
모든 상위 - 모든 하위 객체가 치환되도록 강제하는 것이 아니다.
이를 만족하려면,
상위 객체에는 Eat( ) 과 같은, 공통 기능(치환 가능한 기능)만을 가진 구조로 구성되어야 하며
Fly( ) 와 같은 공통되지 않은 기능은 ISP 원칙에 맞춰 별도의 추상화된 객체로 분리되어야 한다.
5. 참새와 팽귄에 "오리 추가" 예시
기존 사항
✔️ Iflyable 인터페이스 추가 → 비행 가능한 새 구분
✔️ Bird 클래스 → 먹이o
추가 사항
✔️ ISwimmable 인터페이스 추가 → 수영 가능한 새 구분
✔️ Sparrow 클래스 → 수영x, 비행o
✔️ Penguin 클래스 → 수영o, 비행x
✔️ Duck 클래스 → 수영o, 비행o
using System;
namespace LSPExample
{
// ✅ 상위 클래스: 새(Bird)
public abstract class Bird
{
public abstract void Eat();
}
// ✅ 하위 클래스: 날 수 있는 새
public interface IFlyable
{
void Fly();
}
// ✅ 하위 클래스: 수영할 수 있는 새
public interface ISwimmable
{
void Swim();
}
// ✅ 하위 클래스: 참새(Sparrow) → 날 수 있는 새
public class Sparrow : Bird, IFlyable
{
public override void Eat()
{
Console.WriteLine("참새가 먹이를 먹는다.");
}
public void Fly()
{
Console.WriteLine("참새가 날아간다.");
}
}
// ✅ 하위 클래스: 펭귄(Penguin) → 날 수 없지만 수영 가능
public class Penguin : Bird, ISwimmable
{
public override void Eat()
{
Console.WriteLine("펭귄이 물고기를 먹는다.");
}
public void Swim()
{
Console.WriteLine("펭귄이 헤엄친다.");
}
}
// ✅ 하위 클래스: 오리(Duck) → 날 수 있고 수영 가능
public class Duck : Bird, IFlyable, ISwimmable
{
public override void Eat()
{
Console.WriteLine("오리가 먹이를 먹는다.");
}
public void Fly()
{
Console.WriteLine("오리가 날아간다.");
}
public void Swim()
{
Console.WriteLine("오리가 헤엄친다.");
}
}
// ✅ 다형성을 통한 일관된 행동 처리
public class BirdHandler
{
// 모든 새의 공통 행동 처리
public void HandleBird(Bird bird)
{
bird.Eat(); // LSP 원칙에 따라 공통된 행동 처리 가능
}
// 날 수 있는 새만의 행동 처리
public void HandleFlyingBird(IFlyable bird)
{
bird.Fly();
}
// 수영할 수 있는 새만의 행동 처리
public void HandleSwimmingBird(ISwimmable bird)
{
bird.Swim();
}
}
class Program
{
static void Main()
{
BirdHandler handler = new BirdHandler();
// 참새는 Bird이면서 Flyable을 구현 → 다형성 적용 가능
Sparrow sparrow = new Sparrow();
handler.HandleBird(sparrow);
handler.HandleFlyingBird(sparrow);
// 펭귄은 Bird이면서 Swimmable을 구현 → 수영은 가능하지만 날 수 없음
Penguin penguin = new Penguin();
handler.HandleBird(penguin);
handler.HandleSwimmingBird(penguin);
// 오리는 Bird이면서 Flyable + Swimmable을 구현 → 날고 수영 가능
Duck duck = new Duck();
handler.HandleBird(duck);
handler.HandleFlyingBird(duck);
handler.HandleSwimmingBird(duck);
}
}
}