본문 바로가기
공부방

SOLID 원칙

by hseong 2024. 3. 23.

SOLID 원칙은 우리에게 좋은 소프트웨어 구조를 만들기 위한 원칙을 제공해줍니다. 클린 아키텍처에 따르면 SOLID 원칙의 목적은 중간 소프트웨어 구조가 다음과 같도록 만드는 데 있습니다.

  • 변경에 유연하다.
  • 이해하기 쉽다.
  • 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 된다.

SOLID는 이를 구성하고 있는 다섯 가지 원칙의 첫 번째 글자들로 만든 단어입니다. 이는 다음과 같은 원칙들로 구성되어 있습니다.

  • 단일 책임 원칙(Single Responsibility Principle, SRP)
    각 소프트웨어 모듈은 오직 하나의 책임만 가져야 한다.
  • 개방-폐쇄 원칙(Open-Closed Principle, OCP)
    소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.
  • 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
    서브타입(subtype)은 그것의 기반 타입(base type)으로 치환 가능해야 한다.
  • 인터페이스 분리 원칙(Interface Segregation Principle, ISP)
    클라이언트가 자신이 사용하지 않는 메서드에 의존하도록 강제되어서는 안 된다.
  • 의존성 역전 원칙(Dependency Inversion Principle, DIP)
    변동성이 큰 구현체가 아닌 안정된 추상 인터페이스를 의존해야 한다.

좋은 소프트웨어를 만들기 위해 어째서 이러한 원칙을 지켜야만 할까요? 이들을 어겼을 때 발생하는 문제점들은 무엇일까요?

단일 책임 원칙

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

클린 아키텍처의 저자 밥 아저씨는 단일 책임 원칙에 대해서 '하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.' 라고 정리합니다.

여기서 액터(actor)는 시스템이 동일한 방식으로 변경되기를 원하는 한 명 이상의 사람들을 가리킵니다. 또한, 모듈은 소스 파일 혹은 함수와 데이터 구조로 응집된 집합을 가리킵니다. 클래스, 패키지, 라이브러리 등 독립적이고 재사용 가능한 모든 응집된 단위를 모듈이라 할 수 있습니다.

그럼 단일 책임 원칙을 준수하지 않고 여러 액터에 대해서 책임을 어떤 문제가 발생할까요?

우발적 중복

여기 Employee라는 클래스가 있습니다. 해당 클래스가 가지고 있는 두 가지 메서드는 서로 다른 두 명의 집단을 책임지고 있습니다. caculatePay()는 회계팀에서 기능을 정의하고 사용합니다. reportHours()는 인사팀에서 기능을 정의하고 사용합니다. 또한, 두 메서드 모두 업무 시간을 계산하기 위해 regularHours() 메서드를 호출합니다.



public class Employee {
    ...

    public Payment calculatePay() {
        ...
        int regularHours = regularHours();
        ...
    }


    private int regularHours() {
        ...
    }

    ...

    public Report reportHours() {
        ...
        int regularHours = regularHours();
        ...
    }
}

만약 회계팀에서 업무 시간을 계산하기 위한 로직을 변경한다고 가정해봅시다. 해당 업무를 수행하는 개발자는 calculatePay() 내부에서 regularHours()라는 메서드를 호출하는 것을 발견하였습니다. 하지만 reportHours()에서도 동일한 메서드를 호출한다는 것을 인지하지 못하였습니다.



회계팀의 변경된 기능은 의도한대로 잘 동작하였고 배포를 끝마쳤습니다. 그러나 인사팀의 보고서는 엉터리 수치가 포함되어 잘못된 데이터 범벅이 됩니다. 서로 다른 두 액터가 동일한 코드를 의존하고 있었기 이와 같은 문제가 발생하였습니다.

병합

이번에는 회계팀과 인사팀이 둘 모두 모종의 이유로 Employee 클래스의 코드를 변경해야 한다고 생각해봅시다. 둘의 작업은 동시에 일어나고 이들의 변경사항은 서로 충돌합니다. 코드 충돌을 해결하는 과정은 번거롭고 실수로 다른 사람의 변경사항을 건드릴지도 모릅니다.



