코딩 ‘독학’ 외롭지 않아? ‘함께’라면 끝까지 가능해💪
#소프트웨어 

객체 지향 디자인의 SOLID 원칙

이글에서는 객체 지향 디자인의 SOLID 원칙에 대해 설명합니다. 각 원칙이 생겨난 이유와 예제 코드로 간략하게 배워보도록 하겠습니다.

2024-05-14 | 김성혁

SOLID 원칙

1. SOLID 원칙이 필요한 이유

SOLID 원칙은 Robert C. Martin이 2000년 논문 “디자인 원칙과 디자인 패턴 “에서 소개한 개념으로, 이후 Michael Feathers가 SOLID라는 약어를 사용하면서 더욱 발전했습니다. 그리고 지난 20년 동안 이 다섯 가지 원칙은 객체 지향 프로그래밍의 세계에 혁명을 일으켜 소프트웨어 작성 방식을 변화시켰습니다.

그렇다면 SOLID란 무엇이며 더 나은 코드를 작성하는 데 어떻게 도움이 될까요? 간단히 말해, Martin과 Feathers의 설계 원칙은 유지 관리가 쉽고 이해하기 쉬우며 유연한 소프트웨어를 만들도록 장려합니다. 결과적으로 애플리케이션의 규모가 커짐에 따라 복잡성을 줄이고 향후 많은 골칫거리를 줄일 수 있습니다!

다음 다섯 가지 개념이 SOLID 원칙을 구성합니다:

  1. Single Responsibility (단일 책임의 원칙)
  2. Open/Closed (개방폐쇄의 원칙)
  3. Liskov Substitution (리스코프 치환의 원칙)
  4. Interface Segregation (인터페이스 분리의 원칙)
  5. Dependency Inversion (의존성역전의 원칙)

이러한 개념은 어렵게 느껴질 수 있지만 간단한 코드 예제를 통해 쉽게 이해할 수 있습니다. 다음 섹션에서는 이러한 원칙에 대해 자세히 살펴보고 각 원칙을 설명하기 위한 간단한 Java 예제를 살펴보겠습니다.

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

단일 책임 원칙부터 시작하겠습니다. 예상할 수 있듯이 이 원칙은 한 클래스는 하나의 책임만 가져야 한다는 것입니다. 또한 클래스는 변경할 이유가 하나만 있어야 합니다.

이 원칙이 더 나은 소프트웨어를 만드는 데 어떻게 도움이 될까요? 몇 가지 이점을 살펴보겠습니다:

  1. 테스트 (Testing) – 하나의 책임이 있는 클래스는 테스트 케이스 수가 훨씬 적습니다.
  2. 낮은 결합 (Lower coupling) – 단일 클래스에서 기능이 적을수록 종속성이 적습니다.
  3. 정리 (Organization) – 잘 정리된 소규모 클래스는 모놀리식 클래스보다 검색하기 쉽습니다.

예를 들어 간단한 책을 표현하는 클래스를 살펴봅시다:

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, String replacementWord){
        return text.replaceAll(word, replacementWord);
    }

    public boolean isWordInText(String word){
        return text.contains(word);
    }
}

이제 Book 클래스가 잘 작동하고 애플리케이션에 원하는 만큼의 책을 저장할 수 있습니다.
하지만 텍스트를 콘솔로 출력하여 읽을 수 없다면 정보를 저장하는 것이 무슨 소용이 있을까요?
이제 인쇄 방법을 추가해 보겠습니다:

public class BadBook {
    //...

    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 클래스를 활용하여 텍스트를 다른 미디어로 전송할 수도 있습니다.
이메일, 로깅, 그 밖의 다른 어떤 것이든 이 한 가지 문제를 전담하는 별도의 클래스가 있습니다.

3. 개방-폐쇄의 원칙 (Open/Closed)

이제 개방-폐쇄 원칙으로 알려진 SOLID의 O를 살펴볼 차례입니다. 간단히 말해, 클래스는 확장을 위해 열려 있어야 하지만 수정을 위해 닫혀 있어야 합니다. 이렇게 함 으로써 기존 코드를 수정하여 멀쩡한 애플리케이션에 새로운 버그가 발생할 가능성을 차단 할 수 있습니다.

물론 이 규칙의 한 가지 예외는 기존 코드의 버그를 수정하는 경우입니다.

간단한 코드 예제를 통해 개념을 살펴봅시다. 새 프로젝트의 일부로 기타 클래스를 구현했다고 가정해 보겠습니다.
모든 기능을 갖추고 있으며 볼륨 노브도 있습니다:

public class Guitar {

    private String make;
    private String model;
    private int volume;

    //Constructors, getters & setters
}

애플리케이션을 출시하고 모두가 좋아합니다. 하지만 몇 달 후 기타가 조금 지루하다고 판단하여 멋진 불꽃 패턴을 추가하면 로큰롤 느낌을 더 살릴 수 있을 것 같았습니다.

이 시점에서 기타 클래스를 열고 불꽃 패턴을 추가하고 싶을 수도 있지만, 애플리케이션에서 어떤 오류가 발생할지 누가 알겠습니까?

대신 개방-폐쇄의 원칙을 고수하고 기타 수업을 간단히 확장해 보겠습니다:

public class SuperCoolGuitarWithFlames extends Guitar {

    private String flameColor;

