본문으로 바로가기

클린아키텍처 - 설계원칙

category software engineering 2023. 7. 16. 22:16
728x90

아키텍처를 잘 만들기 위해선 벽돌(코드)부터 잘 만들어야 합니다. 이 때 SOLID 원칙에 따른 함수와 데이터 구조를 서로 결합하는 방법을 적용함으로써 변경에 유연하고, 이해하기 쉽고, 다양한 곳에 사용할 수 있는 수준의 소프트웨어 구조를 만들 수 있습니다.

SOLID에 대해 간단한 정의만 복기해보자면,

  • SRP(Single Responsibility Principle) - 소프트웨어 모듈의 변경 이유는 단 하나여야 한다.
  • OCP(Open-Closed Principle) - 코드를 수정하기보다 새로운 코드를 추가하는 방식으로 설계해야 시스템을 쉽게 변경할 수 있다.
  • LSP(Liskov Substitution Principle) - 대체 가능한 구성요소를 이용해 시스템을 만들 수 있으려면 구성요소는 반드시 치환이 가능해야한다, 부모 클래스에서 가능한 행위는 자식 클래스에서 수행이 보장되어야 한다.
  • ISP(Interface Segregation Principle) - 사용하지 않은 것에 의존하지 않아야 한다.
  • DIP(Dependency Inversion Principle) - 고수준 정책을 수현하는 코드는 저수준 세부사항에 의존해서는 안된다. 대신 세부사항이 정책에 의존해야 한다.

하지만 아키텍처 관점에서 무슨 의미인지를 알아보겠습니다.

 

SRP: 단일 책임 원칙

정의

과거부터 기술되어온 위에 내용을 풀어서 책에서는 ‘하나의 모듈(소스파일)은 오직 하나의 액터에 대해서만 책임져야 한다.’ 라고 정의한다.

문제

만약 하나의 모듈이 여러명의 액터를 책임진다면, 개발자는 코드를 작성하다 공통으로 사용하는 알고리즘을 찾고 하나의 메서드로 추출 할 수 있죠. 이 때 한 액터가 수정을 요청하여 변경이 발생하는데, 알지 못하고 공통된 알고리즘을 변경한 경우에 테스트를 하고 배포를 했음에도 다른 액터에서는 원치 않은 변경사항이 발생하여 신뢰할 수 없는 데이터를 갖게 됩니다. 그래서 서로 다른 액터가 의존하는 코드를 분리해야한다고 말합니다.

또 메서드가 서로 다른 액터를 책임질 수도 있습니다. 서로 다른 두 팀의 개발자가 있고 한 팀에서는 모듈의 스키마를 변경하고 또 다른팀에 속한 개발자는 모듈에 속한 메서드를 변경을 한 경우에 혹시 모를 병합에 대한 위험도 뒤따릅니다.

해결

아키텍처 관점에서 이를 해결하는 방법은 데이터와 메서드를 분리하여 서로의 존재를 모르는 방법입니다. 하지만 이런 경우엔 모든 클래스를 인스턴스화 해야 하기 때문에 ‘퍼사드 패턴(*Facade)’을 사용하여 퍼사드 클래스에서 객체를 생성하고 요청된 메서드를 갖는 객체로 위임하는 일을 책임질 수 있습니다. 또 다른 방법으로는 가장 중요한 부분을 데이터와 가장 가깝게 배치하고 나머지 덜 중요한 부분을 퍼사드로 이용할 수도 있습니다.

 

OCP: 개방 폐쇄 원칙

문제

예시가 꽤나 마음에 들었다. 재무제표를 웹페이지로 보여주는 시스템이 있고 음수는 빨간색으로 표시되는 시스템이 있는데 흑백 프린터로 프린트할 때 알아볼 수 없다. 만약 페이지 머리글 바닥글을 표시하고 음수를 괄호로 감싸달라는 요구사항이 왔고, 소프트웨어 아키텍처가 훌륭하다면 기존 코드의 변경사항은 전혀 없이 새로운 코드만 추가되어야 한다.

해결

SRP 원칙에 따라 적절히 요소를 분리하고, DIP을 통해 변경량을 최소화 한다면 가능하다. 예시에서는 보고용 데이터를 계산하는 책임과 보여주거나 출력하는 책임이 나눠질 수 있는데 이를 확실히 나누고 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스코드 의존성을 조직화해야 한다.

처리 과정을 클래스 단위로, 클래스를 컴포넌트 단위로 ****분리하고 Controller , Interactor, Database, Presenter, View 컴포넌트 기준으로 설계를 예를 들어보면, 모든 의존성은 변경을 보호하려는 컴포넌트를 향하도록 단방향으로 이루어져야 한다. 이를 위해 일반적으로 비지니스 로직을 포함한 Interactor는 다른 컴포넌트로 부터 영향을 받지 않도록 OCP를 가장 잘 준수할 수 있는 곳에 위치해야 한다. 그 뒤로는 일반적으로 Controller > Presenter > View 순으로 중요도를 갖기에 중요한 컴포넌트를 향하도록 설계하면 조직화하여 저수준 컴포넌트에서 발생한 변경으로 부터 고수준 컴포넌트를 보호할 수 있다

만약 Interactor 에서 직접적으로 Database 와 같은 컴포넌트로 향한다면 Gateway와 같은 인터페이스를 중간에 두어 의존성을 역전시켜 설계해야한다. 또 Controller에서는 비지니스 로직을 갖고 있는 Interactor의 정보를 은닉하기 위해 __Request 인터페이스를 만들어 직접 사용하지 않는 요소에 의존하지 않는 ISP 원칙 또한 보장받을 수 있습니다.

