상세 컨텐츠

본문 제목

⌈책 리뷰⌋ 로버트 C. 마틴 ⎹ 클린 아키텍처 ⎹ ⭐️

책/책 리뷰

by rakyun 2025. 9. 15. 10:46

본문

 

아키텍처 설계가 후순위가 된다면 시스템을 개발하는 비용이 더 많이 들고, 일부 또는 전체 시스템에 변경을 가하는 일이 현실적으로 불가능해진다. 이렇나 상호아이 발생하도록 용납했다면, 이는 결국 소프트웨어 개발팀이 스스로 옳다고 믿는 가치를 위해 충분히 투쟁하지 않았다는 뜻이다.

 


소프트웨어 개발의 패러다임

소프트(soft) 웨어(ware) 부드러운 제품이라는 뜻으로 동작하는 방향을 쉽게 변경할 수 있어야 한다.
그렇지 못한다면 하드(hard) 웨어(ware) 라고 불려야 할 것이다.

 

구조적 프로그래밍

무분별한 점프(goto 문장)는 프로그램 구조에 해롭다는 사실을 직시하고 구조적으로 프로그래밍을 해야 한다고 생겨난 패러다임, if/then/else, do/while/until 등이 생겨났다.

 

구조적 프로그래밍에서는 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다. 그러고 나서 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려고 시도한다. 이처럼 거짓임을 증명하려는 테스트가 실패한다면 이 기능들은 목표에 부합할 만큼은 충분히 참이라고  여길 수 있는 것이다.


그러나 아무곳으로나 이동이 가능한 무분별한 점프는 재귀적 함수가 불가능하게 만든다. 또한 프로그램의 테스트를 불가능하게 만든다. 프로그램이 잘못됐는지 증명하기 위해 테스트를 진행하는데, 그런 테스트를 할 수 조차 없게 만드는 goto문은 현재 프로그래밍에서는 잘못된 방향인 것이다.

 

소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록(테스트 하기 쉽도록) 만들기 위해 분주히 노력해야 한다.


객체 지향 프로그래밍

절차적 프로그래밍(C)에서 관행적으로 사용되던 캡슐화나 다형성 같은 개념들을, 언어 차원에서 class라는 문법으로 체계화하여 프로그래머가 더 안전하고 일관되게 사용하도록 유도(또는 강제)하는 패러다임

 

기존 언어(C 언어)도 캡슐화나 다형성을 구현할 수 있었다. 그러나 언어가 제공하는 기능이 아니기에 한계가 명확했다.  그렇기에 사용하지 않는 프로그래머도 많았고 사용하지 않으면 프로그래밍에 많은 문제가 생기게 되었다(캡슐화 하지 않아 변경되면 안 되는 변수가 변경된다거나, 다형성을 구현하지 않아 비슷한 코드가 수도 없이 많아지는 등). 그래서 객체 지향 프로그래밍에서는 캡슐화, 다형성을 더 쉽게 구현할 수 있도록 해주고 이를 강제할수도 있게 해준다.

절차지향적 프로그램들은 위와 같은 모습이었다. 갖아 상위 함수가 여러 다른 함수들을 호출하는 전형적인 트리구조이다. 이러한 제약 조건은 소프트웨어 아키텍트가 선택할 수 있는 선택지를 줄게 만든다.


그러나 객체지향에서는 인터페이스를 통해 호출하는 함수와 호출 당하는 함수가 모두 하나의 인터페이스를 의존하게 만든다. 또한 소스 코드가 인터페이스를 구현하면서 인터페이스를 의존하게 되는데 이를 의존성 역전이라고 부른다. 
이렇게 기존에 정해진대로 흘러가는 의존성이 아닌 의존성을 반대로 역전시키면서 개발자는 선택할 수 있는 아키텍트의 폭이 넓어지게 되는 것이다. 이렇게 다형성을 이용하여 소스 코드 의존성에 대한 절대적인 제어 권한을 갖는 것이 객체지향이다.


함수형 프로그래밍

변수의 값이 계속 변하는 것을 방지하기 위해 재할당을 없애자는 패러다임, 불변성으로 심볼의 값이 변경되지 않게 만든다는 개념이다. 함수형 프로그래밍에서 변수는 한 번 할당이 되면 그 값이 절대 변하지 않는다.

