////
Search
Duplicate
🌊

Chapter 04. SOLID

SOLID는 로버트 C. 마틴이 2000년대 초반에 고안한 5가지 원칙을 지칭하는 말로, 5가지 원칙은 아래와 같이 구성되는데, 각 원칙의 앞 글자를 따면 SOLID라는 단어가 만들어지기 때문에 이를 통칭해서 SOLID 원칙이라 부른다.
단일 책임 원칙(Single Responsibility Principle)
개방 폐쇄 원칙(Open-Closed Principle)
리스코프 치환 원칙(Liskov Substitution Principle)
인터페이스 분리 원칙(Interface Segregation Principle)
의존성 역전 원칙(Dependency Inversion Principle)
각 원칙은 객체지향 언어에서 좋은 설계를 얻기 위해 지켜야할 규범과 같은 것이며 목표는 소프트웨어의 유지보수성과 확장성을 높이는 것이다.
유지보수성은 어떻게하면 확보할 수 있을까? 사람이 유지보수를 하니 가독성을 확보하는 것이 우선일까? 그럴 수 있으나 설계 관점에서 코드의 유지보수성을 판단할 때, 사용 가능한 실무적인 맥락이 몇 가지 있다.
영향 범위: 코드 변경으로 인한 영향 범위가 어떻게 되는가?
의존성: 소프트웨어의 의존성 관리가 제대로 이뤄지고 있는가?
확장성: 쉽게 확장 가능한가?
SOLID는 위 질문에 답을 알려주는 원칙이다. SOLID를 따르는 코드는 코드 변경으로 인한 영향 범위를 축소할 수 있고, 의존성을 제대로 관리하며 기능 확장이 쉽다.

1. SOLID 소개

1. 단일 책임 원칙

단일 책임 원칙은 클래스에 너무 많은 책임이 할당되어서는 안 되며, 단 하나의 책임만 있어야 한다고 말한다. 클래스는 하나의 책임만 있을때 변경이 쉬워진다.
과하게 집중된 책임을 가지는 클래스는 코드를 변경하려고 할 때 문제가 된다. 영향 범위를 알 수 없으니 코드 변경 자체가 어려워지는 것이다.
이러한 맥락에서 단일 책임 원칙을 따르라는 말은 클래스가 특정 역할을 달성하는 데만 집중할 수 있게 하라는 의미다. 클래스에 할당된 책임이 하나라면 코드를 이해하는 것도 쉬워진다.
즉, 단일 책임 원칙은 변경과 연결된다. 변경으로 인한 영향 범위를 최소화하는 것이 이 원칙의 목적이다.
그래서 단일 책임 원칙을 소개할 때, 클래스는 하나의 책임만을 가져야 한다라는 말 대신 클래스를 변경해야 할 이유는 단 하나여야 한다로 소개되는 것이다.
소프트웨어는 복잡계이므로 번번히 들어오는 요구사항 변경을 효율적으로 처리하는 것이 중요하다. 따라서 외부의 변경 요청에도 소프트웨어의 항상성을 유지하는 것이 이 원칙의 가장 큰 목적이다.
그렇다면 가볍게 언급하고 있는 책임이란 무엇일까? 우리는 책임이라는 말에 관해 조금 더 생각해 볼 필요가 있다. 왜냐면 책임이라는 단어의 의미는 사실 지나치게 추상적이기 때문이다.
책임이란 무엇이고 이를 어떻게 나눌지 기준이 필요하다. 이 같은 배경에서 SOLID의 창시자 로버트 C. 마틴은 단일 책임 원칙에 다음과 같이 첨언한다.
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.
액터는 메시지를 전달하는 주체다. 그리고 단일 책임 원칙에서 말하는 책임액터에 대한 책임이다. 즉 메시지를 요청하는 주체가 누구냐에 따라 책임이 달라지는 것이다.
단일 책임 원칙을 이해하려면 시스템에 존재하는 액터를 먼저 이해해야 한다. 그리고 그러기 위한 문맥과 상황이 필요하다.
클래스를 변경할 이유는 유일한 액터의 요구사항이 변경될 때로 제한되어야 한다. 이는 최초로 이야기했던 클래스를 변경해야 할 이유는 단 하나여야 한다는 설명과도 이어진다.
단일 책임 원칙의 목표 클래스가 변경됐을 때, 영향을 받는 액터가 하나여야 한다. 클래스를 변경할 이유는 유일한 액터의 요구사항이 변경될 때로 제한되어야 한다.

