로머트 C. 마틴의 Clean Architecture 책을 읽고 정리한 내용입니다.
7장. SRP: 단일 책임 원칙
- SRP에 대한 오해
- 부적절한 이름 때문에 SOLID 원칙 중에서 그 의미가 가장 잘 전달되지 못한 원칙
- 모든 모듈이 단 하나의 일만 해야 한다는 의미로 받아들이기 쉬움
- 해당 원칙은 따로 있음 - 함수는 반드시 하나의, 단 하나의 일만 해야 한다는 원칙
- 이 원칙은 커다란 함수를 작은 함수들로 리팩터링하는 더 저수준에서 사용됨
- 이 원칙은 SOLID 원칙이 아니며, SRP도 아님
- 진정한 SRP의 의미: 단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다.
- SRP의 다른 버전
- 소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경됨
- SRP가 말하는 변경의 이유란 바로 이들 사용자와 이해관계자를 가르킴
- SRP의 다른 버전: 하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다.
- SRP의 최종 버전
- 사용자나 이해관계자라는 단어는 두 명 이상일 경우 여기에 쓰기에는 올바르지 않음
- 여기에서는 집단, 즉 해당 변경을 요청하는 한 명 이상의 사람들을 가르킴 - 이러한 집단은 액터
- SRP의 최종버전: 하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.
- 모듈이란?
- 가장 단순한 정의는 소스 파일
- 코드를 소스 파일에 저장하지 않는 경우
- 단순히 함수와 데이터 구조로 구성된 응집된 결합
- 응집
- 응집된(cohesive)이라는 단어가 SRP를 암시
- 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)
- SRP를 이해하는 가장 좋은 방법은 이 원칙을 위반하는 징후들을 살펴보는 것
[징후 1: 우발적 중복]
- 급여 애플리케이션의 Employee
- 해당 클래스가 세 가지 메서드 calculatePay(), reportHours(), save()를 가짐
- calculatePay(): 회계팀에서 기능을 정의, CFO 보고를 위해 사용
- reportHours(): 인사팀에서 기능을 정의, COO 보고를 위해 사용
- save(): DBA가 기능을 정의, CTO 보고를 위해 사용
- 이들 세 가지 메서드가 서로 매우 다른 세명의 액터를 책임지기 때문에 SRP를 위반
- 단일 클래스에 세 액터가 서로 결합되었고, 이로 인해 다른 팀에서 결정한 조치가 다른팀이 의존하는 무언가에 영향을 줄 수 있음
- 문제의 원인
- 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생
- SRP는 서로 다른 액터가 의존하는 코드를 서로 분리하라고 말함
[징후 2: 병합]
- 문제의 발생
- CTO팀에 속한 DBA는 테이블 스키마를 수정하고, COO팀에 속한 인사 담당자는 보고서 포맷을 변경하기로 결정
- 서로 다른 팀의 개발자가 변경사항을 적용하면 병합이 발생
- 발생한 병합은 CTO와 COO를 곤경에 빠뜨리고 CFO도 영향을 받게 됨
- 문제의 원인
- 많은 사람이 서로 다른 목적으로 동일한 소스 파일을 변경하는 경우에 해당
- 이 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것
[해결책]
- 문제의 해결책
- 다양한 해결책 모두가 메서드를 각기 다른 클래스로 이동시키는 방식
- 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식
- 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들고 세 개의 클래스가 공유하도록 함
- 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함
- 세 클래스는 서로의 존재를 몰라야 함 - 우연한 중복을 피할 수 있음
- 퍼사드(Facade) 패턴
- 이 해결책은 세 가지 클래스를 인스턴스화하고 추적해야하 한다는 단점이 존재
- 이러한 난관에서 빠져나올때 흔히 쓰는 기법이 퍼사드 패턴
- 코드가 거의 없는 EmployeeFacade를 생성, 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임짐
- 가장 중요한 업무 규칙을 데이터와 가깝게 배치하는 방식도 존재
- 가장 중요한 메서드는 기존의 Employee 클래스에 그대로 유지하되, Employee 클래스를 덜 중요한 나머지 메서드들에 대한 퍼사드로 사용할 수 있음
[결론]
- SRP는 메서드와 클래스 수준의 원칙
- 상위의 두 수준에서도 다른 형태로 다시 등장
- 컴포넌트 수준에서는 공통 폐쇄 원칙(Common Closure Principle)
- 아키텍처 수준에서는 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축(Axis of Change)
8장. OCP: 개방-폐쇄 원칙
- OCP의 의미
- 소프트웨어 개체(artifact)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
- 다시 말해 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 개체를 변경해서는 안 된다.
- 소프트웨어 아키텍처를 공부하는 가장 근본적인 이유
[사고 실험]
- 재무제표
- 재무제표를 웹 페이지로 보여주는 시스템에서 동일한 정보를 보고서 형태로 변환해서 출력을 요청
- 소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 가능한 한 최소화. 이상적인 변경량은 0
- 변경량을 최소화하는 방법
- 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(SRP), 이들 요소 사이의 의존성을 체계화함으로써(DIP) 변경량을 최소화할 수 있음
- 컴포넌트 계층구조의 조직화
- 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화함
- 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있음
[결론]
- OCP의 목표
- OCP는 시스템의 아키텍처를 떠받치는 원동력 중 하나
- 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는데 있음
- 이러한 목표를 달성하려면 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 함
9장. LSP: 리스코프 치환 원칙
- 리스코프의 하위 타입에 대한 정의
- 여기에서 필요한 것은 다음과 같은 치환(substitution) 원칙이다. S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.
[정사각형/직사각형 문제]
- LSP를 위반하는 전형적인 문제
- 만약 Rectangle의 하위 타입으로 Square가 존재하면 Square는 하위 타입으로 적합하지 않음
- Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문
- 이런 형태의 LSP 위반을 막기 위한 유일한 방법은 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것
- 이렇게 하면 User의 행위가 사용하는 타입에 의존하게 되므로, 타입을 서로 치환할 수 없게 됨
[LSP와 아키텍처]
- LSP 변천사
- 객체 지향 초창기, LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주
- 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모
- LSP의 이해
- 아키텍처 관점에서 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨 일이 일어나느지 관찰하는 것
[결론]
- LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 함
10장. ISP: 인터페이스 분리 원칙
- ISP의 유래
- 다수의 사용자가 한 클래스의 각 하위 메서드를 사용한다면 다른 메서드를 사용하지 않음에도 다른 메서드레 의존하게 됨
- 이러한 문제는 인터페이스 단위로 분리해서 해결 가능
[ISP와 아키텍처]
- ISP를 사용하는 동기
- 일반적으로, 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일
- 소스 코드 의존성의 경우, 불필요한 재컴파일과 재배포를 강제하기 때문
- 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생
[결론]
- 교훈
- 불필요한 짐을 실은 무언가에 의존하면 예상치 못한 문제에 빠진다는 사실
11장. DIP: 의존성 역전 원칙
- 유연성이 극대화된 시스템
- 소스 코드 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템
- 정적 타입 언어
- use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻
- 동적 타입 언어
- 소스 코드 의존 관계에서 구체 모듈은 참조해서는 안된다
- 비현실적인 아이디어
- 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존
- 구체 클래스에 대한 소스 코드 의존성은 벗어날 수 없고, 벗어나서도 안 됨
- 안정적인 클래스는 변경될 일이 거의 없으며, 있더라도 엄격하게 통제됨
- 이러한 이유로 DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편
- 의존을 피해야 하는 것
- 변동성이 큰(volatile) 구체적인 요소
- 이 구체적인 요소는 우리가 개발하는 중이라 자주 변경될 수 밖에 없는 모듈들
[안정된 추상화]
- 인터페이스와 구현체의 변동성
- 추상 인터페이스에 변경이 생기면 이를 구체화된 구현체들도 따라서 수정해야 한다.
- 반대로 구체적인 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 대다수의 경우 변경될 필요가 없음
- 따라서 인터페이스는 구현체보다 변동성이 낮음
- 안정된 소프트웨어 아키텍처
- 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처
- 구체적인 코딩 실천법
- 변동성이 큰 구체 클래스를 참조하지 말라.
- 대신 추상 인터페이스를 참조
- 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제함
- 변동성이 큰 구체 클래스로부터 파생하지 말라.
- 이전 규칙의 따름정리
- 정적 타입 언어에서 상속은 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 사용하기 어려움
- 따라서 상속은 아주 신중하게 사용해야 함
- 구체 함수를 오버라이드 하지 말라.
- 대체로 구체 함수는 소스 코드 의존성을 필요로 함
- 따라서 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 됨
- 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 함
- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.
[팩토리]
- 주의점
- 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 함
- 사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문
- 자바 등 대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 함
- 아키텍처 경계
- 추상 팩토리는 구체적인 것들과 추상적인 것들을 분리함
- 소스 코드 의존성은 분리할 때 모두 한 방향, 즉 추상적인 쪽으로 향함
- 두 가지 컴포넌트
- 추상 컴포넌트
- 구체 컴포넌트
- 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함
[구체 컴포넌트]
- 피할 수 없는 DIP 위배
- DIP 위배를 모두 없앨 수 없음
- 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 시스템의 나머지 부분과는 분리할 수 있음
- 대표적인 예시로 main 함수
[결론]
- 고수준의 아키텍처 원칙을 다루게 되면서 DIP는 몇 번이고 계속 등장
- DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