이처럼 서로 다른 목적을 가지고 동일한 소스 파일을 변경하려는 경우 여러 징후들이 발생하게 됩니다. 이를 해결하기 위해서는 각 클라이언트에 따라 사용하는 메서드와 인스턴스 변수의 집합을 서로 다른 클래스로 이동시켜 각각의 클래스가 오직 하나의 책임만 가지도록 분리하여야 합니다.

public class Employee {
    ...
}

public class PayCalculator {
    ...

    public Payment calculatePay() {
        ...
    }

    private int regularHours() {
        ...
    }
}

public class HourReporter {
    ...

    public Report reportHours() {
        ...
    }

    private int regularHours() {
        ...
    }
}

개방-폐쇄 원칙

개방-폐쇄 원칙(OCP, Open-Closed Principle)은 '소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다'는 프로그래밍 원칙이다. (출처: 위키백과)

개방-폐쇄 원칙은 다음과 같이 두 가지 중요한 속성을 가집니다.

  1. 확장에 대해 열려 있다.
    애플리케이션의 요구사항이 변경되었을 때, 변경에 맞게 새로운 행위를 추가해 모듈을 확장할 수 있다.
  2. 수정에 대해 닫혀 있다.
    모듈의 행위를 확장하는 것이 해당 모듈의 소스 코드의 변경을 초래하지 않는다.

그럼 개방-폐쇄 원칙을 준수하지 않고 설계를 진행하면 어떤 문제가 발생할까요?

Movie 클래스는 PercentDiscountPolicy 클래스를 사용하여 할일 요금을 계산하고 있습니다. 이때, 새로운 요구사항이 추가된다면 PercentDiscountPolicy의 코드를 수정하여 기능을 확장할 수 있을 것입니다. 하지만 이로 인해 코드는 if/else, switch문으로 범벅이 되고 코드 변경으로 인한 컴파일 또한 다시 수행해야 합니다.

오브젝트에서는 개방-폐쇄 원칙이 런타임 의존성과 컴파일타임 의존성에 관한 이야기라 말하고 있습니다. OCP를 잘 지킨 코드는 컴파일타임에 의존성을 변경하지 않고도 간단한게 런타임 의존성을 변경할 수 있습니다.



요구사항을 구현하는 새로운 클래스를 추가하더라도 Movie 클래스는 여전히 DiscountPolicy 클래스에만 의존합니다. 하지만 런타임에는 새롭게 추가된 OverlappedDiscountPolicy 클래스의 인스턴스와 협력합니다.

중요한 것은 추상화에 의존하는 것입니다. 변하는 것과 변하지 않는 것을 구분하고 모든 요소는 변하지 않는 것에 의존해야 합니다. 새로운 요구사항이 추가되면 문맥에 알맞게 세부적인 부분을 구현하여 확장해나갈 수 있도록 올바른 추상화를 설계해야 합니다.

리스코프 치환 원칙

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

DiscountPolicy라는 클래스는 할인률을 계산하기 위한 메서드를 가지고 있고 Movie는 이를 의존하고 있습니다. MovieAmountDiscountPolicyPercentDiscountPolicy 둘 중 무엇과도 협력할 수 있습니다. 둘 다 각자의 알고리즘을 이용해서 할인률을 계산할 것입니다.



이처럼 자식 클래스는 부모 클래스가 하는 일을 동일한 방식으로 처리할 수 있어야 합니다. 물론 자식마다 세부적인 구현사항은 달라질 수 있습니다.

오브젝트에서는 리스코프 치환 원칙은 상속을 사용하기 위한 가이드라인이며 자식 클래스가 부모 클래스를 대체하기 위해서는 항상 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다고 합니다. 만일 이러한 가이드라인을 어기면 어떻게 될까요?

Java의 Stack은 리스코프 치환 원칙을 어기는 대표적인 예입니다. Stack을 사용할 적에 우리는 LIFO를 구현한 자료구조를 기대합니다. 하지만 Java의 StackVector를 상속하도록 만들어졌습니다. 이 때문에 Stack에는 insertElementAt()이나 remove()와 같이 임의의 위치에 요소를 추가하거나 제거하는, 클라이언트가 Stack기대하는 것과는 다른 메서드가 포함되었습니다.