2. 개방 폐쇄 원칙

개방 폐쇄 원칙은 주로 확장에 관한 이야기를 다룬다. 그래서 이 원칙은 확장에는 열려 있고 변경에는 닫혀 있어야 한다라는 말로 표현되기도 한다.
이 원칙의 주된 목적은 기존 코드를 수정하지 않으면서도 확장이 가능한 시스템을 만드는 것이다.
기존 코드를 수정하지 않으면서도 확장이 가능한 시스템을 만들어야 하는 이유는 무엇일까? 간단하다. 시스템을 운영하면서 코드를 변경하는 것은 매우 위험한 일이기 때문이다.
따라서 코드를 확장하고자 할 때 취할 수 있는 최고의 전략은 기존 코드를 아예 건드리지 않는 것이다.
역할에 집중해 간접적으로 구현체를 사용하도록 하면 새로운 요구사항이 들어와도 기존 코드를 크게 변경하지 않고도 요구사항을 처리할 수 있다.
심지어 이렇게 구현하면 요구사항 확장 요청에도 열려있는 코드가 된다.
OCP의 목표는 확장하기 쉬우면서 변경으로 인한 영향 범위를 최소화하는 것이다.
이 목표는 소프트웨어 설계에 있어서 매우 중요한 가치로 OCP 원칙은 확장에는 열려 있고 변경에는 닫혀 있다라는 말로 명확하면서도 간결하게 표현된다.
또한 이는 코드를 추상화된 역할에 의존하게 만듦으로써 달성할 수 있다.

3. 리스코프 치환 원칙

바바라 리스코프에 의해 고안되어 리스코프 치환 원칙이라 불리는 이 원칙은 한 문장으로 정의하면 기본 클래스의 계약을 파생 클래스가 제대로 치환할 수 있는지 확인하라는 원칙이다.
리스코프 치환 원칙을 위반하는 대표적인 예는 직사각형과 정사각형이다.
파생 클래스가 기본 클래스를 대체하려면 기본 클래스에 할당된 의도와 계약이 무엇인지를 먼저 파악할 수 있어야 한다.
기본 클래스의 의도를 파악하려면 어떻게 해야할까? 바로 테스트 코드를 사용하는 것이다.
초기 코드 작성자가 생각하는 모든 의도를 테스트 코드로 만들어 두면 파생 클래스를 작성하는 개발자들은 테스트를 기본 클래스의 의도를 파악할 수 있다.
인터페이스는 계약이며 테스트는 계약 명세이다.

4. 인터페이스 분리 원칙

