Clean Architecture - 설계원칙 (SOLID)

luneah·2022년 2월 21일
1
post-thumbnail

좋은 소프트웨어는 '유연한' 소프트웨어이다. 그리고 유연한 소프트웨어란 고객의 요구 사항에 따라, 쉽게 그 기능을 변경하거나 추가할 수 있는 제품을 말한다. 따라서 고객의 요구사항이 변화함에 따라, 개발자가 제품의 기능을 쉽게 변화할 수 있는 구조를 설계하도록 돕는 아키텍처가 좋은 아키텍처가 된다.

세상에는 이를 위해 많은 아키텍처가 존재하는데, 그 중 하나가 클린 아키텍처이다.

SRP: 단일 책임 원칙

모든 모듈이 단 하나만의 일만 해야한다는 원칙으로 받아들이기 쉽지만 그렇지 않다. 정확히는 '하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다'라는 원칙이다.

예를 들어 주문 담당자는 고객의 주문을 받고, 계산하며, 요리는 주방 담당자들의 책임이다. 주문 담당자가 고객의 주문을 받고 요리를 하고 주방 담당자가 계산과 요리를 한다면 어떻게 될까? 책임 소재가 불분명해지고 작은 오류에도 시스템 전체가 움직이게 된다.

이는 프로그래밍에도 해당된다. 만약 하나의 클래스에 모든 동작을 집어넣다 보면 시스템은 점차 비대해지고, 한 부분이 고장나면 전체가 무너진다. 특히, 하나의 클래스 내에서 메서드들이 서로 거미줄처럼 연결되어 있다면 더욱 끔찍한 상황이 발생한다.

왼쪽의 경우 worker 클래스가 많은 기능을 담당함으로써 사실상 매장 전체의 책임을 맡고 있다. this.food에 문제가 발생했다. this.food와 관련된 메서드는 cook(), serve() 이다. 만일 어디를 고쳐야할 지 모르겠다면 2개의 메서드를 모두 뜯어봐야 한다. 그러나 오른쪽의 경우 주문, 조리, 매장 관리, 고객 관리 등 각 클래스는 자신의 역할에 관한 것에만 책임을 지고 있다.

깔끔한 설계를 위해서 클래스는 어떠한 경우에도 단일 책임을 져야 한다. 하지만 막상 객체 설계를 시작하면 클래스의 책임 범위를 어디까지 두어야 하는지 고민되는 경우가 많다. 가령, 주문 관리 담당자와 고객 관리 담당자의 책임 범위가 '고객' 이란 접점에서 겹치는 것과 같은 경우이다. 책임 범위가 애매하면 기능도 모호해진다.

책임 범위를 설정할 때는, 실패 상황을 가정해보는 방법이 유용하다. 어떤 메서드에 문제가 생겼을때 큰 피해를 입는 속성들을 생각해보면 도움이 된다.

OCP: 개방-폐쇄 원칙

개방-폐쇄 원칙이란 객체를 다룸에 있어서 객체의 확장은 개방적으로, 객체의 수정은 폐쇄적으로 대하는 원칙이다. 쉽게말해 기능이 변하거나 확장 가능하지만, 해당 기능의 코드는 수정하면 안 된다는 뜻이다.

에어비앤비, 힐튼 호텔 모두 숙박업에 속한다. 하지만 이들의 사업 방식은 극과 극이다. 에어비앤비는 자체적인 숙박 시설을 갖추지 않고, 개인 소유의 주거 공간을 아웃소싱한다. 반면, 힐튼 호텔은 토지를 확보하고, 관광 및 숙박시설 허가를 받아서, 숙박시설을 건립한다.

사업의 성장 속도를 비교해보면, 에어비앤비 쪽이 압도적으로 확장에 유리하다는 사실을 알 수 있다. 에어비앤비는 땅을 구입해서 대규모 시공 및 운영권을 확보할 필요가 없다. 그들은 주거 공간을 가지고 있는 개인과 계약서 만으로도 사업을 확장할 수 있다. 이 때문에 에어비앤비는 엄청난 속도로 전통적인 숙박업의 성장 속도를 따라잡았다.

힐튼 코드

고객 서비스부터 건물 인테리어 및 토지 활용 방안까지 모두 Hilton 클래스의 책임 범위에 있기 때문에 추가되는 기능이 증가할 때마다 메서드가 늘어나고 있습니다. 위의 방식을 사용하면, 기능이 추가될 때마다 Hilton 클래스가 점차 무거워집니다. 만일 Hilton 클래스를 상속받는 클래스가 있다면, 해당 클래스는 시작부터 무거운 짐을 떠안아야 합니다.

