본문 바로가기
IT 공부/객체지향 설계 공부

클린 아키텍처 - 로버트 C 마틴 요약 (ch1 ~ ch3)

by exdus3156 2023. 10. 17.
챕터별로 정리했으나, 제가 이해한 방식대로 저의 언어를 사용해 표현했기 때문에 혹시라도 정보를 얻으실 때는 주의하시기 바랍니다. - 2023/10/17

 

0. 추천사

  • 건출물과는 달리, 소프트웨어의 구조가 과연 우리의 직관과 일치하는지는 분명하지 않다.
  • 소프트웨어는 무엇으로 구성되는가? 소프트웨어는 소프트웨어로 구성된다. 데이터를 받고, 처리해서, 결과를 뱉어낸다. 이런 코딩 거북이들이 연쇄적으로 서로를 떠받치는 형태다. 그러니 건축물에서 볼 수 있는 물리적 구조의 다양성 앞에서 소프트웨어는 무색해진다.
  • 소프트웨어 아키텍처에서의 소프트웨어는 본질적으로 재귀적이고 프랙털 구조로 되어 있다.

 

0. 서문

  • 현재의 소프트웨어는 과거와 동일한 것들로 구성된다. 컴퓨터 프로그래밍을 하는 관행을 정말 유심히 관찰해보면 지난 50년 동안 변한 게 거의 없다는 사실을 깨달을 것이다. 자바, C#과 같은 진보한 언어가 등장하고 객체지향 설계라는 우월한 패러다임을 사용한다고 주장해도 여전히 코드는 순차, 분기, 반복의 집합체일 뿐이다.
  • 소프트웨어 아키텍처의 규칙이란, 그저 프로그램의 구성 요소를 정렬하고 조립하는 방법에 대한 규칙이다. 이 구성요소가 지금껏 변하지 않았기 때문에 새로운 언어, 새로운 프레임워크, 새로운 패러다임에도 불구하고 좋은 아키텍처의 규칙과 속성 또한 아무것도 변한 것이 없다.

 

1. 설계와 아키텍처란

  • 소프트웨어 아키텍처의 목표는 필요한 시스템을 만들고 유지보수하는데 드는 인력을 최소화하는 것에 있다.
  • 현대 대다수 개발자들은 뼈 빠지게 일한다. 하지만 "코드는 나중에 정리하면 돼. 당장은 시장에 출시하는 게 먼저야"라는 흔해 빠진 거짓말에 속는다. 하지만 이렇게 속아 넘어간 개발자가 나중에 코드를 정리하는 경우는 없다. 기회가 되면 언제든지 엉망인 코드를 정리할 수 있다고 과신하는 것뿐이다.
  • 시장은 급속도로 변화하고, 개발자에겐 언제나 만들어져야 할 새로운 기능들이 기다리고 있다. 그러나 엉망인 상태로 계속 작업하다보면 어느 순간부터는 프로그래머들의 재능과 열정이 새로운 기능을 개발하는데 쓰이는 것이 아니라, 엉망인 상황에 대처하는데 소모된다. 

 

2. 두 가지 가치 이야기 (동작 vs 변경 가능성)

  • 많은 프로그래머가 요구사항을 구현하고 버그를 수정하는 일이 자신의 직업이라 믿는다. 그러나 그들은 틀렸다.
  • 소프트웨어는 부드러움(soft)을 지니도록 만들어졌다. 즉, 우리가 소프트웨어를 만든 이유는 기계의 행위를 쉽고 빠르게 변경하기 위해서다. 만약 기계의 행위를 바꾸는 것이 어렵다면 이것을 우리는 하드웨어라고 불렀을 것이다.
  • 변경을 적용하는데 드는 어려움은 변경되는 범위(scope)에 비례해야 하며, 변경사항의 형태(shape)와는 관련이 없어야 한다. 변경 사항의 규모가 크면 변경하기 어려운 것이 합당해보인다. 그러나 새롭고 사소한 기능을 기존의 구조에 맞추는 길이 보이지 않게 된다면 그것은 합당하지 않다. 아키텍처는 변경사항에 독립이어야 하고, 그럴수록 더 실용적이다.
  • 기능인가 아키텍처인가? 동작은 하지만 수정이 현실적으로 불가능한 코드와, 동작은 하지 않지만 변경이 쉬운 코드가 있다면 후자가 더 좋다.
  • 소프트웨어 개발자를 고용하는 이유는 바로 이 딜레마, 즉 긴급하지만 중요하지 않은 기능긴급하면서 중요한 기능을 구분하기 위해서다. 보통 전자와 후자를 구분하지 못하기 때문에 시스템에서 가장 중요한 아키텍처의 중요성을 무시하고 기능 구현에 집착하는 경우가 많은 것이다.

 