인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 인터페이스에는 의존하지 않아야 한다는 원칙이다.
즉 어떤 클래스가 자신에게 필요하지 않은 인터페이스의 메소드를 구현하거나 의존하지 않아야 한다는 말이다.
이를 통해 인터페이스의 크기를 작게 유지하고 클래스가 필요한 기능에만 집중할 수 있다.
이 원칙은 보통 개발자들이 하나의 인터페이스로 모든 것을 해결하려고 할 때 위배된다. 그러므로 이 원칙은 단일 책임 원칙과도 밀접한 관련이 있다.
통합된 인터페이스는 구현체에게 불필요한 구현을 강요할 수 있다. 따라서 범용성을 갖춘 하나의 인터페이스를 만드는 것보단 다수의 특화된 인터페이스를 여럿 만드는 편이 낫다.
⇒ 유지보수성 측면에서 그렇다. 무조건 낫다는 것은 아니다.
통합된 인터페이스를 만드는 것이 좋지 않은 이유는 일반적으로 인터페이스가 통합되면 인터페이스의 역할이 두루뭉술해지기 때문이다.
여기서 저자가 말하는 비슷한 인터페이스를 하나로 통합해서 관리해서는 안 된다라고 말한다. 그런데 비슷한 인터페이스를 하나로 통합하겠다라는 말을 들었을때는 언뜻 부정적으로 인식되지 않는다.
오히려 개발을 조금 더 공부해본 사람들이라면 인터페이스를 통합하여 응집도를 올리니 오히려 좋은 것이 아닌가라고 반문할 수 있다.
실제로 인터페이스를 통합하는 행위는 응집도를 추구하는 행위일 수 있다. 그러나 그것이 곧 응집력이 높아지는 결과로 이어지는 것은 아니다.
응집도라는 개념은 유사한 코드를 한곳에 모은다에서 끝나는 것이 아니기 때문이다. 응집도의 종류는 다양하며 좀 더 세분화된 수준으로 다음과 같은 응집도들이 있다.
기능적 응집도
모듈 내의 컴포넌트들이 같은 기능을 수행하도록 설계된 경우를 말한다.
모듈이 어떤 목적을 가지고 있을 때, 컴포넌트들은 그 목적을 달성하기 위해 협력하며 오직 관련된 작업만 수행하는 경우다.
모듈을 구성할 때, 주문이라는 모듈을 만들기보다는 주문 처리라는 모듈을 만들어 컴포넌트를 구성할 수 있을 것이다.
이는 주문이라는 범용적인 도메인을 다루기보다 주문을 처리하는 것만을 목적으로 하는 모듈이다.
이럴 때, 기능적 응집도를 추구했다고 할 수 있다.
순차적 응집도
모듈 내의 컴포넌트들이 특정한 작업을 수행하기 위해 순차적으로 연결된 경우를 의미한다.
어떤 컴포넌트 출력이 다른 컴포넌트의 입력으로 사용되는 형태를 말한다.
데이터베이스에서 데이터를 조회한 후, 조회 결과를 가공하는 모듈이 있다면 이는 순차적 응집도를 추구하는 것이다.
통신적 응집도
모듈 내의 컴포넌트들이 같은 데이터나 정보를 공유하고 상호 작용할 때 이에 따라 모듈을 구성하는 경우를 의미한다.
모듈 내 컴포넌트들이 메시지를 주고받는 형태나 공유 데이터에 따라 구성되는 경우다.
이메일 전송 모듈을 구성하는 경우, 이메일은 통신에 사용되는 특수한 프로토콜이 있고 데이터희 형식도 발신자, 수신자, 제목, 본문 등으로 어느 정도 정해져있다. 이때 통신적 응집도를 추구했다고 한다.
절차적 응집도
모듈 내의 요소들이 단계별 절차를 따라 동작하도록 설계된 경우를 나타낸다.
모듈의 요소들이 단계별로 연결되어 전체적인 기능을 수행하는 것이다. 계산기 모듈에서 입력을 받는 단계, 연산을 수행하는 단계, 결과를 출력하는 단계 등으로 구성하는 경우가 대표적이다.
논리적 응집도
모듈 내의 요소들이 같은 목적을 달성하기 위해 논리적으로 연관된 경우를 말한다.
모듈의 요소들이 서로 관련된 동작을 수행하지만 특정한 순서나 데이터의 공유가 필요하지는 않다.
회원 관리 모듈에서 회원 등록, 회원 정보 업데이트, 회원 삭제 등의 작업을 수행하는 요소가 있다면 이는 논리적 응집도를 추구했다는 것을 알 수 있다.
일반적으로 기능적 응집도 > 순차적 응집도 > 통신적 응집도 > 절차적 응집도 > 논리적 응집도 순으로 응집도가 높다고 평가한다.
그렇다고 모든 프로젝트에서 이 순서가 보장되는 것은 아니다. 상황에 따라 유기적으로 달라지므로 어떤 응집도가 있고 어떻게 추구할 수 있는지 정도만 알고 있으면 된다.
인터페이스 분리 원칙이 추구하는 것은 무엇일까? 클래스 분리 원칙이 아닌 인터페이스 분리 원칙이다. 그리고 인터페이스는 곧 역할이라고 부를 수 있다고 앞서 이야기했다.
인터페이스 분리 원칙은 역할과 책임을 분리하고 역할을 세세하게 구분하라는 의미다. 따라서 인터페이스를 분리하라는 말은 기능적 응집도를 추구하는 것으로 볼 수 있다.