에어비앤비 코드

Hilton 클래스에 비해 Airbnb 클래스는 간단합니다. contract() 메서드의 인자값에 Host 클래스만 계속 추가하기 때문입니다. 해당 코드에서 방의 구조 변경과 같은 구체적인 사항은 Host 클래스가 자체적으로 수행합니다. Host 클래스는 권한과 자유 그리고 책임을 가지고 있으며, 상황은 유동적입니다. 자체 service() 내용은 Host 가 알아서 관리하면 되기 때문입니다.

그 결과 Airbnb 클래스는 Host 클래스를 추가하는 것만으로도 확장이 가능합니다.

Airbnb 클래스는 Host 클래스를 받기만 할 뿐 자신이 뭔가 기능을 구사하지는 않습니다. 인자로 받은 하위 클래스의 기능을 오직 이용만 합니다. Airbnb 클래스는 똑같은 명령(service())을 지시할 뿐, 하위 Host 클래스는 각자 다른 기능을 구사합니다.

효율성

에어비앤비만 보더라도 확장에 개방적이며, 수정에 폐쇄적인 방침이 얼마나 강력한 효율성을 발휘하는지 알 수 있다.

LSP: 리스코프 치환 원칙

리스코프 치환 원칙은 부모 객체와 이를 상속한 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다는 원칙이다.

자식 객체는 부모 객체의 특성을 가지며, 이를 토대로 확장할 수 있다. 하지만 이 과정에서 무리하거나 객체의 의의와 어긋나는 확장으로 인해 잘못된 방향으로 상속되는 경우가 생긴다. 리스코프 치환 원칙은 올바른 상속을 위해 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙이다.

Rectangle 클래스는 width, height라는 속성 값을 가지고 있으며, 다음과 같은 기능을 한다.

  1. 너비 값 반환, 높이 값 반환 (get width(), get height())
  2. 너비 값 입력, 높이 값 입력 (set width(), set height())
  3. 넓이 값 계산 및 반환 (get area())

리스코프 원칙은 '복제'라는 사실을 기억해야 한다.

Square 클래스는 Rectangle 클래스의 모든 속성과 기능을 물려받는다. 하지만 Square 클래스는 이름 그대로 정사각형의 넓이 값을 반환해야하는 특수성을 지닌다.


문제를 해결하는 방법은 여러 가지가 있다.

  1. 인터페이스를 상속하는 인터페이스를 통해 해결
  2. 여러 개의 인터페이스를 상속하여 해결

ISP: 인터페이스 분리 원칙

인터페이스 분리 원칙이란 객체는 자신이 호출하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.

구현할 객체에게 무의미한 메서드의 구현을 방지하기 위해 반드시 필요한 메서드만을 상속/구현하도록 권고한다. 만약 상속할 객체의 규모가 너무 크다면, 해당 객체의 메서드를 작은 인터페이스로 나누는 것이 좋다.

규모가 너무 큰 객체를 상속했을 때 발생하는 문제와, 이를 인터페이스로 분리하여 해결하는 방법을 도식한 것이다. 왼쪽과 오른쪽 객체가 가운데 객체를 각각 상속할 경우, 왼쪽 객체는 필요한 메서드가 모두 구현되기 때문에 아무런 문제가 없다. 그러나 오른쪽 객체의 경우, 1번 메서드를 제외한 나머지 메서드가 필요 없다. 하지만 이를 상속했기 때문에, 좋든 싫든 해당 메서드를 가지고 있거나, 최악의 경우 필요없는 메서드를 구현해야 한다.

상속 대상인 객체의 메서드를 각 동작별로 구분해 인터페이스를 만들었다. 각 객체가 필요한 인터페이스만을 상속하여 구현하면 되므로 각자가 필요한 메서드만을 가지게 된다. 이것이 인터페이스 분리 원칙이 지향하는 바이다.

DIP: 의존성 역전 원칙

의존성 역전 원칙이란 객체는 저수준 모듈보다 고수준 모듈에 의존해야 한다는 것이다.

고/저수준 모듈

  • 고수준: 인터페이스와 같은 객체의 형태나 추상적 개념
  • 저수준: 구현된 객체

정의를 의존성 역전 원칙에 적용시켜보면, 객체는 객체보다 인터페이스에 의존해야 한다 라고 이해할 수 있다. 즉, 객체의 상속은 가급적 인터페이스를 통해 이루어져야 한다는 것이다.

profile
하늘이의 개발 일기

0개의 댓글