Cute Light Pink Flying Butterfly LSP 리스코프 치환 원칙 | SOLID 설계 원칙 :: 놀면서 돈벌기
본문 바로가기
IT/Architecture

LSP 리스코프 치환 원칙 | SOLID 설계 원칙

by esclife_ 2025. 10. 26.
반응형

LSP(Liskov Substitution Principle) - 리스코프 치환 원칙

바바라 리스코프가 올바른 상속관계를 정의하기 위해 발표한 원칙으로, 서브타입(은 언제나 기반타입으로 교체가 가능해야 한다는 원칙입니다. 교체가능을 쉽게말하면 서브클래스에서 부모클래스의 기능을 기존대로 쓸 수 있어야 한다는 거에요.
(Inpa Blog.)자바에서 Collection 타입 객체에서 LinkkedList 에서 HashSet으로 바꾸어도 add()메서드는 의도대로 정상 작동한다

 

  • 서브타입은 언제나 부모타입으로 교체가 가능해야 한다
  • 상위클래스 타입으로 객체 선언하여, 하위 클래스 인스턴스를 받았을 때 업캐스팅된 상태에서 부모메서드를 사용해도 동작이 정상 실행되어야 한다
  • 다르게 말하면, 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 된다
  • 오버라이딩을 할 때, 재정의를 잘 못 하면 리스코프 치환 원칙을 위반하게 된다 (ex: 오버라이딩 해야 할 메서드를 임의로 null 처리하여 사용하지 않으려고 하면, 실행 시 NullPointException이 부모클래스단에서 발생)
  • 다형성의 특징을 지원하기 위한 원칙
  • 부모메서드의 오버라이딩을 잘 살펴보며 구현해야 한다는 의미
  • 협업 관계에서 신뢰를 의미한다. 자식클래스에서 개발자 편의로 throw, null처리 등으로 부모클래스 일부를 구현하지 않고 넘어갈 수 있지만. 추후 다른 사람이 코드를 돌려봤을 때 해당 자식클래스에서 에러가 발생하는 경우가 그렇다.
더보기

🎇 다형성 이란?

다형성(Polymorphism)은 객체 지향 프로그래밍(OOP)의 핵심 개념 중 하나로, 여러 형태를 가질 수 있다는 의미입니다. 이는 하나의 객체나 메서드가 상황에 따라 다르게 동작하거나 여러 가지 타입으로 취급될 수 있는 능력을 말합니다.

다형성을 준수하려면 클래스를 상속 시켜 타입을 통합할 수 있게 설정하고, 업캐스팅을 해도 메소드 동작에 문제없게 잘 설계하여야 한다. 다형성을 얻는 가장 유연하고 안전한 방법은 인터페이스를 사용하는 것으로,  클래스 상속(extends) 대신 인터페이스 구현(implements)을 사용합니다.

다형성의 형태 설명 예시
오버로딩 (Ad-hoc Polymorphism) 하나의 클래스 내에서 같은 이름의 메서드가 다른 매개변수 목록을 가질 수 있는 능력입니다. 컴파일 시점에 결정됩니다. print(int i)와 print(String s)
오버라이딩/서브타이핑 (Subtype Polymorphism) 부모 클래스나 인터페이스 타입으로 선언된 변수가 자식 클래스 인스턴스를 참조할 수 있는 능력입니다. 런타임에 어떤 메서드가 실행될지 결정됩니다. Animal a = new Dog(); a.makeSound();

다형성의 이점,

  • 코드 재사용성: 공통된 타입을 정의함으로써 코드를 반복하지 않고 재사용할 수 있습니다.
  • 유지보수성 및 확장성: 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 새로운 클래스를 구현하고 부모 타입에 통합할 수 있습니다 (개방-폐쇄 원칙, OCP).
  • 느슨한 결합: 상위 타입에 의존함으로써 구체적인 하위 타입에 대한 의존성을 줄여, 시스템 구성 요소 간의 결합도를 낮춥니다.

 


더보기

🎇업캐스팅 이란?

업캐스팅(Upcasting)이란 객체 지향 프로그래밍에서 자식 클래스(Subclass)의 객체부모 클래스(Superclass) 또는 인터페이스 타입의 변수에 할당하는 것을 말합니다. 이는 객체 지향의 핵심 개념인 다형성(Polymorphism)을 구현하는 기본적인 메커니즘입니다.

특징 설명
타입 변환 자식 타입에서 부모 타입으로의 변환이며, 자동으로 발생합니다 (명시적인 형 변환 연산자 생략 가능).
안전성 업캐스팅은 항상 안전합니다. 자식 타입은 부모 타입의 모든 멤버를 포함하기 때문입니다 (LSP의 기본 전제).
접근 제한 업캐스팅된 참조 변수는 **부모 클래스에 정의된 멤버(변수, 메서드)**만 접근할 수 있습니다. 자식 클래스에만 정의된 멤버는 접근할 수 없습니다.
메서드 호출 하지만, 메서드를 호출할 때는 **실제 객체(자식 클래스 인스턴스)**에 오버라이딩된 메서드가 호출됩니다 (동적 바인딩).
// 업캐스팅 예시
// 부모 클래스
class Animal {
    public void makeSound() {
        System.out.println("Animal makes a sound.");
    }
}

// 자식 클래스
class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof! Woof!");
    }
    public void fetch() {
        System.out.println("Fetching the ball.");
    }
}

