리스코프 치환 원칙(LSP)이란 무엇인가요?

김상욱·2024년 11월 20일

리스코프 치환 원칙(LSP)이란 무엇인가요?

리스코프 치환 원칙(Liskov Substitution Principle, LSP)은 객체 지향 프로그래밍에서 하위 클래스는 언제나 상위 클래스를 대체할 수 있어야 한다는 원칙을 의미. 이 원칙은 객체 지향 설계의 SOLID 원칙 중 하나로, 상속과 다형성을 올바르게 사용하는데 중요한 기준을 제공

프로그램에서 상위 클래스의 객체를 하위 클래스의 객체로 치환하더라도 프로그램의 동작은 일관되게 유지되어야 한다.

  • 하위 클래스는 상위 클래스의 기능 및 행동 규약(contract)를 완전히 준수해야 합니다. 하위 클래스는 상위 클래스의 동작을 변경하거나 제한해서는 안 됩니다.
  • 상위 클래스 객체를 사용하는 코드는 하위 클래스 객체로 교체되더라도 동일하게 작동해야 합니다. 즉, 클라이언트 코드가 상위 클래스와 하위 클래스의 구체적인 구현을 알 필요 없이, 동일한 동작을 기대할 수 있어야 합니다.
  • 하위 클래스는 상위 클래스의 기능을 확장하거나 구체화할 수 있지만, 기존 동작을 파괴하지 않도록 설계해야 합니다.

위반의 경우

class Bird {
    public void fly() {
        System.out.println("새가 날아갑니다.");
    }
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("타조는 날 수 없습니다!");
    }
}

// 클라이언트 코드
public void letBirdFly(Bird bird) {
    bird.fly();
}

// 실행
Bird ostrich = new Ostrich();
letBirdFly(ostrich); // Exception 발생
  • Ostrich는 Bird를 상속받았지만, "날 수 있다"는 상위 클래스의 계약을 위반.
    -> 이 때문에 하위 클래스가 상위 클래스를 치환할 수 없게 됨.

위반하지 않는 경우

interface Bird {
    void sound();
}

class FlyingBird implements Bird {
    public void sound() {
        System.out.println("새가 소리를 냅니다.");
    }

    public void fly() {
        System.out.println("새가 날아갑니다.");
    }
}

class Ostrich implements Bird {
    public void sound() {
        System.out.println("타조가 소리를 냅니다.");
    }
}

// 클라이언트 코드
public void letBirdSound(Bird bird) {
    bird.sound();
}

// 실행
Bird ostrich = new Ostrich();
Bird sparrow = new FlyingBird();

letBirdSound(ostrich); // "타조가 소리를 냅니다."
letBirdSound(sparrow); // "새가 소리를 냅니다."

Bird를 인터페이스로 분리하고, FlyingBird와 Ostrich를 개별적으로 설계하여 날 수 있는 새와 날지 못하는 새를 구분.
-> 하위 클래스가 상위 클래스를 치환해도 문제 발생x

Liskov Substitution Principle를 통하여 하위 클래스가 상위 클래스의 계약을 준수하므로, 코드를 재사용하기 쉽다. 변경 사항이 상위 클래스와 상위 클래스와 하위 클래스 간에 일관되게 작동하므로, 코드 수정이 덜 복잡해집니다.
상위 클래스 기반의 테스트 코드가 하위 클래스에서도 동일하게 작동하므로, 테스트 케이스를 재사용할 수 있다.


1. LSP 위반 사례 찾아 리팩터링하기

실습 목표

  • 상속 구조에서 발생할 수 있는 LSP 위반을 파악하고 수정하는 경험을 쌓기.

실습 과제

  1. 상속 구조로 작성된 코드에서 LSP를 위반하도록 설계된 경우를 작성합니다.
    • 예: 부모 클래스가 특정 메서드를 정의했지만, 자식 클래스가 해당 메서드를 무효화하거나 예외를 발생시키도록 설계.
  2. 위반된 구조를 인터페이스와 구성을 활용해 리팩터링합니다.
    • 예: 공통된 동작을 인터페이스로 분리하고, 각 클래스가 적합한 동작을 구현.

