고딩왕 코범석

객체 지향 설계의 5가지 원칙(SOLID) 본문

Computer Science/OOP

객체 지향 설계의 5가지 원칙(SOLID)

고딩왕 코범석 2020. 12. 31. 10:24
반응형

이번 포스팅은 면접에도 자주 나올것 같은 주제라서 한번 정리해보려고 한다.

우선 SOLID가 어떤 것인지 파악부터 해보자

  • S : 단일 책임 원칙 (SRP - Single Responsibility Principle)
  • O : 개방 폐쇄 원칙 (OCP - Open/Closed Principle)
  • L : 리스코프 치환 원칙 (LSP - Liskov Substitution Principle)
  • I : 인터페이스 분리 원칙 (ISP - Interface Segregation Principle)
  • D : 의존관계 역전 원칙 (DIP - Dependency Inversion Principle)

1. 단일 책임 원칙 (Single Responsibility Principle)

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

이게 무슨말잉교..

우선 책임이란, 해야하는 것을 잘하는 것, 맡은 바를 충실히 이행하는 것이다. 또한, 해당 클래스가 변경되더라도 어쨌 든 책임은 동일해야한다.

이말은 즉, 해당 클래스에 변경이 있을 경우, 파급효과가 적어야한다. 또한 하나의 함수 혹은 클래스가 한가지 기능만 수행하도록 개발되어있다면, 코드를 봤을 때 이해하기 쉬울 뿐더러 유지보수 하기가 굉장히 편리해진다.

그러나 한 클래스나 함수가 여러가지 책임을 맡은 경우, 수정하게 될 일이 생길 때 다른 책임들을 변경해야한다.

예를 들면, Member는 어떤 웹사이트 회원을 의미한다고 가정해보자.
이걸 클래스로 만들었을 때, Member는 가입, 정보조회 및 수정, 내가 이용한 서비스 목록, 삭제 요런 기능들을 잘할 수 있다.

만약 Member에서 엉뚱한 책임(가령 게시글 등록)을 맡게 된다면 유지보수도 어렵고 무슨 의미인지 파악이 힘들다.

꼭 해당 객체가 맡을 수 있는 최선의 책임을 부여하자!
앞의 예에서는 게시글 등록, 기능 요런 기능(책임)들을 Board 라는 클래스를 따로 만들어 주면 좋겠지?

2. 개방 폐쇄 원칙 (Open/Closed Principle)

소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려있어야 하며, 변경에 닫혀있어야 한다.

추상화를 이용하자. 예를 들어 나는 카페 사장이고 아메리카노 밖에 팔지 않는다. 또, 나는 현금을 굉장히 좋아해서 현금으로만 계산할 수 있는 클래스를 작성해보자

public class Calculator{
    private final Cash cash;

    public int returnChange(){    //아메리카노 2000원이라고 해볼게요.
        return cash.getWon() - 2000;
    }
}

public class Cash{
    int won;
       //getter
}

나는 에스프레소 하나는 기깔나게 잘뽑는 다방소년이다. 하지만 문제가 생겼다. 처음으로 손님이 카드를 들고 오셨다. 정중히 거절하였지만 그 고객은 기분이 상당히 나빠하시면서 내 카페 문을 박차고 나가셨다. 하지만 점점 손님들이 많아지면서 카드로 계산을 요구하는 사람들이 많아졌다. 요즘같은 불경기에 나는 카페 장사를 말아잡술수는 없어서 카드계산을 해주기로 마음 먹었다.

나는 커피밖에 모르는 바보이고, 객체지향 설계를 잘 몰라서 CardCalculator 라는 클래스를 만들었다.

public class CardCalculator{
    private final Card card;

    public int returnChange(){
        return card.getWon() - 2000;
    }
}

public class Card{
    int won;
       //getter
}

또 문제가 생겼다.... 이번엔 삼읍페이로 결제하시겠다는 단체손님들이 나보고 계산을 해달라고 한다. 다는 돈미새라서 단체손님들은 받아야한다!! 일단 아메리카노만 먼저 후뚝딱 만들어드리고 클래스를 만들라 했으나... 이거 굉장히 비효율적인거 아니야?

