5.3 SOLID - LSP
1. SOLID 원칙
1. SOLID
SOLID | Eng. | Kor. |
SRP | Single Responsibility Principle | 단일 책임 원칙 |
OCP | Open-Closed Principle | 개방-폐쇄 원칙 |
LSP | Liskov Substitution Principle | 리스코프 치환 원칙 |
ISP | Interface Segregation Principle | 인터페이스 분리 원칙 |
DIP | Dependency Inversion Principle | 의존성 역전 원칙 |
객체지향 개념은 추상화 → 캡슐화 → 다형성 → 상속/인터페이스 → SOLID 원칙 → 디자인 패턴 → 아치텍처 패턴 등이 서로 연결되어 있습니다.
2. 리스코프 치환 원칙(LSP, Liskov Substitution Principle)
A. 개념
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
- "Data Abstraction and Hierarchy(1987)" Barbara Liskov
B. 용어
"서브 타입은 언제나 자신의 기반 타입(Base Type)으로 대체할 수 있어야 한다."
즉, 부모 클래스나 추상클래스, 인터페이스가, 자식 클래스로 변경되어도 동일하게 작동해야 하며,
부모 클래스의 행동을 자식 클래스가 변경해서는 안 됩니다.
C. 고려사항
OCR 예제들은 DIP 예제와 유사하며,
이전에 학습한 SRP과 연관하여 문제점과 해결책을 살펴봅니다.
3. 참새와 팽귄 예시
A. 가정
참새와 팽귄은 모두 새 입니다.
(공통) 참새와 팽귄 모두 "(기능1)먹이를 먹고", "(기능2)걷기"를 할 수 있습니다.
(차이) 하지만 참새는 "(기능3)날 수 있고", 팽귄은 "(x)날 수 없습니다."
B. LSP를 적용한 코드 구조 설계
- 부모 클래스(Bird)는 공통 속성을 정의하고, 자식 클래스(Sparrow, Penguin)에서 구체적인 동작을 정의
- 공통
- 부모 클래스(Bird)의 행동이 자식 클래스에서 일관되게 동작하도록 보장 >> 공통 기능 구현(추상화)
- Bird 클래스의 Eat( ) 메서드는 확장된 모든 새에서 일관되게 동작
- 구체
- IFlyable 인터페이스를 통해 날 수 있는 새만의 행동을 명확히 구분
- 자식 클래스에서 부모 클래스의 동작을 무너뜨리지 않음
>> 개별 기능은 인터페이스나 추상 클래스를 통해 다형성을 적용
- 기대 효과 >> 새로운 새 소스 추가 시 기존 코드를 수정할 필요 없음
3. LSP가 준수된 코드
using System;
namespace LSP_Demo01
{
// 상위 클래스: 새 (공통 행동 = Eat)
public abstract class Bird
{
public abstract void Eat();
}
// 인터페이스: 날 수 있는 새만 구현
public interface IFlyable
{
void Fly();
}
// 하위 클래스: 참새 (Bird + Flyable)
public class Sparrow : Bird, IFlyable
{
public override void Eat()
{
Console.WriteLine("참새가 먹이를 먹는다.");
}
public void Fly()
{
Console.WriteLine("참새가 날아간다.");
}
}
// 하위 클래스: 펭귄 (Bird만, 날 수 없음)
public class Penguin : Bird
{
public override void Eat()
{
Console.WriteLine("펭귄이 물고기를 먹는다.");
}
}
// 핸들러: 다형성 적용
public class BirdHandler
{
public void HandleEat(Bird bird)
{
bird.Eat(); // Bird 공통 동작
}
public void HandleFly(IFlyable bird)
{
bird.Fly(); // 날 수 있는 새만
}
}
class Program
{
static void Main()
{
BirdHandler handler = new BirdHandler();
Sparrow sparrow = new Sparrow();
handler.HandleEat(sparrow); // 🟢 Bird 타입으로 대체 가능
handler.HandleFly(sparrow); // 🟢 Flyable 타입으로 대체 가능
Penguin penguin = new Penguin();
handler.HandleEat(penguin); // 🟢 Bird 타입으로 대체 가능
// handler.HandleFly(penguin); // 🔴 컴파일 오류 (Flyable 아님 → 안전)
}
}
}
- Sparrow 와 Penguin 은 모두 Bird 로 대체 가능 (Eat 호출 시 문제 없음).
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);
}
}
}
6. 예시
가정
GUI 클릭 동작에 빗대면, “클릭을 처리하는 코드가 ‘컨트롤의 구체 타입’을 몰라도 항상 잘 동작해야 한다”
// 인터페이스는 "약속(Contract)"을 정의하는 추상화 수단
// 여기서는 "클릭할 수 있는 능력"을 모든 컨트롤에게 강제한다.
public interface IClickable
{
bool IsEnabled { get; } // 읽기 전용 속성: IsEnabled (활성화 여부)
void Click(int x, int y); // 반드시 구현해야 하는 메서드: Click(x, y) (좌표 기반 클릭 처리)
}
// 공통 기능(예: IsEnabled, Click의 기본 구조)을 제공
// 하지만 세부 동작(OnClick) 구현은 하위 클래스에게 위임
public abstract class Control : IClickable
{
public bool IsEnabled { get; protected set; } = true;
public void Click(int x, int y) // 인터페이스에서 약속한 Click 메서드의 기본 구현
{
if (!IsEnabled) return;
OnClick(x, y); // 세부 동작은 파생 클래스가 책임진다
}
// 추상 메서드: 파생 클래스가 반드시 구현해야 함
protected abstract void OnClick(int x, int y);
}
// Button은 Control(추상 클래스)을 상속받아 추상 메서드 OnClick을 구현
public class Button : Control
{
protected override void OnClick(int x, int y)
{
Console.WriteLine("Button clicked: 기본 실행");
}
}
// ImageButton은 Button(구체 클래스)을 상속받아 동작을 확장한다.
// 기존 Button의 동작을 깨지 않고, "이미지 강조" 기능을 추가
// 🟢 LSP 원칙에 의해, Button과 교환 가능해야 함
public class ImageButton : Button
{
protected override void OnClick(int x, int y)
{
Console.WriteLine("ImageButton clicked: 이미지 강조");
base.OnClick(x, y); // 부모(Button)의 기본 실행 동작도 수행
}
}
#region Dispatcher (사용 측)
// UiDispatcher는 IClickable 인터페이스만 의존하여
// Button, ImageButton, CheckBox, Slider 등 어떤 구체 타입이 들어와도 동일하게 다룰 수 있다.
public static class UiDispatcher
{
public static void DispatchClick(IClickable c, int x, int y)
{
c.Click(x, y); // 타입 분기/캐스팅 필요 없음
}
}
#endregion
#region Program Entry
class Program
{
static void Main()
{
// 배열에 담고
IClickable[] controls =
{
new Button(), // 기본 버튼
new ImageButton() // 확장된 버튼 (🟢 Button과 교환 가능)
};
foreach (var c in controls)
{
UiDispatcher.DispatchClick(c, 10, 20);
}
}
}
#endregion
간단한 예시(2)
상위 클래스 : 사람{기능1(근접공격)}
하위 클래스 : 전사{기능1(근접공격)}
하위 클래스 : 법사{기능1(근접공격) + 기능2(마법공격)}
상위 클래스 : 사람{기능1(근접공격)} 기능을 사용하는 로직에서
하위 클래스 : 전사{기능1(근접공격)},
하위 클래스 : 법사{기능1(근접공격)} 으로 대체되어도 정상적으로 동작한다.
하지만,
상위 클래스가 사람{기능1(근접공격) + 기능2(마법공격)} 이였다면,
하위 클래스의 전사{기능1(근접공격)} 으로 대체될 수 없다.
하위 클래스에 기능(마법공격)이 없기 때문에, 오류를 발생시킨다.
이를 리스코프 치환 법칙을 위배한 구조라고 한다.
ㅇ