클린 아키텍처: 소프트웨어 구조와 설계의 원칙) 챕터 3: 설계 원칙

성승모·2024년 9월 10일
0

Clean Architecture

목록 보기
3/4
post-custom-banner

개요

 좋은 벽돌 없이는 좋은 아키텍처의 빌딩을 만들 수 없다. 하지만 좋은 벽돌이 있어도 빌딩의 아키텍처가 엉망일 수 있다. 그래서 좋은 벽돌로 좋은 아키텍처를 정의하는 원칙이 필요한데 그것이 바로 SOLID다.

 SOLID 원칙의 목적은 중간 수준의 소프트웨어를 아래와 같은 구조로 만드는 것이다

  • 변경에 유연하다.
  • 이해하기 쉽다.
  • 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 된다.

여기서 중간수준이란 모듈 수준에서의 작업을 의미한다

SRP: 단일 책임 원칙

개요

 의미가 잘 전달되지 못한 원칙이다. 그 이유는 부적잘한 이름때문인데 이름을 듣는다면 모든 모듈이 하나의 일만 해야하는 것처럼 느껴지기 때문이다. (실제로 나도 가장 이해가 안됐던 원칙이었다. 한 모듈은 한가지의 일만 해야된다니??) 헷갈리지 말아야 한다. 하나의 일만 해야한다는 원칙은 함수에게 적용되며 이는 더 저수준에서 사용된다. 하지만 이는 SOLID도 아니고 SRP도 아니다.

 역사적으로 다음과 같이 기술되었다.

단일 모듈은 변경의 이유가 하나, 오직 하나 뿐이어야 한다.

시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다. 따라서 변경의 이유는 바로 사용자와 이해관계자의 요구이다. 그렇다면 다음과 같이 바꿔 말할 수 있겠다.

하나의 모듈은 한 집단 또는 이해관계자들에 대해서만 책임져야 한다.

여기서 시스템의 변경을 요구하는 집단 또는 이해관계자들을 액터(actor)라고 부르겠다. 따라서 SRP의 최정 버전은 아래와 같다.

하나의 모듈은 하나의 액터에 대해서만 책임져야 한다.

SRP를 위반하는 징후들

징후1) 우발적 중복

 급여 애플리케이션의 Employee를 생각해보자. 세 가지 메소드calculatePay(), reportHours(), save()를 가진다. 이 클래스는 SRP를 위반하는데 각각의 메소드가 서로 다른 액터를 책임지기 때문이다.

  • calculatePay(): 회계팀이 기능을 정의하고 CFO 보고를 위해 사용한다.
  • reportHour(): 인사팀에서 정의하고 COO 보고를 위해 사용한다.
  • save(): DB 관리자가 기능을 정의하고 CTO 보고를 위해 사용한다.

만약 위 메소드들 중 calculatePay와 reportHour가 업무 시간을 계산하는 또 다른 메소드를 공유한다고 해보자. 그 메소드를 회계팀이 수정을 한다면 인사팀에서 이를 알 수 있는 방법이 있을까?? 없을 것이다. 발견하는 때는 엉터리가 된 보고서를 받은 뒤가 될 것이며 이때문에 수백만 달러의 손실이 발생할지도 모른다. 따라서 서로 다른 액터가 의존하는 코드를 분리하라고 말한다.

징후2) 병합

 당연한 말이지만 같은 소스 코드를 한번에 변경하려 하면 병합 충돌이 발생한다. 이는 모든 액터에게 리스크가 된다.

해결책

 각 함수를 포함하는 클래스를 만들어 Employee 클래스를 공유하도록 만든다. 서로의 존재를 몰라야 한다. 따라서 우연한 중복을 피할 수 있을 것이다.

 하지만 이러한 패턴은 세가지 클래스를 인스턴스화하고 추적해야 하는 단점이 있다. 이를 퍼사드 패턴을 통해 해결하면 될것이다.

OCP: 개방-폐쇄 원칙