생각해보면 어떤 결제수단이든, 그 결제수단에 남아있는 잔고에서 내가 판매하는 아메리카노 금액만 깎아주면 되는거 아니야?

결제수단을 인터페이스로 추상화 해보자

public class Calculator{
    private final Payment payment;

    public int returnChange(){
        return payment.getWon() - 2000;
    }
}

public interface Payment{
    int getBalance();    //잔액을 반환한다.
}

public class Card implements Payment{
    private int balance;

    public int getBalance(){    //Payment Interface의 구현 메서드
        return balance;
    }
}
public class Cash implements Payment{
    // Card class와 구현 동일
}
public class SPay implements Payment{
    // Card class와 구현 동일
}

변경은 닫고 확장 포인트는 열어둔다 = 결제수단은 항상 현금, 카드, 읍읍페이가 될 수 있으며 미래에 다른 결제수단이 나올수도 있다. 하지만 나는 어쨌든 거스름돈을 드려야 한다.

또한, 의존하고 있는 소프트웨어는 꼭 인터페이스를 의존하자! 그래야 변경될 때 유지보수에 있어서 훨씬 유리하다!

3. 리스코프 치환 원칙 (Liskov Substitution Principle)

난 Liskov라는 단어가 뜻이 있는 단어인줄 알았는데 이 원칙을 정의한 양반 성함이셨다.

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야한다.

ㄴㅇㄱ... 이건 또 뭔말잉교..? 심지어 다른 원칙들은 제목만 봐도 얼추 느낌이 올 법 한데 얘는 도당체가 뭔지 모르겠어..

갓글에서 찾아본 바로는 사각형, Bag, 포유류 등의 다양한 예제들이 있었다. 다들 설명들을 잘해주셔서 나도 그 설명들을 바탕으로 포스팅 및 이해해보려고 한다.

  1. 포유류
    • 알을 낳지 않고 새끼를 낳아 번식
    • 젖을 먹여 새끼를 키우고 폐를 통해 호흡
    • 포유류는 체온이 일정한 정온 동물, 털이나 두꺼운 피부로 덮여있다.
    *여기서 원숭이, 강아지, 사람은 포유류 라는 추상 클래스를 상속 받을 수 있다. *-> 리스코프 치환 원칙을 잘 지킨다오리너구리는 알을 낳아 번식하는 동물이기에 포유류 라는 추상클래스를 상속받으면 포유류의 특징을 받아들일 수 없다.
  2. -> 리스코프 치환 원칙을 지키지 못하였다.
  3. 갑분오 : 갑자기 분위기 오리너구리
    오리너구리가 나도 포유류에 껴달래서 껴줬다. 근데!!!!
  4. *-> 위의 하위타입 객체(원숭이, 강아지, 사람)은 상위 타입의 포유류의 특징들을 위반하지 않는다. *
  5. 먼저 포유류의 특징은
  6. 사각형
    public class Rectangle {
        protected double width;
        protected double height;
    
        public double getArea() {
            return this.getWidth() * this.getHeight();
        }
    
        public double getWidth() {
            return width;
        }
    
        public void setWidth(double width) {
            this.width = width;
        }
    
        public double getHeight() {
            return height;
        }
    
        public void setHeight(double height) {
            this.height = height;
        }
    }
    // Rectangle을 상속
    public class Square extends Rectangle {
    
        @Override
        public void setWidth(double width) {
            this.width = width;
            this.height = width;
        }
    
        @Override
     public void setHeight(double height) {
            this.height = height;
         this.width = height;
        }
    }
    // 확인하기
    public class DoWork {
        public boolean work(Rectangle rectangle) {
         rectangle.setHeight(5);
            rectangle.setWidth(4);
    
            return rectangle.getArea() == 20;
        }
    
        public static void main(String[] args) {
            DoWork doWork = new DoWork();
            System.out.println("doWork.work(new Rectangle()) = " + doWork.work(new Rectangle()));    //true
            System.out.println("doWork.work(new Square()) = " + doWork.work(new Square()));            //false
        }
    }
    메인 메서드에서 Rectangle 인스턴스를 매개변수로 넣으면 기대하는 직사각형의 넓이 20이 정상적으로 나와 true를 반환했다.하지만 오버라이딩한 메서드에 의해 결과는 20이 아닌 16이 나오게 되어 false를 반환했다.
  7. DoWork 클래스의 work 메서드는 Rectangle 타입의 객체를 매개변수로 받을 수 있어 Rectangle을 상속받은 Square 클래스도 매개변수로 넣을 수 있다.
  8. 이번엔 사각형 예제를 활용해 좀 더 코드에 가까워져 보자

