본문 바로가기
설계/객체지향설계

[객체지향설계] SOLID 설계 원칙 (3) - LSP (리스코프 치환 원칙)

by J4J 2024. 3. 9.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 solid 설계 원칙 세 번째인 lsp (리스코프 치환 원칙)에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

이전 글

 

[객체지향설계] SOLID 설계 원칙 (1) - SRP (단일 책임 원칙)

[객체지향설계] SOLID 설계 원칙 (2) - OCP (개방 폐쇄 원칙)

 

 

반응형

 

 

LSP (리스코프 치환 원칙) 란?

 

solid 설계 원칙에서 lsp가 의미하는 것은 자료형 A가 자료형 B의 서브 타입이라면 다른 수정 사항 없이 A를 B로 대체할 수 있어야 되는 것을 말합니다.

 

즉, 자바 관점에서 얘기를 해보면 A 클래스가 B의 클래스에 상속되어 있는 관계일 때 다른 소스 코드의 변경 없이 A를 B로 변경했을 때 동일한 동작을 수행해야 된다는 것입니다.

 

 

 

자바 관점에서 lsp는 결국 상속, 오버라이딩 등과 관련이 있게 됩니다.

 

위에서 얘기한 것처럼 자료형 A가 자료형 B의 서브 타입이라고 일컫는 것은 클래스 A는 클래스 B의 상속 객체이다라고 표현될 수 있습니다.

 

또한 A 클래스를 사용하고 있다가 B 클래스로 변경 했을 때 동일한 동작을 수행해야 된다는 것은 오버라이딩의 관점에서 바라볼 수 있습니다.

 

상속받은 A 클래스에 B 클래스가 가지고 있던 기능에 대해서 잘못된 오버라이딩을 수행하게 되면 A 클래스에서 B 클래스로 변경할 경우 동일한 동작을 수행하지 않게 됩니다.

 

 

 

 

lsp가 잘 지켜지고 있는 객체들은 oop의 특징 중 하나인 다형성을 활용하기에도 수월해집니다.

 

다형성은 간단하게 설명하면 모습은 같지만 서로 다른 역할들을 수행할 수 있도록 도와주는 특징을 가지고 있습니다.

 

즉, 다형성을 통해 비즈니스 로직을 구성하면서 상위 클래스가 가지고 있는 기능들을 같이 활용할 때 lsp가 지켜지지 않고 있다면 예상치 못한 side effect를 경험할 가능성이 높아집니다.

 

 

 

lsp와 관련된 간단한 예시를 얘기해보겠습니다.

 

예를 들어 캐릭터를 성장시키는 게임에서 전사 캐릭터가 있는데 전사 캐릭터를 기반으로 한 새로운 캐릭터를 추가한다고 가정하겠습니다.

 

캐릭터에 대한 객체를 생성할 때 각 캐릭터의 공격력을 설정해 주고 모든 캐릭터는 공격을 할 때 한 번의 공격만 하지만 새로운 캐릭터는 두 번의 공격을 하여 데미지를 설정하게 됩니다.

 

그러면 lsp를 고려하지 않을 때 다음과 같은 예상하지 못한 이슈가 발생될 수 있습니다.

 

캐릭터 공격력 데미지
전사 16 16 (= 16x1)
새로운 캐릭터 16 32 (= 16x2)

 

 

 

동일한 전사 관련 캐릭터이지만 같은 공격력임에도 불구하고 서로 다른 데미지 값이 도출되기 때문에 새로운 캐릭터에 대한 객체를 전사 객체로 변경했을 때 동일한 결과를 제공해주지 못합니다.

 

 

 

 

LSP (리스코프 치환 원칙)의 특징

 

그러면 lsp를 이용하여 코드를 설계할 때 얻을 수 있는 이점은 무엇인지에 대해 궁금하게 됩니다.

 

lsp를 사용했을 때 확인할 수 있는 이점은 다음과 같이 얘기해 볼 수 있습니다.

 

  • 상속을 통한 코드 재 사용성
  • 상속 관계에 대한 안정성을 통한 다형성의 활용을 보장
  • 유연한 개발이 가능해짐

 

 

 

 

LSP (리스코프 치환 원칙) 적용

 

lsp를 적용하기 위해서는 계속 언급되는 바와 같이 oop의 특징 중 하나인 상속 개념을 활용합니다.

 

또한 상위 클래스가 가지고 있는 기능을 보장해야 되기 때문에 오버라이딩에 주의를 해야 합니다.

 

 

 

 

사실 lsp는 상위 클래스에 구현되어 있는 기능을 오버라이딩하지 않으면 문제없이 설계를 할 수 있는 것으로 보입니다.

