Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SOLID - Single Responsability Principle #36

Open
simoniful opened this issue Sep 24, 2022 · 0 comments
Open

SOLID - Single Responsability Principle #36

simoniful opened this issue Sep 24, 2022 · 0 comments

Comments

@simoniful
Copy link
Owner

simoniful commented Sep 24, 2022

SOLID라 불리는 아키텍쳐 원칙 중 첫 번째 글자에 해당하는 원칙
용어는 Robert C. Martin이 2003년 그의 유명한 저서 Agile Software Development, Principles, Patterns, and Practices에서
객체 지향 설계 원칙의 일부로 'The Principles of OOD'라는 기사에서 소개

하나의 클래스 / 모듈은 변경을 위한 이유(책임)가 하나뿐이어야 한다

모듈 내부의 요소가 함께 속하는 정도를 기준인 응집도를 기반으로 구성
클래스의 메서드와 데이터 모델, 클래스가 제공하는 어떤 통일된 목적이나 개념 사이의 관계의 추상화 강도
높은 응집도는 견고성, 신뢰성, 재사용성 및 이해성을 포함한 소프트웨어의 몇 가지 바람직한 특성과 연관되어 있기 때문에 높은 응집도를 가진 모듈이 선호된다

결합도와 응집도

결합도(Coupling)

결합도는 서로 다른 모듈간의 상호 의존하는 정도 또는 연관된 관게를 의미
모듈관의 관련성을 나타내는 척도
그래서 결합도가 높게 되면 모듈간의 의존하는 정도가 크기 때문에
코드를 수정할 때 우리도 모르게 다른 모듈에 영향(Side Effect)을 끼칠 수 있음
또한, 오류가 발생 했을 때도 다른 모듈에도 영향을 끼칠 수 있고,
다른 모듈의 영향을 받아 오류가 발생 가능

결합도는 낮은 것이 좋다

응집도(Cohesion)

응집도는 모듈 내부의 요소들 간의 기능적 연관성을 나타내는 척도
모듈이 얼마나 독립적으로 되어있는 정도를 나타냄
응집도가 높다면 수정이나 오류가 발생했을 때 하나의 모듈 안에서 처리 가능
유지보수가 용이

응집도는 높은 것이 좋다

단일(Single)

하나의 모듈
'단일'의 범위 - 하나의 클래스 ~ 응집을 이룬 컴포넌트 집합 / 앱 전체
ex. View의 생명주기를 관장하는 ViewManager ~ 하나의 ViewController 전체
실제로 범위 선정에 있어서 상대적으로 고려할 수 밖에 없음
하지만, 되도록 작은 범위로 구성하여 변경을 세분화하고, 해당 모듈을 필요에 따라 응집할 수 있도록 구성하는 게 가장 이상적
'책임'과 함께 고려하여 적당한 수준을 선택하여 결합을 분리하도록
특정 기능의 변경을 위한 수정이 한 곳에 집중

책임(Responsability)

거시적인 변경의 관점

SRP는 변경의 관점에서 분리하여 이해 가능

책임(Responsability) == 모듈을 변경하는 이유

  • ex. 특정 기능을 변경하기 위해 여러 클래스에 대한 수정이 필요한 경우 → 잘못된 설계
    • : 하나의 기능을 위한 코드가 응집력이 없이 여러 클래스로 흩어져 있는 경우, 동일 기능에 대한 수정은 하나의 모듈에서 이루어져야 함
  • ex. 특정 기능을 변경하기 위해 클래스를 수정했으나, 클래스의 대부분이 코드가 수정되지 않았거나 극히 일부분만 수정된 경우 → 잘못된 설계
    • : 해당 클래스는 수정된 기능 외 여러가지 기능을 가지고 있다는 반증

하나의 클래스가 단일 기능을 가지고 있다고 판단되지만,
차후 변경의 가능성을 볼 때 특정 부분만 변경될 가능성이 높거나 빈번한 경우
해당 변경 부분만 별도의 클래스로 분리하여 관리하는 것이 유용

미시적인 Actor의 관점

단일 모듈은 하나의 Actor에 대해서만 책임져야 한다

모듈 모듈을 변경하는 이유는 바로사용자가 해당 모듈에 기대하는 책임이 변화할 때
만약 사용자가 둘 이상이라 가정하면 모듈이 변화하는 이유는 두 가지 이상이 될 수 있다

  • A 사용자가 이 모듈에 기대하는 책임이 변화한 경우
  • B 사용자가 이 모듈에 기대하는 책임이 변화한 경우

즉, 위와 같이 하나의 모듈에 둘 이상의 사용자가 존재할 경우 단일 책임 원칙은 위배된다
하지만, 사용자가 여러 명이어도 단일 책임 원칙이 위배되지 않는 경우가 있다
바로 모든 사용자가 해당 모듈에 같은 책임을 기대하는 경우이다

