모르지 않다는 것은 아는것과 다르다.

객체지향

SOLID

채마스 2022. 2. 28. 20:04

SOLID

 

SOLID 란?

  • 클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리했다.
  • SRP: 단일 책임 원칙(single responsibility principle)
  • OCP: 개방-폐쇄 원칙 (Open/closed principle)
  • LSP: 리스코프 치환 원칙 (Liskov substitution principle)
  • ISP: 인터페이스 분리 원칙 (Interface segregation principle)
  • DIP: 의존관계 역전 원칙 (Dependency inversion principle)



SRP: 단일 책임 원칙(single responsibility principle)

  • 한 클래스에서 여러 기능을 제공하게 되면 유지보수가 어려움이 있다.
  • 그렇게 때문에, 한 클래스는 하나의 책임만 가져야 한다. 하나의 책임이라는 것은 모호하다.
    • 클 수 있고, 작을 수 있다.
    • 문맥과 상황에 따라 다르다.
  • 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.
  • ex> UI 변경, 객체의 생성과 사용을 분리
  • 예시 코드는 아래와 같다.
  • 단일 책임 원칙을 따르지 않은 코드는 아래와 같다.
/**
 * 자동차 객체
 */
public class Car
{
    private final String WD;

    private final int[] WHEEL = { 0, 0, 0, 0 };

    /**
     * Car 생성자 함수
     *
     * @param wd: [String] 휠 구동 방식
     */
    public Car(String wd)
    {
        WD = wd;
    }

    /**
     * 주행 함수
     *
     * @param power: [int] 동력
     */
    public void run(int power)
    {
        switch (WD.toUpperCase())
        {
            case "FWD" -> {
                WHEEL[0] = power;
                WHEEL[1] = power;
            }

            case "RWD" -> {
                WHEEL[3] = power;
                WHEEL[4] = power;
            }

            case "AWD" -> {
                WHEEL[0] = power;
                WHEEL[1] = power;
                WHEEL[3] = power;
                WHEEL[4] = power;
            }
        }

        System.out.println("휠 동력 상태: " + WHEEL[0] + ", " + WHEEL[1] + ", " + WHEEL[2] + ", " + WHEEL[3]);
    }
}
  • 휠의 구동 방식 별 동작이 하나의 책임으로 본다면 이 객체가 짊어지는 책임은 무려 세가지나 된다.
  • 이렇게 하나의 객체에 너무 많은 책임이 몰려있는 경우, 프로젝트에서 해당 객체의 의존성이 놓아지게 된다.
  • 이러한 현상은 객체지향의 주요 특징 중 하나인 캡슐화를 정면으로 부정하게 된다.
  • 만약 코드의 규모가 크거나 복잡성이 심하다면 코드 수정 시 오만가지 오류가 발생하게 될 것이다.
  • 그렇기 때문에 1객체 = 1책임 으로 최대한 객체를 간결하고 명확하게 설계하는 것이 좋다.
  • 아래와 같이 단일 책임 원칙을 따르는 방식으로 리팩토링할 수 있다.


/**
 * 자동차 추상 객체
 */
abstract public class Car
{
    protected final String WD;

    protected final int[] WHEEL = { 0, 0, 0, 0 };

    /**
     * Car 생성자 함수
     *
     * @param wd: [String] 휠 구동 방식
     */
    public Car(String wd)
    {
        WD = wd;
    }

    /**
     * 주행 함수
     *
     * @param power: [int] 동력
     */
    abstract public void run(int power);
}
/**
 * 전륜차 객체
 *
 * @author RWB
 * @since 2021.08.13 Fri 01:03:13
 */
class FrontWheelCar extends Car
{
    /**
     * FrontWheelCar 생성자 함수
     *
     * @param wd: [String] 휠 구동 방식
     */
    public FrontWheelCar(String wd)
    {
        super(wd);
    }