예시

  1. 날 수 있는 새와 날 수 없는 새를 구분한 구조 설계.
  2. 결제 방식(카드, 현금, 포인트 등)에서 결제 로직을 인터페이스 기반으로 리팩터링.

2. 인터페이스를 활용한 상위-하위 클래스 교체 실습

실습 목표

  • 다형성을 활용해 상위 클래스와 하위 클래스를 자유롭게 교체하며 동작의 일관성을 확인.

실습 과제

  1. Payment라는 상위 인터페이스를 작성하세요.

    public interface Payment {
        void pay(int amount);
    }
  2. 두 개 이상의 구현체를 작성합니다.

    • CardPayment (카드 결제)
    • CashPayment (현금 결제)
  3. 클라이언트 코드는 Payment 타입만 사용하도록 작성하세요.

    public class PaymentProcessor {
        public void processPayment(Payment payment, int amount) {
            payment.pay(amount);
        }
    }
  4. 다양한 구현체를 사용해 클라이언트 코드의 동작을 검증합니다.


3. Spring에서의 LSP 실습

Spring 프로젝트에서도 리스코프 치환 원칙을 준수하도록 설계 연습을 할 수 있습니다.

실습 목표

  • 서비스 클래스 계층 설계 및 DI(의존성 주입)에서 인터페이스와 상속을 올바르게 활용.

실습 과제

  1. 인터페이스 기반 서비스 설계

    • 예: NotificationService 인터페이스를 작성.
      public interface NotificationService {
          void send(String message);
      }
  2. 다양한 구현체 작성

    • EmailNotificationService (이메일 발송)
    • SmsNotificationService (SMS 발송)
  3. Spring Bean으로 등록

    @Service
    public class EmailNotificationService implements NotificationService {
        @Override
        public void send(String message) {
            System.out.println("Email sent: " + message);
        }
    }
    
    @Service
    public class SmsNotificationService implements NotificationService {
        @Override
        public void send(String message) {
            System.out.println("SMS sent: " + message);
        }
    }
  4. 클라이언트 코드에서 인터페이스로 주입받기

    • NotificationService만 사용해 동작 확인.
  5. 하위 클래스 교체를 통해 동작 검증

    • @Primary 또는 프로파일을 사용하여 환경에 따라 구현체를 교체.

4. 테스트 코드 작성

실습 목표

  • LSP를 고려한 설계가 테스트에서 일관성 있게 동작하는지 확인.

실습 과제

  1. JUnit을 사용해 상위 클래스와 하위 클래스 간의 동작 일관성을 확인하는 테스트 작성.

    @Test
    void testNotificationService() {
        NotificationService service = new EmailNotificationService();
        service.send("Hello via Email");
    
        service = new SmsNotificationService();
        service.send("Hello via SMS");
    }
  2. 하위 클래스에서 새로운 동작을 추가해도 상위 클래스의 동작을 위반하지 않는지 검증.


5. 실제 프로젝트에 적용

  1. 기존에 작업했던 프로젝트 코드에서 상속이 사용된 부분을 검토하세요.
    • 특정 하위 클래스에서 부모의 동작을 무효화하는 부분이 있는지 확인.
  2. 인터페이스 기반 설계로 리팩터링해보세요.
    • Service 계층이나 Controller 계층에서 LSP 위반 가능성을 최소화.

추가 팁

  • Spring의 @Qualifier@Primary를 활용하면 여러 구현체 간의 동작 교체를 쉽게 실습할 수 있습니다.
  • LSP를 준수하려면 단일 책임 원칙(SRP)이나 인터페이스 분리 원칙(ISP)과 함께 고려하는 것이 좋습니다.

위 실습을 통해 LSP 준수 설계 경험과 함께 Spring 환경에서의 활용 방법까지 익힐 수 있습니다.

0개의 댓글