여기서, Actor라는 단어를 정의할 필요가 있다
Actor는 특정 모듈에 동일한 책임을 기대하는 사용자들의 집합
따라서, 위의 예시에서 보았듯이 하나의 Actor는 단일 책임원칙을 위배하지 않는다

Clean Architecture에서 예시

Before

1) 우발적 중복

  • 상황
    • calculatePay(): 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용
    • reportHours(): 인사팀에서 기능을 정의하며, COO 보고를 위해 사용
    • save(): DB 개발팀에서 기능을 정의하며, CTO 보고를 위해 사용
  • 문제
    • 개발자가 3가지 다른 기능의 메서드를 Employee라는 단일 클래스에 배치하여 3개의 다른 액터가 서로 결합

CFO 팀에서 결정한 변경 조치가 COO팀이 의존하는 무언가에 영향 미치는 것이 가능
calculatePay() 메서드와 reportHours() 메서드가 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유한다고 해보자
그리고, 개발자는 코드 중복을 피하기 위해 이 알고리즘을 regularHours()라는 메서드에 넣었다

  1. CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 약간 변경하기로 결정
  2. COO 팀에서는 초과 근무를 제외한 업무 시간을 CFO 팀과는 다른 목적으로 사용하기에 변경을 원치 않는 경우
  3. 개발자는 calculatePay() 메서드가 regularHours()를 호출한다는 사실을 발견
  4. 개발자가 reportHours() 메서드가 regularHours()를 호출한다는 사실은 놓침
  5. CFO 팀이 원하는 방식으로 동작하는지 검증하고 시스템을 배포
  6. 결국 reportHours() 메서드가 COO팀에서 원하는 방식으로 동작하지 않아 문제가 발생

이러한 문제는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생

2) Merge & Conflict

단일 모듈에 다양하고 많은 메서드를 포함하면 병합이 자주 발생
특히, 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 높아진다

  1. DB 개발팀에서 데이터베이스의 Employee 테이블 스키마를 수정하기로 결정했고,
  2. COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다
  3. 두 명의 서로 다른 개발자가 Employee 클래스를 체크 아웃받은 후 각 팀의 변경사항을 적용
  4. 스키마를 변경하였기에 reportHours() 에서 기존 Employee 인스턴스의 프로퍼티를 보고서에서 사용하지 못하는 경우가 발생
  5. 서로의 변경사항은 서로 충돌. 결과적으로 병합이 발생

이러한 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것

class Employee {
  enum WorkType {
    case finance 
    case human 
    case development
  }
  
  let workType: WorkType
  var calculatedHour: Int?
  
  init(workType: WorkType) {
    self.workType = workType
  }
  
  /// 업무 시간을 계산해서, 회계팀장에게 보고
  func calculatePay() {
    let workTime = regularHours()
    calculatedHour = workTime
    let pay = workTime * 1000
    
    print("report \(pay) to CFO")
  }
  
  /// 업무 시간을 계산해서, 인사팀장에게 보고
  func reportHours() {
      let workTime = regularHours()
      calculatedHour = workTime

      print("report \(workTime) to COO")
  }

  /// 현재 계산된 업무시간을 DB에 저장
  func save() {
      guard let calculatedHour = calculatedHour else { return }
      print("save \(calculatedHour) to DB")
  }

  // 초과 근무를 제외한 업무 시간을 계산하는 메서드 -  calculatePay() 와 reportHours() 에서 사용
  // 코드의 중복을 피하기 위해  두 함수에서 공통적인 부분을  regularHours()로 이용
  // 각 팀의 요청 사항에 의해 해당 로직을 변경한다면 꼬임이 발생
  private func regularHours() -> Int {
      return Int.random(in: (0...100))
  }
}

let financialManager = Employee(workType: .finance)
let personnelManager = Employee(workType: .human)
let developer = Employee(workType: .development)

financialManager.calculatePay()
personnelManager.reportHours()

After

문제의 해결책은 여러 디자인 패턴으로 다양하게 접근해볼 수 있지만,
대부분이 메서드를 각기 다른 클래스로 이동 시키는 방식

1) 역할에 따른 모듈 분리와 전역적인 Facade 패턴을 통한 응집

가장 확실한 해결책은 데이터와 메서드를 분리하는 방식
아무런 메서드가 없는 간단한 데이터 구조인 Data 클래스를 만들어, 세 개의 클래스가 공유하도록 구성
세 클래스는 서로의 존재를 알지 못하고 자신의 메서드에 반드시 필요한 소스 코드만 포함 - '우연한 중복' 해결

반면에 이러한 분리는 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 게 단점

Facade 패턴을 통한 추적 보완 가능
Facade 클래스는 단순하게 구체적인 세 클래스의 객체를 생성하고,
요청된 메서드를 해당 객체로 위임하는 일을 책임지도록 구성 가능