3. 패러다임 개요

  • 각 패러다임(구조적, 객체지향, 함수형)은 모두 프로그래머에게서 권한을 박탈한다. 어느 패러다임도 새로운 기능을 부여하지 않는다. 즉, 패러다임은 무엇을 할 수 있는지 말하기보다는 무엇을 해서는 안 되는지 말해준다.
  • 구조적, 객체지향, 함수형을 제외하면 새롭게 생긴 패러다임은 없다. 앞으로도 새로운 패러다임이 등장하는 일은 없을 것이다. 왜냐하면 패러다임의 본질이 프로그래머에게서 무언가를 박탈하는 것이며, 더 이상 박탈할 것이 남아있지 않기 때문이다.

 

4. 구조적 프로그래밍

구조적 프로그래밍은 제어흐름에 제한된 규칙을 부여한다.
  • 데이크스트라가 초기에 인식한 문제는 프로그래밍은 어렵다는 사실이었다. 모든 프로그램은 설령 단순해보일지라도 인간의 두뇌로 감당하기에는 너무나 많은 세부사항을 담고 있었다. 아주 작은 세부사항이라도 간과하면 결국 예상 외의 방식으로 실패하곤 했다.
  • 데이크스트라는 연구 과정에서 goto문과 같이 제어흐름을 프로그래머가 직접 결정하는 방식이 해롭다는 사실을 발견했다. 자유로운 제어흐름을 보장하는 goto문과는 달리, if/else, for/while과 같은 단순한 제어흐름(분기, 반복)으로 코드를 구조화하면 수학적으로 분석 및 증명이 가능한 구조가 될 수 있다고 추측했다.
    • 그는 프로그램을 유클리드식으로 증명하고 싶었던 것이다. 순차, 분기, 반복은 일관된 논리적 법칙이고, 잘게 쪼개진 기능들은 일종의 명제다. 따라서 데이크스트라가 유클리드 계층구조를 지향했던 것은, 분해된 각 기능들이 참이면서 순차/분기/반복의 구조로 결합된다면 자동으로 전체 프로그램(명제)도 참(문제를 일으키지 않음)임이 증명되리라는 기대에서 추구했던 것이었다.
  • 개발자 멋대로 정하는 goto와 같은 직접적 제어흐름을 버리고 분기, 반복, 그리고 순차적 실행 구조를 택함으로서 고기능의 요구사항을 저수준의 함수들로 분해할 수 있게 되었다.
    • 물론 goto를 사용한다고해서 저기능 모듈로 분해가 불가능한 것은 아니지만, 프로그래머가 직접 제어 흐름을 조작하는 방식은 모듈이 분해/조합되는 방식이 무한하다. 제어 흐름에 대한 극한의 자유로움은 달리 말하면 구조가 없다는 뜻이기도 하다.
    • 순차, 분기, 반복 구조의 프로그램이 모든 소프트웨어를 만들 수 있다는 것은 실제로 뵘(bohm), 야코피니(jacopini)에 의해 증명되었다.
    • 이렇게 제한된 몇 가지 제어흐름 문법을 사용하면 모든 프로그램을 만들 수 있으면서, 동시에 그 구조는 재귀적이다. 재귀적 구조는 goto문을 사용하면 쉽게 깨져버린다.
  • 그러나 데이크스트라가 최초에 프로그래밍에 대해 연구할 때 그가 진정 바랐던 것은 엄밀한 구조와 모듈 분해를 사용한 코드라면 요구사항에 대해 완벽하게 동작하는 프로그램임을 수학적으로 증명하는 것이었다.
  • 그러나 엄밀한 증명은 불가능했다. 오늘날 구조적 분해와 각 모듈에 대한 엄밀한 입증으로 고품질의 소프트웨어를 증명할 수 있다고 생각하는 프로그래머는 없다.
  • 오늘날의 구조적 프로그래밍이 추구하는 것은 최초의 기대와는 달리 타협적이다. goto가 아닌 순차/분기/반복으로 모듈을 테스트가 가능한 세부 기능으로 나누고, 각 기능들이 최소한 거짓은 아님을 통과한다면(테스트 주도 개발) 전체 프로그램도 적당히 참이라고 여기는 것이다.
  • 이는 다소 충격적인데, 데이크스트라가 제시한 프로그래밍의 구조는 겉으로는 수학적 구조처럼 보이지만 정작 수학이 아니라 귀납적인 과학에 가까운 작업이다.
  • 오늘날에도 구조적 프로그래밍은 절대적인 위치를 지키고 있다. 구조적 프로그래밍이 가치 있는 이유는 프로그램을 반증 가능한 단위로 쪼개는 능력 때문이다. 물론 그렇게 나눈 작은 컴포넌트들을 구조적 프로그래밍이 제한한 방식(순차, 분기, 반복)으로 합친다고 해서 전체 프로그램에 버그가 없다는 것을 증명하진 못한다. 그러나 적어도 goto와 같이 예측 불가능한 제어 흐름으로 인해 프로그램의 정상성을 예측조차 할 수 없다는 것보다는 낫다.

 

