클린 아키텍처: 설계원칙(SOLID)

게시일
9/15/2020
Tags
book
book>IT
development
development>설계&인프라
* <클린 아키텍처> 책을 읽고 책 내용을 일부 발췌해서 개인적인 생각을 더하여 정리하였습니다.
좋은 소프트웨어 아키텍처는 좋은 코드에서 부터 시작한다. 그럼 무엇이 좋은 코드일까? 여기엔 여러가지 기준이 있을 것이다. 예를 들어 네이밍이 잘 되어 있는지, 가독성이 좋은지 등등 있겠지만 다음에서 소개할 SOLID 원칙을 얼마나 잘 지키며 작성했는지도 하나의 기준이 될 수 있을 것 같다.
OOP(Object Oriented Programming)을 공부했던 사람이라면 SOLID 원칙을 한번쯤 들어봤을 것이다. 그러나 실제로 그것이 무엇인지 물어보면 제대로 알지 못하거나 지식으로는 알고 있지만 실제 코드 작성시에는 지켜지지 않는 경우가 많다. 물론 나의 이야기이다. SOLID 원칙의 목적은 중간 수준의 소프트웨어 구조가 아래와 같도록 만드는 것이다.
변경에 유연하다
이해하기 쉽다
많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 된다
중간 수준이라 함은 프로그래머가 이들 원칙을 모듈 수준에서 작업할 때 적용할 수 있다는 뜻이다. 즉, 코드 수준보다는 조금 상위에서 적용되며 모듈과 컴포넌트 내부에서 사용되는 소프트웨어 구조를 정의하는 데 도움을 준다. 다시 말해 컴포넌트나 고수준의 아키텍처에도 SOLID의 원칙에 대응되는 원칙들이 있다.
SRP: 단일 책임 원칙
SOLID 의 제일 첫번째 원칙으로, 흔히 잘 못 이해하는 부분이 있는데 그것은 '모든 모듈이 단 하나의 일만 해야 한다'라는 의미로 받아들이는 것이다. 이건 잘못된 오해인데 이것에 해당하는 것은 함수이다. SRP를 하나의 문장으로 설명하면 아래와 같다
하나의 모듈은 하나의, 오직 하나의 액터(actor)에 대해서만 책임져야 한다.
여기서 액터란 모듈을 호출하는 주체라고 생각하면 될 것 같다. 아래의 사진은 책에 나왔던 사진인데, 개인적으로는 이 사진 하나로 위의 문장을 이해하게 되었다. CFO 입장에서는 급여 계산을 위해 calculatePay()을 호출하고 COO는 인사관리를 위해 reportHours()을 호출하고 CTO는 DBA 관점에서 Employee에 대한 정보를 저장하기 위해 save()를 호출하는 상황이다.
<클린 아키텍처 그림 7.1>
이렇게 하나의 클래스에 2개 이상의 액터에 대한 책임을 지게 되면 calculatePay, reportHours, save 내부의 로직을 수정시에 서로가 서로에게 영향을 줄 가능성이 생기게 된다. 이를 해결하기 위해서는 여러가지 방법이 있겠지만 각 액터별로 클래스를 분리하는 것이 일반적인 해결방안이다.
<클린 아키텍처 그림 7.3>
OCP: 개방-폐쇄 원칙
소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
클린 아키텍처의 가장 기본 원칙이라 생각된다. 소프트웨어 개발을 하다보면 비즈니스 요구사항에 맞춰 수시로 요구사항이 변경되기 마련인데 작은 요구사항 변경으로 코드의 큰 수정이 필요하다면 잘 못된 설계이다. 개인적인 능력의 부족으로 이 원칙을 잘 설명할 수는 없지만 아래 그림을 보았을 때 전체적으로 어떤 방향으로 설계를 해야하는지 조금은 명확히 다가왔었다.
<클린 아키텍처 그림 8.2>
재무 정보를 가져와서 웹 또는 PDF로 보여주는 서비스의 아키텍처를 그린 그림이다. <DS>는 데이터 구조, <I>는 인터페이스이다. 여기서 주목해야될 부분이 몇 가지 있는데 이것이 OCP 원칙을 지키는 길인 것 같다.
1.
이중선으로 그려진 것이 하나의 컴포넌트를 표현한 것이다. 각 컴포넌트 사이의 화살표는 한 방향으로만 흐른다는 것이다. 그리고 A → B 의 방향으로 된 것의 의미는 A가 B를 의존하고 있다는 의미인데, 이렇게 의존했을 때 A의 변경사항이 B에 영향을 주지 않게 된다. 왜냐하면 A에서 B를 호출하지만 B는 A를 전혀 호출하지 않기 때문이다.
2.
Interactor 컴포넌트가 가장 고수준의 컴포넌트인 이유는 무엇일까? 가장 중요한 비즈니스 로직을 담당하기 때문이다. 그로 인해 상대적으로 덜 중요한 컴포넌트의 변경에 영향을 받지 않게 하기 위해 가장 고수준의 컴포넌트가 되었다.
LSP: 리스코프 치환 법칙
리스코프가 정의한 하위 타입은 아래와 같다.
S 타입의 객체 Os 각각에 대응하는 T 타입 객체 Ot가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 Ot의 자리에 Os을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위타입이다.
이렇게 S가 T의 하위 타입이라면 S타입으로 생성된 Os 대신에 T타입으로 생성된 Ot를 대체해도 프로그램 수정이 없어야 한다는 것이다. 이를 위반하는 예시를 보면 조금 도음이 되는데, 직사각형과 정사각형 클래스이다.
<클린 아키텍처 그림 9.2>
class Square: Rectangle() { public fun setH(int h) { this.height = h; this.width = h; } public fun setW(int w) { this.height = h; this.width = h; } }
Kotlin
정사각형은 높이와 너비가 같아야 하기 때문에 위와 같은 형태의 코드가 될 것이다. 하지만 아래와 같이 코드 테스트를 하게 되면 예상했던 것과 다르게 오류가 발생할 것이다.
val r: Rectangle = Square() r.setW(5) r.setH(2) assert(r.area() == 5 * 2)
Kotlin
setH()을 호출하는 순간 높이와 너비가 2가 되기 때문에 r.area는 기대했던 것과 다르게 4가 나오게 될 것이다. 이러한 케이스가 나오지 않게 하는 것이 LSP 원칙인데. 다시말해 LSP의 핵심 내용은 상위 클래스에서 정의된 역할을 자식 클래스도 동일하게 역할을 수행하도록 하는 것이라고 생각하면 좋을 것 같다.
ISP: 인터페이스 분리 원칙
ISP의 원칙은 꼭 필요하고 사용하는 인터페이스만 갖고 있도록 분리하는 것이다. 불필요한 것까지 갖고 있게 되면 이 불필요한 기능이 수정이 일어나거나 버그가 발생했을 때 함께 영향을 받을 가능성이 많기 때문이다.
DIP: 의존성 역전 원칙
DIP의 핵심은 추상화된 것에 의존하며 구체화된 것에는 의존하지 않도록 하는 것이다. 이미 위에 OCP에 대한 얘기를 하면서 보여준 그림을 자세히 보면 이미 DIP원칙에 따라 구현되어 있는 부분들이 있다. 그럼 왜 구체적인 것에 의존하지 않도록 해야되는 것일까? 그것은 구체적인 것들이 자주 변경되기 때문이다. 예를 들어, java의 내장클래스인 java.lang.String 클래스를 사용한다고 했을 때 이를 추상 클래스로 만들어서 사용하는 사람은 없을 것이다. 왜냐하면 어느정도 안정된 클래스이고 변경사항이 거의 없는 클래스이기 때문이다. 다시 돌아와서 우리는 변동성이 큰 구체적인 클래스에 의존하지 않아야 하며 이를 위한 실천법으로 4가지를 책에선 제안하고 있다.
1.
변동성이 큰 구체 클래스를 참조하지 말라
대신 추상 인터페이스를 참조하라. 이 규칙은 객체 생성 방식을 강하게 제약하며, 추상 팩토리를 사용하도록 가제한다.
2.
변동성이 큰 구체 클래스로부터 파생하지 말라
상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력하면서도 동시에 뻣뻣해서 변경하기 어렵다.
3.
구체 함수를 오버라이드 하지 말라 구체 함수는 의존성을 필요로 하고 이것을 오버라이드하게 되면 의존성 또한 상속하게 되는 것이다. 차라리 추상 함수를 만들고 각 구현체들에서 각자 용도에 맞게 구현해야 한다
4.
구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라
Today