디자인 패턴(신)/만들면서 공부하는 디자인 패턴

[만들면서 공부하는 디자인 패턴] 1 - SOLID원칙 Review

공대키메라 2024. 3. 30. 13:00

최근에 필자는 회사에서 프로젝트를 하다가 인생 최대의 위기(?)에 맞닥드리게 된다.

 

그것은 바로... 발(영어로 foot)로 짜인 프리 랜서들의 코드에 맞닥드렸다는 사실이다.

 

나에게 이러한 것은 정말 신선한 분노를 선사했다. (하... 진짜... 다시 만나기만해봐...) Ship Sell Key들(배는 열쇄들을 판다)

 

그러면서 문득 이런 생각이 들었다.

 

해당 기능들을 어떻게 내가 구현했어야 이 사단이 안났을까?

 

 

자바는 객지지향을 구현하는데 있어서 굉장히 좋다고 들었는데 이것을 만약 내가 객체지향을 적용해서 코드들을 다시 수정하면 어떻게 해야하는걸까? 하는 것에서 출발을 했다.

 

OOP(Object Oriented Programing - 객체지향프로그래밍)을 해도 물론 유지보수는 나의 상황처럼 유지보수가 일어난다.

 

실제 프로젝트에서는 다양한 개발을 하는데 요구사항이 변하게 되는데, 이것을 극대화 하기 위한 첫걸음으로

 

SOLID를 다시 공부하고자 한다. 

 

필자는 이전에 디자인패턴(구) 에서 공부를 진행했는데 그때는 너무 기본적인 레벨이 낮아서 공부를 해도 해도 내것이 잘 되지가 않았던 것 같다.

 

다시 여러 패턴들에 대해 공부를 진행하고 디자인 패턴(신)이 완성이 되면 구버전을 닫을 것이다. 

 

1. 상황

키메라는 회사에서 객체 지향을 적용한 객실 예약 시스템을 만들려고 한다.

그런데 이런! 무엇부터 시작을 해야 할 지 모르겠다.

그래서 우선은 코드를 막 난발했는데 무언가 이상하다.

 

 

2. S! Single Responsibility Principle

 

단일 책임 원칙(single responsibility principle)이란 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화 해야 함을 일컫는다. 클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다.

출처 : http://ko.wikipedia.org/wiki/단일_책임_원칙


클래스를 변경하는 이유는 단 한 가지여야 한다.
- 로버트 C.마틴

 

 

작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는데 집중되어야 한다는 원칙이다. 

 

그러면 책임 영역이 확실하기 때문에 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있다고 한다.

 

뿐만 아니라 책임을 적절히 분배함으로써 코드의 가독성 향상, 유지보수 용이라는 이점도 있고, 다른 원리들을 적용하는 기초가 된다!

 

예를 들어서 예약(Reservation) 클래스가 있다면 예약만 처리해야지, 객실을 예약을 했다고 해서 객실 작업 코드가 예약 클래스 안에 있으면 안된다는 말이다.

 

* 잘못된 예시

public class Reservation {
    public void makeReservation() {
        System.out.println("do reservatiojn...");
    }

    public void changeRoomStatus() {
        System.out.println("change room status...");
    }
}

 

 

아니... 예약을 처리하는데 왜 객실을 처리하는 작업이 있어?

 

이렇게 되면  한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 없게 된다!

 

예약은 예약 업무만, 객실은 객실 업무만 처리하도록 하기 위해 별도의 객실 class를 만들어 사용해야 할 것이다.

 

2. O! Open-closed Principle


소프트웨어 개발 작업에 이용된 많은 모듈 중에 하나에 수정을 가할 때 그 모듈을 이용하는 다른 모듈을 줄줄이 고쳐야 한다면, 이와 같은 프로그램은 수정하기가 어렵다.

개방-폐쇄 원칙은 시스템의 구조를 올바르게 재조직(리팩토링)하여 나중에 이와 같은 유형의 변경이 더 이상의 수정을 유발하지 않도록 하는 것이다.

개방-폐쇄 원칙이 잘 적용되면, 기능을 추가하거나 변경해야 할 때 이미 제대로 동작하고 있던 원래 코드를 변경하지 않아도, 기존의 코드에 새로운 코드를 추가함으로써 기능의 추가나 변경이 가능하다.