5-1. 객체지향 

  • 객체지향 개념은 다소 모호하게 알려져 있다. 단순히 데이터와 함수를 묶은 단위(객체, 클래스)로 시스템을 분할한다거나, 객체가 실세계의 모델링이라고 말하는 설명도 있으나 모두 객체지향의 본질과는 관련이 없다.
  • 대신 객체지향의 본질로 흔히 언급되는 것들이 캡슐화, 상속, 다형성이다.
  • 안타깝게도 캡슐화는 객체지향의 본질이 아니다. 객체지향 언어가 캡슐화를 편리하게 구현하도록 지원해주고는 있으나, 객체지향 이전에도 이미 프로그래머들은 캡슐화를 사용해왔다. 오히려 C언어를 사용하면 자바나 C++, C# 보다 더 완벽한 캡슐화 구현이 가능하다. 자바와 같은 객체지향 언어들에는 언제나 캡슐화를 깨트리는 우회 방법이 있기 때문이며, Python이나 Javascript, Ruby의 경우 엄밀한 캡슐화를 지원해주지도 않는다.
  • 상속 또한 마찬가지로 굳이 객체지향 언어에서만 가능한 것은 아니다. C언어로도 충분히 상속을 구현할 수 있으며, C언어 개발자가 아니더라도 많은 개발자들은 객체지향 이전에도 이미 상속을 나름 구현해 사용하고 있었다.
  • 즉, 캡슐화와 상속 개념은 객체지향의 개념이 아니라 프로그래밍의 한 방법일 뿐이며, 객체지향을 구현한 언어를 사용하지 않은 프로그램에서도 쉽게 찾아볼 수 있는 개념들이다. 단지 객체지향 언어들은 캡슐화와 상속을 조금 더 편리하게 제공해줄 뿐이다.
  • 다형성 또한 그것이 함수의 포인터를 응용해 구현할 수 있다는 점에서 객체지향만의 특별한 개념은 아니다! 이미 1950년대에, 객체지향과는 별개로, 프로그램이 독립적으로 설계되어 플러그인 형태로 결합하는 방식이 좋다는 것이 알려졌고, 이것이 유닉스 운영체제 개발에도 십분 활용되었다.
  • 여기서 중요한 요점은, 다형성을 쉽고 편리하게 제공해준 OOP 언어들이 객체지향적 설계의 "확산"을 가져왔다는 것이다. 왜냐하면 C언어로 다형성을 구현하는 것은 함수 포인터를 사용한다는 측면에서 매우 위험한 것으로 인지되었기 때문에 대다수의 프로그래머들은 이 다형성을 확장하여 자신들의 프로그램에 적용하지 않았다. 즉, 유용하다고 알고는 있었지만 실제로 다형성이 적용된 것은 유닉스처럼 복잡하고 큰 프로그램에만 적용되었던 것이다.
  • 다형성을 쉽고 편리하고 안정적으로 제공해주는 객체지향의 장점은 프로그래머들이 플러그인 아키텍처를 구성할 수 있게 하는 밑거름이 되었다. 아키텍처의 관점에서 보면 다형성의 편리함은 제어흐름을 소스 코드에서 결정하지 않고 코드 바깥에서 간접적으로 전환하게 만들어 주었다.

 