    /**
     * 주행 함수
     *
     * @param power: [int] 동력
     */
    @Override
    public void run(int power)
    {
        WHEEL[0] = power;
        WHEEL[1] = power;

        System.out.println("휠 동력 상태: " + WHEEL[0] + ", " + WHEEL[1] + ", " + WHEEL[2] + ", " + WHEEL[3]);
    }
}
/**
 * 후륜차 객체
 *
 * @author RWB
 * @since 2021.08.13 Fri 01:05:57
 */
class RearWheelCar extends Car
{
    /**
     * RearWheelCar 생성자 함수
     *
     * @param wd: [String] 휠 구동 방식
     */
    public RearWheelCar(String wd)
    {
        super(wd);
    }

    /**
     * 주행 함수
     *
     * @param power: [int] 동력
     */
    @Override
    public void run(int power)
    {
        WHEEL[2] = power;
        WHEEL[3] = power;

        System.out.println("휠 동력 상태: " + WHEEL[0] + ", " + WHEEL[1] + ", " + WHEEL[2] + ", " + WHEEL[3]);
    }
}
/**
 * 사륜차 객체
 */
public class AllWheelCar extends Car
{
    /**
     * AllWheelCar 생성자 함수
     *
     * @param wd: [String] 휠 구동 방식
     */
    public AllWheelCar(String wd)
    {
        super(wd);
    }

    /**
     * 주행 함수
     *
     * @param power: [int] 동력
     */
    @Override
    public void run(int power)
    {
        WHEEL[0] = power;
        WHEEL[1] = power;
        WHEEL[2] = power;
        WHEEL[3] = power;

        System.out.println("휠 동력 상태: " + WHEEL[0] + ", " + WHEEL[1] + ", " + WHEEL[2] + ", " + WHEEL[3]);
    }
}
  • 위 세개자 객체모두 Car에 포함괴므로 Car를 상속받아 구현한다. 각 객체의 run() 메소드에 동작을 구현함으로써, 각각의 객체가 하나의 책임을 가지게 된다.
  • 이렇게 객체별로 책임을 나누면, 코드 변경 시에도 해당하는 객체만 수정하면 되므로, 의존성이 낮아져 올바른 모듈화를 구현할 수 있다.
  • 수정에 따른 영향도도 매우 작아지는 장점이 있다.



OCP: 개방-폐쇄 원칙 (Open/closed principle)

  • 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
  • 쉽게 말해서 보여줄건 보여주고 숨길건 숨긴다는 의미이다.
  • 이렇게 되면, 객체 자신의 수정에 대해서는 유연하고, 다른 클래스가 수정될 때는 영향을 받지 않는다.
  • 예를들어 라이브러리를 사용하는 객체의 코드가 변경된다고 해서 라이브러리 코드까지 변경하진 않는다.
  • 객체 간의 의존성을 최소화하여 코드변경에 따른 영향력을 낮추기 위한 원칙이다.
  • 인터페이스나 추상클래스를 통해 접근 하도록 한다.
  • 예시 코드는 아래와 같다.
public class Pos
{

