ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SOLID] 객체지향 설계 원칙
    백엔드/객체지향 | 디자인패턴 2023. 1. 28. 01:03
    반응형

     

    우아한테크코스 프리코스를 할 때, 인터넷에서 뒤적뒤적 거리면서 공부를 했었지만 간단한 코드와 글만 봐서는 이해가 되지 않았었다. 그런데 지금 객체지향 책을 읽어보니 이해가 되어서 나중에 까먹어도 최대한 쉽게 다시 이해할 수 있도록 정리를 해보았다.

    SOLID를 이해하려면 기본적인 객체지향 지식은 있어야 의미를 제대로 알 수 있다.

    참고로 객체지향 설계는 지키면 지킬수록 좋은 이상적인 방법론이지 무조건 지켜야 한다는 것은 아니다. 그래서 완벽한 객체지향 설계가 아니더라도 최대한 객체지향 설계를 맞추게 된다면 유지보수하기 좋은 코드가 될 것이다.

     


     

     

    1. 단일 책임 원칙 (SRP)


    한 클래스는 하나의 책임을 가져야 한다.

     

    객체지향 설계에서 중요한 것은 한 객체에 최소의 책임을 부여하는 것이다. 그래서 단일 책임 원칙이란 클래스는 하나의 책임을 가져야 한다는 원칙이며 객체지향 설계 원칙중에서 가장 중요한 원칙이다.

     

    스타벅스에서 소비자가 커피를 구매한다고 가정하자

    현실 세계에서는 소비자는 알바생에게 커피 구매를 요청하게 된다.

     

     

    이렇게 되면 소비자는 알바생에게 커피를 주문하는 책임이 생기고, 알바생은 소비자에게 커피를 주문 받아야하는 책임이 생긴다.

    커피를 주문할 때 동시에 결제도 해야한다. 결제도 그림에 추가하자

     

     

    소비자는 결제를 하며 현금영수증을 등록할 수도 있다.

     

     

    이러면 소비자에게 세 가지 책임이 생긴다. 커피를 주문하고, 커피 주문한 금액을 결제해야하고, 현금영수증 번호를 알려줘야 한다.

     

    이 상태에서 코드를 작성한다고 가정해보자

     

    1) 커피 주문 : 커피 주문하기는 특정 커피가 메뉴에 존재하는지 / 커피를 주문하기가 있다

    2) 결제하기 : 결제는 현금 / 기프티콘 / 카카오페이 / 삼성페이로 결제할 수 있다.

    3) 현금영수증 : 현금영수증은 카드 / 번호 입력으로 진행할 수 있다.

     

    이것만 하더라도 소비자에게 최소 8가지의 메서드가 필요하고, 특히 결제에서 다양한 메서드가 파생될 수 있다.

     

    만약 결제에 토스 결제가 추가해본다고 하자. 코드를 추가해야 하려면 몇라인에 코드를 추가하는게 적절한지 찾아보려고 하는데, 커피 주문 / 결제하기 / 현금영수증 코드를 모두 확인해봐야 한다. 이러면 생산성이 떨어지게 되고, 나중에 또 다른 기능을 추가할 때도 코드를 다시 확인해봐야 한다. 토스 결제를 추가하는데, 커피 주문, 현금영수증 코드를 봐야할 필요는 없을 것이다.

    이제 결제를 다른 객체로 따로 떼어서 책임을 분리하고, 이때의 객체 이름을 지갑이라고 하자

     

     

    여기서 지갑의 책임은 소비자가 커피를 주문할 때, 결제를 도와주는 책임을 가지고 있다. 다시 한 번 토스 결제를 추가해보려고 살펴보자. 토스 결제를 하는데 현금, 기프티콘, 카카오페이, 삼성페이 코드를 확인할 필요가 있을까? 분명히 볼 필요가 없다. 그래서 여기서도 지갑의 책임을 다른 새로운 객체들에게 분리할 수 있다. 그리고 동시에 지갑은 상위 클래스로 변경하여 상속 받을 수 있도록 한다.

     

     

    이제 지갑의 책임은 다양한 결제 방식에 연결하여 결제를 도와주는 역할만을 가지고 있다. 다시 토스 결제를 추가하려고 하면 어느 코드를 볼 필요 없이 새로운 객체를 추가해주면 되고, 만약 카카오페이 코드를 고쳐야하는 경우 새로운 코드를 작성하기 위해 다른 객체의 코드를 볼 필요 없다.

     

    현금영수증도 마찬가지다. 현실세계에서 현금영수증을 카드로 하게 된다면 지갑에 있지만, 지갑은 결제를 도와주는 객체이므로 새로운 객체를 만들 수 있다.

     

     

    이렇게 단일 책임 원칙을 지키면서 코드를 작성한다면 클래스 수는 많아지지만, 책임 클래스가 확실하기 때문에 유지보수할 때 코드를 수정할 클래스가 확실히 정해진다는 장점이 있다.


     

     

     

    2. 개방 폐쇄 원칙 (OCP)


    소프트웨어 요소는 확장에 대해서는 열려있으나, 변경에는 닫혀 있어야 한다.

     

    이 문장만 읽어보면 무슨 소리인지 모르지만, 문장을 더 길게 쓰자면 '어떤 기능에서 새로운 기능을 추가할 때, 어떤 기능을 사용하는 기존 코드는 변경하지 않는 것이다.'로 볼 수 있다. 이것도 이해를 하기 힘드니 예시로 이해하는 것이 더 좋다.

     

     

    위에서 사용했던 토스 결제 추가로 말하자면 토스 결제 코드는 추가하면서 결제 기능을 사용하는 지갑, 소비자에는 코드 변경이 일어나지 말아야 한다는 것이다.

     

    개방 폐쇄 원칙을 적용하는 대표적인 방법은 추상화와 상속이 있다.

    • 추상화는 지갑에 결제 기능이 있다는 것을 명시해두면 소비자는 지갑의 결제 메서드를 사용하여 토스 결제를 추가하더라도 지갑과 소비자의 결제 코드가 변경될 일이 없다.
    • 상속의 경우엔 카카오페이, 삼성페이와 같이 휴대폰으로 결제하는 기능을 유지보수하기 쉽게 하나의 상위 클래스를 만든다고 하면 토스 결제는 해당 클래스를 상속하고, 오버라이딩을 하여 원칙을 지킬 수 있다.

     

    반대로 개방 페쇄 원칙을 적용한 것 같은데, 사실은 개방 폐쇄 원칙을 깨뜨리는 경우도 있다.

    • 다운 캐스팅 : instanceof 를 사용할 때 많이 발생한다. 타입 캐스팅을 통해 실행하는 메서드가 있으면 개방 폐쇄 원칙을 지키지 않은 것이다.
    • 비슷한 if-else문 존재 : 만약 현금 / 기프티콘 / 카카오페이 / 삼성페이 결제를 if-else문으로 나눈다고 하면 4개의 조건문이 나오게 된다. (현금 결제이면 현금 객체에서 출금하고, 카카오페이 결제면 카카오페이 객체에서 출금하고, ...) 이것은 출금이라는 공통적인 기능이 있으므로 결제 하나로 합치는 방법이 존재하므로 개방 폐쇄 원칙이 아닌 경우다.

     

    개방 폐쇄 원칙을 지키면서 코드를 작성한다면 변경에 유연해진다는 장점을 가지고 있다. 객체지향에서 변경의 유연함이란 다른 코드를 건들지 않으면서 새로운 코드를 추가하는 것이다.


     

     

     

    3. 리스코프 치환 원칙 (LSP)


    프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

     

    이 문장 역시 '상위 타입 객체를 하위 타입 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야한다.'로 이해할 수 있다. 이것 역시 예제를 통해 알아보자.

     

     

    만약 카카오페이를 결제하는데, 금액이 부족하면 남은 금액은 다른 결제 방식으로 진행한다고 가정해보자. 이때 다른 결제는 삼성페이나 현금이나 아무거나 해도 괜찮다. 만약 카카오페이 결제를 할 때 잔액이 부족하다면, 지갑 클래스를 파라미터로 하는 메서드를 호출할 것이다. 이걸 코드로 표현하면 카카오페이 안에 remainCredit(Wallet wallet)이라는 메서드를 가질 것이고, Wallet은 카카오페이를 제외한 다른 결제 방식을 찾아 진행할 것이다. 여기서 리스코프 치환 원칙은 카카오페이 객체에서 지갑 객체를 사용해도 문제가 없어야 한다는 원칙이다.

     

    리스코프 치환 원칙은 개방 폐쇄 원칙과 관련이 있는데, 리스코프 치환 원칙이 깨지면 개방 폐쇄 원칙이 깨진다는 특징을 가지고 있다. 리스코프 치환 원칙이 깨지는 경우엔 크게 두 가지가 존재한다.


    1) 객체의 타입을 확인하는 경우 (instanceof를 사용하는 경우)

     

    위 그림에서 토스 결제를 추가하려고 하는데, 토스 결제는 곧 서비스할 예정이라 도입할 준비만 해두어야지 실제 결제를 하면 안된다. 그러면 결제 코드를 아래처럼 작성할 수도 있게 된다.

     

    1
    2
    3
    4
    5
    6
    public void credit(Wallet wallet, Money money) {
        if (wallet instaceof TossPay) {
            throw new CantSupportCreditException();
        }
        wallet.withdraw(money);
    }
    cs

     

    instanceof를 사용하게 되면 동시에 개방 폐쇄 원칙이 깨질 확률이 높아진다. (변경에 유연하지 않음)

    거기다가 지갑은 결제를 할 수 있어야 하는데, 토스 결제는 지금 결제를 할 수 없는 상태이다. 지갑이 가지고 있는 credit 명세를 지킬 수 없는 상황이므로 Wallet을 대체할 수 없으므로 리스코프 치환 원칙을 위반하게 된다.

    리스코프 치환 원칙을 적용하려면 위 메서드 안에 TossPay 없이 Wallet으로만 진행해야 한다. 그러므로 코드를 작성할 때, 현재 결제 방식이 결제가 가능한 상태인지 확인하는 메서드인 public boolean isCreditAvailable() 같은걸 추가해야한다.

     

    1
    2
    3
    4
    5
    6
    7
    public void credit(Wallet wallet, Money money) {
        if (!wallet.isCreditAvailable()) {
            throw new CantSupportCreditException();        
        }
     
        wallet.withdraw(money);
    }
    cs

     


    2) 상위 클래스에서 지정한 리턴값이 아닌 경우

     

     

    만약 지갑 객체로 결제를 할 때, 성공적으로 결제하면 1, 아니라면 -1을 반환한다고 가정해보자. 그런데 카카오페이 객체에서는 카카오페이로 결제할 때 금액이 부족해서 남은 금액을 다른 방식으로 결제하는 경우엔 0을 반환하는 코드가 있다고 해보자

    해당 이벤트가 발생한다면 결제 성공도 아니고, 결제 실패도 아닌 상황이 나오게 된다. 이것은 지갑에 나와있던 명세와는 다른 행동이다. 그러므로 상위 타입인 지갑을 올바르게 대체할 수 없으니 리스코프 치환 원칙을 위배하게 된다.


    리스코프 치환 원칙은 원래 의도한대로 설계한 명시된 명세를 지키도록하고, 확장에 유연하도록 만드는 원칙이다. 명시된 명세는 위에 설명한 반환값뿐만 아니라, 에러 종류, 기능까지 모두 포함된다.

     

    리스코프 치환 원칙을 지키면서 코드를 작성하게 된다면 코드를 수정할 부분이 적어지므로 기능 확장에 용이해진다는 장점을 가지고 있다.


     

     

     

    4. 인터페이스 분리 원칙 (ISP)


    클라이언트는 자신이 사용하는 메서드에만 의존해야 한다.

    인터페이스 분리 원칙은 다른 원칙에 비해 이해하기 쉬운 편이다. 자신이 사용하는 메서드에만 의존을 해야 한다는 것이다.

     

     

    카카오페이는 카카오페이 머니로 일단 충전한 뒤 결제하고, 삼성페이도 비슷한 방식으로 결제한다고 가정해보자. 그리고 상위 클래스인 지갑은 이것을 고려하여 포인트 충전이라는 기능을 추가했다고 하자. 현금과 기프티콘은 사용하지도 않는 포인트 충전 코드를 추가해야하고, 만약 포인트 충전 기능이 변경 / 삭제가 된다면 현금과 기프티콘은 이것을 반영해서 수정해야한다.

    이것이 인터페이스 분리 원칙을 위반하는 것이고, 인터페이스 분리 원칙을 지키려면 카카오페이와 삼성페이 상위 클래스에 포인트 충전 클래스가 존재해야한다.

     

     

    이렇게 설계를 해야 현금과 기프티콘은 포인트 충전에 영향을 받지 않을 수 있다.

     

    인터페이스 분리 원칙은 단일 책임 원칙과 연관이 있는데, 다른 기능에 영향을 최소화한다는 것이다.

    인터페이스 분리 원칙을 잘 지키게 되면 단일 책임 원칙을 지킬 수 있게 되고, 단일 책임 원칙을 지키게 된다면 인터페이스와 콘크리트 클래스를 재사용을 높여주는 효과를 가지게 된다.


     

     

     

    5. 의존 역전 원칙 (DIP)


    고수준 모듈이 저수준 모듈의 구현에 의존하지 말고, 저수준 모듈이 고수준 모듈의 추상 타입에 의존해야 한다.

     

    이것도 역시 문장만 보면 무슨 소리인지 모르겠으니 우리는 이미 배운 내용이다. 예시를 통해 알아보자

     

    고수준 모듈이 저수준 모듈의 구현에 의존하는 케이스는 아래 코드와 같을 것이다.

     

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void credit() {
        if (현금) {
            ...
        } else if (기프티콘) {
            ...
        } else if (카카오페이) {
            ...
        } else if (삼성페이) {
            ...
        }
    }
    cs


    이건 지갑 클래스의 코드이다. 이렇게 코드를 작성할 경우 새로운 결제 방식이 추가되거나 변경될 때, 지갑 클래스의 코드도 같이 변경해야 한다는 것을 알 수 있다. 이 상황처럼 고수준 모듈이 저수준 모듈의 구현에 의존하게 된다면 코드 변경이 유연하지 못하게 된다.

     

    저수준 코드가 변경되더라도 고수준 코드는 그대로 유지하는 방법은 추상화를 통해서 해결할 수 있다.

     

     

    이렇게 지갑이라는 클래스로 결제하기라는 메서드를 만들어두면, 현금 / 기프티콘 / 카카오페이 / 삼성페이는 모두 결제하기 메서드를 통해 세부 기능 구현을 한다. 이러면 만약 카카오페이의 결제 작동 방식이 변경되더라도 지갑의 결제하기 메서드의 코드는 달라지지 않는다.

    의존 제어 원칙은 소스 코드 상에서의 의존을 역전하는 것이다. 코드를 상상해보면 소비자가 결제를 할 때 지갑에 의존을 하지, 지갑이 소비자에 의존하지 않는다.

     

    의존 역전 원칙 또한 개방 폐쇄 원칙과 연관이 있는데, 코드가 변경되는 부분에서 개방 폐쇄 원칙을 지킬 수 있도록 의존 역전 원칙이 도와준다.


    반응형

    댓글

Designed by Tistory.