5-2. 다형성의 특별함, 의존성 역전!

  • 전형적인 소프트웨어의 구조를 생각해보자. 고수준 모듈이 저수준 모듈의 함수를 호출하며 프로그램은 순차적으로 진행된다. 이때 고수준 모듈은 저수준 모듈의 소스코드에 의존한다. 제어흐름에 따라 저수준의 모듈을 호출하는데, 실제로 호출되는 소스 코드를 고수준 모듈이 구체적으로 지정한다. 제어흐름과 소스코드 의존의 흐름이 같다.
  • 반대로 객체지향 코드에서는 제어흐름과 소스코드 의존의 방향이 다르다. 고수준 모듈은 오직 인터페이스만을 지정한다. 소스 코드 차원에서 고수준 모듈은 인터페이스에 의존하지, 실제 제어 흐름에 따른 소스 코드를 지정하진 않는다.
  • 즉, 소스 코드와 고수준 모듈 사이에 인터페이스를 추가함으로서 고수준 모듈은 더 이상 저수준 모듈의 소스 코드의 영향을 받지 않는다. 오히려 지켜야 할 쪽은 저수준 모듈이다. 저수준의 모듈이 상위 모듈이 결정한 인터페이스 포맷을 지켜야 하는 것이다!
  • 예를 들면, Business Rule이 UI나 Database의 저수준 모듈을 호출하지 않는다. Business Rule 모듈은 자신이 요구하는 사항을 인터페이스로 제공한 후 소스코드 상에서 이 인터페이스에 의존한다. Business Rule 모듈은 UI나 Database의 구체적인 모듈을 모른다. 몰라도 된다. 구체적인 서비스를 제공해주는 UI와 Database 모듈이 자신들이 제공하는 서비스(메소드)를 외부로 공개하고 그치는 것이 아니라, 이제 이들이 Business Rule 모듈의 인터페이스를 구현해야 하는 것이다.
  • 이것이 의존성 역전(DI)이다. 이전에는 UI와 Database 모듈이 자신들이 알아서 메서드를 만들고 Business Rule이 이 메소드를 구체적으로 import하고 사용했다면, 이제는 UI와 Database 모듈이 "Business Rule을 위해서!" 인터페이스를 구현해야 한다.
  • 객체지향의 편리한 다형성 덕분에 아키텍처는 소스 코드 의존성의 방향에 대한 절대적인 권한을 행사할 수 있다. 이것이 객체지향이 제공하는 진정한 힘이다.
  • 각 컴포넌트는 배포와 개발에 있어 독립성을 갖추게 된다. Business Rule 모듈은 독립 개발이 가능하다. 충분히 추상화된 UI와 Database 기능을 인터페이스로 결정하고, 이 인터페이스에만 의존하면 된다. 적절한 서비스가 올 것이라는 기대를 가지고 모듈을 마음대로 개발하면 된다. 반대로 저수준의 UI와 Database도 독립적으로 개발하고 배포할 수 있다. 단지 배포할 때만 Business Rule 모듈의 인터페이스를 구현해 해당 모듈에 맞게 제공해주면 된다.
객체지향이란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다. 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 핵심 모듈은 세부사항과 관련된 저수준의 모듈에 대한 독립성을 부여할 수 있다. (p.79)

 