리스코프 치환 원칙이었던 상위 객체를 하위객체로 치환하였지만 결과는 서로 달랐다.

이 경우에는 리스코프 치환 원칙에 어긋났다고 할 수 있겠다!

그렇다면 이 경우는 어떻게 설계하는 것이 올바른 설계일까???

Shape이라는 슈퍼 클래스를 만들고 나서 사각형, 삼각형 등등 도형들은 Shape 클래스를 상속받는 구조로 설계하는게 올바르다.

꼭 기억하자. 자식 클래스가 부모 클래스의 행위를 일관성있게 하려면 최소한 부모 클래스의 인스턴스가 실행하는 메서드는 자식 클래스의 인스턴스들도 일관성 있게 실행할 수 있어야 한다.

4. 인터페이스 분리 원칙 (Interface Segregation Principle)

클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.

이걸 쉽게 말해보자면? 특정 객체에 대한 책임을 덜어주는 것이며, 더 쉽게 풀면 기능을 매우 쪼갠다음 클래스가 단 하나의 책임(단일 책임)을 지니게 하는 것을 도와주는 원칙이라 할 수 있다.

개방 폐쇄 원칙에서 예를 들었던 카페를 통해서 예제를 한번 다뤄보자. 장사가 너무 잘되어서 2호점을 차릴 생각에 신이난 고사장은 2호점을 차리기 전에 카페에서 하는 일들을 인터페이스로 추려보았다.

public interface Cafe{
    String sweepCafe();        //청소하기
    String makeBeverage();    //음료만들기
    String serveBeverage();    //주문한 음료를 제공하기
    String liquidate();        //결제하기
}

그리고 카페에서 내가 할일들을 구현해 보았다.

public class FirstStore implements Cafe{
    @Override
    public String sweepCafe(){
        return "청소중";
    }

    @Override
    public String makeBeverage(){
        return "주문한 음료 만들기";
    }

    @Override
    public String serveBeverage(){
        return "주문한 손님께 음료 드리기";
    }

    @Override
    public String liquidate(){
        return "결제하기";
    }
}

나는 2호점은 우선 결제 무인기를 설치하기로 마음 먹었으며 결제를 내가 직접하지 않아도 된다. 그리고 2호점을 오픈한 기념으로 일주일간 디저트도 같이 만들어서 드리기로 했다. 그리고 이를 구현해보자.

public class SecondStore implements Cafe{
    @Override
    public String sweepCafe(){
        return "청소중";
    }

    @Override
    public String makeBeverage(){
        return "주문한 음료 만들기";
    }

    @Override
    public String serveBeverage(){
        return "주문한 손님께 음료 드리기";
    }

    @Override
    public String liquidate(){    //2호점에서는 이걸 안해도 되는데..?
        return "결제완료";
    }

    public String makeDessert(){
        return "디저트도 같이 드린다.";
    }
}

2호점에서 안해도 될 일인 결제하기를 Cafe 인터페이스에 의해 의존되어 있다.

분리해보자. 우선 1호점과 2호점에서 공통적으로 할 일인 청소, 음료만들기, 음료제공하기를 Cafe 인터페이스에 유지하였다.

public interface Cafe{
    String sweepCafe();        //청소하기
    String makeBeverage();    //음료만들기
    String serveBeverage();    //주문한 음료를 제공하기
    //String liquidate();    제거
}