    //constructor, getters + setters
}

기타 클래스를 확장하면 기존 애플리케이션에 영향을 미치지 않을 수 있습니다.

4. 리스코프 치환의 원칙 (Liskov Substitution)

다음으로는 다섯 가지 원칙 중 가장 복잡한 리스코프 치환입니다. 간단히 말해, 클래스 A가 클래스 B의 하위 유형인 경우 프로그램의 동작을 방해하지 않고 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);
    }
}

코드에서 설명한 대로 엔진을 켜고 출력을 높일 수 있습니다.

하지만 잠깐만요, 우리는 이제 전기차 시대에 살고 있습니다:

public class ElectricCar implements Car {

    public void turnOnEngine() {
        throw new AssertionError("I don't have an engine!");
    }

    public void accelerate() {
        //this acceleration is crazy!
    }
}

엔진이 없는 자동차를 혼합에 투입함으로써 프로그램의 동작을 본질적으로 바꾸고 있습니다. 이는 노골적인 리스코프 대체 원칙 위반이며, 앞의 두 가지 원칙보다 수정하기가 조금 더 어렵습니다.

한 가지 가능한 해결책은 엔진이 없는 자동차의 상태를 고려한 인터페이스로 모델을 재작업하는 것입니다.

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

SOLID의 I는 인터페이스 분리를 의미하며, 단순히 큰 인터페이스를 작은 인터페이스로 분할해야 함을 의미합니다. 이렇게 하면 클래스를 구현하는 사람이 관심 있는 메서드에만 신경을 쓰면 됩니다.

이 예제에서는 사육사가 되어 보겠습니다. 좀 더 구체적으로 곰 우리에서 일하게 될 것입니다.

곰 사육사로서의 역할을 간략하게 설명하는 인터페이스부터 시작하겠습니다:

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

열렬한 사육사로서 저희는 사랑하는 곰들을 씻기고 먹이를 주는 것을 기쁘게 생각합니다. 하지만 곰을 쓰다듬는 행위가 얼마나 위험한지 잘 알고 있습니다. 안타깝게도 저희의 인터페이스는 다소 커서 곰을 쓰다듬을 수 있는 코드를 구현할 수밖에 없습니다.

큰 인터페이스를 세 개의 개별 인터페이스로 분할하여 이 문제를 해결해 보겠습니다:

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!
    }
}

더 나아가, 앞의 예제에서 BookPrinter 클래스를 분할하여 동일한 방식으로 인터페이스 분리를 사용할 수도 있습니다. 단일 인쇄 메서드가 있는 Printer 인터페이스를 구현함으로써 별도의 ConsoleBookPrinter 및 OtherMediaBookPrinter 클래스를 인스턴스화할 수 있습니다.

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

의존성역전의 원칙은 소프트웨어 모듈의 분리를 의미합니다. 이렇게 하면 상위 레벨 모듈이 하위 레벨 모듈에 의존하는 대신 둘 다 추상화에 의존하게 됩니다.

이를 보여주기 위해 옛날 방식으로 돌아가서 코드로 Windows 98 컴퓨터에 생명을 불어넣어 보겠습니다:

public class Windows98Machine {}

하지만 모니터와 키보드가 없는 컴퓨터가 무슨 소용이 있을까요? 생성자에 각각 하나씩을 추가하여 인스턴스화하는 모든 Windows98컴퓨터에 모니터와 표준 키보드가 미리 포장되어 제공되도록 해 보겠습니다:

public class Windows98Machine {

    private final StandardKeyboard keyboard;
    private final Monitor monitor;

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

}

이 코드가 작동하면 Windows98컴퓨터 클래스 내에서 표준 키보드와 모니터를 자유롭게 사용할 수 있습니다.

문제가 해결되었나요? 아니요. 표준 키보드와 모니터를 새로운 키워드로 선언함으로써 이 세 가지 클래스를 서로 긴밀하게 연결했습니다.

이로 인해 Windows98컴퓨터를 테스트하기 어려울 뿐만 아니라 필요에 따라 StandardKeyboard 클래스를 다른 클래스로 교체할 수 있는 기능도 잃었습니다. 그리고 모니터 클래스도 사용할 수 없게 되었습니다.

보다 일반적인 키보드 인터페이스를 추가하고 이를 클래스에서 사용함으로써 스탠다드키보드에서 머신을 분리해 보겠습니다:

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;
    }
}

여기서는 종속성 주입 패턴을 사용하여 키보드 종속성을 Windows98Machine 클래스에 쉽게 추가할 수 있습니다.

또한 표준 키보드 클래스를 수정하여 키보드 인터페이스를 구현하여 Windows98Machine 클래스에 주입하기에 적합하도록 구현해 보겠습니다:

public class StandardKeyboard implements Keyboard { }

이제 클래스는 분리되어 키보드 추상화를 통해 통신합니다. 원한다면 인터페이스의 다른 구현으로 컴퓨터의 키보드 유형을 쉽게 바꿀 수 있습니다. 모니터 클래스에도 동일한 원리를 적용할 수 있습니다.

종속성을 분리했으며 원하는 테스트 프레임워크를 사용하여 Windows98Machine을 자유롭게 테스트할 수 있습니다.

7. 결론

먼저 SOLID의 간략한 역사와 이러한 원칙이 존재하는 이유부터 살펴보았습니다.

원칙을 위반하는 코드 예시를 통해 각 원칙의 의미를 한 글자씩 분석해 보았습니다. 그런 다음 코드를 수정하여 SOLID 원칙을 준수하도록 하는 방법을 살펴 봤습니다.

이 글은 Baeldung.com 의 글을 번역한 글입니다.