7. 단일 책임 원칙 (Single Responsibility Principle)

  • SOLID 설계 원칙을 설명하기 이전에, 먼저 이러한 원칙이 어느 수준에서 논해지는지 알아야 한다. 예를 들어, 어떤 기능을 더 작은 단위의 함수로 분해하는 원칙은 SOLID로 설명될 수 없다. 이것은 저수준의 원칙을 따라야 하며, 클린 코드, 혹은 리팩토링 개념으로 설명된다. 예를 들어, 함수가 하나의 일을 해야 한다는 원칙은 올바르지만, 이것은 SOLID의 SRP와는 별개의 원칙인 것이다. SOLID는 이 보다는 조금 더 높은 수준, 즉 "중간 수준"에서 논해지는 원칙이다.  비유하자면 벽돌을 만드는 원칙과 벽돌을 쌓는 원칙이 다르다는 것이며, SOLID에 대한 설명은 벽돌을 쌓는 원칙에 가깝다.
  • 단일 책임 원칙, SRP는 변경의 이유가 오직 하나의 사용자(액터)에 의해 변경되어야 한다는 원칙이다. 
  • 모듈은 여러 데이터와 함수를 응집시켜 외부에 서비스를 제공해준다. 따라서 모듈과 모듈은 서로의 경계선 바깥에서는 독립이다. 그러나 일단 하나의 모듈 아래 응집된 데이터와 함수들은 서로 밀접하게 결합되어버린다. 섬세하게 내부 함수와 데이터를 관련된 것끼리 구분짓는다고 해도 서로 간의 영역을 침범하는 것을 막을 수는 없는 것이다.
  • 모듈이 서로 다른 사용자에 의해 사용될 때 문제가 발생할 수 있다. SRP 원칙이 지켜지지 않으면 이해관계자가 요청한 하나의 기능을 수정하다 다른 이해관계자가 사용하는 기능에 치명적인 문제를 일으킬 수 있기 때문이다. 아래의 예시를 보자.
    • 인사팀이 reportWorkingHours() 메소드를 사용한다. 이 메소드는 인사 보고를 위해 직원들의 근무 시간을 계산해준다.
    • 회계팀이 calculatePay() 메소드를 사용한다고 해보자. 이 메소드는 회계 급여 처리를 위해 직원들의 급여를 계산해준다.
    • 이 두 메소드들을 하나의 Employee 모듈 안에 넣었다고 해보자. 두 메소드들이 같은 Employee(직원) 데이터를 공유하므로 마냥 잘못된 설계라고 단정할 수는 없다.
    • 이때, 두 메소드는 모두 같은 Employee 모듈 안에 있는 regularHours() 메소드를 공유한다. 이 메소드는 초과근무나 특별근무를 제외한 정기 근무 시간을 계산한다. 보고를 위한 근무시간 계산과 급여 산정을 위한 계산 모두에게 필요한 기능이다.
    • 만약 회사의 근무 시간 책정 정책이 변경되어 이 regularHours() 메소드가 변경되면 어떻게 될까?
      • 인사팀은 이러한 변경을 감지하고 regularHours() 메소드의 변경에 맞게 reportWorkingHours()도 변경한다. 그러나 회계팀은 전산상에서 regularHours()가 변경되리라 예상하지 못했다고 해보자.
      • 결국 calculatePay()는 regularHours()가 변경된지도 모른 채 이 메소드의 결과를 토대로 급여를 계산하게 되고, 이것은 회사에 아주 치명적인 문제를 일으킬 수 있다. 회사의 근무 시간 산정 정책이 변경된 것이지, 실제 국가의 노동 제도에 따른 급여 계산에는 맞지 않기 때문이다.
    • 참고로 위 문제는 퍼사드 패턴 등으로 해결 가능하다.

 

