Do.

Architecture - Single Responsibility Principle(SRP) 본문

General Dev

Architecture - Single Responsibility Principle(SRP)

Hey_Hen 2022. 9. 14. 01:33

Intro

SRP는 SOLID라 불리는 아키텍처 원칙 중 첫번째 글자에 해당하는 원칙입니다.
용어는 로버트 C. 마틴이 2003년 저서 Agile Software Development, Principles, Patterns, and Practices에 소개한 개념으로 OOD의 원리 라는 기사에서 소개했다고 합니다. 도서 클린 아키텍처(로버트 C. 마틴 저)에 따르면 일부 정의된 아키텍처 원칙 중 몇몇을 마이클 페더스라는 사람이 그것들을 재배열해서 나오게 된 이름이라고 하네요!

Single Responsibility Principle

SRP는 직역하면, 단일 책임 원칙 이라고 해석할 수 있는데, SOLID 원칙 중 가장 의미가 제대로 전달되지 못한 원칙 중 하나라고 합니다. 그 이유는 이름 때문이라고...(당신이 지은거 아님..?)
여기서 말하는 SRP의 설명은 단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다. 라고 설명을 하고 있습니다. 동시에 모든 모듈이 하나의 일만 해야 한다. 라는 것은 잘못된 전달이라고 합니다. 그렇게 알고있는 것은 함수는 단 하나의 일만 해야 한다, (커다란 함수를 작은 함수들로 리팩터링 하는 저수준 원칙) 이며 이는 SOLID 원칙, 즉 SRP가 아니라고 설명합니다.

그러게 이름을 왜 그렇게 지어서..

여기서 말하는 SRP 의 수준은 "모듈" 로 보입니다. 모듈이란 가장 단순한 정의로는 소스파일 그 자체 라고 볼 수도 있습니다. (아닌 경우도 있다고 함?) 좁게는 클래스, 넓게는 하나의 응집된 컴포넌트 그 자체로 볼 수 있을 것 같습니다. 절대 함수와 같은 저수준 원칙을 얘기하는 것이 아닙니다!

그럼 뭔데...?

제가 이해하기로, 책에서는 Actor 라는 개념을 중요하게 얘기합니다. 모듈은 하나의 Actor에 의해서만 변경될 수 있다는 얘기입니다. 이게 무슨 말일까요?

저는 실제로 경험한 것 중 가장 적합한 예시는 바로 병합 이라고 생각합니다.
프로그램을 만들다 보면, 우리는 중복 코드를 발견하고 이를 병합 하려는 행동을 보입니다. 리팩터링 중 가장 흔하게 할 수 있는 것 중 하나죠?
그런데 병합을 할 때 가끔 우리는 잘못된 병합을 하는 것 같습니다. 잘못된 병합을 시도하고, 중복코드를 줄이고 뿌듯해 하죠! SRP 위배는 바로 여기서 발생합니다.

대체 왜 합쳐놨냐고!!!!

네, 코드를 수정하다가 무언가 수정했는데, 그로 인해서 다른 곳에서 기능이 변하는 경우죠, 이걸 대체 왜 합쳐놨지? 라고 생각이 들때가 바로 SRP를 위배한 현장을 목격한 것입니다.

이해하기 편하도록 예시 케이스를 하나 만들어 봤습니다. 실제 사건을 모티브로 각색해 보았어요!


커뮤니티 어플리케이션이 있습니다.
이 커뮤니티는 글을 쓰고 포인트를 받을 수 있는데, 회원 구분에 따라 포인트가 차등 지급 되고 있습니다.
인증회원의 경우 기본 포인트5에 활동기간 및 레벨 등의 가중치를 얻고
미인증 회원의 경우 기본 포인트만5 만 지급받는 형식입니다.
개발 초창기에는 그것 말고는 다른 케이스가 없어서 글쓰기 라는 하나의 모델에 인증/미인증 회원 두 액터가 모두 의존하고 있는 상황입니다.

여기서 포인트지급 정책이 바뀌기로 했습니다. 미인증 회원의 글쓰기 포인트가 너무 짜서, 기본 포인트를 증가 시키기로 했습니다.
그런데 여기서 한가지 문제가 발생하죠? 기본 포인트를 수정하게 되면 인증 회원의 포인트까지 증가하게 됩니다.
코드로 보면 이렇습니다.

유저 클래스