5. 의존성 역전 원칙

의존성 역전 원칙이란 다음 두 조건을 따르는 것이다.
상위 모듈은 하위 모듈에 의존해서는 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
여기서 우리가 얻을 수 있는 포인트들은 다음과 같다.
고수준 모듈은 추상화에 의존해야 한다.
고수준 모듈이 저수준 모듈에 의존해서는 안 된다.
저수준 모듈은 추상화를 구현해야 한다.
즉, 의존성 역전 원칙은 고/저수준 모듈 모두 추상화에 의존해야 한다는 원칙이다. 이 말이 와닿지 않을 수 있으니 먼저 의존성에 대해서 이야기 해보자.

2. 의존성

의존은 다른 객체나 함수를 사용하는 상태라고 정의할 수 있다.
다시 말해 어떤 객체가 다른 코드를 사용하고 있기만 해도 이를 가리켜 의존하고 있다고 할 수 있다.
의존이라는 말을 들었을 때 우리는 통상적으로 두 클래스가 무언가 강하게 연결된 상태를 생각한다. 하지만 의존의 정의는 의외로 단순하고 명확하다. 사용하기만 해도 의존하고 있는 것이다.
때문에 소프트웨어는 의존하는 객체들의 집합이라고 볼 수 있다. 객체지향에서 객체들은 필연적으로 협력하는 데 서로를 사용하기 때문이다. 의존은 소프트웨어 설계의 핵심이다.
컴퓨터 공학에는 의존을 표현하는 또 다른 용어가 있는데, 바로 결합이라는 용어다. 의존과 마찬가지로 어떤 객체나 코드를 사용하기만 해도 결합이 생긴다고 할 수 있다.
결합은 결합이 어떻게 되어있느냐에 따라서 강결합으로 평가되기도 약결합으로 평가되기도 한다.
이처럼 결합이 얼마나 강하게 되어있는지를 평가하는 지표가 있는데, 이를 결합도라고 한다. 마찬가지로 결합도는 의존성과 같은 말이다.
의존성 자체는 어려운 개념이 아니아. 약한 의존 상태로 만들어두고 유지하는 것은 어렵다. 따라서 우리는 어떻게 객체 간의 의존성을 약화시킬 지 고민해야 한다.

1. 의존성 주입

의존성을 약화시키는 기법으로 잘 알려진 의존성 주입은 스프링을 배울 때 특히나 자주 언급되는 개념이다. 의존성 주입은 단순히 말해서 필요한 의존성을 외부에서 넣어주는 것을 의미한다.
의존성 주입에는 생성자 주입, 필드 주입, 세터 주입 등이 존재하며 각각의 장단점을 가진다.
의존성 주입이 왜 의존성을 약화시키는 기법일까? 의존하는 형태를 바꿔 의존성을 약화시키기 때문이다.
의존성 주입이 시스템에 불필요한 강한 의존을 피하게 해주는데 도움을 준다는 것을 설명에 앞서 다음과 같은 격언들 들어본적이 있을 것이다. new 연산자 사용을 자제하라
왜냐면 new를 사용하는 것이 사실상 하드 코딩이고 강한 의존성을 만드는 대표적인 사례이기 때문이다.
new를 사용하는 순간 구현에 집중할 수 밖에 없게 된다. 따라서 사실상 하드 코딩이 된다고 설명한 것이다.

2. 의존성 역전

의존의 방향이 바뀌는 것을 의존성 역전이라고 한다. 추상화를 사용하여 계층을 추가했을 뿐인데, 의존의 방향이 바뀌게 된다.
이는 의존성이 가지고 있는 의존성 전이라는 특징 때문에 그렇다. 즉 다시 얘기하면 의존성 역전은 화살표의 방향을 바꾸는 기법이다.
의존성 역전을 사용하면 어떻게 될까? 코드가 추상에 의존하는 형태로 바뀐다. 그래서 의존성 역전 원칙은 세부 사항에 의존하지 않고 정책에 의존하도록 코드를 작성하라는 말로 바꿔 작성할 수 있다.
이제 의존성 역전이 경계를 만든다라는 말도 이해할 수 있을 것이다. 앞선 개념에서 더 나아가 의존성 역전은 경계를 만드는 기법이며 모듈의 범위를 정하고 상하 관계를 표현하는 데 사용할 수 있는 수단이다.
의존성 역전을 통해 생긴 경계는 곧 모듈의 경계로 사용될 수 있다.

