클린 소프트웨어 - 단일 책임 원칙

단일 책임 원칙

여기에서 말하는 응집력은, 모듈 요소 간 기능적인 연관이 아닌, 모듈이나 클래스의 변경을 야기하는 것으로 정의한다.

단일 책임 원칙

Single Responsiblity Principle

  • 한 클래스는 단 한 가지의 변경 이유만을 가져야 한다.

하나의 클래스가 두 개 이상의 책임을 가지고 있을 때, 각각의 책임을 별도 클래스로 분리할 수 있다.

책임 : 해당 클래스가 변경되는 이유

한 클래스가 하나 이상의 책임을 맡는다면, 그 책임들은 결합된다. 하나의 책임에 변경 사항이 있어 변경한 경우 다른 책임에 대해 제대로 된 동작을 하지 못하도록 할 가능성이 생긴다.

이처럼 변경을 했을 때 예상치 못한 방식으로 잘못 동작하는 취약한 설계를 유발하게 된다.

class Rectangle {
var area: Double { ... }
func draw() { ... }
}

위와 같은 경우 Rectangle 클래스는 수학적으로 그 넓이를 계산하는 기능과 화면에 직사각형을 그리는 기능, 즉 두 개의 책임을 갖게 된다. SRP를 위반하였다고 볼 수 있다.

GUI를 사용하지 않는 프로그램이 해당 모듈을 사용할 때 반드시 GUI 관련 모듈을 포함해야 하므로 쓸 데 없는 시간과 자원 낭비를 불러일으키게 된다. 또한 하나의 책임에 대해 변경했을 때 이 모듈을 사용하는 다른 프로그램을 다시 빌드하고, 다시 테스트하고, 다시 배포해야할 수 있다. 이러한 과정을 거치지 않으면 다른 프로그램이 잘못 동작할 수 있기 때문이다.

이 문제를 해결하기 위해 각각의 책임을 분리하여 별도 클래스에 작성할 수 있다.

class GeometricRectangle {
var area: Double { ... }
}

class Rectangle {
let geometricRectangle = GeometricRectangle()
func draw() { ... }
}

이렇게 되면 GUI를 사용하지 않는 프로그램은 GeometricRectangle 클래스를 사용하면 되며, GUI 관련 모듈을 import할 필요도 없어져 불필요한 자원 낭비를 하지 않아도 된다. 또한 하나의 책임에 대한 변경이 다른 프로그램의 재빌드 등을 불러일으키지 않게 된다.

책임이란 무엇인가?

SRP의 관점에서 책임은 ‘변경을 위한 이유’로 정의된다. 한 클래스를 변경하기 위한 여러 개의 이유를 생각해낼 수 있다면, 이 클래스는 여러 개의 책임을 맡고 있어 SRP를 위반하고 있는 것이다.

하지만 우리는 일반적으로 책임을 묶어서 생각하는 데 익숙해져 있으므로 이러한 것들을 생각해 내는 것이 분명 쉽지 않다.

protocol Modem {
func dial(to: String)
func hangup()
func send(_: Character)
func receive() -> Character
}

위의 Modem 인터페이스는 연결 관리(dial(to:) / hangup())와 데이터 통신(send(_:) / receive())이라는 두 가지 책임을 갖고 있다.

이 책임을 분리해야 할지, 분리하지 않아도 될지 판별하는 기준은, 애플리케이션이 어떻게 바뀌느냐에 달려 있다.

애플리케이션이 연결 관리 함수의 시그니처에 영향을 주는 방식으로 변화한다면, 데이터 통신과 관련된 함수와 함께 자주 재컴파일되고 재배포되어야 하므로 경직성의 악취를 풍기게 된다. 이 경우 책임을 분리해야 할 필요가 있다.

protocol DataChannel {
func send(_: Character)
func receive() -> Character
}

protocol ConnectionManager {
func dial(to: String)
func hangup()
}

final class Modem: DataChannel, ConnectionManager {
...
}

프로토콜을 분리하여 두 가지 책임을 분리하였고 하나의 클래스가 두 가지 책임을 받아 구현하도록 하였다.

하지만 애플리케이션이 서로 다른 시간에 두 가지 책임의 변경을 유발하는 방식으로 바뀌지 않는다면 이들을 분리할 필요는 없다. 이 때 책임을 분리하면 오히려 불필요한 복잡성의 악취를 풍기게 할 수 있다.

말하자면, 변경의 축은 변경이 실제로 일어날 때만 변경의 축이 된다. 아무 증상도 없는데 SRP나 다른 원칙을 적용하는 것은 현명하지 못하다.

결합된 책임 분리하기

위의 예제에서 결국 하나의 클래스가 두 가지 책임을 받아 결합하였다. 하지만 인터페이스를 분리하는 것으로 애플리케이션의 다른 부분에 한하여 개념을 분리하는 시도는 할 수 있었다.

이는 바람직한 일은 아니지만, 모든 의존성은 필요악일 수 있다.

영속성

class Employee {
func calculatePay()
func store()
}

위의 클래스는 업무 규칙 책임과 영속성 책임을 모두 가지고 있어 SRP를 위반한다. 특히 업무 규칙은 자주 바뀌는 경향이 있지만 영속성은 자주 바뀌지 않는 경향이 있고, 바뀌는 이유도 서로 완전히 다르다.

테스트 주도 개발을 적용한다면 이러한 설계가 나타나기 전에 책임을 분리하도록 만들 수 있을 것이다.

하지만 테스트가 분리를 강제하지 않았고, 경직성과 취약성의 악취가 강해진 경우 퍼사드 패턴이나 프록시 패턴을 사용하여 두 책임이 분리되도록 리팩토링할 수 있다.

결론

SRP는 가장 간단하나 제대로 적용하기 가장 어려운 원칙 중 하나이다. 우리는 너무나도 자연스럽게 책임을 하나로 결합하고 있다.

이러한 책임을 찾고 하나씩 분리하는 것이 소프트웨어 설계에서 실제로 하는 일의 대부분이다.