출처 : http://ko.wikipedia.org/wiki/개방_폐쇄_원칙

 

 

확장에는 열려있고 변경에는 닫혀있다?

 

확장에 열려있다는 말은 새로운 변경사항이 발생했을 때 유연하게 코드를 추가 또는 수정할 수 있다는 것이며,

 

변경에 닫혀 있다는 말은 객체를 직접적으로 수정한다는 걸 제한한다는 말이다. 기능이 추거되거나 수정할 떄, 객체를 직접적으로 수정해야 한다면 새로운 변경사항에 대해 유연하게 대응할 수 없는 애플리케이션이다.

 

즉, 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있도록 설계해야 한다는 말이다.

여기서 변경에 닫혀 있는 주체는 원래 코드인 것이지, 파일이 추가되고 하는데 변경이 어떻게 없어! 라는 오해를 하지 않도록 주의하자!

 

이를 위해서는 추상화를 해야 한다고 한다. 

 

Java에서는 interface 혹은 abstract class를 활용하면 될 것이다.

 

public interface Vehicle {
    void move();
    void fix();
}

public class Car implements Vehicle{

    @Override
    public void move() {
        System.out.println("drive the road");
    }

    @Override
    public void fix() {
        System.out.println("fix the car!!");
    }
}

public class Airplane implements Vehicle{

    @Override
    public void move() {
        System.out.println("Fly to the sky! Infinity and beyond!");
    }

    @Override
    public void fix() {
        System.out.println("fix the blade!");
    }
}

 

 

 

예시 코드를 보면 변경에는 열려 있고, 수정에는 닫혀 있다. 왜? interface를 활용해서 새로운  코드를 잘 생성할 수 있고(변경에 열려 있음) 수정에는 닫혀 있으니(interface는 수정 안함) 말이다.

 

여기서 가장 어려운 것은 어떤 것을 추상화해서 사용할 것인지, 즉 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징을 어떻게 잘 도출해 낼 수 있는지가 관건인데... 그것이 사실 업무를 하다보면 어렵다.

 

3. L! Liskov Subtitution

컴퓨터 프로그램에서 자료형 S가 자료형 T의 서브타입라면 필요한 프로그램의 속성(정확성, 수행하는 업무 등)의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체(치환)할 수 있어야 한다는 원칙이다.

출처 : http://ko.wikipedia.org/wiki/리스코프_치환_원칙

 

즉, 부모 객체와 이를 상속한 자식 객체가 있다면 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있어야 한다는 원칙이다.

 

다르게 말하면 서브타입은 언제나 그들의 베이스 타입으로 교체될 수 있어야 한다는 원칙이다.

부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체할 수 있어야 하며, 이로 인해 프로그램의 정확성이 훼손되어서는 안된다!

 

이를 잘 구현하기 위해서는 올바른 상속과 구현이 필요하다는데... 한 번 보도록 하겠다.

 

* 잘못된 예

class Rectangle {
    protected int width, height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

 

 

해당 코드는 왜 잘못된 것일까?

 

리스코프 치환 원칙의 의미를 다시 살펴보면 서브타입은 언 제 나 그들의 베이스 타입으로 교체될 수 있어야 한다는 원칙이다. 그 말은 서브타입이 베이스 타입으로 교체되더라도 정확성이 훼손되면 안된다는 말이다.

 

Square Class를 Rectangle을 상속해서 구현시에, 작동이 이상해진다는 문제가 해당 원칙을 위반하게 된다.

Rectangle의 경우에는 너비를 수정할 때 따로, 높이를 수정할 때 따로 변하지만 

 

Square에서는 너비 혹은 높이 둘 중 하나만 수정하더라도 높이 혹은 너비가 수정이 되버린다.

 

이것은 우리가 생각하는 예상된 동작이 아니므로 원칙을 위배한다는 것이다.

 

그러면 각 클래스가 독립적으로 잘 동작하도록 만들려면 어떻게 해야할까?

 

Shape이라는 공통의 인터페이스를 만들어서 구현하는 class에서 각자 작업을 독립적으로 하면 된다.

 

* 올바른 예시

interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    protected int width, height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

 

 

막상 해보니 별거 없다. 말이 어려워서 그렇지 java에서 interface로 잘 추상화 할 줄 안다면 뭐... 쌉가능!

 

4. I! Interface Segragation Principle

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.

인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다. 이와 같은 작은 단위들을 역할 인터페이스라고도 부른다.

출처 : http://ko.wikipedia.org/wiki/인터페이스_분리_원칙

 

다시 말하면 범용 인터페이스 하나보다는 특정 클라이언트를 위한 여러 개의 인터페이스 분리가 더 좋다는 말이다.

인터페이스를 너무 큰 개념으로 잡지 말라는 것으로 이해할 수 도 있다.

 

* 잘못된 예시

public interface Car {
    void drive();
    void turnLeft();
    void turnRight();