Stack을 사용하는 클라이언트는 LIFO 자료구조에 맞는 행위를 기대하고, Vector를 사용하는 클라이언트는 동적인 배열에 맞는 행위를 기대합니다. Stack은 결코 Vector를 대체할 수 없습니다. 그렇기 때문에 리스코프 치환 원칙은 언제나 클라이언트의 관점에서 바라보아야 합니다. 상속 관계에 있는 클래스는 언제나 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 있을 때만 올바릅니다.

이처럼 리스코프 치환 원칙은 우리에게 올바른 상속 구조를 구현하기 위한 가이드라인을 제공합니다. 자식 클래스가 부모 클래스를 안정적으로 대체할 수 있다면 클라이언트의 코드는 어떠한 변경도 없이 자식 클래스와 협력할 수 있습니다.

인터페이스 분리 원칙

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다. (출처: 위키백과)

인터페이스 오염

여기 유튜브 시청자라는 클라이언트가 있습니다. 클라이언트는 유튜브 머신 인터페이스를 이용하여 특정한 구현에 의존하지 않고 해당 인터페이스를 구현하는 객체와 협력할 수 있습니다.



어느날 유튜브 시청자유튜버 활동을 통해 수익 창출을 해보기로 결심하였습니다. 이를 위해 유튜브 머신영상 업로드, 영상 관리와 같은 새로운 메서드를 추가하였습니다.



이제 유튜브 머신을 구현하는 구현체들은 새롭게 추가된 메서드들을 강제로 구현해야 합니다. 여기서 문제가 발생합니다. 고사양 컴퓨터에는 이러한 메서드의 구현부를 추가하는 것이 의도된 것일 수 있습니다. 하지만 태블릿영상 업로드, 영상 관리와 같은 메서드를 가지게 되는 것을 원치 않았음에도 이를 강제로 구현해야 상황이 발생합니다.

이처럼 새로운 기능이 추가되면서 인터페이스는 점점 비대해집니다. 응집도가 낮아지고 서로 다른 클라이언트의 집합은 서로 다른 메서드 그룹을 사용하게 됩니다. 또한, 인터페이스의 구현체에서 원치 않는 기능을 강제로 구현해야 하는 상황이 발생할 수 있습니다. 이러한 상황을 방지하기 위해서는 ISP를 적용하여 인터페이스를 작게 나누어야 합니다. 인터페이스를 작게 나눈 다음에는 다음과 같이 다중 상속을 이용해서 문제를 해결할 수 있을 겁니다.



재컴파일

클린 아키텍처에서는 클라이언트가 자신이 사용하지 않는 메서드에 의존하도록 강제되었을 때, 다른 클라이언트가 해당 메서드에 가하는 변경에 영향을 받게 된다고 말하고 있습니다.

ClientAClientB가 의존하는 Something이라는 클래스가 있다고 해봅시다. ClientA는 오직 clientAUse만 사용하고, ClientB는 오직 clientBUse만 사용하고 있습니다. 두 클라이언트는 각각 자신이 사용하지 않는 메서드에 의존하고 있기 때문에 인터페이스 분리 원칙을 위반하였습니다.



정적 타입 언어의 경우 이러한 상황에서 ClientB가 사용하고 있는 clientBUse의 시그니처가 변경되는 경우 변경이 발생한 Something과 이에 의존하고 있는 ClientAClientB 모두 다시 컴파일을 수행해야 합니다.

다만, 자바의 경우 호출할 정확한 메서드를 런타임에 결정하는 late binding 덕분에 다른 정적 타입 언어와는 다른 양상을 보입니다. 의존하고 있는 클래스의 변경이 발생하더라도 현재 클라이언트가 사용하고 있는 메서드와 관계없다면 다시 컴파일을 수행할 필요가 없습니다. 이 때문에 ISP는 언어 종류에 따라 영향받는 정도가 다를 수 있다고 합니다.

의존성 역전 원칙

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

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

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

의존성 역전 원칙은 구체적인 것보다는 추상적인 것에 의존하라는 원칙입니다. 물론 String과 같은 구체 클래스는 예외입니다. String은 매우 안정적이며 설령 변경이 발생하더라도 엄격한 통제하에서 일어납니다. 때문에 이러한 안정성이 보장된 환경에 의존하는 것은 용납되는 편입니다.