public class UpcastingExample {
    public static void main(String[] args) {
        // 🐾 업캐스팅 발생 지점
        Animal myAnimal = new Dog(); 
        
        // 1. 부모 타입 메서드 호출 (다형성): 실제 객체인 Dog의 메서드가 실행됨.
        myAnimal.makeSound(); // 출력: "Woof! Woof!" 
        
        // 2. 접근 제한: Dog 클래스에만 있는 메서드에는 접근 불가.
        // myAnimal.fetch(); // 컴파일 에러 발생
    }
}

 

 

업캐스팅의 이점,

  • 배열/컬렉션 관리: 여러 종류의 자식 객체(예: Dog, Cat, Cow)를 하나의 부모 타입 배열 (Animal[])이나 리스트 (List<Animal>)로 묶어 일괄적으로 관리할 수 있습니다.
  • 유연성: 메서드의 매개변수로 부모 타입을 지정함으로써, 해당 메서드는 그 부모를 상속받는 모든 자식 클래스 객체를 인수로 받을 수 있게 되어 코드의 재사용성과 확장성이 높아집니다 (OCP 준수).

 


 

더보기

🎇 인스턴스 란?
객체 지향 프로그래밍(OOP)에서 클래스를 기반으로 메모리에 실제로 생성된 실체를 말합니다. 클래스를 기반으로 인스턴스를 메모리에 만드는 과정을 인스턴스화(Instantiation)라고 합니다. C#이나 Java 같은 언어에서는 주로 new 키워드를 사용하여 수행됩니다.

// 이 코드에서, new Dog()를 통해 Dog 클래스의 정의에 따라 메모리 공간에 생성된 실체가 바로 인스턴스입니다.
// 하나의 클래스로부터 수십, 수백 개의 독립적인 인스턴스를 만들 수 있습니다.
// 1. 클래스 정의 (설계도)
public class Dog 
{
    public string Name;
    public int Age;
}

// 2. 인스턴스화 (객체 생성)
// 'myDog'은 Dog 클래스의 인스턴스(객체)를 참조하는 변수입니다.
Dog myDog = new Dog(); 

// 3. 인스턴스 사용
myDog.Name = "Buddy"; 
myDog.Age = 3;

 


LSP 위반 사례 (Bad Design) ❌

새는 날 수 있다는 부모의 일반적인 행위에 자식 객체가 따르지 못해 LSP를 위반합니다.

// 부모 클래스: 일반적인 새
abstract class Bird {
    // 모든 새가 날 수 있을 것으로 '기대'하는 메서드
    public abstract void fly(); 
    public void eat() {
        System.out.println("Bird is eating.");
    }
}

// 자식 클래스: 펭귄 (날 수 없음)
class Penguin extends Bird {
    @Override
    public void fly() {
        // LSP 위반: 부모의 'fly' 행위를 따르지 못하고 예외를 발생시키거나 비정상적인 행동을 함.
        throw new UnsupportedOperationException("Penguins cannot fly!"); 
    }
    
    public void swim() {
        System.out.println("Penguin is swimming.");
    }
}