개요

 이 원칙은 간단하다. "소프트웨어 개체는 확장엔 열려있고 변경에는 닫혀 있어야 한다." 아키텍처를 공부하는 근본적인 이유이다. 확장하는데 엄청난 수정 비용을 요구한다면 이는 실패한 아키텍트다.

사고실험

 재무제표를 웹페이지로 보여주는 시스템이 있다고 하자. 스크롤이 가능하고 음수는 빨간색으로 출력한다. 이제 한 액터가 보고서 형태롤 변환하기 위해 흑색 프린터로 출력해 달라고 요청했다고 하자. 이 보고서는 이제는 페이지 번호, 머리글과 바닥글, 음수는 괄호로 등 새로운 형태를 가져야 한다.

 개발자는 당연히 새로운 코드를 작성해야 한다. 그렇다면 원래 코드는 얼마나 수정해야 할까? 좋은 아키텍처라면 수정되는 양은 최소화될 것이며 이상적인 값은 0이다. 어떻게 하면 될까? 서로 다른 목적으로 분리하고(SRP) 이들 간 의존성을 체계화시킴으로써(DIP) 최소화할 수 있다.

 두 가지 책임으로 분리할 수 있다. 하나는 보고서용 데이터를 계산하는 것, 둘째는 프린팅하기에 적합하도록 표현하는 책임이다. 이 둘의 책임은 서로 독립적이어야 하며 확장될때 변경이 발생하지 않음을 보장해야 한다.

방향성 제어

 각 요소(View, Presenter, Controller, Interceptor, DataBase)들이 서로 의존해야 할땐 interface를 이용하여 의존성을 역전시켜야 한다. (왜 의존성을 역전시켜야 하는가는 DIP 부분에서 자세히 설명이 나온다!)

정보 은닉

 또한 각 요소들이 서로의 내부를 모습을 많이 알지 못하도록 하기 위해서이다.

결론

 클린 아키텍처를 떠받치는 중요한 요소 중 하나이다. OCP의 목적은 시스템을 확장하기 쉽게 하고 변경이 시스템에 많은 영향을 미치는 것을 방지한다. 이러한 목표를 위해선,

  1. 시스템을 컴포넌트 단위로 분리
  2. 저수준 컴포넌트의 변경으로부터 고수준 컴포넌트를 보호하는 계층 구조

가 필요하다.

LSP: 리스코프 치환 원칙

개요

바바라 리스코프는 하위 타입을 다음과 같이 정의했다. "S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면 S는 T의 하위타입이다." 위 원칙을 이해하기 위해 예제를 살펴보자.

상속을 사용하도록 가이드하기

위 설계는 LSP를 준수하는데, Billing 어플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 대해 전혀 의존하지 않기 때문이다.

정사각형/직사각형 문제

여기서 Squre는 Rectangle의 하위타입으로 적합하지 않다.
 1. Rectangle의 H,W는 독립적으로 변경가능하지만 Squre는 그렇지 않다.
 2. 1번을 방지하기 위해 Rectangle이 Square인지 user 단에서 검사하여 해결할 수 있겠지만 이는 User의 행위가 사용하는 타입에 의존하게 되는것이다.

즉, Rectangle과 Squre는 치환가능하지 않다.

LSP와 아키텍처 및 결론

 객체 지향이 등장한 초창기에는 상속을 사용하도록 가이득하는 방법 정도였다. 하지만 시간이 지날수록 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변해왔다.

 따라서, LSP는 아키텍처 수준으로 확장할 수 있고 반드시 확장해야한다. 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야할 수도 있다.

ISP: 인터페이스 분리 원칙

개요

 위 상황에서 User1은 op1, User2는 op2, User3은 op3를만을 이용하고 Ops가 정적 타입 언어로 작성된 클래스라고 해보자.

 User1은 op2,op3을 쓰지 않음에도 이를 impot해야한다. 따라서, op2와 op3가 변경되면 User1도 다시 컴파일하여 배포해야하며 이는 낭비이다.

 따라서, 오퍼레이션을 인터페이스 단위로 분리하여 이런 문제를 해결할 수 있다.