변수의 값이 변함으로 인해 경합, 조건, 교착상태, 동시 업데이트 등의 많은 문제가 생긴다. 만약 어떤 변수도 갱신되지 않는다면 이런 문제가 일어나지 않는다. 그러나 완전한 불변성은 실현 가능하지 않다. 우리의 자원은 한정되어 있기 때문이다. 그렇기에 다음과 같은 개념들이 나온다.

 

가변성 분리

 

  • 에플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리한다. 불변 컴포넌트는 순수하게 함수형 방식으로만 작업이 처리되고, 어떤 가변 변수도 허용하지 않는다.
  • 현명한 아키텍트라면 가능한 한 많은 처리를 불변 컴포넌트로 옮겨야 하고, 가변 컴포넌트에서는 가능한 한 많은 코드를 빼내야 한다.
위 세 가지 프로그래밍 패러다임은 모두 개발자에게 새로운 권한을 부여하기는 커녕 권한을 박탈하는데 목적을 둔다. 즉 패러다임은 무엇을 해야 할지를 말하기 보다는 무엇을 해서는 안 되는지를 말해준다.

SOLID

SRP(Single Responsibility Principle)

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

 

위 클래스는 SRP를 위반한다. 왜냐하면 CFO 보고를 위해 사용되는 calculatePay(), COO 보고를 위해 사용되는 reportHours(), CTO 보고를 위해 사용되는 save()3개의 서로 다른 엑터가 하나의 객체로 통합되었기 떄문이다.

만약 caculatePay(), reportHours() 함수의 코드가 겹치는 부분이 있어 개발자가 regularHours()라는 함수로 분리해서 두 개의 함수가 하나의 함수를 의존하는 상황일때 CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 수정하기 위해 regularHours() 함수를 수정하게 되면 그 영향이 COO 팀에서 사용하는 reportHours() 함수에도 가게 되고 이는 큰 에러를 일으키게 된다.

그뿐만 아니라 이렇게 서로 다른 엑터가 하나로 묶여있다면 코드를 수정하고 병합하는 과정에서도 에러가 생기게 된다. CFO 팀에서 코드를 수정하고 CTO 팀에서 코드를 수정했을때 두 개의 코드가 충돌은 나겠지만 결국 병합 될 것이고 그 병합으로 인해 이상한 코드가 탄생하게 될 것이다.

 

해결책

  • 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식이다. 

아무런 메서드 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어서 세 개의 클래스가 공유하도록 한다. 각 클래스는 서로의 존재를 몰라야 한다.
그러나 이런 경우 개발자가 세 가지 클래스를 인스턴스화 하고 추적해야 한다는 단점이 있다.

 

그런 경우 Facade 패턴을 적용하여 문제를 해결한다.

public class EmployeeFacade {
    private final PayCalculator payCalculator = new PayCalculator();
    private final HourReporter hourReporter = new HourReporter();
    private final EmployeeSaver employeeSaver = new EmployeeSaver();
    private final EmployeeData employeeData; // 예시 데이터

    public EmployeeFacade(EmployeeData employeeData) {
        this.employeeData = employeeData;
    }

    // 클라이언트에게 제공되는 단순화된 메서드
    public void calculatePay() {
        payCalculator.calculatePay(this.employeeData);
    }

    public void reportHours() {
        hourReporter.reportHours(this.employeeData);
    }

    public void save() {
        employeeSaver.saveEmployee(this.employeeData);
    }
}

public class Client {
    public static void main(String[] args) {
        EmployeeData data = new EmployeeData();
        EmployeeFacade facade = new EmployeeFacade(data);

        // 클라이언트는 파사드의 단순한 메서드만 호출하면 된다.
        facade.calculatePay();
        facade.save();
    }
}
  • 위 코드를 보면 파사드 클라스가 세부 클라스들을 가지고 있고 각 세부 클라스들의 함수 호출을 대신해준다.

즉 모듈을 변경할때 변경의 이유가 하나여야 한다는건 변경하는 주체도 하나여야 한다는 것이다.


OCP (Open Closed Principle)