// 클라이언트 코드 (LSP 위반 상황)
public class LspViolationExample {
    public static void main(String[] args) {
        Bird myBird = new Penguin(); // 부모 타입으로 자식 객체 치환
        
        // 클라이언트는 Bird 타입이므로 'fly()'가 정상 작동하리라 기대함.
        // 하지만 여기서 예외(Exception)가 발생하여 프로그램의 정확성이 깨짐.
        try {
            myBird.fly(); 
        } catch (UnsupportedOperationException e) {
            System.out.println("LSP Violation: " + e.getMessage());
        }
    }
}

문제점: 클라이언트(사용자)는 Bird 타입 객체에 fly()를 호출하면 성공적으로 날 것이라고 기대하지만, Penguin 객체로 치환되었을 때 예외가 발생하여 부모의 계약(Contract)을 깨뜨립니다.


🛠️ 부모 클래스 수정 불가 시 LSP 준수 방법

부모 클래스(Bird)를 수정할 수 없는 상황에서 LSP를 위반하는 자식 클래스(Penguin)를 추가해야 한다면, 이는 Adapter Pattern (어댑터 패턴) 또는 Composition (합성)을 활용하여 문제를 해결하고 LSP를 준수할 수 있습니다.

핵심은 펭귄이 실제로 나는 기능이 없음에도 불구하고 부모의 fly() 메서드를 강제로 구현해야 하는 상황을 안전하게 처리하는 것입니다.

 

1. 가장 흔한 방어적 구현 (Unsupported Operation)

부모 클래스의 계약을 어쩔 수 없이 구현해야 할 때, 해당 기능이 지원되지 않음을 명시적으로 알리는 방법입니다. LSP를 엄밀하게 준수했다고 보기는 어려우나, 수정 불가능한 기존 시스템과의 호환성을 위해 사용하는 실용적인 방어책입니다.

 

// 부모 클래스 (수정 불가)
abstract class Bird {
    public abstract void fly(); 
}

// 자식 클래스 개발자의 선택
class Penguin extends Bird {
    @Override
    public void fly() {
        // 런타임에 에러를 발생시켜 이 메서드를 호출하면 안 됨을 명시적으로 알림.
        // 이는 LSP를 위반하지만, '수정 불가능한' 부모 계약을 처리하는 현실적인 방법.
        throw new UnsupportedOperationException("Penguin cannot fly. Do not call this method."); 
    }
    
    public void swim() {
        System.out.println("Penguin is swimming.");
    }
}

2. Null/No-Op 구현 (Do Nothing)

이 방법은 예외를 발생시키지 않고, 메서드가 호출되어도 아무런 동작도 하지 않게 하여 프로그램의 비정상적인 종료를 막습니다. 클라이언트가 부모 타입으로 fly()를 호출하더라도 안전합니다.

// 자식 클래스 개발자의 선택
class Penguin extends Bird {
    @Override
    public void fly() {
        // 아무것도 하지 않음 (No-Operation, No-Op).
        // 클라이언트 코드의 실행 흐름을 깨뜨리지 않아 LSP 위반의 충격을 줄임.
    }
    
    public void swim() {
        System.out.println("Penguin is swimming.");
    }
}

💡 궁극적인 해결책: 상속 대신 합성(Composition) 사용

만약 기존의 Bird 타입으로 펭귄을 치환할 필요성이 없다면, 상속 관계 자체를 끊고 대신 **합성(Composition)**을 사용하여 문제를 근본적으로 해결해야 합니다.

  • 상속은 IS-A 관계 (펭귄은 새이다)
  • 합성은 HAS-A 관계 (새는 날개/깃털을 가지고 있다)

새로운 Penguin 클래스는 더 이상 LSP 위반의 원인이 되는 Bird 클래스를 상속하지 않고, 필요한 기능만 독립적으로 구현합니다.

// 기존 Bird 클래스는 그대로 둠 (수정 불가)

// 완전히 새로운 독립적인 클래스로 펭귄을 정의
class Penguin { 
    // Bird를 상속하지 않음. fly() 메서드도 존재하지 않음.
    public void eat() {
        System.out.println("Penguin is eating.");
    }
    
    public void swim() {
        System.out.println("Penguin is swimming.");
    }
}