    void steer();
    void steerLeft();
    void steerRight();
}

 

자동차 interface가 있다고 하자.

여기에 기본적으로 차가 할 일을 명시했는데, 수륙 양용 자동차가 있다고 하면... 

차가 보트의 역할도 할 수 있지 않은가? 그래서 차에다가 보트 조작법을 추가하였다.

 

그런데 이렇게 되면... 다른 차를 구현할 때는 필요가 없는 보트 조작법이 생길테고, 또 일반 보트를 구현할 때는 필요없는 차 조작법을 상속하게 된다.

 

결국 ISP 에 따르면 인터페이스를 더 작은 인터페스로 나누면 된다.

 

자동차 인터페이스랑 보트 인터페이스로 말이다.

 

*  올바른 예시

public interface Car {
    void drive();
    void turnLeft();
    void turnRight();

}

public interface boad {
    void steer();
    void steerLeft();
    void steerRight();
}

 

수륙 양용 차를 만들고 싶으면? 차와 보트 인터페이스를 두 개 다 상속하면 된다.

 

간단하지 않은가용?

 

 5. D! Dependency Inversion Principle

 

객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다.
이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 이 원칙은 다음과 같은 내용을 담고 있다.

첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.

둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다.

출처 :http://ko.wikipedia.org/wiki/의존관계_역전_원칙

 

프로그래머는 구체화가 아니아 추상화에 의존해야 한다. 즉, 구현 클래스(구현체)가 아니라 인터페이스(역할)에 의존하라는 이야기다.

 

이렇게 말해도 잘 와닿지가 않는다. 

 

 

김영한 선생님 강의에서 전에 들었었는데, 연극 로미오와 줄리엣이 있다고 하자.

 

여기에는 오전타임, 오후타임에 배우들이 달라지는데, 이 배우들은 배역에 초점을 맞춰서 연극을 진행하는 것이다.

 

다시 말해서 대본에서 주어진 역할(인터페이스), 배역에 의존해서 연극을 진행하는 것이다.

 

즉, 추상화된 것에 의존하게 만들고 구상 클레스에 의존하지 않게! 해야 한다는 것이다.

 

 

DIP 를 지키기 위한 방법으로는 다음이 있다.

 

1. 변수에 구상 클래스의 레퍼런스를 저장하지 말자.

=> 자식이 부모에 종속되던 구조가 부모가 자식에게 종속되어버리는 상황이 온다.

 

2. 구상 클래스에서 유도된 클래스를 만들지 말자.

=> 만약 구상 클래스에서 유도된 클래스를 생성한다면, 기반 클래스에 대한 변경이 파생 클래스에 영향을 미칠 가능성이 높아진다.

 

3. 베이스 클래스에서 이미 구현되어 있는 메소드를 오버라이드 하지 말자.

=> 기반 클래스의 인스턴스를 하위 클래스의 인스턴스로 교체해도 시스템의 정확성이 변하지 않아야 한다. 만약 베이스 클래스에서 이미 구현된 메소드를 오버라이드한다면, 이러한 원칙을 위반할 수 있다.

 

*  잘못된 예시

class LightBulb {
    public void turnOn() {
        System.out.println("LightBulb: Bulb turned on...");
    }

    public void turnOff() {
        System.out.println("LightBulb: Bulb turned off...");
    }
}

class ElectricPowerSwitch {
    public LightBulb lightBulb;
    public boolean on;

    public ElectricPowerSwitch(LightBulb lightBulb) {
        this.lightBulb = lightBulb;
        this.on = false;
    }