8. 개방 폐쇄 원칙 (Open Close Principle)

  • OCP는 '소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다'는 원칙이다.
  • OCP는 소프트웨어 아키텍처를 공부하는 근본적인 이유다.
  • 책임을 분리하는 것이 SRP의 목적이라면, OCP는 여러 책임 중 하나가 변경되더라도 다른 책임들의 변경을 최소화하는 것을 목표로 한다. 즉, 책임을 다른 책임의 변경으로부터 보호하는 정책을 말한다. 그런데 무엇을 확장하고 무엇을 보호해야 한다는 것일까?
  • 변경으로부터 보호하는 방법 중 하나로는 소스코드 의존의 방향을 관리하는 것이다. A컴포넌트를 B컴포넌트로부터 보호하고 싶다면, B컴포넌트가 A컴포넌트에 의존해야 한다. 이때 B컴포넌트가 A컴포넌트에 비해 덜 중요한 부분을 책임진다.
  • 이를 위해서는 먼저 시스템을 컴포넌트 단위로 크게 분할하고, 그 중요도를 기준으로 컴포넌트들의 계층을 구분해야 한다. 컴포넌트란 여러 객체를 포함하는 한 단계 더 큰 차원의 모듈이다. 예를 들어, 아주 간단한 웹 애플리케이션이 있다. 이것을 아주 크게 보면 View, Presenter, Controller, Database, Interactor로 나눌 수 있을 것이다.
  • 비즈니스 정책을 담고 있는 Interactor 컴포넌트가 가장 중요하다. 따라서 Controller, Database 등의 컴포넌트는 Interactor에 의존한다. 즉, Controller와 Database 컴포넌트가 Interactor를 향해 화살표를 날리는 UML을 그릴 수 있다. 반대로 Interactor 컴포넌트에서는 Controller와 Database의 코드에 대해 전혀 관심이 없다. 화살표를 날리지 않는다. 계층적으로 가장 중요하기 때문에 가장 보호받는 것이다.
  • 만약 Interactor 컴포넌트가 하위 계층인 컴포넌트(Controller, Database)를 사용해야 한다면 어떻게 될까? 이때도 의존성의 방향이 반대로 가면 안 되므로 Interactor는 인터페이스를 열어 젖힌다. 즉, 이 인터페이스 규격에 맞춰줘야 하는 쪽은 하위 컴포넌트들이다. 이렇게 하위 컴포넌트를 향한 의존은 다형성을 응용해 의존성을 역전시킴으로서 컴포넌트 사이의 계층적 의존 관계를 유지할 수 있다.
  • 반대로 고수준 모듈과 컴포넌트의 변경이 하위의 모듈과 컴포넌트에게 여파를 미치지 않도록 보호하는 전략도 있다. 저수준 모듈이 고수준 모듈에 의존하므로, 고수준 모듈이 변경되면 저수준 모듈은 변경의 압박을 받는다. 하지만 그렇다고 이 여파를 내버려둘 수는 없다! 이것을 최소화할 수 있어야 한다. 
    • 여기서도 인터페이스를 활용할 수 있다. 그러나 목적은 다르다. 원래 인터페이스는 고수준 모듈이 저수준 모듈에 의존하는 것을 방지할 수 있도록 의존성 역전(DI)을 일으키기 위해 사용되지만, 이것은 인터페이스를 사용하는 하나의 전략일 뿐이다.
    • 인터페이스는 컴포넌트 내부에 대해 외부 컴포넌트가 너무 많은 정보를 알지 못하도록 방지하는 역할을 수행하기도 한다. 고수준 컴포넌트는 외부 컴포넌트가 사용할 인터페이스를 제공해주고, 그 인터페이스를 구현해준다. 따라서 외부 컴포넌트는 오직 이 인터페이스가 제공하는 범위 안에서만 서비스를 사용할 수 있고, 컴포넌트와 관련된 나머지는 모른다.
    • 이를 통해 고수준 컴포넌트는 내부의 클래스와 작은 모듈들의 변경으로 인해 외부 컴포넌트까지 변경되는 것을 최대한 방지할 수 있다. 왜냐하면 인터페이스와 해당 인터페이스에서 사용하는 데이터를 따로 개발해 외부로 제공했기 때문에, 외부 컴포넌트에 대한 추이 종속성을 해결할 수 도 있으며 외부를 향한 인터페이스, 즉 경로가 확실하므로 해당 경로를 집중적으로 관리할 수 있게 되는 것이다.
    • 예를 들어, 외부 컴포넌트가 고수준 컴포넌트 내부의 Entities 클래스를 직접 사용하고 있다면, Entities의 변경으로 인해 그것을 직접 사용하는 외부 컴포넌트는 다시 검증해야 할지도 모른다. 
    • 즉, 외부 컴포넌트는 자신과 직접적인 관련이 없어도 되는 요소에는 절대로 의존해서는 안 된다. 이를 위해 고수준 컴포넌트 또한 자신의 내부를 최대한 은닉하는 것이다. 이를 위해 인터페이스가 활용된다. 
  • 결국 OCP의 궁극적 목표는 일차적으로는 저수준 컴포넌트의 변경으로 인한 여파가 고수준 컴포넌트에 미치지 않도록 보호하는 기술이다. 저수준 컴포넌트는 자유롭게 변경하거나 새로운 동작 추가가 가능하다. 그리고 "다음 우선 순위로" 고수준 컴포넌트의 변경으로 인한 여파로 인해 저수준 컴포넌트가 변경되는 것도 "되도록" 보호한다. OCP는 자유로운 변경과 최대한의 보호를 동시에 뜻하는 원칙이다.

 