3. 의존성 역전과 스프링

스프링은 의존성 주입은 제공하지만 의존성 역전 원칙을 준수하지는 않는다.
예전에 진행했던 프로젝트의 계층형 구조를 떠올려보자 우리는 의존성 역전 원칙을 준수하며 개발을 진행했을까?

4. 의존성이 강조되는 이유

이번 장을 시작하면서 설계 관점에서 유지보수성을 판단할 때 크게 세 가지 맥락이 있다고 했다. 이를 다시 한번 복기해보자.
영향 범위: 코드 변경으로 인한 영향 범위가 어떻게 되는가?
의존성: 소프트웨어에서 의존성 관리가 제대로 이뤄지고 있는가?
확장성: 쉽게 확장 가능한가?
지금까지 다룬 내용을 잘 따라왔다면 이제는 이 세 가지 맥락에 문제가 있다고 생각할 때, 적용해볼법한 방법들을 설명할 수 있을 것이다.
영향 범위에 문제가 있다면 응집도를 높이기 위한 방법을 도입해 적절한 모듈화로 단일 책임 원칙을 준수하는 코드를 만든다.
의존성에 문제가 있다면 의존성 주입과 의존성 역전 원칙을 적용해 의존성을 약화시킨다.
확장성에 문제가 있다면 의존성 역전 원칙을 이용해 개방 폐쇄 원칙을 준수하는 코드로 만든다.
이런 내용들은 큰 틀에서 유지보수성이 좋은 소프트웨어 설계를 하고 싶다면 코드를 변경하거나 확장할 때 영향받는 범위를 최소화할 수 있어야 한다고 말하고 있다. 그리고 코드의 영향 범위를 최소화하려면 의존성을 잘 다뤄야한다.
변경으로 인한 영향 범위를 축소하는 것이 목표이고, 의존성을 잘 관리하는 것은 이 목표를 달성하기 위한 방법이다.
의존성을 잘 다룬다는 것은 무슨 의미일까? 앞서 말했듯 다른 객체나 시스템을 사용한다는 의미고 이는 객체 간의 협력과 시스템 간의 협력이 곧 의존성이라는 뜻이다.
따라서 의존성 자체를 없애는 것은 불가능하다. 의존성을 잘 관리한다는 것은 불필요한 의존성을 줄이는 것은 목표로 하지만 없애는 것이 목표는 아니다. 그보다는 의존성을 끊는 것이 목표다.
그렇다면 의존성을 끊는다는 것은 뭘까? 이를 이해하려면 의존성이 가지고 있는 특징인 의존성 전이를 먼저 이해해야 한다.
의존성은 컴포넌트 간의 상호작용을 표현하는 것이므로 한 컴포넌트가 변경되거나 영향을 받으면 관련된 다른 컴포넌트에도 영향이 갈 수 있다.
그런데 이렇게 영향을 받은 컴포넌트는 연쇄적으로 또 다른 관련 컴포넌트에 영향을 주는데, 이를 가리켜 의존성이 전이된다고 표현한다.
이때, A → B → C → D 순서와 같이 의존성이 표현된다고 할 때, C가 변경되면 B, A가 차례로 영향을 받는다. 즉 의존성은 화살표 방향의 역방향으로 전이된다.
의존성이 전이된다는 특징 때문에 소프트웨어 설계가 중요해진다. 코드 변경에서 자유로워지려면 연결을 최소화하고 어쩔 수 없는 연결마저도 약한 연결 상태로 만들어야 한다.
이때 의존성 역전을 사용하면 의존성 전이를 끊을 수 있다. 이는 변경으로 인한 영향 범위를 해당 변경사항이 발생한 컴포넌트로 한정한다.
따라서 의존성 역전이 경계를 만드는 기술인 것이다. 코드가 변경되더라도 경계 밖으로는 영향이 가지 않기 때문이다.
더불어 소프트웨어 개발과 관련된 격언 중, 의존성에 관한 격언이 하나 있는데, 순환 참조를 만들지 말라라는 격언이다.
이는 의존성은 전이된다는 특징을 이해하면 이해할 수 있는데, 의존성 전이의 영향 범위를 확장시키는 주범이기 때문이다.
순환 참조가 있다는 것은 순환하는 의존성을 갖고 있는 컴포넌트들이 같은 컴포넌트라는 선언과 다를 바 없다. 순환 의존하는 컴포넌트의 영향 범위가 같아지기 때문이다.
순환 참조는 꼭 양방향 참조에서만 일어나는 일이 아니다. A → B → C → A와 같은 상황에서도 일어나는 일이다.
같은 논리로 A, B, C는 사실상 같은 컴포넌트다.
순환 참조는 복잡한 의존성 그래프를 유도하고 의존성 전이 범위를 확장시킨다. 의존 그래프 사이에 사이클이 생겨선 안 된다.
순환 참조를 만들지 않으려면 양방향 참조를 끊어내고 단방향으로 만들어야 한다. 시스템에 존재하는 모든 의존 방향을 단방향으로 만들어 문제가 발생했을 때, 어느 것이 문제인지 추적 가능하게 해야 한다.