하지만 오버라이딩을 금지할 수 없기에 만약 오버라이딩을 하더라도 상위 클래스의 기능이 보장되는 방향으로 로직을 설계하는 것을 항상 고민해봐야 합니다.

 

 

 

또한 오버라이딩을 통해서만 기능을 상속시켜줘야 하는지에 대해서도 고민해 볼 수 있습니다.

 

상속 만을 활용하지 않고 클래스와 관련된 기능들이 정의된 새로운 클래스 및 인터페이스를 활용하는 것도 하나의 방법일 수 있습니다.

 

 

 

 

예제 코드 (1) - 잘못된 오버라이딩 Bad Case

 

위에서 말한 예시를 코드화시켜보겠습니다.

 

먼저 bad case에 대해서 작성하면 다음과 같이 구성할 수 있습니다.

 

// main
package com.jforj.solidlsp.badcase;

public class Main {

    public static void main(String[] args) {
        Warrior warrior = new Warrior();
        warrior.setPower(16);
        System.out.println("warrior damage : " + warrior.getDamage());

        Warrior newWarrior = new NewWarrior();
        newWarrior.setPower(16);
        System.out.println("newWarrior damage : " + newWarrior.getDamage());
    }
}


// warrior
package com.jforj.solidlsp.badcase;

public class Warrior {

    private int power;

    public void setPower(int power) {
        this.power = power;
    }

    public int getPower() {
        return power;
    }

    public int getDamage() {
        return getPower() * 1;
    }
}


// new warrior
package com.jforj.solidlsp.badcase;

public class NewWarrior extends Warrior {

    @Override
    public int getDamage() {
        return getPower() * 2;
    }
}

 

bad case 결과

 

 

 

 

기존의 전사 직업에 구현해 둔 기능을 활용하면서 새로운 직업에 대해서는 공격 횟수를 달리하여 데미지를 계산하도록 코드를 작성했습니다.

 

이렇게 코드가 잘못 구현된 경우에는 결과와 같이 상위 객체로 실행했을 때와 하위 객체로 실행했을 때 서로 다른 결괏값이 나오는 것을 확인할 수 있습니다.

 

 

 

 

예제 코드 (2) - 잘못된 오버라이딩 Best Case

 

위의 bad case에 대해 lsp를 활용할 경우 어떻게 코드가 변경되는지 확인해 보겠습니다.

 

// main
package com.jforj.solidlsp.bestcase;

public class Main {

    public static void main(String[] args) {
        printWarrior();
        printNewWarrior();
    }

    public static void printWarrior() {
        Character warrior = new Warrior();
        warrior.setPower(16);
        warrior.setCount(1);
        System.out.println("warrior damage : " + warrior.getDamage());

        Character character = new Character();
        character.setPower(16);
        character.setCount(1);
        System.out.println("warrior character damage : " + character.getDamage());
    }

    public static void printNewWarrior() {
        Character newWarrior = new NewWarrior();
        newWarrior.setPower(16);
        newWarrior.setCount(2);
        System.out.println("newWarrior damage : " + newWarrior.getDamage());

        Character character = new Character();
        character.setPower(16);
        character.setCount(2);
        System.out.println("newWarrior character damage : " + character.getDamage());
    }
}


// character
package com.jforj.solidlsp.bestcase;

public class Character {

    private int power;
    private int count;

    public void setPower(int power) {
        this.power = power;
    }

    public void setCount(int count) {
        this.count = count;
    }

    public int getPower() {
        return power;
    }

    public int getCount() {
        return count;
    }

    public int getDamage() {
        return getPower() * getCount();
    }
}


// warrior
package com.jforj.solidlsp.bestcase;

public class Warrior extends Character {

}


// new warrior
package com.jforj.solidlsp.bestcase;

public class NewWarrior extends Character {

}

 

best case 결과

 

 

 

 

전사 캐릭터 객체보다 더 상위의 객체를 생성한 뒤 전반적인 모든 캐릭터들의 공통 기능을 구현해 두는 형태로 코드를 변경했습니다.

 

기존의 목적이었던 전사 캐릭터와 새로운 캐릭터는 더 이상 상속 관계가 아니기에 고려해야 될 대상이 아닌 것으로 변경되었습니다.

 

하지만 반대로 lsp 설계에 부합하기 위해서는 전사 캐릭터 / 새로운 캐릭터들이 모두 상위 객체로 변경되었을 때 동일하게 기능 보장이 이루어져야 합니다.

 

그러므로 결과를 확인했을 때 lsp가 모두 지켜지고 있는 것을 확인할 수 있습니다.

 

 

 

 

 

 

 

 

 

이상으로 solid 설계 원칙 세 번째인 lsp (리스코프 치환 원칙)에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글