    public void press() {
        if (on) {
            lightBulb.turnOff();
            on = false;
        } else {
            lightBulb.turnOn();
            on = true;
        }
    }
}

 

 

현재 LightBulb를 ElectricPowerSwitch가 직접 의존하고 있다.

ElectricPowerSwitch 가 고수준 모듈이고...  LightBulb 가 저수준 모듈인데 이것을 직접적으로 의존하니 DIP 를 위반한 것이다.!

 

이런 경우 다른 종류의 스위치 기능을 추가하거나 다른 유형의 전등을 제어하고 싶을 때 유연성이 떨어진다.

 

* 올바른 예시

interface Switchable {
    void turnOn();
    void turnOff();
}

class LightBulb implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("LightBulb: Bulb turned on...");
    }

    @Override
    public void turnOff() {
        System.out.println("LightBulb: Bulb turned off...");
    }
}

class ElectricPowerSwitch {
    public Switchable device;
    public boolean on;

    public ElectricPowerSwitch(Switchable device) {
        this.device = device;
        this.on = false;
    }

    public void press() {
        if (on) {
            device.turnOff();
            on = false;
        } else {
            device.turnOn();
            on = true;
        }
    }
}

 

Switchable이라는 인터페이스를 도입해서 고수준 모듈과 저수준 모듈 사이의 의존을 제거했다. 

이로 인해 ElectricPowerSwitch는 다양한 Switchable 구현체에 대해 동작할 수 있게 되어, 시스템의 유연성과 확장성이 대폭 향상되었다.

 

정말 그런지 확인해볼까?

 

* 코드 추가

class Fan implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("Fan: Fan turned on...");
    }

    @Override
    public void turnOff() {
        System.out.println("Fan: Fan turned off...");
    }
}

// 사용 예
public class Main {
    public static void main(String[] args) {
        Switchable bulb = new LightBulb();
        ElectricPowerSwitch bulbSwitch = new ElectricPowerSwitch(bulb);
        bulbSwitch.press(); // 불을 켭니다.
        bulbSwitch.press(); // 불을 끕니다.

        Switchable fan = new Fan();
        ElectricPowerSwitch fanSwitch = new ElectricPowerSwitch(fan);
        fanSwitch.press(); // 팬을 켭니다.
        fanSwitch.press(); // 팬을 끕니다.
    }
}

 

 

실제로 원래 코드는 건들지 않고 추가가 용이하며(확장성 용이) 심지어 원하는 추가를 아주 유연하게(유연성 향상) 할 수 있었다.

 


 

필자는 이 원칙을 기억하며, 여러가지 디자인 패턴을 프로젝트에 적용하며 공부할 예정이다.

 

주소 : https://github.com/thelovemsg/design_pattern_msg_v1

 

추가되는 섹터마다 branch 를 새로 추가해서 만들어 나갈 예정이다.

 

여러 말이 구구절절 적혀 있을 텐데, 원래는 완벽하게 만들고자 했는데 가장 최우선 학습 과제로는 현재 디자인 패턴을 필자는 선택을 했다.

 

이유는 더 이상 유지보수성이 현저히 떨어지는 코드를 만들면 안되겠다는 생각이 들었다.

 

DDD 구색을 살짝 갖춘 JPA + QueryDSL 적용 프로젝트인데, 

 

프론트나 그러한 것에는 시간을 많이 투자하지 않을 생각이고, 이에 대한 논의사항이나 이랬으면 좋았겠으나 안한다 등 관련 내용은 Issue에 정리하려고 한다.

 

아마 이 다음 바로 논의해볼 문제는 요금 적용 문제이다.

 


 

마지막으로 더 추가하자면 필자는 헤드퍼스트 디자인 패턴 책으로 공부를 하는데 여기서 많이 사용되는 디자인 패턴에 대해서 집중적으로 소개를 하고 있다.

 

필자도 실제로 미니 프로젝트를 만들어보면서 도움이 될만한 것만 적용해볼 것이다. 즉, 모두 다루지는 않겠다는 말이다. 

 

참고:

널널한 개발자 TV_ SRP(Single Resposibility Principle)

널널한 개발자 TV_ OCP(Open Closed Principle)

https://www.baeldung.com/java-open-closed-principle

아주 쉽게 이해하는 ocp 개방폐쇄원칙

인터페이스 분리원칙, SOLID, 디자인패턴, Interface segregation

널널한 개발자 TV_ DIP(Dependency Inversion Principle)

디자인패턴, 의존관계 역전, Dependency inversion , SOLID, 솔리드