    public boolean purchase(Object card, String name, int price)
    {
        boolean result;

        switch (card.toUpperCase())
        {
            case "A" -> result = ((CardA) card).send(price);
            case "B" -> result = ((CardB) card).send(price);
            case "C" -> result = ((CardC) card).send(price);

            default -> {
                System.out.println("유효하지 않은 카드사");
                result = false;
            }
        }

        return result;
    }
}
  • 위의 코드는 카드 리더기에서 카드 인식시 카드 정보가 담긴 객체릴 Object로 캐스팅하여 전종하는 코드이다.
  • 만약 신생 업체가 생긴다면 어떻게 코드를 바꿔야할까?
  • 아래와 같이 바꿔야 할것이다.



    public boolean purchase(String card, int price){

        boolean result;

        switch (card.toUpperCase())
        {
            // 신생 업체가 생길 때마다 해당 업체를 구분하는 로직을 추가한다.
            case "A" -> result = ((CardA) card).send(price);
            case "B" -> result = ((CardB) card).send(price);
            case "C" -> result = ((CardC) card).send(price);
            case "D" -> result = ((CardD) card).send(price);
            case "E" -> result = ((CardE) card).send(price);
            case "F" -> result = ((CardF) card).send(price);

            default -> {
                System.out.println("유효하지 않은 카드사");
                result = false;
            }
        }

        return result;
}
  • 보다시피 매우 비효울적이다.
  • 이러한 코드를 아래와 같이 객체지향의 관점으로 접근하여 리팩토링할 수 있다.


    public interface Purchasable{

        /**
        * 카드사 정보 전송 및 결과 반환 함수
        *
        * @param price: [int] 금액
        *
        * @return [boolean] 전송 결과
        */
        boolean send(int price);
    }
  • 먼저 공통된 형태로 로직을 수행하기 위해 Purchasable 인터페이스를 구현했다. 또한 아래와 같이 모든 카드 객체는 Purchasable 를 상속받도록 강제했다.


    /**
    * A 카드 객체
    */
    class CardA implements Purchasable
    {

        @Override
        public boolean send(int price)
        {
            System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
            return true;
        }
    }

    /**
    * B 카드 객체
    */
    class CardB implements Purchasable
    {

        @Override
        public boolean send(int price)
        {
            System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
            return true;
        }
    }

    /**
    * C 카드 객체
    *
    */
    class CardC implements Purchasable
    {

        @Override
        public boolean send(int price)
        {
            System.out.println(getClass().getSimpleName() + " " + price + "원 결제 요청");
            return true;
        }
    }
  • 이렇게 되면 카드 객체는 Purchasable 인터페이스를 상속받는다. 이렇게 되면 객체의 동작에 전송이 각각 구현되어있어, 타 객체의 코드에 의존하지 않게된다.
  • 이제 가장 중요한 Pos 클래스를 리팩토링 해보겠다. 구현은 아래와 같다.


    public class Pos
    {
        public boolean purchase(Purchasable purchasable, int price)
        {
            return purchasable.send(price);
        }
    }
  • CardA, CardB, CardC 등 각각 개별적인 객체지만, 이제 Purchasable이라는 부모 객체가 있으므로 이를 묶을 수 있다.
  • 리팩토링 하기 이전 코드의 경우, 기능 추가를 위해선 코드의 추가가 요구됐다. 다시말해, 기능을 확장하기 위해선 코드의 수정이 필요했다는 의미이다.
  • 반면, 리팩토링한 코드의 경우, Purchasable 라는 통합된 인터페이스를 사용하기 때문에 카드 추가에 따라 코드 단계에서 대응할 필요가 없다. 즉, 코드의 변경없이 기능 확장이 가능하다.
  • 결론적으로 기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안된다는 의미를 중요하다.





LSP: 리스코프 치환 원칙 (Liskov substitution principle)

  • 결론부터 말하면, 하위 클래스는 항상 상위 클래스로 교체 될 수 있어야 한다.
  • 그렇기 때문에 상위 클래스에 제공되는 여러 기능은 하위 클래스가 모두 사용가능 해야 한다.
  • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 다시말해서 부모객체와 이를 상속한 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있어야 한다는 원칙이다.
  • 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 다형성을 지원하기 위 한 원칙, 인터페이스를 구현한 구현체는 믿고 사용하려면, 이 원칙이 필요하다.
  • ex> 자동차 인터페이스의 엑셀은 앞으로 가라는 기능, 뒤로 가게 구현하면 LSP 위반, 느리 더라도 앞으로 가야함
  • 리스코프 치환 원칙을 지키기 위해선 가급적 부모 객체의 일반 메소드를 그 의도와 다르게 오버라이딩 하지 않는 것이 중요하다.





