031. (Clean Architecture) 7. SRP - 단일 책임 원칙

7. SRP - 단일 책임 원칙

객체지향 언어를 다루는 개발자라면 SOLID라는 객체지향의 다섯가지 기본 원칙을 자주 들어보았을 것이다.

이번 포스팅부터는 SOLID 원칙을 각각 탐구해보도록 하자.

제일 먼저 S에 해당하는 단일 책임 원칙(Single responsibility principle) 이다.

단일 책임 원칙은 SOLID 중에 가장 의미가 잘 전달되지 못한 원칙이기도 할 것이다.

이름만 봐서는 모든 모듈이 단 하나의 일만 해야한다는 의미로 받아들이기 쉽기 때문이다.

사실 단 하나의 일만 해야한다는 원칙을 따로 있고, 정확한 단일 책임 원칙은 아래와 같이 묘사된다.

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

소프트웨어 시스템은 사용자와 이해 관계자를 만족시키기 위해 변경되며, 이 사용자와 이해관계자가 바로 변경의 이유이다.

따라서 단일 책임 원칙은 아래와 같이 바꿔서 작성할 수 도 있다.

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

이제 완벽해진 걸까?

안타깝게도 사용자와 이해관계자라는 단어는 사실 올바르게 쓰인 것이 아니다.

시스템이 동일한 방식으로 변경되기를 원하는 사용자나 이해관계자가 두 명 이상일수도 있기 때문인데,

그래서 단일의 어떤 사람보다는 해당 변경을 요청하는 한 명 이상의 사람들이 모인 집단을 가리킨다.

앞으로 이러한 집단을 액터(Actor) 라고 지칭하겠다.

이제 단일 책임 원칙은 아래와 같이 재정의된다.

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

그럼 여기서 모듈은 무엇일까?

가장 단순한 정의는 바로 소스 파일이라고 볼 수 있다.

대부분의 경우에는 소스 파일로 취급해도 들어맞는다.

하지만 일부 언어와 개발 환경에서는 코드를 소스 파일에 저장하지 않기땜누에, 이 때의 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.

여기서 응집된(cohesive) 라는 단어가 단일 책임 원칙을 암시한다.

단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이기 때문이다.

단일 책임 원칙을 잘 이해하기 위해서 이 원칙을 위반하는 케이스에 대해 파악해보자.

케이스 1: 우발적 중복

아래는 급여 어플리케이션을 구현한 Employee 클래스의 예시이다.

The Employee class

Employee 클래스는 calculatePay(), reportHours(), save() 메서드를 가지고 있다.

위의 클래스는 단일 책임 원칙을 위반하는데, 위 세 메서드가 서로 다른 세 명의 액터를 책임지기 때문이다.

  • calculatePay() 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
  • reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
  • save() 메서드는 데이터베이스 관리자가 기능을 정의하고, CTO 보고를 위해 사용한다.

개발자는 위 세 메서드를 Employee라는 단일 클래스에 배치하여 세 액터가 결합되어버렸다.

이 결합으로 인해 CFO 팀에서 행한 조치가 COO 팀이 의존하는 무언가에 영향을 줄 수 있게 되었다.

예를 들어 calculatePay() 메서드와 reportHours()` 메서드가 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유한다고 가정해보자.

이때 개발자는 코드 중복을 피하기 위해 아래와 같이 regularHours()라는 메서드에 이 알고리즘을 넣었다고 가정한다.

Shared algorithm

이제 CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 약간 수정한다고 결정했다고 가정한다.

반면 인사를 담당하는 COO 팀에서는 초과 근무를 제외한 업무 시간을 CFO 팀과는 다른 목적으로 사용하기 때문에 위와 같은 변경을 원하지 않는다고 가정한다.

이 변경을 적용하기 위해 개발자는 calculatePay() 메서드가 편의 메서드인 regularHours() 메서드를 호출한다는 사실을 발견한다.

하지만 reportHours() 메서드에서도 regularHours() 메서드를 호출한다는 사실을 발견하지 못했다.

개발자가 변경사항을 적용 후 테스트를 진행하며, CFO 팀은 새로운 요구사항이 잘 적용되었는지를 검증하고 시스템은 배포 된다.

이때까지 COO팀은 이 변경사항을 모르는 중이다.

이제 COO 팀 직원은 reportHours() 메서드가 생성한 보고서를 계속해서 사용하고 있고, 보고서에는 엉터리 수치들이 포함된다.

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

결국 단일 책임 원칙은 서로 다른 애겉가 의존하는 코드를 서로 분리하라고 말한다.

케이스 2: 병합

소스 파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생할 수 있다는 건 쉽게 짐작할 수 있을 것이다.

특히 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 더 높아진다.

위의 급여 애플리케이션을 예시로 다른 케이스를 가정해보자.

DBA가 속한 CTO 팀에서 Employee 클래스의 데이터를 관리하는 테이블 스키마를 약간 수정한다고 한다.

이와 동시에 COO팀에서는 reportHours() 메서드를 이용한 보고서의 포맷을 변경하기로 결정했다.

두 명의 서로 다른 개발자가 각각의 변경사항을 적용하는 경우, 이 변경사항들은 서로 충돌하고 병합이 발생하게 된다.

참고 개발 도구가 고도화된 지금까지도, 병합에는 여전히 위험이 동반되므로 주의해야 한다.

이때의 병합은 CTO 팀과 COO 팀 모두를 곤경에 빠드리고, CFO 팀에도 영향을 주게 될 것이다.

이런 케이스들은 많은 사람들이 서로 다른 목적으로 소스 파일을 변경하는 경우에 해당하며, 역시 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이 정답이다.

해결책

그럼 이제 서로 다른 액터를 뒷받침하도록 코드를 서로 분리해보자.

가장 간단한 방법으로는 아래와 같이 각 메서드를 각기 다른 클래스로 이동시키는 방식일 것이다.

The three classes do not know about each other

이러면 우연한 중복을 피할 수 있을 것이다.

하지만 이 방법은 개발자가 세 가지 클래스를 유지보수해야한다는 게 단점이다.

이때 쓸 수 있는 기법으로 파사드 패턴이 있다.

The Facade pattern

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

만약 중요한 업무 규칙은 관련된 데이터와 가깝게 배치하는 방식을 선호한다면 아래와 같이 Employee 클래스는 그대로 유지하고 덜 중요한 메서드들에 대해 파사드를 적용할 수 도 있다.

The most important method is kept in the original Employee class and used as a Facade for the lesser functions

이 해결책은 “모든 클래스는 하나의 메서드가 가져야한다”라는 주장에 위배될지도 모른다.

하지만 현실적으로 대부분의 클래스는 모두 다수의 메서드를 포함하게 될 것이다.

이처럼 여러 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는 하나의 유효범위가 되어야 한다.

해당 유효범위 바깥에서는 이 가족에게 감춰진 식구(private 멤버)가 있는지를 전혀 알 수 없다.

결론

단일 책임 원칙은 메서드와 클래스 수준의 원칙이다.

하지만 이보다 상위의 수준에서는 또 다른 형태로 다시 등장한다.

컴포넌트 수준에서는 공통 폐쇄 원칙(Common Closure Principle) 이 되고, 아키텍처 수준에서는 아키텍처 경계의 생성을 책임지는 변경의 축(Axis of Change) 가 된다.