9. 리스코프 치환 원칙 (Liskov Substitution Principle)

  • LSP는 겉으로 보면 그저 상속의 직관적인 원리처럼 들린다. LSP는 표면적으로는 Animal 객체를 상속하려면 개념적으로 Dog이나 Cat 등이 와야 한다는 식으로 너무 간단하게 언급되고 만다. 실제로 LSP는 초기 개발자들에게 그저 상속을 사용하는 가이드라인에 불과했다.
  • 그러나 LSP는 직관적이고 개념적으로 타당한 타입의 상속 구조를 말하는 것이 아니다. 분류학은 소프트웨어 개발과 상관 없는 분야다. Animal 객체에 Dog나 Cat이 와도 "말이 되니까" LSP라고 하는 것은 LSP의 본질에 대한 올바른 설명이 아니다.
  • LSP는 어떤 모듈t 가 T의 하위 타입으로 분류되기 위해서는 T를 사용한 모든 프로그램의 코드의 T자리에 t를 대입해도 프로그램의 행위가 변하지 않아야 한다는 원리다. 결국 해당 타입을 사용하는 상위 모듈의 행동 원리와 호환이 되어야만 비로소 하위 타입이 된다는 것이다.
  • 가장 유명한 설명으로는 정사각형/직사각형 문제가 있다. Rectangle 클래스를 Square 클래스가 상속하는 것은 개념적으로는 말이 되는 것 같다. 하지만 Rectangle 객체는 "사각형"을 의미하고, 따라서 이 객체를 사용하는 다른 모듈은 사각형의 가로와 세로 길이를 마음대로 조절할 수 있다고 여길 수도 있다. 그러나 Square는 그렇지 않다. 정사각형은 가로와 세로 길이를 독립적으로 변경할 수 없다!
  • 또 다른 유명한 LSP 위반 사례는 자바의 stack이다. java는 statck 자료구조를 구현하기 위해 list 자료구조를 상속해서(!) 구현해버렸다. 그런데 list는 인덱스 접근이 가능하므로, list를 사용하는 상위 모듈은 기본적으로 인덱스를 통한 접근이 가능하다고 여긴다. 이 프로그램에 과연 statck을 대입할 수 있을까? 당연히 치환 불가능이다. 이것은 LSP를 위반한 유명한 사례다.
  • 보다 더 현실적이고 실무적인 예시를 들어, LSP가 위반되는 사례를 어렵지 않게 제시할 수 있다. 아래의 예시는 LSP가 단순히 인터페이스와 클래스 간의 상속 관계를 넘어서서 아키텍처 전반에 적용될 수 있는 폭넓은 원칙임을 설명해준다.
    • 다양한 택시 파견 서비스를 통합하는 시스템을 설계한다고 해보자. 회사마다 독립적인 시스템을 구축하고 있기 때문에 구태여 새롭고 거대한 통합 애플리케이션은 설계하지 않기로 했다. 대신 택시 회사들이 기존에 구축한 시스템을 최대한 이용해야 한다. 이러한 조건에서는 중앙의 시스템이 REST API로 웹 통신을 하는 식으로 관리할 수 있다.
    • 예를 들어, companyA.com/driver/Bob/address/.../time/.../destination/.... 이런 식이다.
    • 이것은 일종의 중앙 시스템이 제시한 인터페이스로, 하위 컴포넌트인 각 택시 업체들이 지켜야 할 URI다.
    • 그런데 한 업체의 프로그래머들이 이러한 URL 사양을 제대로 읽지 않고 destination을 dest 문자열로 처리했다고 해보자. 인터페이스를 지키지 않은 쪽은 이 업체다. 따라서 이 업체가 다시 새로 시스템을 설계해야 하지만, 이 회사가 해당 지역에서 가장 큰 업체(갑)일 뿐만 아니라 대표의 아내가 우리 회사 대표와 긴밀한 사이라면?
    • 결국 통합 시스템이 이 업체를 위한 if문을 따로 만들어 예외 제어 흐름을 설계했다. 그런데 이 택시 업체가 다른 택시 업체를 인수해버렸다. 그런데 인수된 회사의 도멘인은 유지된다고 한다. 통합 시스템의 개발자들은 또 if 문을 사용해 인수된 회사를 위한 예외를 만들어야 할까?
  • 이런 식으로 LSP 원칙이 파괴될 수 있다. LSP는 파괴되면 예외문이 발생한다. 위 예시는 치환이 안 되는 것, 즉 사실상 서로 다른 시스템(모듈)을 억지로 동일하게 다루려다 문제가 생기는 것이다. 심지어 그 예외 처리 코드가 상위 모듈이 결코 의존해서는 안 되는 "하위모듈" 때문에 발생하게 되는 것이다.
  • LSP는 파괴되면 객체지향의 본질이라 할 수 있는 다형성이 파괴되기 때문에 문제가 된다.

 