ISP: 인터페이스 분리 원칙 (Interface segregation principle)

  • 결론부터 말하면, 제공하는 기능에 대한 인터페이스에만 종속적이어야 한다.
  • 만약 하나의 객체가 여러 기능을 제공해야 한다면, 인터페이스로 분리하여 제공하여 사용하지 않는 기능에 종속적이지 않을 수 있다.
  • 인터페이스 분리 원칙이란 객체는 자신이 호출하지 않는 메소드에 의존하지 않아야 한다는 원칙이다.
  • 구현할 객체에게 무의미한 메소드의 구현을 방지하기 위해 반드시 필요한 메소드만을 상속/구현하도록 권고한다.
  • 만약 상속할 객체의 규모가 너무 크다면, 해당 객체의 메소드를 작은 인터페이스로 나누는 것이 좋다.
  • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
  • 인터페이스가 명확해지고, 대체 가능성이 높아진다.
  • 예시 코드는 아래와 같다.
  • 인터페이스 분리 원칙을 준수하지 않은 코드는 아래와 같다.
    /**
    * 스마트폰 추상 객체
    */
    abstract public class SmartPhone
    {
        /**
        * 통화 함수
        *
        * @param number: [String] 번호
        */
        public void call(String number)
        {
            System.out.println(number + " 통화 연결");
        }

        /**
        * 문자 메시지 전송 함수
        *
        * @param number: [String] 번호
        * @param text: [String] 내용
        */
        public void message(String number, String text)
        {
            System.out.println(number + ": " + text);
        }

        /**
        * 무선충전 함수
        */
        public void wirelessCharge()
        {
            System.out.println("무선 충전");
        }

        /**
        * AR 함수
        */
        public void ar()
        {
            System.out.println("AR 기능");
        }

        /**
        * 생체인식 추상 함수
        */
        abstract public void biometrics();
    }
  • 먼저 스마트폰이라는 객체가 있다.
  • 그리고 스마트폰을 상속받는 S20, S2 객체가 있다.
  • 여기서 S20는 최신형 핸드폰이기 때문에 대부분의 기능을 지원하지만 S2는 구형이기 때문에 기본적인 기능 외에는 지원되지 않는다.
/**
 * S20 객체
 */
public class S20 extends SmartPhone
{
    /**
     * 생체인식 함수
     */
    @Override
    public void biometrics()
    {
        System.out.println("S20 생체인식 기능");
    }
}
/**
 * S2 객체
 */
public class S2 extends SmartPhone
{
    /**
     * 무선충전 함수
     */
    @Override
    public void wirelessCharge()
    {
        System.out.println("지원 불가능한 기기");
    }

    /**
     * AR 함수
     */
    @Override
    public void ar()
    {
        System.out.println("지원 불가능한 기기");
    }

    /**
     * 생체인식 추상 함수
     */
    @Override
    public void biometrics()
    {
        System.out.println("지원 불가능한 기기");
    }
}
  • S20 의 경우에는 상관없지만, S2에 경우에는 해당 기능을 지원하지 않으므로 오버라이딩해서 재정의 해줘야만한다.
  • 딱봐도 비효율 적이다.
  • 이제 인터페이스 분리 원칙을 준수한 코드로 리팩토링 해보겠다.
/**
 * 스마트폰 객체
 */
public class SmartPhone
{
    /**
     * 통화 함수
     *
     * @param number: [String] 번호
     */
    public void call(String number)
    {
        System.out.println(number + " 통화 연결");
    }

    /**
     * 문자 메시지 전송 함수
     *
     * @param number: [String] 번호
     * @param text: [String] 내용
     */
    public void message(String number, String text)
    {
        System.out.println(number + ": " + text);
    }
}
  • 위의 코드처럼 스마트폰은 보편적인 동작만을 가지도록 변경했다.
/**
 * 무선충전 인터페이스
 */
public interface WirelessChargable
{
    /**
     * 무선충전 추상 함수
     */
    void wirelessCharge();
}
/**
 * AR 인터페이스
 */
public interface ARable
{
    /**
     * AR 추상 함수
     */
    void ar();
}
/**
 * 생체인식 인터페이스
 */
public interface Biometricsable
{
    /**
     * 생체인식 추상 함수
     */
    void biometrics();
}
  • 원래 SmartPhone의 객체의 메소드였던 각 기능은 인터페이스 단위로 나뉘어졌다.
  • 이를 통해서 S20과 S2 모두 필요한 객체만을 상속받아 구현할 수 있다.
/**
 * 무선충전 인터페이스
 */
public interface WirelessChargable{
    /**
     * 무선충전 추상 함수
     */
    void wirelessCharge();
}
/**
 * AR 인터페이스
 */
