Study/객체지향

SRP: 단일 책임 원칙

voider 2021. 7. 7. 22:29

SRP: 단일 책임 원칙

단일 책임 원칙은 그 이름처럼, 모든 모듈이 단 하나의 일만 해야 한다는 의미가 아니다. 단 하나의 일만 해야 한다는 원칙은 함수에게 부여되는 원칙이다. 이 원칙은 커다란 함수를 작은 함수로 리팩터링하는 저수준에서 사용된다. 그렇다고 이 원칙이 SRP는 아니다. 역사적으로 SRP는 아래와 같이 정의되어 왔다.

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

소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다. SRP가 말하는 '변경의 이유'란 바로 이들 사용자와 이해관계자를 가리킨다. 사실 이 원칙은 아래와 같이 바꿔 말할 수도 있다.

"하나의 모듈은 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다."

여기사 '사용자'와 '이해관계자'는 (같은 방식으로)변경을 요청하는 한 명 이상의 집단(이하 액터actor)을 말한다. 그래서 SRP를 최종적으로 정의하자면 다음과 같다.

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

여기서 모듈이란, 단순한 정의로 '소스 파일'이다. 대부분 이 정의는 잘 들어맞는다. 하지만 일부 언어와 개발환경에서 코드를 소스파일에 저장하지 않는다. 이런 경우 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.

'응집된cohesive'이라는 단어는 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성이다.

아마도 이 원칙을 이해하는 가장 좋은 방법은 이 원칙을 위반하는 징후를 살펴보는 일이다.

징후1: 우발적 중복

CFO, COO, CTO라는 세 명의 액터가 사용하게 될 Employee 클래스를 정의한다.

public class Employee {
    public void calculatePay() {...}
    public void reportHours() {...}
    public void save() {...}
}

Employee는 세 명의 액터를 책임지므로 SRP 위반이다.

  • calculatePay() 메서드는 회계팀에서 기능을 정의하고, CFO 보고를 사용한다.
  • reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용된다.
  • save() 메서드는 DBA가 기능을 정의하고 CTO 보고를 위해 사용된다.

개발자가 이 세 메서드를 Employee라는 단일 클래스에 배치하여 세 액터가 서로 결합되었다. 이 결합으로 CFO 팀에서 결정한 조치가 COO 팀에 의존하는 무언가에 영향을 줄 수도 있다.

예를 들어, calculatePay() 메서드와 reportHours() 메서드가 초과 근무를 제외한 업무시간을 계산하는 알고리즘을 공유한다고 하자. 그리고 개발자는 코드 중복을 피하기 위해 이 알고리즘을 regularHours()라는 메서드에 넣었다고 해보자.

calatePay() --------> regularHours() <--------- reportHours()

두 메서드가 regularHours()에 의존한다.

이제 CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 약간 수정하기로 결정했다고 하자. 반면 인사를 담당인 COO 팀은 초과 근무를 제외한 업무 시간을 CFO 팀과 다른 목적으로 사용하므로 변경을 원하지 않는다.

이 변경을 적용하는 업무를 할당받은 개발자는 calculatePay() 메서드가 편의 메서드인 regularHours()를 호출한다는 사실을 발견한다. 하지만 안타깝게도 이 함수가 reportHours()에서도 호출된다는 사실은 발견하지 못한다.

개발자는 요청된 변경사항을 적용하고 신중하게 테스트한다. CFO 팀은 새로운 메서드가 원하는 방식으로 동작하는 지 검증한 다음, 시스템은 배포된다. 당연히 COO 팀은 이런 일이 벌어지고 있다는 사실을 모르고, 계속해서 reportHours() 메서드가 생성한 보고서를 여전히 이용한다. 뒤늦게 보고서가 엉터리인 사실을 깨닫게 되었을 때는 이미 돌이킬 수 없는 상황일 수도 있다.

서로 다른 Actor가 의존하는 코드를 너무 가까이 배치했기 때문에 벌어지는 문제다. SRP를 따른다면 Actor가 다르다면 코드를 분리해야 한다.

징후2: 병합

소스파일에 다양한 메서드를 포함하면 병합이 자주 발생할 것이라고 짐작한다. 특히 이 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 높아진다.

예를 들어 DBA가 속한 CTO 팀에서 DB의 Employee 테이블 스키마를 약간 수정하기로 결정했다고 가정하자. 이와 동시에 인사 담당자가 속한 COO 팀에서는 reportHours() 메서드의 보고서 포매을 변경하기로 결정했다.

두 명의 다른 개발자가, 그리고 아마 서로 다른 팀에 속해있을 두 개발자가 Employee 클래스를 체크아웃 받은 후, 코드를 변경한다. 서로의 존재를 모르고 같은 메서드를 수정하고 있는 두 개발자의 코드는 안타깝게도 충돌하고 병합이 필요해진다.

병합은 위험이 따른다. 도구가 뛰어나도 모든 경우를 해결하지 못한다. 위험이 뒤따른다. 이 예에서 발생한 병합은 CTO와 COO 모두를 곤경에 빠뜨린다. 마찬가지로 CFO에게까지 영향을 끼칠 지 모른다.

더 많은 징후가 있겠지만, 결국 핵심은 SRP를 지키지 않은 코드는 여러 사람이 서로 다른 목적으로 동일한 코드를 변경할 위험이 있다는 것이다. 이 위험에서 벗어나려면 서로 다른 액터를 뒷받침하는 코드를 서로 분리해야 한다.

해결책 

이 문제의 해결책은 다양하다. 그 모두가 메서드를 각기 다른 클래스로 이동시키는 방식이다. 아마 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식이다. 즉, 아무 메서드가 없는 간단한 데이터 구조인 EmployeeData클래스를 만들고, 세 개의 클래스가 공유하도록 한다. 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다. 세 클래스는 서로의 존재를 몰라야 한다. 따라서 '우연한 중복'을 피할 수 있다.

                       EmployeeSave
                            |
                            ▽
PayCalcalator ------> EmployeeData <------- HourReporter

반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 게 단점이다. 이런 난관에서 빠져나올 때 흔히 쓰는 기법으로 퍼사드Facade 패턴이 있다.

EmployeeFacade에 코드는 거의 없다. 이 클래스는 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.

어떤 개발자는 가장 중요한 업무 규칙을 데이터와 가깝게 배치하는 방식을 선호한다. 이 경우라면 가장 중요한 메서드는 기존의 Employee클래스에 그대로 유지하되, Employee 클래스를 덜 중요한 나머지 메서드들에 대한 퍼사드로 사용할 수 있다.

모든 클래스는 반드시 단 하나의 메서드를 가져야 한다는 주장에 근거하여 앞의 해결책에 반대할 수 있다. 하지만 이 주장은 현실과 전혀 다르다. 각 클래스에서 지불, 보고서, 생성, 데이터 저장 기능을 구현하는 데 필요한 메서드의 개수는 실제로 훨씬 만혹, 이들 클래스 모두 다수의 private메서드를 포함한다. 이처럼 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는하나의 유효범위가 된다. 해당 유효범위 바깥에서 이 가족에게 감춰진 식구(private)가 있는지 없는지 전혀 알 수 없다.

'Study > 객체지향' 카테고리의 다른 글

DIP: 의존성 역전 원칙  (0) 2021.07.31
ISP: 인터페이스 분리 원칙  (0) 2021.07.31
LSP: 리스코프 치환 원칙  (0) 2021.07.13
OCP: 개방 폐쇄 원칙  (0) 2021.07.10