개발/개념

SOLID 원칙을 위한 완전한(Solid) 가이드

미니소곰 2020. 3. 24. 02:40

Introduction

이 튜토리얼에서는 객체지향의 SOLID원칙에 대해서 얘기합니다.

먼저, SOLID원칙이 생긴 어떻게 생겨났는지와 소프트웨어 설계 시 고려해야할 것을 알아봅니다.

그리고, 핵심을 파악하기 위해 각 원리의 예제를 통해 설명합니다.

 

 

SOLID 원칙의 이유

SOLID원칙은 Robert C. Martin의 2000년도 논문(Design Principles ans Design Patterns)에서 소개되었습니다.

이후 이 개념은 Michael Feathers에 의해 SOLID로 알려졌습니다.

지난 20년 동안, 이 5가지 원칙은 객체지향 프로그래밍에 혁명을 일으켜 소프트웨어 작성방식을 변화시켰습니다.

 

그럼, SOLID는 무엇이고 어떻게 더 좋은 코드를 작성하도록 도울까요?

Martin과 Feathers의 디자인 원칙은 유지보수와 이해하는 것이 쉽고 유연한 소프트웨어를 만들도록 합니다.

결과적으로, 이 원칙은 애플리케이션의 크기가 커질 때, 앱의 복잡성을 줄이고 문제생길 수 있는 부분을 제거합니다.

 

SOLID원칙은 다음과 같습니다:

  1. Single Responsibility
  2. Open/Closed
  3. Liskov Substitution
  4. Interface Segregation
  5. Dependency Inversion

이 단어 중 일부는 무슨 소리인지 알기 어렵지만, 간단한 코드 예제로 쉽게 이해할 수 있습니다.

다음 섹션에서 각 원칙의 의미를 자바 예제와 함께 설명합니다.

 

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

단일 책임의 원칙부터 살펴봅시다.

우리가 예상하듯이, 이 원칙은 "클래스는 하나의 책임만을 가져야한다."입니다.

또한 변경해야할 이유는 한가지여야한다고 말합니다.

 

이 원칙은 더 좋은 소프트웨어를 만드는데 어떻게 도움이 될까요?

이 원칙의 이점을 살펴봅시다:

  1. 테스팅 - 하나의 책임만 갖는 클래스는 테스트 케이스가 훨씬 적습니다.
  2. 낮은 결합(coupling) - 단일 클래스의 기능이 적을수록 종속성이 줄어듭니다.
  3. 조직, 구성(Organization) - 소규모의 잘 구성된 클래스는 모놀리식(monolithic) 클래스보다 보기 쉽습니다.

 

예로 간단한 책을 나타내는 클래스를 봅시다.

public class Book {
 
    private String name;
    private String author;
    private String text;
 
    //constructor, getters and setters
}

이 코드에서, 우리는 책 인스턴스와 관련된 이름, 저자, 텍스트를 저장합니다.

이제 텍스트를 쿼리하는 몇가지 메서드를 추가해 봅시다.

public class Book {
 
    private String name;
    private String author;
    private String text;
 
    //constructor, getters and setters
 
    // methods that directly relate to the book properties
    public String replaceWordInText(String word){
        return text.replaceAll(word, text);
    }
 
    public boolean isWordInText(String word){
        return text.contains(word);
    }
}

이제 우리의 책 클래스는 잘 작동합니다. 그리고 우리는 우리의 앱에 우리가 좋아하는 만큼 많은 책을 저장할 수 있습니다. 그러나 텍스트를 콘솔에 출력하고 읽을 수 없다면, 정보를 저장해서 무얼 할까요

출력 메서드를 추가해보겠습니다.

public class Book {
    //...
 
    void printTextToConsole(){
        // our code for formatting and printing the text
    }
}

근데, 이 코드는 앞에서 설명한 단일 책임 원칙을 위반합니다.

이를 바로잡기 위해서, 우리는 텍스트 출력에만 관련이 있는 별도의 클래스를 구현해야합니다.

public class BookPrinter {
 
    // methods for outputting text
    void printTextToConsole(String text){
        //our code for formatting and printing the text
    }
 
    void printTextToAnotherMedium(String text){
        // code for writing to any other location..
    }
}

어썸! 우리는 우리는 책 클래스의 출력 책임을 줄여주는 클래스를 개발한 것 뿐만아니라, 우리는 또한 BookPrinter 클래스를 사용해서 다른 미디어에 텍스트를 보낼 수 있습니다.

이메일이든 log를 남기는 것이든 상관없이, 우리는 출력을 담당하는 별도의 클래스를 갖게 되었습니다.

 

2. Open for Extension, Closed for Modification (개방폐쇄원칙)

개방 폐쇄 원칙으로 알려진 원칙을 살펴봅시다. 

간단히 말해서, 클래스는 확장을 위해서 열려야하지만, 수정을 위해서는 닫혀야합니다.

