Java

LSP

99duuk 2024. 12. 29. 17:14

20241229
Liskov Substitution Principle, LSP
*"상위 클래스 사용하던 자리에는 하위 클래스 사용할 수 있다"*


상위 클래스의 객체를 사용하는 모든 곳에서 하위 클래스의 객체로 대체하더라도 프로그램의 동작이 바뀌지 않아야 한다.

==하위 클래스는 상위 클래스를 대체할 수 있어야 한다. ==
상위 클래스가 기대하는 동작(계약)을 하위 클래스가 깨지지 않도록 동작해야 한다는 원칙

  1. 상위 클래스의 역할을 유지해야함

     상위 클래스의 메서드와 동작은 하위 클래스에서도 **동일하게 작동** 해야 함
     하위 클래스는 상위 클래스가 가진 동작의 **의미**를 바꾸거나 깨뜨리면 안 됨.
  2. 대체 가능성 보장

     코드에서 상위 클래스를 사용하던 자리(메서드 호출, 변수 등)에 하위 클래스를 넣어도 **플고그램이 정상적으로 작동**해야 함.

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

class Sparrow extends Bird {
    // 스패로우는 새니까 날 수 있다. (상위 클래스의 동작을 깨지 않음)
    @Override
    void fly() {
        System.out.println("Sparrow flying!");
    }
}

class Zoo {
    void makeBirdFly(Bird bird) {
        bird.fly(); // 상위 클래스 Bird의 메서드를 호출
    }
}

public class Main {
    public static void main(String[] args) {
        Zoo zoo = new Zoo();

        Bird bird = new Bird();
        zoo.makeBirdFly(bird); // Bird가 날 수 있음

        Bird sparrow = new Sparrow();
        zoo.makeBirdFly(sparrow); // Sparrow도 Bird처럼 날 수 있음
    }
}

Bird가 기대하는 fly 동작을 Sparrow도 일관되게 제공

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

class Penguin extends Bird {
    // 펭귄은 날 수 없으므로 fly 메서드가 이상해진다. (상위 클래스 동작 깨짐)
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly!");
    }
}

class Zoo {
    void makeBirdFly(Bird bird) {
        bird.fly(); // 상위 클래스 Bird의 메서드를 호출
    }
}

public class Main {
    public static void main(String[] args) {
        Zoo zoo = new Zoo();

        Bird bird = new Bird();
        zoo.makeBirdFly(bird); // Bird가 날 수 있음

        Bird penguin = new Penguin();
        zoo.makeBirdFly(penguin); // Penguin도 Bird처럼 취급했지만 에러 발생
    }
}

펭귄(Penguin)이 상위 클래스(Bird)의 기대 동작(날 수 있음)을 위반했기 때문에, 코드에서 예외가 발생
=>Penguin은 Bird처럼 사용할 수 없기 때문에 리스코프 치환 법칙을 위반


*이렇게만 봤더니 그럼 메서드 오버라이딩이 잘못된 건가?* *오버라이딩을 어떻게 해야 lsp 위반하는 거고, 어떻게 하면 위반하지 않는 건데?* 라는 의문이 들음.,.

포인트는 !!

리스코프 치환 법칙(Liskov Substitution Principle, LSP)은 상위 클래스와 하위 클래스 간의 "계약" 을 다루는 원칙임

"하위 클래스는 상위 클래스를 완전히 대체할 수 있어야 하며, 
상위 클래스의 기대 동작(기본 동작)을 반드시 준수해야 한다."
다시 돌아가서 행동적 계약 이란게 되게 헷깔리는데 결국 메서드 오버라이딩은 잘못된 사용법이 아니잖아. 
그럼 "상위 클래스의 동작 기대" 가 뭔데? 


"날기"를 예로 들면 "날 수 없음" 예외는 동작 기대를 깨뜨리는 거고, 
"높이 날기", 
"낮게 날기", 
"앉았다가 날기", 
"짧게 날기", 
"오랫동안 날기", 
"나는 것처럼 보이지만 실제로는 달리는 것처럼 날기", 
"많이 먹어서 에너지가 충분할 때만 날기", 
"에너지가 충분하고, 날씨가 좋을 때만 날기" 

이런 게 동작 기대야?

동작 기대

상위 클래스가 정의한 메서드(또는 기능)에 대해 사용자가 기대하는 일관된 동작을 말함