ISP와 언어, 아키텍처

 ISP는 언어 타입에 의존한다.

 아키텍처에서 ISP를 사용하는 근본적인 동기를 살펴보면, 잠재적인 우려사항을 볼 수 있다. 일반적으로 필요 이상으로 많은걸 포함하는 모듈에 의존하는 것은 해로운 일이다. 그 예로 시스템S에서 프레임워크F를 도입하려 한다고 생각해보자. 그런데 F는 데이터베이스D에 의존하고 D에 F에겐 불필요한 기능이 포함되어있다고 할때, 그 기능이 D에서 수정이 되었을때 F를 재배포해야 할 수도 있다.

결론

 여기에서 배울 교훈은 불필요한 짐을 실은 무언가에 의존하면 예상치도 못한 문제에 빠질 수 있다는 사실이다.

DIP: 의존성 역전 법칙

개요

 DIP가 말하는 "유연성이 극대화된 시스템"이란 소스 코드가 추상에 의존하며 구체에는 의존하지 않는 시스템이다

 이 아이디어는 언뜻 보기엔 비현실적이다. 소프트웨어는 반드시 구체적인 것들에 의존한다. 예를 들어 자바의 String은 구체 클래스이며 이를 추상 클래스로 만드려는 시도는 현실성이 없다. 반면, 이 클래스는 매우 안정적이다. 개발자는 변경이 자주 발생할 것이라고 염려할 필요가 없다.

 이러한 이유로 DIP를 논할땐 OS나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다. 우리가 피하고자하는 것은 "변동성이 큰 구체적인 요소"이다. 그리고 이 요소는 대부분 우리가 열심히 개발 중인 모듈들일 것이다.

안정된 추상화

 물론 추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 수정해야 한다. 하지만 반대의 대다수의 경우 인터페이스가 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.

 실제로 띄어난 아키텍트는 변동성을 낮추기 위해 노력한다. 구현체에 의존하는 일은 지양하고 추상 인터페이스를 선호해야한다. 이를 위해선 다음 실천법들을 지키면 좋다.

  1. 변동성이 큰 구체 클래스를 참조하지 말라.
  2. 변동성이 큰 구체 클래스로부터 파생하지 말라.
  3. 구체 함수를 오버라이드 하지 말라.
  4. 구체적이고 변동성이 크다면 절대로 그 이름을 언급하지 말라.

팩토리

 위 규칙들을 준수하기 위해선 객체 생성을 주의해서 해야한다. 자바 등 대다수의 객체 지향 언어에선 이를 추상 팩토리를 이용해 의존성을 처리한다.

 Application은 Service 인터페이스를 통해 ConcreteImpl를 사용한다. 이는 구체적인 객체를 사용하는 것이므로 팩토리 패턴을 이용해 이를 ServiceFactoryImpl로 생성하여 Service 타입으로 반환해준다.

 곡선은 아키텍처의 경계를 뜻한다. 구체적인 것들로부터 추상적인 것을 분리한다. 소스 코드 의존성은 해당 곡선과 교차할때 모두 한방향(추상적인 쪽)으로 향한다. 반대로, 제어하는 것은 그 반대 방향으로 가로지른다는 것에 주목하자. 소스 코드 의존성과 제어 흐름은 서로 반대이며 이러한 이유로 이 원칙을 의존성 역전 법칙이라고 부른다.

결론

 물론 구체 컴포넌트에는 구체적인 의존성이 하나 있다. ServiceFactoryImpl가 ConcreteImpl에 의존한다. 이는 당연하다. DIP를 모두 없애는건 불가능하다. 하지만 DIP를 위배하는 클래스들을 구체 컴포넌트 내부로 모으고 이를 통해 다른 시스템들과 분리시킬 수 있다.

profile
안녕하세요!
post-custom-banner

0개의 댓글