2) 중요도에 따른 데이터 인접 방식 배치 및 부분적 Facade 패턴의 활용

가장 중요한 메서드는 기존의 Employee 클래스에 유지하고,
Employee 클래스를 덜 중요한 나머지 메서드들에 대한 Facade로 사용

모든 클래스는 반드시 단 하나의 메서드를 가져야 한다는 주장에 근거하면 해당 해결책에 반대 하겠지만
현실적으로 각 클래스에서는 다수의 private 메서드를 포함
여러 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는 하나의 유효범위가 된다
해당 유효범위 바깥에서는 이 가족에게 감춰진 식구(private 멤버)가 있는지를 전혀 알 수 없기에 대안으로 활용 가능

class Employee {
    enum WorkType {
        case finance
        case human 
        case development 
    }
    let workType: WorkType
    let data: Data
    let employeeFacade: EmployeeFacade

    init(workType: WorkType, data: Data) {
        self.workType = workType
        self.data = data
        self.employeeFacade = EmployeeFacade(payCalculator: PayCalculator(data: data),
                                             hourReporter: HourReporter(data: data),
                                             employeeSaver: EmployeeSaver(data: data))
    }
}

// Facade 패턴을 통한 분리된 코드 응집
class EmployeeFacade {
    private let payCalculator: PayCalculator
    private let hourReporter: HourReporter
    private let employeeSaver: EmployeeSaver

    init(payCalculator: PayCalculator, hourReporter: HourReporter, employeeSaver: EmployeeSaver) {
        self.payCalculator = payCalculator
        self.hourReporter = hourReporter
        self.employeeSaver = employeeSaver
    }

    func calculatePay() {
        payCalculator.calculatePay()
    }

    func reportHours() {
        hourReporter.reportHours()
    }

    func save() {
        employeeSaver.save()
    }
}

class PayCalculator {
    var data: Data

    init(data: Data) {
        self.data = data
    }

    /// 업무 시간을 계산해서, 회계팀장에게 보고
    func calculatePay() {
        let workTime = Int.random(in: (0...100))
        let pay = workTime * 1000
        data.calculatedHourByPay = pay

        print("report \(pay) to CFO")
    }
}

class HourReporter {
    var data: Data

    init(data: Data) {
        self.data = data
    }

    /// 업무 시간을 계산해서, 인사팀장에게 보고
    func reportHours() {
        let workTime = Int.random(in: (0...100))
        data.calculatedHourByHour = workTime

        print("report \(workTime) to COO")
    }
}

class EmployeeSaver {
    var data: Data

    init(data: Data) {
        self.data = data
    }

    /// 현재 계산된 업무시간을 DB에 저장
    func save() {
        print("save \(data.calculatedHourByPay), \(data.calculatedHourByHour) to DB")
    }
}

class Data {
    var calculatedHourByPay: Int?
    var calculatedHourByHour: Int?
}

func main() {
    let data = Data()

    let financialManager = Employee(workType: .finance, data: data)
    financialManager.employeeFacade.calculatePay()

    let personnelManager = Employee(workType: .human, data: data)
    personnelManager.employeeFacade.reportHours()

    let developer = Employee(workType: .development, data: data)
    developer.employeeFacade.save()
}

결론

단일 책임 원칙은 메서드와 클래스 수준의 원칙
많은 디자인 패턴을 통해서 SRP를 지키는 클래스들의 모습을 확인 가능

  • Abstract Factory: 생성을 위한 추상 인터페이스 + 구체적 팩토리(특정 한 가지 객체만 생성, 독립적 - SRP)
  • Bridge: 추상 인터페이스 + 구현체(SRP)
  • Command: 추상 인터페이스 + 동작 구현의 Concrete(SRP)
  • State, Strategy 등 인터페이스와 역할 분리를 통한 유사 형태를 확인 가능
    나머지 SOLID의 경우 SRP로 회귀되는 경우가 많음

또한, 단일 책임 원칙의 의미는 고수준에서도 다른 형태로 등장한다
Component 수준에서는 공통 폐쇄 원칙 (Common Closure Principle)이다
Architecture 수준에서는 Architecture의 경계를 정의하는 변경의 축 (Axis of Change)이다

The Principles of OOD에서 예시

하나의 클래스 / 모듈은 변경을 위한 이유(책임)가 하나뿐이어야 한다

책임을 분리하는 것이 왜 중요할까?
각각의 책임은 변화의 축이기 때문

요구 사항이 변경되면, 그 변화는 클래스 간 책임의 변화를 통해 분명히 드러나게 된다
클래스가 둘 이상의 책임을 지면, 클래스가 바뀌는 이유는 둘 이상이 된다
클래스에 둘 이상의 책임이 있는 경우, 그 책임은 결합되게 된다