public interface ARable{
    /**
     * AR 추상 함수
     */
    void ar();
}
/**
 * 생체인식 인터페이스
 */
public interface Biometricsable{
    /**
     * 생체인식 추상 함수
     */
    void biometrics();
}
/**
 * S20 객체
 */
public class S20 extends SmartPhone implements WirelessChargable, ARable, Biometricsable{
    /**
     * 무선충전 함수
     */
    @Override
    public void wirelessCharge()
    {
        System.out.println("무선충전 기능");
    }

    /**
     * AR 함수
     */
    @Override
    public void ar()
    {
        System.out.println("AR 기능");
    }

    /**
     * 생체인식 함수
     */
    @Override
    public void biometrics()
    {
        System.out.println("생체인식 기능");
    }
}
/**
 * S2 객체
 */
public class S2 extends SmartPhone
{
    /**
     * 문자 메시지 전송 함수
     *
     * @param number: [String] 번호
     * @param text: [String] 내용
     */
    @Override
    public void message(String number, String text)
    {
        System.out.println("In S2");

        super.message(number, text);
    }
}
  • 인터페이스는 다중 상속을 지원하므로, 필요한 기능을 인터페이스로 나누면 해당 기능만을 상속받을 수 있다.
  • 인터페이스 분리 원칙은 객체가 반드시 필요한 기능만을 가지도록 제안하는 원칙이다.
  • 불필요한 기능의 상속/구현을 최대한 방지함으로써 객체의 불필요한 책임을 제거한다.
  • 객체를 상속할 땐 해당 객체가 상속 받는 객체에 적합한 객체인지, 의존적인 기능이 없는 지 판단하여 올바른 객체를 구현, 상속하도록 설계해야한다.





DIP: 의존관계 역전 원칙 (Dependency inversion principle)

  • 프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.” 의존성 주입은 이 원칙 을 따르는 방법 중 하나다.
  • 구체적인 것은 이미 구현이 되어있고 변하기 쉬운 것이다.
  • 여기서 추상적이라는 것은 인터페이스나 추상 클래스이다.
  • 쉽게 이야기해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.
  • 역할(Role)에 의존하게 해야 한다는 것과 같다.
  • 객체 세상도 클라이언트 가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다!
  • 예시 코드는 아래와 같다.
  • 의존성 역전 원칙을 준수하지 않은 코드는 아래와 같다.
    /**
    * 한손검 객체
    */
    public class OneHandSword
    {
        private final String NAME;
        private final int DAMAGE;

        public OneHandSword(String name, int damage)
        {
            NAME = name;
            DAMAGE = damage;
        }

        /**
        * 공격 데미지 반환 함수
        *
        * @return [int] 공격 데미지 (데미지 +-5)
        */
        public int attack()
        {
            return DAMAGE + new Random().nextInt(10) - 5;
        }

        /**
        * 객체 문자열 반환 함수
        *
        * @return [String] 이름
        */
        @Override
        public String toString()
        {
            return NAME;
        }
    }
    /**
    * 캐릭터 객체
    */
    public class Character
    {
        private final String NAME;
        private int health;
        private OneHandSword weapon;

        /**
        * Character 생성자 함수
        *
        * @param name: [String] 이름
        * @param health: [int] 체력
        * @param weapon: [OneHandSword] 무기
        */
        public Character(String name, int health, OneHandSword weapon)
        {
            NAME = name;
            this.health = health;
            this.weapon = weapon;
        }

        /**
        * 공격 데미지 반환 함수
        *
        * @return [int] 공격 데미지
        */
        public int attack()
        {
            return weapon.attack();
        }

        /**
        * 피격 함수
        *
        * @param amount: [int] 피격 데미지
        */
        public void damaged(int amount)
        {
            health -= amount;
        }

        /**
        * 무기 교체 함수
        *
        * @param weapon: [OneHandSword] 무기
        */
        public void chageWeapon(OneHandSword weapon)
        {
            this.weapon = weapon;
        }

        /**
        * 캐릭터 정보 출력 함수
        */
        public void getInfo()
        {
            System.out.println("이름: " + NAME);
            System.out.println("체력: " + health);
            System.out.println("무기: " + weapon);
        }
    }
  • 위의 코드는 게임 캐릭터를 구현한 Character 객체이다. 하지만 알다시피, 무기엔 한손검만 있는게 아니다. 그러나 Character 객체는 애초에 한손검 외엔 쓸 수 없는 구조이다.
  • Character 의 인스턴스 생성시 OneHandSword에 의존성을 가지기 때문이다.
  • 또한 공격을 담당하는 attack() 메소드 역시 OneHandSword에 의존성을 가진다.
  • 위 코드의 가장 큰 문제는 이미 완전하게 구현된 저수준 모듈을 의존하고 았다는 점이다.
  • 위의 잘못된 코드는 아래와 같이 의존성 역전 원칙을 준수한 코드로 리팩토링 할 수 있다.
    /**
    * 공격 인터페이스
    */
    public interface Attackable
    {
        /**
        * 공격 추상 함수
        *
        * @return [int] 공격 데미지
        */
        int attack();

        /**
        * 객체 문자열 반환 추상 함수
        *
        * @return [String] 이름
        */
        @Override
        String toString();
    }   