우리가 의존하지 않도록 피해야 하는 것은 변동성이 큰 구현체들입니다. 새로운 요구사항이 추가되거나 개발중에 언제든지 변경될 수 있는 모듈들에 대해서 의존하는 것은 피해야 합니다.

의존성 역전 원칙을 무시하였을 때 어떤 문제가 벌어질까요?

Movie라는 클래스가 있습니다. Movie는 영화 요금의 계산을 위해서 PercentDiscountPolicy라는 클래스에 의존하고 있습니다. PercentDiscountPolicy는 할인조건에 해당하는 경우 일정 퍼센트만큼 차감해야할 금액을 Movie에게 반환해줍니다.



어느날 새로운 요구사항이 추가되었습니다. 기존에는 조건에 해당하는 사람들만 일정 퍼센트만큼 할인해주던 것에서 모든 관람객들을 대상으로 1,000원 씩 할인해주는 이벤트를 진행하기로 하였습니다. 개발자는 새로운 요구사항을 구현하기 위해 AmountDiscountPolicy 클래스를 추가합니다. 또한, Movie가 영화 금액을 계산하기 위해 협력하는 객체를 변경하기 위해 코드를 변경해야 합니다.

public class Movie {

//    private final PercentDiscountPolicy discountPolicy;
    private final AmountDiscountPolicy discountPolicy;

    public Movie(...) {
        ...
//        this.discountPolicy = new PercentDiscountPolicy(...);
        this.discountPolicy = new AmountDiscountPolicy(...);
    }

    ...

    private Money calculateFee(...) {
        int discountAmount = discountPolicy.calculateDiscountAmount(...);
        ...
    }
}

이처럼 구체적인 것에 대한 의존은 변경에 유연하게 대응할 수 있는 가능성을 없애버립니다. Movie는 오직 일정 퍼센트 할인이라는 문맥에서만 작동하고 일정 금액 할인이라는 문맥에서는 사용할 수 없습니다. Movie를 재사용하기 위해서는 내부 구현을 변경해야만 합니다. 그러나 새로운 요구사항이 추가될 때마다 내부 구현을 변경해야 한다면 그러한 의존성은 올바르지 못한 의존성입니다.

올바른 의존성으로 바꾸기 위해서는 보다 추상적인 것에 의존하도록 해야합니다. 일정 퍼센트 할인과 일정 금액 할인이라는 문맥으로부터 할인 요금 계산이라는 추상적인 역할만 추출하여 이것에만 의존하도록 만들어야 합니다.

public class Movie {

    private final DiscountPolicy discountPolicy;

    public Movie(..., DiscountPolicy discountPolicy) {
        ...
        this.discountPolicy = discountPolicy;
    }

    ...

    private Money calculateFee(...) {
        int discountAmount = discountPolicy.calculateDiscountAmount(...);
        ...
    }
}

이제 Movie는 할인 요금 계산이라는 메시지만 노출하는 추상적인 DiscountPolicy에 의존합니다. 또한, 생성자를 통해서 의존성을 드러내고 런타임에 주입받음으로써 할인 요금 계산을 수행하는 다양한 DiscountPolicy 인스턴스들과 협력할 수 있습니다.



컴파일타임에 Movie는 추상적인 DiscountPolicy에 의존하고 런타임에는 구체적인 AmountDiscountPolicy를 의존합니다. 이렇게 소스 코드의 의존성이 제어흐름과 반대 방향으로 역전되는 것을 의존성 역전이라 합니다. 우리는 이를 통하여 새로운 요구사항이 추가되더라도 변경에 유연하게 대응할 수 있습니다.

오브젝트에서 설명하는 바에 따르면 의존성 역전 원리는 전통적인 설계 방법과 객체지향을 구분하는 가장 핵심적인 원리라고 말하고 있습니다. 요구사항이 빠르게 진화하는 코드에서 의존성 역전 원리가 지켜지고 있지 않다면 이는 변경을 적절하게 수용할 수 없는 절차적인 코드가 존재할 수밖에 없다고 설명하고 있습니다.

참고

클린 아키텍처
오브젝트