// 클라이언트가 이 새를 사용하려면 타입을 직접 명시해야 함.
public class LspAlternativeExample {
    public static void main(String[] args) {
        // Penguin은 Bird가 아니므로, Bird 타입으로 치환할 수 없음.
        Penguin myPenguin = new Penguin();
        myPenguin.swim(); // 안전하게 호출
        // myPenguin.fly(); // 컴파일 에러 발생 (메서드가 없으므로)
    }
}

 

이 방식은 LSP를 걱정할 필요 없이 펭귄의 고유한 속성만을 갖는 가장 객체지향적이고 깔끔한 설계이며, 합성이 상속보다 우선한다는 원칙을 따르는 것입니다. 리스코프 치환 원칙은 오버라이딩시 기능을 부모와 다르게 동작하게 구현하면 안된다는 것이지, 무조건 상속(Inheritance)을 사용하라고 강요하는 것이 아닙니다. 


LSP 준수 사례 (Good Design) ✅

상속의 대상을 '날 수 있는' 행위가 아닌, '새'의 더 일반적인 속성으로 변경하여 LSP를 준수합니다.

// 최상위 타입: 모든 새의 공통 행위를 정의하는 인터페이스
interface Bird {
    void eat(); 
}

// 인터페이스: 날 수 있는 새의 '행위'를 계약으로 정의
interface FlyingBird {
    void fly();
}

// 인터페이스: 수영할 수 있는 새의 '행위'를 계약으로 정의
interface SwimmingBird {
    void swim();
}

// 구체적인 클래스 1: 참새 (날 수 있음)
class Sparrow implements Bird, FlyingBird {
    @Override
    public void eat() {
        System.out.println("Sparrow is eating.");
    }

    @Override
    public void fly() {
        System.out.println("Sparrow is flying high!"); // 정상 작동
    }
}

// 구체적인 클래스 2: 펭귄 (날 수 없고, 수영함)
class Penguin implements Bird, SwimmingBird {
    @Override
    public void eat() {
        System.out.println("Penguin is eating fish.");
    }
    
    @Override
    public void swim() {
        System.out.println("Penguin is swimming fast!"); // 고유 기능
    }
}

// 클라이언트 코드 (LSP 준수 상황)
public class LspComplianceExample {
    public static void main(String[] args) {
        // 1. Bird 타입으로 치환 (LSP 준수)
        Bird mySparrow = new Sparrow();
        mySparrow.eat(); // Sparrow의 eat() 정상 작동
        
        Bird myPenguin = new Penguin();
        myPenguin.eat(); // Penguin의 eat() 정상 작동 (부모의 계약 준수)

        System.out.println("\n--- Flying Test ---");
        // 2. FlyingBird 타입으로만 fly() 호출 (안전함)
        if (mySparrow instanceof FlyingBird) {
            ((FlyingBird) mySparrow).fly(); // Sparrow는 날 수 있으므로 안전하게 호출
        }

        // if (myPenguin instanceof FlyingBird) { // 펭귄은 FlyingBird가 아니므로 안전하게 건너뜀
        //    ((FlyingBird) myPenguin).fly();
        // }
    }
}

해결 방법:

  1. **인터페이스 분리 원칙 (ISP)**을 적용하여 **Bird**를 모든 새의 공통 행위(eat)만 정의하는 가장 일반적인 계약으로 만듭니다.
  2. **FlyingBird**와 **SwimmingBird**처럼 특정 행위를 나타내는 인터페이스를 분리합니다.
  3. 이제 Penguin은 FlyingBird를 구현하지 않으므로, 클라이언트가 Penguin 객체에 fly()를 호출할 위험이 사라집니다. 클라이언트 코드는 객체의 실제 능력을 확인하고 호출하게 되므로, 부모 타입으로 치환해도 프로그램의 정확성이 깨지지 않습니다. (LSP 준수)

참고 자료

 

💠 객체 지향 설계의 5가지 원칙 - S.O.L.I.D

객체 지향 설계의 5원칙 S.O.L.I.D 모든 코드에서 LSP를 지키기에는 어려움. 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동되어

inpa.tistory.com

 

💠 완벽하게 이해하는 LSP (리스코프 치환 원칙)

리스코프 치환 원칙 - LSP (Liskov Substitution Principle) 리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반

inpa.tistory.com

 

반응형