📕 목차
1. SOLID 원칙
2. SRP: 단일 책임 원칙
3. OCP: 개방-폐쇄 원칙
4. LSP: 리스코프 치환 원칙
5. ISP: 인터페이스 분리 원칙
6. DIP: 의존성 역전 원칙
1. SOLID 원칙
📌 개요
- 좋은 SW 시스템은 Clean Code로부터 시작한다.
- 좋은 벽돌로 좋은 아키텍처를 정의하는 원칙이 바로 SOLID 원칙
- 함수와 데이터 구조를 클래스로 배치하는 방법과 이들 클래스를 서로 결합하는 방법을 설명한다.
클래스는 단순히 함수와 데이터를 결합한 집합일 뿐, '클래스'라는 단어를 사용하는 것이 SOLID 원칙이 객체 지향 SW에만 적용되는 것은 아니다.
📌 목적
SOLID 원칙의 목적은 중간 수준 소프트웨어 구조가 아래와 같도록 만드는 것이다.
- 변경에 유연하다.
- 이해가 쉽다.
- 많은 SW 시스템에 사용될 수 있는 컴포넌트의 기반이 된다.
✒️ 중간 수준
프로그래머가 이들 원칙을 모듈 수준에서 작업할 때 적용할 수 있다는 의미.
코드 수준 보다는 조금 상위에서 적용되며, 모듈과 컴포넌트 내부에서 사용되는 SW 구조를 정의하는데 도움을 준다.
📌 요약
- 단일 책임 원칙 (Single Responsibility Principle)
- 개방 폐쇄 원칙 (Open-Closed Principle)
- 리스코프 치환 원칙 (Liskov Substitution Principle)
- 인터페이스 분리 원칙 (Interface Segregation Principle)
- 의존성 역전 원칙 (Dependency Inversion Principle)
2. SRP: 단일 책임 원칙
📌 정의
💡 각 소프트웨어 모듈은 변경의 이유가 하나, 단 하나여야 한다.
- 가장 의미가 잘못 전달된 원칙인데, "단 하나의 일만 해야 한다는 원칙"은 커다란 함수를 작은 함수들로 리팩토링하는 저수준에서 사용되는 원칙이다. (이는 SOLID도, SRP 원칙도 아니다.)
- 변경의 이유: 이들 사용자와 이해 관계자
- 원칙을 바꿔 말하면, "하나의 모듈은 하나의, 오직 하나의 Actor(사용자 또는 이해관계자)에 대해서만 책임져야 한다."
📌 원칙 위배 징후1: 우발적 중복
서로 다른 Actor를 위한 3개의 메서드를 단일 클래스를 배치하여, 세 Actor가 결합되면서 SRP가 깨졌다.
만약 calculatePay()와 reportHours() 메서드에서 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유하기로 했을 때, 중복을 회피하기 위해 regularHours()라는 메서드로 묶었다고 치자.
CFO 팀에서 계산하는 방식을 약간 수정하면, 변경을 원치않던 COO 팀의 계산 방식까지 변경된다.
- 과연 개발팀은 regularHours()를 수정할 때 reportHours()에서도 호출된다는 걸 쉽게 알아차릴 수 있을까?
- 테스트에서 걸러졌다면 참 좋겠지만, 만약 그렇지 못하면? 이 잘못된 동작은 그대로 배포가 될 것이다.
📌 원칙 위배 징후2: 병합
CTO 팀과 COO 팀의 개발자가 각자의 작업을 수행하고 변경사항을 적용할 때 병합이 발생할 수밖에 없다.
최근의 도구가 아무리 뛰어나도, 병합에는 항상 위험이 뒤따르게 된다.
이 문제들은 결국 많은 사람이 서로 다른 목적으로 동일한 소스 파일을 변경하는 경우 발생한다.
📌 해결책: Facade Pattern
- EmployeeFacade는 세 클래스의 인스턴스를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일만을 책임진다.
- 가장 중요한 업무 규칙을 데이터와 가깝게 배치하는 방식도 고려할 수 있다.
- 가장 중요한 메서드는 기존의 Employee 클래스에 그대로 유지하되, 덜 중요한 나머지 메서드들에 대해서만 Facade로 이용하는 방법이다.
📌 결론
여기서 나오는 SRP는 메서드와 클래스 수준의 원칙이지만, 더 상위 수준에서는 다른 형태로 등장한다.
- 컴포넌트 수준: 공통 폐쇄 원칙(Common Closure Principle)
- 아키텍처 수준: 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축(Axis of Change)
3. OCP: 개방-폐쇄 원칙
📌 정의
💡 소프트웨어 개체(artifact)는 확장에는 열려 있어야 하고, 변경에는 닫혀있어야 한다.
- 기존 코드를 수정하기 보다 반드시 새로운 코드를 추가하는 방식으로의 행위를 변경할 수 있도록 설계해야 한다.
- 요구 사항을 살짝 활장하는 데 수정 비용이 엄청 크다면, 해당 SW를 설계한 아키텍트는 엄청난 실패에 맞닥뜨린다.
📌 사고 실험
재무제표를 웹 페이지로 보여주는 시스템이 존재할 때, 동일한 정보를 보고서 형태로 변환하여 프린터로 출력해 달라고 요청했다고 해보자.
보고서의 양식을 맞추기 위해서 새로운 코드를 작성해야 할 텐데, 기존 코드는 얼마나 많이 수정되어야 할까?
💡 SW 아키텍처가 훌륭하다면 이상적인 변경되는 코드의 양은 0이다.
- 서로 다른 목적으로 변경되는 요소를 적절하게 분리한다. (SRP)
- 이들 요소 사이의 의존성을 체계화한다. (DIP)
여기서 가장 중요한 것은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다.
이처럼 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다.
또한, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.
📌 해결책
- 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 컴포넌트 단위로 구분해야 한다.
- <I> 인터페이스, <DS> 데이터 구조
- → 사용(using) 관계, ⇾ 구현(implementation) 혹은 상속(inheritance) 관계
- 모든 컴포넌트 관계는 단방향으로만 이루어지고 있음에 주목하라.
- 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.
- A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면, 반드시 A 컴포넌트가 B 컴포넌트에 의존해야 한다.
- 아키텍트는 기능이 어떻게(how), 왜(why), 언제(when) 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트 계층 구조로 조직화한다.
- View에서 발생한 변경으로부터 Presenter를 보호하고 있다.
- Presenter에서 발생한 변경으로부터 Controller를 보호하고 있다.
- Interactor는 다른 모든 것에서 발생한 변경으로부터 보호하고자 한다.
- OCP가 가장 잘 준수할 수 있는 곳에 위치한다.
- Interactor가 바로 업무 규칙을 포함하는 가장 높은 수준의 정책을 포함하기 때문이다. - 보호 계층구조가 '수준(level)'이라는 개념을 바탕으로 어떻게 생성되는지에 주목하라.
- Interator는 가장 높은 수준의 개념이며, 최고의 보호를 받는다.
- View는 가장 낮은 수준의 개념 중 하나이며, 거의 보호를 받지 못한다.
- Presenter는 View보다는 높고, Controller나 Interactor보다는 낮은 수준에 위치한다.
- 컴포넌트 계층 구조를 위와 같이 조직화하면, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.
📌 다시 보는 방향성 제어의 중요성
- FinancialDataGateway 인터페이스의 역할은 의존성을 역전시키기 위함이다.
- 해당 인터페이스가 없었다면 의존성이 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.
- FinancialReportPresenter 인터페이스와 2개의 View 인터페이스도 같은 목적을 지닌다.
📌 정보 은닉
- FinancialReportRequest 인터페이스는 Controller가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해 존재한다.
- 해당 인터페이스가 존재하지 않으면, Controller는 FinancialEntities에 대해 추이 종속성을 갖는다.
✒️ 추이 종속성(transitive dependency)
클래스 A가 클래스 B에 의존하고, 클래스 B가 클래스 C에 의존하면, 클래스 A는 클래스 C에 의존하게 된다.
최악의 경우 클래스 C가 수정되면 A까지 수정되어야 한다.
C와는 전혀 상관 없는 기능임에 불구하고!
4. LSP: 리스코프 치환 원칙
📌 정의
💡 상호 대체 가능한 구성요소를 이용해 SW를 만들려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다.
- 하위 타입(sub type) 개념에서 중요한 것은 치환(substitution) 원칙이다.
- S 타입의 객체 o1이 각각에 대응하는 T타입 객체 o2가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.
📌 LSP를 준수하는 예
- Builling 애플리케이션의 행위가 구체 클래스에 전혀 의존하지 않는다.
- 하위 타입은 모두 License 타입을 치환할 수 있다.
📌 LSP를 위반하는 예 (정사각형/직사각형 문제)
- Square는 Rectangle의 하위 타입으로 적합하지 않다.
- Rectangle은 높이와 너비가 서로 독립적으로 변경될 수 있다.
- Square는 높이와 너비가 반드시 함께 변경된다.
- User는 Rectangle의 존재만을 알기 때문에 사용에 혼란을 주게 된다.
- if 구문 등으로 LSP의 위반을 막기 위한 검사 메커니즘을 User에 추가하면, User의 행위가 사용하는 타입에 의존하게 되므로 타입을 서로 치환할 수 없게 된다.
📌 LSP와 REST 서비스 위배 사례
다양한 업체의 서비스를 통합하는 애플리케이션을 만든다고 가정하자.
어느 업체일지는 신경쓰지 않고, 가장 적절한 업체를 탐색하기 위해 다음과 같은 URI로 REST 서비스의 응답을 받아야 한다.
이해를 돕기 위해서 특정 사용자 정보를 조회한다고 생각하자.
example.com/users/jaayng
시스템은 URI에 사용자 정보 조회를 위한 정보를 덧붙여야 한다.
example.com/users/jayang/address/korea/gender/male/age/99
이렇게 URI 규칙을 설정하면, 다양한 업체에서 동일한 REST 인터페이스를 반드시 준수하도록 강제해야 한다.
그런데 다른 업체 test에서 프로그래머를 몇 명 고용했다고 치자.
이들 프로그래머는 convention을 제대로 읽지 않아서, gender를 gen으로 사용한다.
그리고 test 업체가 우리 회사 대표와 뭐 가족이라도 되었다고 가정을 해보자.
그럼 우리 회사 시스템 아키텍처는 해당 예외 사항을 처리하는 로직을 처리해야 한다.
if (users.getDispatchUri().startsWith("test.com"))...
물론 이런 처리 방법은 굉장히 끔찍한 방법이다. (보안 침해, 에러 등등)
그런데 여기서 또 다른 회사들을 인수하면 어떻게 될까?
합병된 회사가 브랜드와 웹 사이트는 test사와 독립적으로 유지하면서, 회사 시스템은 모두 통합한다면?
또 if문을 추가해야 하나?
이를 해결하기 위해서 User URI를 key로 사용하는 설정용 데이터 베이스를 이용하는 파견 명령 생성 모듈을 만들고, REST 서비스들의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리해야 할 수도 있다.
💡 LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야 한다. 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가하게 될 수도 있다.
5. ISP: 인터페이스 분리 원칙
📌 정의
💡 SW 설계자는 사용하지 않은 것에 의존하지 않아야 한다.
- User1은 opUser2()와 opUser3()를 전혀 사용하지 않음에도 두 메서드에 의존하게 된다.
- 이러한 의존성은 Service 클래스에서 opUser2()가 변경되면 User1도 다시 컴파일한 후 새로 배포해야 한다.
- User1의 소스코드가 U1Ops와 opUser1()에만 의존하므로 Service의 변경 사항이 전파되지 않는다.
📌 ISP와 Language
- 정적 타입 언어는 import, use, include와 같은 타입 선언문을 강제하며, 소스 코드 의존성이 발생한다.
- 이로 인해 재컴파일 또는 재배포가 강제되는 상황이 무조건 초래한다. (물론 이마저도 언어에 따라 영향받는 정도가 다르다.)
- 하지만 동적 타입 언어는 런타임에 추론이 발생하므로, 훨씬 유연하며 결합도 낮은 시스템을 구축할 수 있다.
여기까지만 보면 ISP를 아키텍처가 아니라, 언어와 관련된 문제라 결론내릴 수도 있다.
📌 ISP와 Architecture
OCP에서 봤던 추이 종속성 문제를 다시 살펴보자. (좀 더 넓은 범위에서)
A 시스템 구축을 위해 B 프레임워크를 시스템에 도입하면, A 시스템은 B 프레임워크에 종속된다.
그런데 B 프레임워크는 반드시 C 데이터베이스를 사용하도록 강제하면 A 시스템은 C 데이터베이스에 종속된다.
(A → B → C)
그런데 B 프레임워크에서 전혀 불필요한 기능(당연히 A 시스템에도 필요없다.)이 C 데이터베이스에 포함되어 있다고 가정하자.
해당 기능 때문에 C 데이터베이스 내부가 변경되면, B 프레임워크를 재배포해야 할 수도 있으며, 졸지에 A 시스템까지 재배포해야 할 수도 있다.
더 심각한 문제는 C 데이터베이스 기능 중, A, B에서 전혀 불필요한 기능일지라도 영향을 준다는 사실이다.
💡 불필요한 짐을 실은 무언가에 의존하면, 예상치도 못한 문제에 빠질 수도 있다.
6. DIP: 의존성 역전 원칙
📌 정의
💡 고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대 의존해서는 안 된다.
- 소스 코드의 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템이어야 한다.
- 하지만 DIP를 논할 때 OS나 플랫폼같이 안정성이 보장된 환경에서는 무시하는 편이다.
- 자바의 String은 구체 클래스지만 소스 코드 의존성을 벗어날 수도 없고, 벗어나서도 안 된다.
- 하지만 String 클래스가 변경될 일은 거의 없으며, 있더라도 매우 엄격하게 통제된다.
- 우리가 의존하지 않도록 피해야 할 것은 변동성이 큰 구체적인 요소에 해당한다.
📌 안정된 추상화
- 변동성이 큰 구체 클래스를 참조하지 말라
- 언어가 정적이든 동적이든 추상 인터페이스를 참조하라.
- 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제한다.
- 변동성이 큰 구체 클래스로부터 파생하지 말라
- 상속은 아주 신중하게 사용해야 한다. (이펙티브 자바 chap 4 #18)
- 구체 함수를 오버라이드 하지 말라
- 구체 함수는 소스 코드 의존성을 필요로 하므로, 이를 오버라이드하면 의존성을 제거할 수 없게 된다.
- 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현하라
- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라
📌 팩토리
[Design Pattern] Factory
"헤더퍼스트 디자인패턴" + "면접을 위한 CS 전공 지식 노트"에 개인적인 의견과 생각들을 추가하여 작성한 포스팅이므로 틀린 내용이 있을 수 있습니다. (있다면 지적 부탁 드립니다.) 📕 목차 1.
jaeseo0519.tistory.com
- 변동성이 큰 구체 클래스를 사용할 때, 추상 팩토리 패턴을 사용하곤 한다.
- 추상 컴포넌트(인터페이스)는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
- 구체 컴포넌트(구현체)는 업무 규칙을 다루기 위한 모든 세부사항을 포함한다.
- 여기서도 잘 보면 소스 코드 의존성이 제어흐름과 반대 방향으로 역전되는 것을 확인할 수 있다.
- 구체 컴포넌트 영역이 추상 컴포넌트 영역을 의존한다.
💡 모든 DIP 위배를 없앨 수는 없지만, DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모아서 시스템의 나머지 부분과 분리할 수 있다.