즉, 이 메서드를 호출하면 어떤 결과를 얻을 수 있는가? 에 대한 신뢰

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

상위 클래스 Bird에서 fly()를 정의했다면, 이에 대해 호출하는 사용자는

  1. 날 수 있다.
  2. 호출하면 오류 없이 정상적으로 동작한다.
  3. 구체적인 방식(높이 날기, 짧게 날기 등)은 하위 클래스에 따라 다를 수 있다.

동작 기대 깨뜨리는 사례

  • 기대 충족 경우
    class Sparrow extends Bird {
      @Override
      void fly() {
          System.out.println("Flying high!");
      }
    }
    

class Pigeon extends Bird {
@Override
void fly() {
System.out.println("Flying in circles!");
}
}


`Sparrow`와 `Pigeon`은 각각 다르게 날지만, **"날 수 있다"는 기본 기대를 충족**하고 있음


- 기대 깨뜨리는 경우
하위 클래스가 상위 클래스의 `fly()` 메서드를 오버라이딩하여, "날 수 없다"거나 **예외를 던지는 경우** 기대를 깨뜨림
``` java
class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly!");
    }
}

여기서 "모든 새는 날 수 있다" 는 기대가 깨졌기 때문에, 동작 기대를 충족하지 못함

이로 인해 Penguin은 LSP를 위반하게 됨


"동작 기대" 의 범위

"이 메서드가 실행되면 에러가 나지 않고 정상적으로 동작해야 한다." 는 수준에서 시작됨..
그리고 상황에 따라 더 구체적으로

범위 범위
기본 계약 (Minimal Contract) 메서드가 호출되면 정상적으로 실행되어야 하고, 적어도 "날 수 있다"는 결과를 제공해야 함.
추가 조건 (Extended Contract) "높이 날기", "짧게 날기" 등 구체적인 동작은 하위 클래스가 결정할 수 있음
상위 계약과 충돌 (위반) "날 수 없음" 또는 예외를 던지는 경우는 기본 계약을 깨뜨리는 것이므로, 동작 기대 충족하지 못함.
다양한 날기 방식은 "기대 충족"

"높이 날기", "짧게 날기", "앉았다가 날기" 등은 동작 방식이 다를 뿐, "날 수 있다"는 기대를 충족함.
이런 경우는 LSP를 만족시킴

"날 수 없음"은 "기대 깨뜨림"

Penguin처럼 "날 수 없다"고 구현하거나, 예외를 던지는 것은 상위 클래스의 계약을 어기고 기대를 깨뜨리게 됨


"해가 떠있고, 밥을 먹었을 때만 날기"

조건부 동작은 동작의 세부 조건인 추가 조건 에 해당함.
상위 클래스의 기본 계약은 충족하면서, 동작을 세부적으로 제한하는 상황

class Bird {
    void fly() {
        System.out.println("Flying...");
    }
}

class ConditionalBird extends Bird {
    private boolean isDaytime;
    private boolean isFull;

    ConditionalBird(boolean isDaytime, boolean isFull) {
        this.isDaytime = isDaytime;
        this.isFull = isFull;
    }

    @Override
    void fly() {
        if (isDaytime && isFull) {
            System.out.println("Flying because it's daytime and I'm full!");
        } else {
            System.out.println("Cannot fly right now.");
        }
    }
}
  • 기본 계약: "날 수 있다."
  • 추가 조건: "해가 떠있고 배가 불렀을 때만 날기."
  • 이 경우, 기본 계약을 충족하면서 조건부로 동작을 제한합니다. 이는 LSP를 위반하지 않음

하지만,
상위 클래스의 기본 계약을 깨뜨리면 안 됨.

**날기(fly)"의 기본 계약**: "날 수 있다."  
이 경우, 기본 계약은 호출했을 때 반드시 **정상적인 결과**를 반환하는 것임.

조건부 동작을 구현했더라도, 호출이 실패하거나, 예외가 발생한다면 이는 LSP를 위반함

@Override
void fly() {
    if (!isDaytime || !isFull) {
        throw new UnsupportedOperationException("Cannot fly under these conditions!");
    }
    System.out.println("Flying...");
}

상위 클래스의 기본 계약을 깨뜨리는 예외를 던지기 때문에 이는 LSP를 위반함


만약 "파라미터에 따라 동작 여부가 달라지는" 형태라면, 조건부 동작은 기본 계약의 일부로 포함되어야함

class Bird {
    void fly(boolean isDaytime, boolean isFull) {
        if (isDaytime && isFull) {
            System.out.println("Flying...");
        } else {
            System.out.println("Cannot fly under these conditions.");
        }
    }
}
  • 기본 계약: fly()는 호출되었을 때 항상 유효한 결과를 반환
  • 파라미터를 통해 조건을 설정하더라도, 호출이 실패하지 않으므로 LSP를 준수

*여기서 또 Cannot fly under these conditions.가 위반이 아닌 이유가 헷갈림..*

왜 "Cannot fly under these conditions."는 동작 기대를 위반하지 않는가?

**핵심 이유: "정상적인 동작"으로 간주되기 때문**
  • fly() 메서드의 기본 계약"호출되었을 때, 오류 없이 적절한 결과를 제공" 하는 것임
  • "날 수 없다"는 메시지를 반환하는 것은 동작의 한 종류로 간주될 수 있으며, 이는 오류를 발생시키지 않기 때문에 여전히 정상적인 결과에 해당함.
  1. "cannot fly" 메시지는

    • 조건에 따라 다르게 동작하지만, 메서드는 호출될 때 항상 실행되고 결과를 반환하고,
    • 결과가 의미 있고 예측 가능하므로 동작 기대를 충족한다...
  2. throw new UnsupportedOperationException("Cannot fly under these conditions!");

    • 특정 조건에서 예외를 던지며 실행 자체를 중단한다.
    • 상위 클래스의 호출자가 "fly()는 정상적으로 실행된다"는 기대를 깨뜨리기 때문에 LSP를 위반한다...

동작 조건을 외부로 분리 (행동 전략 패턴)
조건부 동작이 복잡해질 경우, 행동을 전략(Strategy)으로 분리하면 더 깔끔한 설계를 만들 수 있음

interface FlyBehavior {
    void fly();
}

class DaytimeFullFlyBehavior implements FlyBehavior {
    private boolean isDaytime;
    private boolean isFull;

    DaytimeFullFlyBehavior(boolean isDaytime, boolean isFull) {
        this.isDaytime = isDaytime;
        this.isFull = isFull;
    }

    @Override
    public void fly() {
        if (isDaytime && isFull) {
            System.out.println("Flying because conditions are met!");
        } else {
            System.out.println("Cannot fly under these conditions.");
        }
    }
}

class Bird {
    private FlyBehavior flyBehavior;

    Bird(FlyBehavior flyBehavior) {
        this.flyBehavior = flyBehavior;
    }

    void performFly() {
        flyBehavior.fly();
    }
}
  • 조건부 동작이 복잡하더라도 FlyBehavior로 분리하면 Bird 클래스가 간단해짐
  • 이는 LSP를 완벽히 준수하며, 확장성도 높아짐

동작 기대는 "기능이 정상적으로 호출되었을 때의 결과"

LSP는 "기능이 정상적으로 호출될 수 있는지"를 기준으로 판단함.

LSP에서 중요한 것은 "호출이 실패하지 않고, 의미 있는 결과를 반환한다"는 점임

그러니까

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins cannot fly!");
    }
}

는 LSP 위반이고,

class Bird {
    void fly() {
        System.out.println("I can fly!");
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        System.out.println("Cannot fly under these conditions.");
    }
}

는 LSP 준수이다.

  • 호출 결과로 "날 수 없다"는 메시지를 받는 것은 예상 가능한 결과임.
  • 이는 호출 실패가 아니며, 정상적인 동작의 일환으로 간주됨.

호출자는 항상 fly()를 호출할 수 있으며, 결과로 "날 수 없다"는 메시지를 받을 뿐임.


상태 결과 LSP 준수 여부
"날았다" 정상적으로 "날기" 동작 수행 ✅ 준수
"날 수 없다" 메시지 반환 호출 성공, 동작 결과를 예측 가능하게 반환 ✅ 준수
예외 발생 (UnsupportedOperationException) 호출 실패, 상위 클래스의 기대 동작 위반 ❌ 위반

'Java' 카테고리의 다른 글

리스트처리(for -> forEach), switch (enum), Null(Optional)  (1) 2024.12.25
추상화....인터페이스...  (0) 2024.12.24
클라이언트 ip 가져오기  (0) 2024.12.24
추상 클래스 / 인터페이스  (0) 2024.12.20
캡슐화  (0) 2024.12.17