성장, 그리고 노력

부족하더라도 어제보다 더 잘해지자. 노력은 절대 배신하지 않는다.

도구, 기술, 이론/이론

소프트웨어 설계 원칙 - SOLID

제이콥(JACOB) 2020. 1. 25. 19:23
 대부분의 내용은 Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)에 의존한다. 
이미 이 책을 읽고 이해하였다면, 아래 글은 읽지 않아도 무방하다. 

좋은 소프트웨어 시스템은 Clean Code로부터 시작한다. 

 코드(Code)란 건물을 짓는다고 했을때 벽돌과 같다. 여기에 대해서 Robert C.Martin이 쓴 Clean Architecture에 적절한 비유가 있어 가져와 봤다.

If the bricks aren't well made, the architecture of the building doesn't matter much. On the other hand, you can make a substantial mess with well-made bricks. This is where the SOLID principles come in.

출처: Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)

 SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 클래스를 서로 결합하는 방법을 설명해준다. 그리고 이러한 원칙은 중간 수준의 소프트웨어 구조가 아래와 같도록 만드는데 목적이 있다. 

 

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

SRP: 단일 책임 원칙(Single Responsibility Principle)

SOLID 법칙 중 가장 오해가 많은 원칙이다. 이 원칙의 이름을 듣는다면 모든 모듈이 단 하나의 일만 해야 한다는 의미로 받아들이기 쉽다. 하지만 이렇게 이해하는 것은 "함수는 반드시 하나의, 단 하나의 일만 해야 한다는 원칙"을 설명하는 것이며, SRP가 아니다. 

SRP: A module should have one, and only one, reason to change.
// 단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다.

SRP가 말하는 '변경의 이유'란 바로 이들 사용자들과 이해관계자들을 가리킨다. 이들을 actor로 명명하고, 위에 정의를 아래와 같이 바꿔 말할 수 있다.

SRP: A module should be responsible to one, and only one, actor.
// 하나의 모듈은 하나의, 오직 하나의 액터(사용자들 또는 또는 이해관계자들)에 대해서만 책임져야 한다.

 그리고 여기서 말하는 모듈(module)이란, 대부분의 경우에는 소스 파일(Source File)을 가리킨다. 하지만 일부 언어와 개발 환경에서는 코드를 소스 파일에 저장하지 않기 때문에 이러한 경우 모듈은 함수(Functions)와 데이터 구조(Data Structures)로 구성된 응집된 집합(Cohesive Set)이다. '응집된(Cohesive)'이라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(Cohesion)이다. 

 

 SRP는 메서드와 클래스 수준의 원칙이다. 하지만 컴포넌트 수준에서는 공통 폐쇄 원칙(Common Closure Principle)이 된다. 또한 아키텍처 수준에서는 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축(Axis of Change)이 된다. 

OCP: 개방-폐쇄 원칙(Open-Closed Principle)

A software artifact should be open for extension but closed for modification.
// 소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. 

 OCP소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 산출물을 변경해서는 안된다. 이는 소프트웨어 아키텍처를 공부하는 가장 근복적인 이유가 된다. 만약 요구사항을 살짝 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패에 맞닥뜨린 것이다. 

 

 아키텍트는 기능이 어떻게(how), 왜(why), 언제(when) 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다. 컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다. 

 

 OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는데 있다. 이런 목표를 달성하기 위해서는 위에서 말했듯이, 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 한다. 

LSP: 리스코프 치환 원칙(Liskov Substitution Principle)

 S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.

 잘 정의된 인터페이스와 그 인터페이스의 구현체끼리의 상호 치환 가능성에 기대한다. LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 한다. 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할  수 있기 때문이다. 

ISP: 인터페이스 분리 원칙(Interface Segregation Principle)

출처:  Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)

 위 그림에서 보면 다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다. 여기서 User1은 오직 op1을, User2은 오직 op2을, User3은 오직 op3을 사용한다고 해보자. 그리고 OPS가 정적 타입 언어로 작성된 클래스라고 해보자. User1에서는 op2와 op3을 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다. 이러한 의존성으로 인해 OPS 클래스에서 op2의 소크 코드가 변경되면 User1도 다시 컴파일한 수 새로 배포해야 한다. 사실 User1과 관련된 코드는 전혀 변경되지 않았음에도 말이다. 

 

 이러한 경우 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다. 

 

출처:  Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)

 일반적으로 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일이다. 소스 코드의 의존성의 경우 이는 분명한 사실인데, 불필요한 재컴파일과 재배포를 강제하기 때문이다(하지만 이는 언어의 특성에 따라 다르기도 하다). 이는 코수준인 아키텍처 수준에서도 마찬가지 상황이 발생한다. 

 여기서 우리는 불필요한 짐을 실은 무언가에 의존하면 예상치도 못한 문제에 빠진다는 사실이다. 

DIP: 의존성 역전 원칙(Dependency Inversion Principle)

 의존성 역전 원칙에서 말하는, 유연성이 극대화된 시스템이란 소스 코드 의존성이 추상(Abstraction)에 의존하며 구체(Concretion)에는 의 존하지 않는 시스템이다. 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다. 구체적인 대상에는 절대로 의존해서는 안된다.

 

 하지만 이는 확실히 비현실적이다. 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문이다. 예를 들어 자바에서 String은 구체 클래스이며, 이를 애써 추상 클래스로 만들려는 시도는 현실성이 없다. java.lang.String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수 없고, 벗어나서도 안된다. String 클래스는 매우 안정적이며, 변경되는 일은 거의 없고 변경되더라고 엄격하게 통제된다.

 

 우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소이다. 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수밖에 없는 모듈들이다.

 

안정된 추상화(Stable Abstractions)

 실제로 뛰어난 소프트웨어 설계자와 아키텍트라면 인터페이스의 변동성을 낮추기 위해 애쓴다. 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력한다. 이는 소프트웨어 설계의 기본이다. 

 즉 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 추상 인터페이스를 선호하는 아키텍처라는 뜻이다. 여기에 대해서는 매우 구체적인 코딩 실천법이 있다.

 

- 변동성이 큰 구체 클래스를 참조하지 말라(Don't refer to volatile concrete classes). 

대신 추상 인터페이스를 참조하라. 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리(Abstract Factory)를 사용하도록 강제한다.

 

- 변동성이 큰 구체 클래스로부터 파생하지 말라(Don't derive from volatile concrete classes).

정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다. 따라서 상속은 아주 신중하게 사용해야 한다. 

 

- 구체 함수를 오버라이드 하지말라(Don't override concrete functions). 

대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 따라서 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다. 이러한 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다. 

 

- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라(Never mention the name of anything concrete and volatile).

 

팩토리(Factories)

위 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다. 사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다. 자바 등 대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.

출처:  Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)

 위 그림에서 곡선은 아키텍처 경계를 뜻하며, 구체적인 것들로부터 추상적인 것들을 분리하며 시스템을 두 가지 컴포넌트로 분리한다. 

하나는 추상 컴포넌트이며 이는 애플리케이션의 모든 고수준 업무 규칙(business rules)을 포함한다. 다른 하나인 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다. 

 제어 흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점에 주목하자. 다시 말해 소스 코드 의존성은 제어 흐름과는 반대 반향으로 역전되며, 이 원칙을 의존성 역전(Dependency inversion)이라고 한다.

 

 위 그림에서 ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존한다. 이는 DIP를 위배하지만 일반적인 일이며 DIP 위배를 모두 없앨 수는 없다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다. 

반응형