그리고 1호점에서만 할 일인 결제하기, 2호점에서만 할 일인 디저트 만들기를 인터페이스로 묶었다.

public interface CafeFirstStore{
    String liquidate();
}

public interface CafeSecondStore{
    String makeDessert();
}

리팩토링 후 1호점과 2호점 카페를 구현했다.

public class FirstStore implements Cafe, CafeFirstStore{
    @Override
    public String sweepCafe(){
        return "청소중";
    }

    @Override
    public String makeBeverage(){
        return "주문한 음료 만들기";
    }

    @Override
    public String serveBeverage(){
        return "주문한 손님께 음료 드리기";
    }

    @Override
    public String liquidate(){
        return "결제하기";
    }
}

public class SecondStore implements Cafe, CafeSecondStore{
    @Override
    public String sweepCafe(){
        return "청소중";
    }

    @Override
    public String makeBeverage(){
        return "주문한 음료 만들기";
    }

    @Override
    public String serveBeverage(){
        return "주문한 손님께 음료 드리기";
    }

    @Override
    public String makeDessert(){
        return "디저트도 같이 드린다.";
    }
}

리팩토링이 되었다. 여기서 알아가야할 점은

  1. 클래스는 기능에 의존하지 않고 하나의 책임만 책임지기 (단일 책임 원칙)
  2. 확장에 용이하게 설계하기

추후에 3, 4호점을 쭉쭉 차려나가도 이 방법대로 리팩토링을 진행한다면 나는 깔끔하게 카페를 운영할 수 있게 된다.

도움받은 곳

5. 의존관계 역전 원칙 (Dependency Inversion Principle)

상위 모듈은 하위 모듈의 구현에 의존하지 않고 하위 모듈이 상위 모듈에 정의한 추상 타입에 의존해야 한다.

DIP는 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것 보다는 변화하기 어려운 것에 의존하는 원칙이다.

개방 폐쇄 원칙에서의 결제 예제에서 결제 유형이라 보면 되겠다. 예제를 이 상황에 맞게 또 각색해보자.(위의 OCP에서 사용했던 예제는 상황만 참고해주세요!)

우선, 나는 결제를 한 다음에(2000원을 받고) 아메리카노를 만들어 드린다. 카드결제 손님들이 많아졌고, 손님들은 항상 삼성카드로 긁으셨다. 삼성카드말고 다른 카드로 결제하는 손님들이 없어서 나는 카드결제시 삼성카드에만 의존하는 밑의 그림과 같은 클래스를 만들기로 결정했다.

image

어느날 손님이 카드결제를 원하셨다. 근데 카드가 삼성카드가 아니고 신한카드였다! 당장 이 상황을 해결하기 위해서는 신한카드 클래스를 따로 만들면 되지만, 다른 카드사가 나타날게 분명하다. 이 때 의존관계 역전 원칙을 어떻게 지킬 수 있을까?

  • 변화하기 어려운 것 : 카드로 결제하기
  • 변화하기 쉬운 것 : 카드사 ex)삼성, 신한, 비씨 등등

캐셔는 결제하는데 있어서 어느 카드사인지는 중요하지 않다. 카드결제를 해서 2000원을 결제하면 되는 것이다. 의도에 맞게 해당 클래스 구조를 수정하면?

image

이렇게 변경하게 되면 결제 카드사가 추가 되더라도 기존 코드에 영향을 미치지 않는다. 또한 OCP원칙에서 추가, 확장에는 열려있고(카드사의 구현 추가) 변경에는 닫혀있는(기존의 코드 수정을 하지 않음) 원칙도 잘 지켜졌다.

여기까지 객체 지향 설계 5대 원칙에 대해서 알아보았다. 아마 내가 좀 더 개발자로서 경험이 풍부해지게 된다면 이 글은 삭제되고 원칙당 포스트를 하나로 해서 좀 더 디테일하게 알아볼 것 같다.

저에서 시작된 가짜뉴스, 논팩트, 잘못된 지식 공유에 대해서는 댓글로 꼭 피드백해주시면 엄청 감사하게 받겠습니다!

반응형