이렇게 함으로써, 우리는 기존의 코드를 수정하거나 앱에 잠재적인 버그를 만드는 것을 막습니다.

 

물론, 기존 코드의 버그를 수정하는 경우는 예외입니다.

 

간단한 예제 코드를 통해 개념을 자세히 살펴보겠습니다. 새로운 프로젝트로 기타 클래스를 구현했다고 가정해봅시다.

이것은 볼륨 노브를 갖출정도로 완전합니다.

public class Guitar {
 
    private String make;
    private String model;
    private int volume;
 
    //Constructors, getters & setters
}

우리는 이 앱을 런칭했고 모두들 이것을 좋아합니다. 그러나, 몇달뒤 우리는 기타(Guitar)가 밋밋하다고 생각했고, 불꽃문양을 넣어 좀더 락 앤 롤스럽게 만들 수 있다고 생각했습니다.

 

이 시점에 기타(Guitar)클래스를 열어 불꽃 문양을 추가하고 싶을 수 있습니다. 하지만 (이미 런칭한) 앱에 어떤 에러가 발생할지 모릅니다.

 

기타 클래스에 추가하는 대신, 개방 폐쇄 원칙을 고수하고 간단하게 Guitar클래스를 확장하세요.(extend, 상속)

public class SuperCoolGuitarWithFlames extends Guitar {
 
    private String flameColor;
 
    //constructor, getters + setters
}

기타 클래스를 확장해서, 우리는 기존의 앱이 영향받지 않도록 할 수 있습니다.

 

 

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

다음은 리스코프 치환입니다. 이것은 5가지 원칙 중 가장 복잡합니다.

간단히 말해서, 클래스 A가 클래스 B의 하위 유형(subtype)이라면, 프로그램이 동작하는데 문제 없이 B를 A로 교체할 수 있어야 합니다.

 

이 개념을 이해하는데 도움이 되도록 예제코드를 보겠습니다.

public interface Car {
 
    void turnOnEngine();
    void accelerate();
}

위에, 우리는 간단한 차 인터페이스를 정의했습니다. 이 인터페이스의 몇가지 메서드는 모든 자동차가 충족시켜야할 것들입니다. - 엔진 시동을 걸기, 가속하기.

 

인터페이스를 구현하고 메서드에 관련된 코드를 작성해봅시다.

public class MotorCar implements Car {
 
    private Engine engine;
 
    //Constructors, getters + setters
 
    public void turnOnEngine() {
        //turn on the engine!
        engine.on();
    }
 
    public void accelerate() {
        //move forward!
        engine.powerOn(1000);
    }
}

코드가 나타내는 것 처럼, 우리는 시동을 걸 수 있는 엔진을 가지고 있고, 파워를 증가시킬 수 있습니다.

잠시만... 2019년 엘론 머스크는 바빴습니다.

 

우리는 이제 전기차의 시대에 살고있습니다.

public class ElectricCar implements Car {
 
    public void turnOnEngine() {
        throw new AssertionError("I don't have an engine!");
    }
 
    public void accelerate() {
        //this acceleration is crazy!
    }
}

엔진없는 차를 포함하면서, 우리는 본질적인 프로그램의 메서드를 바꾸고 있습니다.

(메서드의 원래 의도대로 작동하지 않음)

이는 리스코프 치환에 대한 명백한 위반입니다. 그리고 이전 2개의 원칙보다 바로잡기 어렵습니다.

 

바로잡기 위한 한가지 가능한 방법은 엔진 없는 자동차의 상태를 고려한 인터페이스로 새로 작업하는 것 입니다.

 

 

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

이번에는 인터페이스 분리 원칙입니다. 이 원칙은 간단히 설명하자면 "큰 인터페이스는 작은 인터페이스들로 나뉘어야 한다"입니다. 이렇게 해서, 우리는 클래스를 구현할 때  관심있는 메서드만 신경쓸 수 있습니다.

 

이번 예제에서, 우리는 동물원 사육사로서 우리의 손을 시험해보도록 하겠습니다. 구체적으로, 우리는 곰 우리에서 일할 것 입니다.

 

곰 사육사의 역할을 간단하게 나타내는 인터페이스로부터 시작하겠습니다:

public interface BearKeeper {
    void washTheBear();
    void feedTheBear();
    void petTheBear();
}

 

열렬한 사육사이기 때문에, 우리는 사랑하는 곰들을 씻기고 먹이는 것을 더 기쁘게 생각합니다. 그러나 우리는 곰을 쓰다듬는 것의 위험함을 너무 잘 알고 있습니다. 하지만 불행하게도, 우리의 인터페이스는 꽤 비대해서, 우리는 곰을 쓰다듬는 코드를 구현하는 선택밖에 할 수 없습니다.

 

우리의 비대한 인터페이스를 3개의 개별 인터페이스로 나누어 이 문제를 해결해 보겠습니다:

