[개발서적] 클린 아키텍처 3부 설계 원칙 - 내용 정리 및 요약
이번에는 로버트 C 마틴의 클린 아키텍처를 읽은 내용을 정리해보도록 하겠습니다. 개인적인 설명은 기울임으로 표시해두었으니, 읽으면서 참고하시면 될 것 같습니다.
0. 서론
[ 도입 ]
- SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 클래스들을 서로 결합하는 방법을 설명해줌
- SOLID는 객체 지향 소프트웨어에만 적용되지는 않음, 클래스는 단순히 함수 + 데이터의 집합을 의미함
- SOLID의 목적은 중간 수준(코드보다 상위 수준인 모듈과 컴포넌트 내부)의 소프트웨어 구조가 다음과 같도록 만드는데 있음
- 변경에 유연함
- 이해하기 쉬움
- 많은 소프트웨어 시스템에서 사용되는 컴포넌트의 기반이 됨
참고로 도입 부분에 중요한 부분이 있는데, "좋은 소프트웨어 시스템은 깔끔한 코드로부터 시작한다. 좋은 벽돌을 사용하지 않으면 빌딩의 아키텍처가 좋고 나쁨은 그리 큰 의미가 없는 것과 같다." 라고 설명한 부분이다. 결국 좋은 코드가 없이는 좋은 아키텍처도 없는 것이고, 이 책을 보는데 좋은 코드를 작성하는 능력이 부족하다면 먼저 좋은 코드를 작성하는 연습부터 하는 것이 더욱 도움이 될 것 같다.
여기서 모듈, 컴포넌트라는 단어가 자주 사용되는데 모듈이란 소스 코드 수준(7장)이며, 쉽게 얘기해서 하나의 클래스 수준이며, 컴포넌트는 배포 가능한 가장 작은 단위(12장)이다.
7장. SRP: 단일 책임 원칙
[ 서론 ]
- SOLID 원칙 중에서 그 의미가 가장 전달되지 못한 원칙으로, 하나의 일만 해야한다는 의미가 아님
- SRP는 "단일 모듈은 변경의 이유가 하나, 오직 하나뿐이여야 한다."는 원칙임
- 여기서 변경의 이유는 하나의 액터(동일한 변경을 요청하는 한 명 이상의 사람들)에 해당함
- 즉, 최종적으로 "하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임을 져야 한다."는 원칙임
이 부분을 읽을 때는 반드시 현재 프로젝트에서 SRP를 위반하는 것이 무엇인지를 찾아보면서 읽을 것을 추천한다.
SRP는 변경의 이유가 1개여야 하고, 이를 위해서는 1명의 액터만 책임지는 코드를 작성해야 한다는 것이다. 서로 다른 액터에 의한 코드는 서로 다른 이유로 변경될 수 있고, 실제로 현업 프로젝트에서도 경험중이다. 현업 프로젝트에서는 액터가 크게 제품을 만드는 사람과 사용하는 사람으로 구분되는데, 이를 합쳤다가 낭패를 보는 상황이 계속해서 발생하고 있다. 내가 합쳐야 한다고 주장했던 사람인데, 빠른 시일 내에 팀원들과 얘기하고 다시 분리하는 작업을 거쳐야 할 것 같다.
또한 모듈은 단순히 함수와 데이터로 구성된 응집된 집합이라고 나오는데, "응집"이란 단일 액터를 책임지는 코드가 묶이는 것이다.
[ 징후1: 우발적 중복 ]
- ex) Employee 클래스는 calculatePay, reportHours, save 메소드를 가짐
- calculatePay: 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용됨
- reportHours: 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용됨
- save: DBA가 기능을 정의하고, CTO 보고를 위해 사용됨
- Employee 클래스는 3가지 메소드가 서로 다른 3명의 액터를 책임지므로 SRP를 위반함
- 이로 인해 CFO 팀에서 결정한 조치가 COO 팀이 의존하는 무언가에 영향을 줄 수 있음
- 예를 들어 calculatePay와 reportHours에서 공통으로 사용하는 regularHours(업무 시간 계산) 메소드가 있음
- CFO 팀에서 업무 시간을 계산하는 방식(regularHours)을 수정하면 COO 팀도 영향을 받게 됨
- 이는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문이며, SRP는 서로 다른 액터가 의존하는 코드를 분리하라고 말함
일반적으로 중복은 나쁘며, 제거해야 할 대상이라고 말한다. 하지만 중복을 제거했더니, 오히려 1개의 코드가 서로 다른 액터를 책임지면서 SRP를 위배하게 되고, 수정 시에 영향을 줄 수 있다는 것이다. 결국 중복에도 좋은 중복과 나쁜 중복이 있다는 것인데, 단순히 중복은 항상 나쁘다가 아니라 의도된 중복도 있다는 것을 아는 것이 중요한 것 같다.
나도 의도적으로 중복을 허용하는 경우가 있다. 반대로 중복을 제거해야 하는 경우지만 1개의 클래스로 처리한 적도 있다. 현재 갖는 생각은 분리가 필요해진 시점이 왔을 때, 귀찮아서 1개의 클래스로 코드를 붙여나가는게 아니라 주저없이 분리를 해야 한다는 것이다.
[ 징후2: 병합(Merge) ]
- 소스 파일에 다양하고 많은 메소드를 포함하면 병합이 발생하기 쉬운데, 서로 다른 액터를 책임진다면 그 가능성이 더욱 높음
- 변경사항은 서로 충돌하고 결과적으로 병합이 발생하는데, 병합에는 항상 위험이 따름
- 병합 외에 더 많은 SRP 위반의 징후들이 있지만, 전부 많은 사람이 서로 다른 목적으로 동일한 소스 파일을 변경하는 경우임
- 이 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것
[ 해결책 ]
- 해결책은 다양하지만, 결국 해법은 메소드를 서로 다른 클래스로 이동시키는 방식임
- 가장 확실한 방법은 데이터와 메소드를 분리하는 방식임
- 자료구조 클래스를 만들어 3가지 클래스가 공유하고, 각 클래스는 반드시 필요한 소스 코드만을 포함함
- 서로의 존재를 모르게 함으로써 "우연한 중복"을 피할 수 있음
- 하지만 이는 개발자가 3가지 클래스를 객체로 만들고 추적해야 함
- 파사드 패턴을 사용하면 이러한 문제를 해결할 수 있음
- 파사드 클래스에는 코드가 거의 없음
- 객체들을 생성하고, 요청을 객체로 위임하는 책임을 짐
- 비즈니스 로직과 데이터를 가깝게 배치하면서도 해결할 수 있음
- 핵심 메소드들은 기존의 클래스에 유지함
- 이를 파사드로 세부 구현 메소드를 가질 수 있음
[ 결론 ]
- 단일 책임 원칙은 메소드와 클래스 수준의 원칙임
- 하지만 상위의 모듈과 컴포넌트 수준에서도 공통 폐쇄 원칙으로 다시 등장함
- 아키텍처 수준에서는 아키텍처 경계의 생성을 책임지는 변경의 축이 됨
8장. OCP: 개방 폐쇄 원칙
[ 서론 ]
- OCP는 “소프트웨어 개체(artifact)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.”는 원칙임
- 즉, 행위를 확장할 수 있어야 하지만 개체를 변경해서는 안되며, 이것이 아키텍처를 공부하는 근본적인 이유임
- OCP는 클래스와 모듈 설계에 도움이 되는 원칙으로만 알고 있지만, 아키텍처 컴포넌트 수준에서 OCP는 훨씬 중요한 의미를 가짐
OCP는 개체의 행위를 확장할 때 객체를 변경하지 않고 새로운 코드를 작성하도록 하는 원칙이다. 이는 결국 다형성 기반으로 구현체를 변경할 수 있또록 구현해야만 가능하다. "아키텍처 컴포넌트 수준에서 OCP를 고려할 때"를 읽으면서 스프링 프레임워크가 떠올랐는데, 스프링 코드를 보면 OCP를 극한으로 잘 지켜서 확장에 매우 유연함을 느낄 수 있다. 개발자는 본인에게 필요한 기능을 언제든지 프레임워크에 플러그인처럼 확장시킬 수 있다. 또한 철저하게 인터페이스에 의존하도록 되어 있으며, 이로 인해 버전 호환성도 굉장히 높다.
[ 사고 실험 ]
- 소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 최소화될 것이며, 이상적인 변경량은 0임
- 이를 위해 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(SRP), 이들 사이의 의존성을 체계화(DIP) 해야 함
- ex) 재무 재표를 웹으로 보여주는 시스템에 프린터 출력 기능이 추가되는 경우에 SRP를 적용하면 다음과 같음
- 위에서 가장 중요한 영감은 보고서 생성의 책임이 2가지(웹, 프린터)로 분리된다는 사실임
- 두 책임 중에서 변경이 생기더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 조직화하고 변경이 발생하지 않음을 보장해야 함
- 이를 위해 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 아래의 이중선으로 표시한 컴포넌트 단위로 구분해야 함
- 위의 모든 의존성은 소스코드 의존성을 나타냄
- 이중선은 화살표와 오직 한방향으로만 교차하는데, 이는 모든 컴포넌트 관계가 단방향임을 의미함
- 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려짐
- ex) A의 변경으로부터 B를 보호하려면 A가 B에 의존해야 함, A -> B
- 위에서는 Presenter에서 발생한 변경으로부터 Controller를 지키고자 함
- Interactor는 의존하는 곳이 없으므로, 다른 모든 것에서 변경한 발생으로부터 보호하고자 함
- 즉, 어떠한 변경도 Interactor에 영향을 주지 않음
- Interactor는 비즈니스 로직을 포함하는 컴포넌트로써, OCP를 가장 잘 준수하도록 되어 있음
일반적인 계층형 아키텍처에서는 의존 관계가 컨트롤러 -> 서비스 -> 레포지토리를 향한다. 이는 데이터베이스를 보호하는 것이다. 현실 세계에서 기획자는 "데이터베이스에 어떤 컬럼 추가해주세요"라고 요청하지 않는다. 중심이 되어야 하는 것 비즈니스 로직이다. 그러므로 비즈니스 로직을 보호하기 위해 서비스가 의존하는 곳이 없어야 하는데, 이를 위해 의존성 역전이 필요하다.
비즈니스 로직이 가장 중요하다는 것은 "웹 출력"과 "프린터 출력"은 동일하게 외부 세계와의 통신이라는 세부 사항일 뿐이라는 것이다. 데이터베이스도 마찬가지인데, 이는 모두 헥사고날 아키텍처에서 "어댑터"라는 동일한 역할로 여겨진다.
[ 방향성 제어 ]
- 이중선 그림에서 Interactor와 Database 사이에 Financial DataGateway가 위치한 이유는 의존성을 역전시키기 위함
- 인터페이스가 없다면 Interactor의 의존성이 Database를 향하게 됨
여기서 인터페이스의 용도는 의존성을 역전시키기 위함이다. 이는 헥사고날 아키텍처의 "포트"로 이어진다. 포트란 외부와의 통신을 위한 인터페이스인데, 외부로 향하는 의존성(API 호출, DB, 캐시 등)을 역전시키기 위한 포트가 "아웃고잉 포트"가 된다.
[ 정보 은닉 ]
- FinancialReportRequester는 방향성 제어가 아닌 Controller가 Interactor 내부에 대해 너무 많이 알지 못하도록 존재함
- 만약 이 인터페이스가 없다면, Controller는 FinancialEntities에 대해 추이 종속성을 갖게 됨
- 추이 종속성이란 모든 소프트웨어 엔티티에서 A가 B에 의존하고, B가 C에 의존한다면 A 역시 C에 의존한다는 개념임
- 이는 클래스 이외의 소프트웨어 모든 엔티티(패키지, 컴포넌트)에도 동일하게 적용됨
- Controller의 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만, 반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기 위해 Interactor 내부를 은닉함
여기서 소프트웨어 엔티티는 단순 클래스가 아니다. 클래스부터 시작해 패키지, 컴포넌트 등 모든 개체를 의미한다고 나와 있다.
또한 여기서 인터페이스의 용도는 정보 은닉이다. 앞서 설명하였듯 인터페이스는 헥사고날 아키텍처에서 포트인데, 이는 컨트롤러에서 서비스(내부)로 향하므로 "인커밍 포트"가 된다. "아웃고잉 포트"와 의도가 확실히 다르다.
한가지 인상깊었던 부분은 인터페이스로 내부를 은닉하여 Interactor의 변경으로부터 Controller를 보호할 수 있다는 것이다. Interactor로 넘기는 파라미터가 변경되어야 하는 경우에, 외부와의 통신 규약인 퍼블릭 인터페이스를 지키기 위해 어댑터 메소드를 둬서 내부를 감추는 등의 상황을 의미하는 것 같다.
[ 결론 ]
- OCP는 시스템의 아키텍처를 떠받치는 원동력 중 하나임
- OCP의 목표는 시스템을 확장하기 쉬운 동시에, 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는 것
- 이를 위해 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트의 변경으로부터 고수준 컴포넌트를 보호할 수 있는 구조가 되어야 함
9장. LSP: 리스코프 치환 원칙
[ 서론 ]
- 1988년 바바라 리스코프는 하위 타입을 아래와 같이 정의함
- "여기서 필요한 것은 다음과 같은 치환 원칙이다. S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다."
[ 상속을 사용하도록 가이드하기 ]
- 요금을 계산하는 메소드(callFee)를 갖는 License 인터페이스가 있음
- License는 2가지 하위 타입이 존재하며, 각각은 서로 다른 알고리즘으로 라이센스 비용을 계산함
- 그리고 Billing 애플리케이션은 이 메소드를 호출할 때, 이는 LSP를 준수함
- Billing 애플리케이션의 행위가 License 하위 타입에 전혀 의존하지 않으므로 하위 타입은 모두 License 타입을 치환할 수 있음
[ 정사각형/직사각형 문제 ]
- LSP를 위반하는 전형적인 문제로는 정사각형/직사각형 문제가 있음
- 위에서 Square(정사각형)은 Rectangle(직사각형)의 하위 타입으로 적합하지 않음
- 왜냐하면 Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있지만, Square는 반드시 함께 변경되기 때문임
- 이를 막는 유일한 방법은 User에서 Rectangle이 실제로 Square 타입인지 검사하는 메커니즘을 추가하는 것임
- 하지만 이렇게 되면 User가 Square의 타입에 의존하게 되므로 타입을 서로 치환할 수 없게 됨
Rectangle r = new Square();
r.setW(5);
r.setH(2);
// setH에 의해 W도 변경되어야 하므로, 테스트는 실패한다.
assertThat(r.area()).isEqualTo(10);
[ LSP와 아키텍처 ]
- 객체 지향이 등장한 초창기에 LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주됨
- 하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 광범위한 소프트웨어 설계 원칙으로 변모해왔음
- 아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에 무슨 일이 일어나는지 관찰하는 것
[ LSP 위배 사례 ]
- 아키텍트는 버그로부터 시스템을 격리해야 함
- ex) 택시 파견 서비스를 통합하는 애플리케이션을 만드는 상황임
- A 택시 회사의 택시 파견 URI는 aaa.com/driver/Bob/pickupAddress/24/pickupTime/153/destination/ORD
- 새로운 팀이 B 택시 회사 파견 URI를 일부 다르게 설계함 ex) destination -> dest
- REST 서비스들이 서로 호환되지 않음
- 아키텍처에서는 if로 분기 처리가 아니라, 치환되지 않는 REST 서비스들의 인터페이스를 처리하는 매커니즘이 필요함
[ 결론 ]
- LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 함
- 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할 수 있기 때문임
책에서는 LSP를 포함해 SOLID가 단순 클래스 레벨에만 적용되는게 아님을 설명하고 있다. 하지만 LSP 따위! 하고 가볍게 읽고 넘어가버린 나는 친구들과의 스터디에서 "LSP는 정사각형/직사각형!" 하고 넘어가려고 했다. 그때 옆의 친구가 책에서 "LSP는 정사각형/직사각형보다 아키텍처 수준에서 적용된다는 얘기 아냐?" 얘기를 해주었고, 정신을 차릴 수 있었다.
책은 계속 생각하면서 봐야 하는데, 어느 순간 생각을 멈추고 텍스트만 읽는 나를 느낄 때가 있다. 심지어 이번에는 단순 텍스트만 읽는 것도 인지조차 못했던 것 같다. 혹여나 나처럼 책의 내용 따위 가볍게 무시하는(?) 분들과 나 스스로를 위해 부끄러운 일화를 남겨두었다. 여담으로 정줄을 놓고 내용을 놓칠 때 잡아주는 동료가 있다는 것 역시 스터디의 장점인 것 같다.
10장. ISP: 인터페이스 분리 원칙
[ 서론 ]
- 다수의 사용자가 OPS 클래스를 사용하는 상황임
- User1은 op1만 사용함에도 불구하고, 사용하지 않는 op2, op3 메소드에 의존하게 됨
- OPS의 코드가 변경되면 User1도 다시 컴파일 후 배포해야 함
- 오퍼레이션을 인터페이스 단위로 분리하면 이를 해결할 수 있음
- 이제 User1의 소스 코드는 U1Ops와 Op1에는 의존하지만 OPS에는 의존하지 않음
- 따라서 OPS에서 발생한 변경이 User1과 관계없다면 User1을 다시 컴파일하고 새로 배포하지 않아도 됨
[ ISP와 언어 ]
- ISP는 아키텍처가 아니라 언어와 관련된 문제라고 결론내릴 여지가 있음
- 정적 타입 언어
- C나 자바와 같은 언어들이 존재함
- import, use, include와 같은 타입 선언문의 사용이 강제됨
- 이로 인해 소스 코드 의존성이 발생하고, 재컴파일 및 배포가 강제되는 상황이 무조건 초래됨
- 동적 타입 언어
- 루비나 파이썬과 같은 언어들이 존재함
- 이러한 선언문이 존재하지 않는 대신 런타임에 추론이 발생함
- 소스 코드 의존성이 아예 없으며 재컴파일 및 배포가 필요 없음
- 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있음
자바와 같은 정적 타입언어가 문제되는 매우 쉬운 상황이 있다. 예를 들어 컨트롤러에서 엔티티를 바로 사용하는 경우가 있다고 하자. 이때 엔티티 클래스의 이름을 바꾼다고 할 때, 우리가 변경되길 원하는 것은 도메인 부분이지만 실제 변경은 컨트롤러까지 전파된다. 이는 언어가 갖는 한계이며, 이러한 문제를 막기 위해서는 새로운 클래스를 만들고 컨버팅을 해서 변경을 독립시켜야 한다.
[ ISP와 아키텍처 ]
- 일반적으로 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로움
- 소스 코드 의존성의 경우에는 불필요한 재컴파일과 재배포를 강제됨
- 이는 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생함
- 시스템(S)를 구축중인 아키텍트가 프레임워크(F)를 도입하길 원하고, 개발자는 데이터베이스(D)를 반드시 사용하도록 만들었음
- 따라서 S는 F에 의존하며, F는 다시 D에 의존하는 상황임
- F에서는 불필요한 기능하며 S와는 전혀 관계없는 기능이 D에 포함된다고 할 때, 그 기능 때문에 D 내부가 변경되면 F를 재배포해야 할 수 있고 S까지 재배포해야 할 수 있음
- 더 심각한 문제는 D 내부의 기능 중 F와 S에서 불필요한 기능에 문제가 발생해도 F와 S가 영향을 받는다는 것
[ 결론 ]
- 여기서 배울 수 있는 교훈은 불필요한 짐을 실은 무언가에 의존하면 예상치 못한 문제에 빠진다는 것
- 이는 13장 “컴포넌트 응집도"에서 공통 재사용 원칙을 논할 때 자세히 다룸
Common의 저주에 걸려본 사람이라면 이 부분에 많이 공감할 것이다. 공통 코드를 관리하기 위한 common 모듈은 계속해서 커지다가 Common을 건들면 이를 사용하는 다른 모듈들이 영향을 받으므로 수정이 불가능해지는 "Common의 저주"에 빠지게 된다. 나도 Common 모듈을 수정하다가 예상치 못한 문제에 빠진적이 많았다. 그러므로 항상 불필요한 것에 의존하지 않도록 주의해야 한다.
11장. DIP: 의존성 역전 원칙
[ 서론 ]
- “의존성이 극대화된 시스템”이란 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템임
- 하지만 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존하므로, 이를 규칙으로 보는 것은 비현실적임
- 자바의 String은 구체 클래스이며, 이를 추상화시키려는 의도는 현실성이 없음.
- String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수 없고, 벗어나서도 안됨.
- String 클래스는 매우 안정적임. String 클래스가 변경되는 일은 거의 없으며, 있더라도 엄격하게 통제됨
- 프로그래머와 아키텍트는 String 클래스에 변경이 자주 발생하리라고 염려할 필요가 없음
- 따라서 DIP를 논할 때 운영체제나 플랫폼과 같이 안정성이 보장된 환경에 대해서는 무시함
- 의존하지 않도록 피하고자 하는 것은 변동성이 큰 구체적인 요소(우리가 개발 중이라서 자주 변경될 수 밖에 없는 모듈들)임
최근에 토비님과 토비의 스프링 읽기 모임을 하고 있는데, "반드시 추상화에 의존해야 하는가?"에 대한 얘기가 나왔었다. 이와 관련된 토비님은 다음과 같이 얘기하셨다. "항상 인터페이스를 쓰지 않아도 되지만, 인터페이스를 쓰는 것을 추천한다. 왜냐하면 잘못된 방식으로 코드가 작성되는 문을 열어두는 것이기 때문이다. DI = 외부 변경에 영향을 받고 싶지 않는건데, 그 문을 열어두는 느낌이다."
하지만 개인적으로 서비스를 유스케이스 단위로 나누었는데, 이를 위한 인터페이스를 두니 불필요한 작업을 하는 느낌이 많이 들었다. 앞서 설명하였듯 인터페이스는 2가지 용도(정보 은닉, 의존성 역전)가 있는데, 정보 은닉의 목적은 서비스만 잘 나눴다면 생산성 측면에서 굳이 두지 않아도 된다는 생각이 많이 들었다.
[ 안정된 추상화 ]
- 인터페이스에 변경이 생기면 구현체들도 수정해야 하지만, 구현체들에 변경이 생기더라도 인터페이스는 대부분 변경될 필요가 없음
- 인터페이스는 구현체보다 변동성이 낮으며, 뛰어난 소프트웨어 설계자와 아키텍트라면 인터페이스의 변동성을 낮추려고 노력함
- 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력하며, 이는 소프트웨어 설계의 기본임
- 즉, 안정된 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻임
- DIP에서 전달하려는 내용은 매우 구체적인 코딩 실천법으로 요약할 수 있음
- 변동성이 큰 구체 클래스를 참조하지 말라
- 대신 추상 인터페이스를 참조하라
- 이는 언어가 정적 타입이든 동적 타입이든 관계없이 모두 적용된다.
- 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제함
- 변동성이 큰 구체 클래스로부터 파생하지 말라
- 이 규칙은 이전 규칙의 따름 정리이지만, 별도로 언급할 만한 가치가 있다.
- 정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다.
- 따라서 상속은 아주 신중히 사용해야 한다.
- 동적 타입 언어라면 문제가 덜 되지만, 의존성을 가짐에는 변함이 없으므로 신중을 거듭해야 한다.
- 구체 함수를 오버라이드하지 말라.
- 대체로 구체 함수는 소스 코드 의존성을 필요로 한다.
- 따라서 구체 함수를 오버라이드 하면 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다.
- 이러한 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.
- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.
- 사실 이 실천법은 DIP 원칙을 다른 방식으로 풀어 쓴 것이다.
- 변동성이 큰 구체 클래스를 참조하지 말라
여기서 구체 함수를 오버라이딩하는 부분이 있는데, 구체 함수를 오버라이드하면 부모 코드가 바뀌었을 때 영향을 받게 되고, 결국 이는 소스 코드 의존성이 있다는 것이다. 추상 함수는 외부와 소통하는 퍼블릭 인터페이스이므로 경우가 다르다고 봐야 할 것 같다.
[ 팩토리 ]
- 위의 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 함
- 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문임
- 자바 등 대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 함
- 위의 그림에 대한 예시는 다음과 같음
- Application은 Service 인터페이스를 통해 ConcreteImpl을 사용함
- 하지만 Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 함
- Application은 ConcreteImpl에 대한 소스 코드 의존성을 만들지 않으면서 인스턴스 생성을 위해, ServiceFactory 인터페이스의 mackSvc 메소드를 호출함
- mackSvc 메소드는 ServiceFactoryImpl에 구현되며, ConcreteImpl 인스턴스를 생성한 후 Service 타입으로 반환함
- 곡선은 아키텍처의 경계에 해당하는데, 구체적인 것들로부터 추상적인 것들을 분리함
- 위는 추상 컴포넌트, 아래는 구체 컴포넌트에 해당함
- 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함함
- 구체 컴포넌트는 비즈니스 로직을 위해 필요한 모든 세부 사항을 포함함
- 소스 코드 의존성과 제어 흐름이 다름
- 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향함
- 제어 흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로 지름
- 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전되므로, 이 원칙을 의존성 역전이라고 부름
[ 구체 컴포넌트 ]
- 위의 그림에서 구체 컴포넌트에는 구체적인 의존성이 (ServiceFactoryImpl에서 ConcoreteImpl에 의존함) 있고, DIP에 위배됨
- 하지만 이는 일반적이며 DIP 위배를 모두 없앨 수 없음
- 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모아 시스템의 나머지 부분과는 분리할 수 있음
다시 한번 프레임워크의 필요성이 등장한 것 같다. Spring과 같은 DI 프레임워크가 없다면 어딘가에서 new로 구체적인 의존성 관리를 해주어야 했을 것이다. 하지만 프레임워크를 사용하면 어딘가에 반드시 필요한 구체적인 의존성도 해결할 수 있다.
최근에 "침투적인(Invasive) 코드 작성"에 대한 얘기가 많이 나오는 것 같다. 예를 들어 @Controller나 @Service 등과 같은 어노테이션을 사용하는 것은, 소스 코드에 import 프레임워크; 문이 들어가게 되므로 프레임 워크의 변경이 어려워져서 좋지 않다는 것이다. 이를 해결하려면 직접 의존 관계를 설정해주면서 "구체적인 의존성 관리"를 해주는 부분이 필요한데, 이에 대해서는 개인적으로 침투를 허용하자는 입장이다. 직접 의존 관계을 작성하는 것은 불필요한 시간을 소모하는 것이며, 관리 포인트가 늘어난다는 생각이다.
[ 결론 ]
- 고수준의 아키텍처 원칙을 다루면서 DIP는 몇번이고 계속 등장할 것임
- 그리고 DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 됨
- 의존성은 더 추상적인 엔티티가 있는 쪽으로만 향하는데, 이 규칙은 추후 의존성 규칙(Dependency Rule)이라고 부름
위의 내용은 로버트 C 마틴의 클린 아키텍처 책을 읽고 정리한 내용입니다. 개인적인 설명은 기울임으로 표시해두었으니, 읽으면서 참고하시면 될 것 같습니다! 혹시 추가적인 의견 있으면 편하게 댓글 남겨주세요ㅎㅎ
관련 포스팅
- 클린 아키텍처 1부 소개 - 내용 정리 및 요약
- 클린 아키텍처 2부 벽돌부터 시작하기: 프로그래밍 패러다임 - 내용 정리 및 요약
- 클린 아키텍처 3부 설계 원칙 - 내용 정리 및 요약
- 클린 아키텍처 4부 컴포넌트 원칙 - 내용 정리 및 요약
- 클린 아키텍처 5부 아키텍처 - 내용 정리 및 요약
- 클린 아키텍처 6부 세부사항 - 내용 정리 및 요약