소프트위에 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

 

 

  • 열린 화살표는 사용 관계이고, 닫힌 화살표는 구현 또는 상속 관계이다.
  • <DS>는 Data 구조체이며 <I>는 인터페이스이다. 
  • 그림에서 보듯이 인터페이스를 의존하게 되면 각 컴포넌트는 세부 구현에 의존하지 않아도 된다. 세부 구현은 인터페이스의 규격만 맞추어 준다면 언제든지 코드의 변경이 일어나더라도 인터페이스를 의존하는 컴포넌트에는 영향이 가지 않는다.

  • 각 컴포넌트들을 따로 빼서 보면 컴포넌트의 관계는 단방향으로만 이루어져 있다. A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하고 싶다면 A 컴포넌트가 B 컴포넌트에 의존해야 한다.

    여기 그림에서는 FinancialReportInteractor 컴포넌트가 가장 변경으로 부터 닫혀있다.
보호의 계층 구조가 수준이라는 개념을 바탕으로 어떻게 생성되는지 바라보면 Interactor는 가장 높은 수준의 개념이며 따라서 최고의 보호를 받고 View는 가장 낮은 수준의 개념으로 거의 보호를 받지 못한다. 

아키텍처 수준에서 OCP가 동작하는 방식은 기능을 어떻게, 왜, 언제, 발생하는지에 따라서 분리하고 분리한 기능을 컴포넌트의 계층구조로 조직화한다. 그리고 저수준 컴포넌트에서 발생한 변경이 고수준 컴포넌트에 영향이 없어야 한다.

LSP (Liskov Substitution Principle)

  • Personal License, Business License 두 개의 클래스는 License 클래스를 상속 받았다. 그런 경우 Billing 애플리케이션은 License의 하위 두 타입 중 무엇을 사용하는지에 의존하지 않는다. 위와 같은 설계는 LSP를 준수했다고 할 수 있다.

ISP (Interface Segregation Principle)

  • 위 OPS라는 클래스를 User1, User2, User3가 사용할때 User1은 오직 op1을 User2는 op2, User3는 op3만을 사용한다고 가정해보자. 그리고 OPS가 정적 타입 언어(컴파일 언어)라고 해보자.

    그럼 op2의 변경이 일어나면 아무 상관없는 User1까지 재 컴파일을 한 후에 새로 배포해야 한다. User1은 op2, op3를 사용하지 않지만 op2, op3에 의존하게끔 설계가 되어 있다.

 

  • 아까의 예시를 위와 같이 각 User에 맞는 Interface를 만들고 그 모두를 OPS에서 구현해준다면 각 User 클래스는 다른 함수에 의존하지 않게 독립적으로 분리 된다.
System S ---> Framework F ---> Datbase D
시스템 S가 F라는 프레임워크를 사용하고 F라는 프레임 워크는 D라는 데이터베이스를 사용한다고 가정하면 시스템 S와 아무런 상관도 없는 기능이 D에서 변경될 경우 F를 재배포해야 하고 그로 인해 S까지 재배포해야 한다. 이러한 상황을 위해 인터페이스로 분리하는 것이 좋다.

 

DIP (Dependency Inversion Principle)

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

위 규칙들은 모두 구체 클래스, 구체 함수 등 구체화 된 것에 의존하지 말고 항상 추상화 된 것에 의존하라는 말이다. 그러나 여기서 말하는 구체적인 것은 그냥 구체적이만 한 것이 아니라 구체적이면서 변동성도 큰 클래스를 말한다. 

 

 

  • 위 그림은 앞서 말한 네 가지 규칙들을 지키기 위해 변동성이 큰 구체를 생성하는 패턴인 팩토리 패턴이다.

    위 Application은 어떻게든 ConcreteImpl을 사용해야 한다. 이때 구조를 위와 같이 짜서 Application이 ServiceFactory라는 인터페이스에 의존하고 그 ServiceFactory는 Service라는 인터페이스를 가지고 있게 만들면서, ConcreteImpl은 Service 인터페이스를 구현하고 ServiceFactorImpl 또한 ServiceFactory를 구현하게 되면 Application은 변동성이 큰 구체적인 클래스들에는 의존하지 않고 변동성이 작은 Interface에만 의존하게 된다.

  • 이제 위 그림을 다시 자세히 보면 application과 구현체 모두 interface를 의존하면서 의존성이 역전 된 모습을 볼 수 있다.

 

 

 

 

 

 

관련글 더보기