궁금증

다들 이런 의존성을 계획할 때 컴포넌트를 추가하기 위해 실제로 특정 툴을 사용해서 추이 종속성이라고 하는 의존성 비순환 원칙에 맞게 설계되는걸 확인하는지 궁금합니다. 아니면 사용하다보니 import cyclic 에러가 발생하면 인터페이스를 추가하시나요? 누구나 한눈에 볼 수 있게 설계도를 작성하시는걸 선호하시는분이 있는 지 궁금하네요.

 

LSP: 리스코프 치환 원칙

좋은 예시

그래프를 보면 Billing 은 License 클래스의 calcFee() 라는 함수를 호출하면 서로 다른 알고리즘을 사용하는 Personal License와 Business License가 상속을 통해 License의 calcFee()를 치환할 수 있다.

택시 사례도 마음에 들었는데 나도 모르게 이런 행위를 하고 있지 않을까 반성해봤다. 만약 택시 파견 REST API에 다음과 같은 코드

const baseURI = purplecab.com/driver/Bob
# PUT
	/pickupAddress/24 Maple St.
	/pickupTime/153
	/destination/ORD

를 작성했는데 다양한 택시 업체에서 다음과 같은 룰을 지켜줘야 하는데 거대 업체가 들어와서 destionation/ 을 dest/ 로 처리해달라고 요청했고 이런 예외 사항을 위해 조건문으로 처리해야할까? 좋은 아키텍처라면 이 버그 같은 코드로부터 시스템을 격리해야 한다.

해결

이런 경우엔 파견 URI를 키로 사용하는 설정용 데이터베이스를 이용하는 모듈을 만들어 해결할 수 있다.

URI Dispatch Format

Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
. /pickupAddress/%s/pickupTime/%s/destination/%s

만약 치환가능성을 위배한다면 시스템 아키텍처가 전체적으로 오염 되므로 이를 주의해야 한다.

 

ISP: 인터페이스 분리 원칙

예시

만약 각 유저가 하나의 오퍼레이션을 갖는데 다음과 같은 경우엔 User1은 op2, op3를 전혀 사용하지 않음에도 소스코드에 의존성이 생겨 op2의 소스코드가 변경 되면 User1도 다시 컴파일 후 배포해야 한다. 이런 경우에 중간에 U1Ops<I>, U2Ops<I>, U3OPs<I>를 만들고 각각 인터페이스에서 필요한 메서드를 정하고 OPS가 각 인터페이스를 바라보게 구성해서 해결할 수 있지만 정적 언어는 import, use, include와 같은 타입 선언문이 요구되고 이로 인해 재컴파일과 재배포 또한 강제된다. 따라서 이는 아키텍처 보다는 언어에 관련이 깊은 원칙이다.

하지만 아키텍처 관점에서 바라본다면,

만약 시스템 A와 B에서 C 데이터베이스 시스템을 반드시 이용해야 하는 경우 A와 B에서 원치 않는 기능을 C에서 구현하고 배포 하더라도 혹은 변경이 발생한다면 A와 B에 영향이 갈 수 밖에 없다는 점을 알아두자.

 

DIP: 의존성 분리 원칙

정의

유연성을 극대화하기 위해 소스코드 의존성이 추상에 의존하고 구체화에 의존하지 않는 시스템을 만드는 원칙이다. 정적 타입 언어에서는 import, use, include 와 같은 구문은 오직 인터페이스나 추상 클래스와 같은 선언 만을 참조해야 한다는 말이다.

하지만 Java의 Strings 클래스, 운영체제나 플랫폼과 같이 안정성이 보장되는 경우는 자주 변경되지 않기에 이를 무시하고 변동성이 큰 구체적인 요소인 우리가 열심히 개발 중인 모듈을 이야기 하는것이다.

DIP 원칙을 풀어서 말하면,

  • 변동성이 큰 구체 클래스를 참조하지말고, 대신 추상 인터페이스를 참조하자.
  • 변동성이 큰 구체 클래스로부터 파생하지 말자. 의존성을 갖게 된다.
  • 구체 함수를 오버라이드 하지 말자. 의존성이 생기므로 추상 함수를 선언하고 각자 용도에 맞게 구현체에서 구현하는게 맞다.
  • 구체적이고 변동성이 크다면 그 이름을 언급하지 말라. 객체로 사용하지 말라.

 

의존성 역전

변동성이 큰 구체적인 객체는 항상 주의해서 생성해야 한다. 대부분 객체 지향 언어에서는 바람직한 의존성을 처리할 때 추상 팩토리를 사용하여 의존성을 피한다. 모든 소스코드 의존성을 추상적인 쪽으로 향하게 하여 아키텍처를 분리하여 비지니스 로직을 분리하는데 이 때 소스코드 의존성을 제어흐름과 반대방향으로 역전되는 의존성 역전이 일어난다.

하지만 모든 DIP을 없앨 수는 없다. 다만 위배되는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고 시스템의 나머지 부분과 분리하는게 핵심이다. 일반적으로 main 함수를 포함한 메인 컴포넌트에서 구체 컴포넌트를 갖고 인터페이스를 통해 구체화에 의존하지 않는 시스템을 지키는 의존성 규칙을 지켜야한다.