3. SOLID와 객체지향

SOLID한 코드는 객체지향적인 코드다라는 문장은 맞는 말일까? 얼추 맞지만 조금 다르다. 엄격하게 말해 SOLID와 객체지향은 추구하는 바가 약간 다르기 때문이다.
SOLID 원칙이 추구하는 것은 객체지향 설계다 따라서 SOLID가 추구하는 방향과 객체지향이 추구하는 방향은 조금 다르다.
객체지향의 핵심은 역할, 책임, 협력이다. 반면 SOLID는 객체지향 방법론 중 하나로 변경에 유연하고 확장할 수 있는 코드를 만드는 데 초점을 둔다.
쉽게 말해 SOLID는 설계 원칙이고 설계 원칙은 응집도를 높이고 의존성을 낮추는 데 집중한다.
따라서 SOLID를 추구하는 것이 곧 객체지향으로 이어지는 것은 아니다. 따라서 SOLID를 무작정 따르기보다 객체지향의 본질인 역할, 책임, 협력을 제대로 이해하고 적절한 구현을 함께 고려해야 한다.
SOLID의 목표는 높은 응집도와 낮은 결합도였다. SOLID의 설명을 외우지말고 SOLID가 무엇을 해결하려 했는지 기억하자.

4. 디자인 패턴

디자인 패턴은 소프트웨어 설계를 하면서 자주 만나게 되는 문제 상황을 정의하여 이를 해결할 수 있는 모범 설계 사례를 모아놓은 것이다.
이 23가지 패턴은 크게 생성, 구조, 행동으로 분류할 수 있다.
생성 패턴은 객체 생성을 유연하고 효율적으로 처리하는 방법을 소개한다.
구조 패턴은 객체를 조합해서 더 큰 구조를 형성하는 방법을 소개한다.
행동 패턴은 객체 간의 행위와 역할을 조정하는 방법을 다루는데, 이를 통해 객체 간의 상호작용르 개선하고 유연성을 높이는 것을 목표로 한다.
디자인 패턴이 어떻게 생겼고 예제를 외우려 하는 것은 좋은 공부 방법이 아니다. 어떤 상황에서 어떤 문제를 어떻게 해결하는지 이해하는 것이 좋은 공부 방법이다.
도식화된 패턴의 생김새를 외우기보다 의존 그래프를 파악하고 변경으로 인해 영향을 받는 범위가 어디까지인지 확인하며 디자인 패턴 학습에 임해보자.
패턴은 ‘문제 인식’, ‘해결 과정’, ‘해결 방법’을 정리한 것이다. 개발자의 업무는 문제를 해결하는 것이지 패턴을 도입하는 것이 아니다. 패턴은 도구일 뿐이다.