10. 인터페이스 분리 원칙(Interface Seperation Principle)

  • ISP는 프로그래밍 언어의 사양에 따라 다소 다른 강도로 영향을 받으므로 언어에 따라 다소 주의를 기울여야 한다.
    • C++, C#과 같은 정적 타입 언어에서는 개발자가 import, using, use, include와 같은 선언물을 사용하도록 강제한다. 따라서 user1, user2, ... 등이 각자 하나의 A클래스의 메소드를 각기 다르게 사용한다고 해도, A클래스가 변경되면 이 선언문으로 인해 강제적으로 재컴파일이 요구된다.
    • 그러나 파이썬, 루비, 자바스크립트와 같이 동적 타입 언어에서는 런타임에 타입을 추론한다. 선언문이 존재하지 않기 때문에 별도로 컴파일될 수 있으며, 따라서 위와 같은 컴파일과 재배포의 수고로움이 덜어진다.
    • 사실 자바의 경우는 이런 재컴파일과 재배포가 필요하지 않을 수도 있는데, 이것은 자바의 독특한 컴파일 방식 때문이다. 이렇게 ISP는 언어마다 영향이 다른데 그렇다고 해서 모든 프로그래밍 언어의 특수성을 고려해야 할 필요는 없다.
  • 하지만 그렇다고 해서 과연 자바나 파이썬 등을 사용하면 ISP는 고려할 필요가 없어지는가? 그렇진 않다. 언어에 따라 ISP의 강도가 달라지긴 하지만, 조금 더 생각하면 ISP를 어기면 불필요하게 클라이언트 모듈이 서비스 모듈의 쓸데 없는 메소드까지 사용할 수도 있게 된다. ISP는 언어와는 관련이 없는 원칙이다.
  • 심지어 ISP는 아키텍처 관점에서도 중요하다. ISP가 궁극적으로 말하고자 하는 것은 클라이언트 모듈과 딱히 관련 없는 요소를 변경했다는 이유로 클라이언트까지 변경의 압박을 받아서는 안 된다는 것이다. 관련이 없다면 정말로 해당 요소와는 아무런 관련이 없게끔 소스 코드 차원에서 확실하게 분리를 해야 한다.
  • ISP는 인터페이스를 활용해 구현할 수 있는데, 컴포넌트는 외부에 인터페이스를 사용자에 맞게 여러 개를 만들어 자신이 직접 상속해 구현한다. 외부 컴포넌트들은 해당 인터페이스를 사용한다. 그러면 각 컴포넌트들은 인터페이스 외부에 있는 것은 모르게 된다. 

 

11. 의존성 역전 원칙 (Dependancy Inversion Principle)

  • 객체지향의 핵심 중의 핵심인 의존성 역전 원칙이다.
  • DIP는 소스 코드 의존성이 추상(인터페이스, 추상클래스 등)에만 의존하며 구체(concrete)에는 의존하지 않는 것을 말한다. 즉, 자바를 예로 들면 import를 통해 해당 모듈이 알아야 하는 다른 모듈은 오직 인터페이스나 추상클래스만 와야 한다는 것이다.
    • 그렇다고 System.out.println(), String과 같이 언어적 차원에서 제공해주는 구체 클래스나, 운영체제가 제공해주는 메소드나 클래스마저 부정하는 것은 아니다. (그래서도 안 된다.)
    • 여기서 말하는 구체적인 요소란 개발자가 열심히 개발하고 있는, 그래서 자주 변경되는 코드를 말한다.
  • 그런데 만약 인터페이스가 변경된다면 어떻게 될까? 인터페이스가 변경된다면 그 변경은 어쩔 수 없이 대대적인 변경을 수반하게 된다. 인터페이스는 일종의 최후의 변경 보루다. 실제로 실력 있는 아키텍처와 개발자를 논하는 기술 중 하나는 바로 인터페이스의 변동성을 낮추는 추상화 능력이다.
  • 실천적인 의미에서 보면, 구체 클래스를 참조하지 말 것, 구체 클래스를 상속하지 말 것, 구체 함수를 오버라이드하지 말 것, 구체 클래스 생성에는 특별히 주의를 기울일 것 등을 지키는 것이다.
  • 그렇다고 이 원칙을 절대적으로 지키는 것은 불가능하다. 아무리 추상화된 인터페이스를 기준으로 설계한다고 해도, 결국 어느 실행 지점에서는 구체적인 클래스를 명시해야 하기 때문이다. 단 고수준의 아키텍처에서는 되도록 추상화된 클래스와 인터페이스를 기준으로 설계를 하는 것이 좋다는 뜻이다.