public interface BearCleaner {
    void washTheBear();
}
 
public interface BearFeeder {
    void feedTheBear();
}
 
public interface BearPetter {
    void petTheBear();
}

 

이제 인터페이스 분리 원칙으로, 우리는 우리에게 중요한 메서드만 구현할 수 있습니다.

 

public class BearCarer implements BearCleaner, BearFeeder {
 
    public void washTheBear() {
        //I think we missed a spot...
    }
 
    public void feedTheBear() {
        //Tuna Tuesdays...
    }
}

 

그리고 마침내, 우리는 그 위험한 것(곰 쓰다듬기)을 미친사람에게 둘 수 있습니다.

 

public class CrazyPerson implements BearPetter {
 
    public void petTheBear() {
        //Good luck with that!
    }
}

더 나아가서, 우리는 이전 예제인 BookPrint 클래스를 인터페이스 분리원칙으로 분리할 수 있습니다.

Printer 인터페이스를 단일 print 메서드로 구현함으로서, 우리는 ConsoleBookPrinter와 OtherMediaBookPrinter 클래스를 각각 인스턴스화 할 수 있습니다.

 

 

5. Dependency Inversion (의존성 역전 원칙)

의존성 역전의 원칙은 소프트웨어 모듈의 디커플링을 말합니다. 이 방법은, 상위 수준의 모듈이 하위 수준 모듈에 의존하는 대신, 모두 추상화에 의존합니다.

 

이를 보여주기 위해서, 구시대로 돌아가서 코드로 윈도우98을 만들어 봅시다.

public class Windows98Machine {}

 

근데, 모니터와 키보드가 없으면 소용이 없죠. 각 생성자에 하나씩 추가해서, 우리가 인스턴스화하는 모든 윈도우98 컴퓨터에 모니터와 표준키보드가 포함되도록 합시다.

 

public class Windows98Machine {
 
    private final StandardKeyboard keyboard;
    private final Monitor monitor;
 
    public Windows98Machine() {
        monitor = new Monitor();
        keyboard = new StandardKeyboard();
    }
 
}

 

이 코드는 작동하고, 우리는 윈도우98 컴퓨터 클래스에서 표준 키보드와 모니터를 자유롭게 사용할 수 있을 겁니다.

문제가 해결되었나요? 그렇지 않죠.. 

새로운 키워드로 표준 키보드와 모니터를 선언함으로써, 3가지 클래스를 밀접하게 묶었습니다.

 

이로 인해 윈도우98컴퓨터를 더욱 테스트하기 어렵게 만들었을 뿐만 아니라, 우리는 표준 키보드를 다른 키보드로 바꿔야하는 상황이 오더라도 바꿀 수 없게 되었습니다. 그리고 모니터도 그렇습니다.

 

우리의 머신을 표준 키보드로부터 분리(decouple)시켜봅시다. 더 일반적인 키보드 인터페이스를 추가하고 이것을 우리 클래스 안에서 사용합시다.

 

public interface Keyboard { }
public class Windows98Machine{
 
    private final Keyboard keyboard;
    private final Monitor monitor;
 
    public Windows98Machine(Keyboard keyboard, Monitor monitor) {
        this.keyboard = keyboard;
        this.monitor = monitor;
    }
}

여기서 우리는 의존성 주입 패턴을 사용해서 윈도우98머신 클래스에 키보드 종속성을 쉽게 추가할 수 있습니다.

 

윈도우98머신 클래스에 적합하도록, 표준키보드클래스를 키보드 인터페이스를 구현하여 수정해봅시다:

public class StandardKeyboard implements Keyboard { }

이제 우리 클래스는 분리(decouple)되었습니다. 그리고 키보드 추상을 통해서 소통합니다. 만약 우리가 원한다면, 우리는 쉽게 인터페이스를 구현하여 다른 키보드로 바꿀 수 있습니다. 모니터 클래스에 대해서도 같은 원칙을 적용할 수 있습니다.

 

엑설런트! 우리는 의존성을 분리(decouple)했으며 우리가 어떤 테스트 프레임워크를 사용하든 윈도우98머신을 자유롭게 테스트할 수 있습니다.

 

 

결론

우리는 이 튜토리얼에서 객체지향 설계의 SOLID원칙에 대해 자세히 살펴보았습니다.

우리는 간략히 SOLID의 역사와 이 원칙의 존재 이유로 설명을 시작했습니다.

SOLID의 문자별로, 원칙을 위반하는 예제 코드로 의미를 설명했고 SOLID원칙에 맞도록 바로잡는 방법을 살펴보았습니다.

 

이 코드는 깃허브에서 볼 수 있습니다.

 

 

 

출처:

https://www.baeldung.com/solid-principles

 

A Solid Guide to SOLID Principles | Baeldung

A quick and practical introduction to SOLID with examples.

www.baeldung.com