/**
 * 한손검 객체
 */
public class OneHandSword implements Attackable
{
    private final String NAME;
    private final int DAMAGE;

    /**
     * OneHandSword 생성자 함수
     *
     * @param name: [String] 무기 이름
     * @param damage: [int] 데미지
     */
    public OneHandSword(String name, int damage)
    {
        NAME = name;
        DAMAGE = damage;
    }

    /**
     * 공격 데미지 반환 함수
     *
     * @return [int] 공격 데미지 (데미지 +-5)
     */
    @Override
    public int attack()
    {
        return DAMAGE + new Random().nextInt(10) - 5;
    }

    /**
     * 객체 문자열 반환 함수
     *
     * @return [String] 이름
     */
    @Override
    public String toString()
    {
        return NAME;
    }
}
  • 위와 같이 OneHandSword 객체는 Attackable 를 상속받는 한손검 객체이다.
  • 이제 가장 중요한 Character 객체를 리팩토링해보겠다.
    /**
    * 캐릭터 객체
    */
    public class Character
    {
        private final String NAME;
        private int health;
        private Attackable weapon;

        /**
        * Character 생성자 함수
        *
        * @param name: [String] 이름
        * @param health: [int] 체력
        * @param weapon: [Attackable] 무기
        */
        public Character(String name, int health, Attackable weapon)
        {
            NAME = name;
            this.health = health;
            this.weapon = weapon;
        }

        /**
        * 공격 데미지 반환 함수
        *
        * @return [int] 공격 데미지
        */
        public int attack()
        {
            return weapon.attack();
        }

        /**
        * 피격 함수
        *
        * @param amount: [int] 피격 데미지
        */
        public void damaged(int amount)
        {
            health -= amount;
        }

        /**
        * 무기 교체 함수
        *
        * @param weapon: [Attackable] 무기
        */
        public void chageWeapon(Attackable weapon)
        {
            this.weapon = weapon;
        }

        /**
        * 캐릭터 정보 출력 함수
        */
        public void getInfo()
        {
            System.out.println("이름: " + NAME);
            System.out.println("체력: " + health);
            System.out.println("무기: " + weapon);
        }
    }
  • 리팩토링하기전의 코드와 가장큰 차이점은 OneHandSword를 파라미터로 받는 것이 아니라, 더 고수준인 Attackable을 파라미터로 받았다는 것이다.
  • 그 덕분에 Attackable 을 상속하는 모든 객체를 다룰 수 있게 되었다. 이는 공격 가능한 모든 무기를 사용할 수 있게 되는 셈이다.




REFERENCES

'객체지향' 카테고리의 다른 글

디자인 패턴에 함수형 프로그래밍 적용하기  (0) 2022.03.06
다형성  (0) 2022.02.28
디자인 패턴  (0) 2022.02.28
POJO  (0) 2022.02.28
refactoring  (0) 2022.02.28