final class User {
  private(set) var certification = false
  private(set) var signUpDate = Date()
  private(set) var level = 0
  private(set) var point = 0
  
  func increasePoint(point: Int) {
    self.point += point
  }
}

게시판 모델

struct WriteBoard {
  
  private let pointCalculator = PointCalculator()
  
  func write(user: User) {
    user.increasePoint(point: pointCalculator.point(user: user))
  }
}

포인트 지급 정책 모델

struct PointCalculator {
  private let basePoint = 5
  private let levelPointWeight = 1.25
  private let signUpDatePointWeight = 2
  
  func point(user: User) -> Int {
    if user.certification {
      return basePoint * (signUpDatePoint(signUpDate: user.signUpDate) + levelPoint(level: user.level))
    } else {
      return basePoint
    }
  }
  
  private func signUpDatePoint(signUpDate: Date) -> Int {
    signUpDatePointWeight * Int(Date().timeIntervalSinceNow - signUpDate.timeIntervalSinceNow)
  }
  
  private func levelPoint(level: Int) -> Int {
    Int(levelPointWeight * Double(level))
  }
}

코드를 보면 문제가 되는 부분은 basePoint가 미인증 유저와, 인증 유저 2개의 액터에 걸쳐 있다는 것입니다. 2개의 액터 즉 SRP 위반입니다.

해결방법

이를 해결하는 방법은 다양한데, POP를 통해서 한번 해결해보도록 하겠습니다.
기존 포인트 지급 정책을 기본 포인트, 추가 포인트 정책 프로토콜로 쪼개서 가집니다.
그리고 포인트를 지급할 수 있는 인터페이스를 추가합니다.

즉 기존 포인트 계산기는 새로 생긴 프로토콜에 의존합니다.

protocol BasePointPolicy {
  var basePoint: Int { get }
}

protocol WeightPointPolicy {
  var levelPointWeight: Double { get }
  var signUpDatePointWeight: Double { get }
}

protocol PointGarentInterface {
  func point(user: User) -> Int
}

이제 이 3개를 조합해서 2개의 포인트 계산기 구현을 만들 수 있습니다.

미인증회원 포인트 지급 정책

struct InvalidateUserPointCalculator: BasePointPolicy, PointGarentInterface {
  let basePoint = 10
  
  func point(user: User) -> Int {
    basePoint
  }
}

인증회원 포인트 지급 정책

struct CertifiedUserPointCalculator: BasePointPolicy, WeightPointPolicy, PointGarentInterface {
  let basePoint = 5
  let levelPointWeight = 1.25
  let signUpDatePointWeight: Double = 2
  
  func point(user: User) -> Int {
    basePoint * (signUpDatePoint(signUpDate: user.signUpDate) + levelPoint(level: user.level))
  }
  
  private func signUpDatePoint(signUpDate: Date) -> Int {
    Int(signUpDatePointWeight * Date().timeIntervalSinceNow - signUpDate.timeIntervalSinceNow)
  }
  
  private func levelPoint(level: Int) -> Int {
    Int(levelPointWeight * Double(level))
  }
}


그래서 게시판이 하나의 포인트 계산기를 의존하지 않고 포인트 계산기 인터페이스를 의존합니다.

struct WriteBoard {
  private let pointCalculator: PointGarentInterface
  
  func write(user: User) {
    user.increasePoint(point: pointCalculator.point(user: user))
  }
}

결국 인증회원은 CertifiedUserPointCalculator에 미인증회원은 InvalidateUserPointCalculator에 각각 1:1로 의존하게 됩니다. 다르게 말하면 각 포인트 계산기는 자신이 담당하는 엑터에 의해서만 수정될 수 있습니다. 프로토콜에 의해서 포인트 지급 정책은 수정될일이 없는거죠!

결론

- SRP는 클래스의 기능, 함수의 기능이 하나이어야 된다는 뜻이 아니라 변경의 이유가 하나 이어야 한다. 즉 단 하나의 액터만 상대 해야 한다는 뜻입니다.
- SRP 위반은 주로 병합에서 나타나는 것으로 볼 수 있습니다. 무언가 병합해야 한다면 혹시 다른 액터를 가진 것을 병합하고 있지는 않는지 고민해볼 필요가 있을 것 같습니다.

Reference.

로버트 C. 마틴: 클린 아키텍처

Comments