한 책임에 대한 변경은 다른 책임들을 충족시키는 클래스의 능력을 손상시키거나 저해할 수 있다
이러한 종류의 결합은 변경 시 예상치 못한 방식으로 깨지기 쉬운 디자인으로 이어진다

Rectangle 클래스에는 표시된 두 가지 메서드가 있다
하나는 화면에 직사각형을 그리고 다른 하나는 직사각형의 면적을 계산한다

두 개의 서로 다른 응용 프로그램이 Rectangle 클래스를 사용

한 응용 프로그램은 계산 기하학을 수행한다
그것은 기하학적인 모양의 수학을 돕기 위해 사각형을 사용한다
화면에는 직사각형이 그려지지 않는다

다른 응용 프로그램은 본질적으로 그래픽 구성을 한다
약간의 계산 기하학을 할 수 있지만, 확실히 View에 직사각형을 그린다

해당 설계는 SRP를 위반한다 Rectangle 클래스에는 두 가지 책임이 있다

  • 첫 번째 책임은 직사각형 기하학의 수학적 모델을 제공하는 것
  • 두 번째 책임은 그래픽 사용자 인터페이스에서 직사각형을 렌더링하는 것

SRP 위반은 몇 가지 끔찍한 문제를 야기한다
첫째, 우리는 Computational Geometry Application에 GUI를 포함해야 한다
.NET에서 GUI 어셈블리는 CGA과 함께 구축되고 배치되어야 한다

둘째, GraphicalApplication 변경으로 인해 Rectangle이 변경되는 경우
해당 변경으로 인해 CGA을 재구성하고 다시 테스트하고 다시 배포해야 할 수 있다
만약 우리가 이러한 절차를 잊는다면, 해당 애플리케이션은 예측할 수 없는 방식으로 망가질 것이다

더 나은 설계는 두 책임을 완전히 다른 두 클래스로 분리하는 것이다
설계에서 Rectangle의 계산 부분을 Geometric Rectangle 클래스로 이동

위와 같이 변경할 경우 고치면 draw()가 변경될 때 Geometric Rectangle까지 영향을 미치지 않는다
하지만, CGA의 요구로 area()의 시그니처가 변경되면,
Geometric Rectangle이 바뀌어야하고 Graphic Rectangle도 바뀌어야 한다
약간은 좋아졌지만 완벽하지는 않다

더 본직적인 Rectangle의 responsibility를 추상클래스로 분리
CGA의 요청으로 area()가 바뀌게 되면 변화의 여파는 Geometry Rectangle에서 끝난다
GA의 요청으로 draw()가 바뀌게 되면 변화의 여파는 Graphic Rectangle에서 끝난다

단일 책임 원칙(SRP)의 맥락에서 우리는 "변화의 이유"가 될 책임을 정의 한다
만약 클래스를 바꾸는 이유를 하나 이상 생각할 수 있다면, 그 클래스는 둘 이상의 책임이 있다

하지만, 우리는 책임을 묶어서 생각하는 것에 익숙하다
예를 들어, 모뎀 인터페이스를 생각해 보자

public protocol Modem {
  func dial (pno: String)
  func hangup()
  func send(char: Character)
  func recieve() 
}

대부분은 해당 인터페이스 합리적으로 구성 되었다고 느낀다
선언하는 네 가지 기능은 확실히 모뎀에 속하는 기능이기 때문에

그러나, 여기에는 두 가지 책임이 제시되어 있다

  • 첫 번째 책임은 연결 관리: dial / hangup
  • 두 번째는 데이터 통신: send / recieve

두 가지 책임의 분리 여부를 어떻게 확인 가능할까?

애플리케이션이 어떻게 변경되느냐에 달려 있다
애플리케이션이 연결 관리 함수의 시그니쳐에 영향을 미치는 방식으로 변경되면
send / recieve를 호출하는 클래스는 원하는 것보다 더 자주 다시 컴파일하고 다시 배치해야 하기 때문에 경직성을 띈 설계라고 할 수 있다
따라서, 두 책임은 분리되어야 한다

public protocol DataChannel {
  func send(char: Character)
  func recieve()
}

public protocol Connection {
  func dial (pno: String)
  func hangup()
}

public class Modem: DataChannel, Connection {
  public func send(char: Character) {}
  public func recieve() {}
  public func dial(pno: String) {}
  public func hangup() {}
}

반면에, 애플리케이션이 두 책임이 서로 다른 시간에 변경되도록 하는 방식으로 변경되지 않는다면, 그것들을 분리할 필요가 없다
실제로, 그것들을 분리하는 것은 불필요한 복잡성을 가져오게 된다

핵심은 변화의 축은 변화가 실제로 일어날 때 변화의 축일 뿐이다
증상이 없다면 SRP나 그 문제에 대한 다른 원칙을 적용하는 것은 현명하지 않다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant