[Software Design] SRP (Single Responsibility Principle)

최석현·2019년 10월 28일
4

책임

로버트 C. 마틴은 책임변경하려는 이유라고 정의했다. 변화의 시기와 이유가 같다면 같은 책임 아래 있다고 보는 것이다. 반대로, 한 객체 내에서 변화의 시기, 이유가 다른 부분이 존재한다면 그 객체는 여러 책임을 가지고 있는 것이다. 그에 따라 이렇게 좀 더 알아보기 쉽게 정의할 수 있을 것 같다.

책임은 객체에 의해 정의되는 응집도있는 행위와 상태의 집합이다.

생성 책임

객체의 생성은 매우 중요한 작업 중의 하나이다. 객체를 생성하는 과정의 중요성 때문에 GoF의 디자인 패턴에도 5가지 종류의 생성 패턴이 존재한다. 또한 생성 과정에서 만나는 다양한 문제점들을 해결하기 위해서 많은 소프트웨어들에서 Factory Method를 이용한다.

이 생성 책임은 세부적으로 다음과 같은 작업들이 정상적으로 이루어지도록 만든다. 첫번째는 적절한 구체 객체를 생성하는 것이다. 생성 패턴이나 Factory Method에서는 사용자가 사용하는 생성자 객체 혹은 생성 메소드에 따라서, 또는 생성 메소드에 의해 전달되는 파라메터에 따라서 다른 객체를 생성하고 제공한다. 생성 책임을 지고 있는 모듈로부터 적절한 객체를 제공 받음으로써 어플리케이션이 적절한 동작을 취할 수 있도록 한다. 두번째로 객체의 타입을 감춘다. 구체 객체를 생성했다고 해도 그 구체 객체의 타입으로 사용하지 않는 편이 좋을 경우가 있다. 대표적으로 다형성을 통해 구현된 일련의 객체들을 받아 사용하는 경우이다. 이 때 생성 책임을 지는 모듈은 구체 객체의 타입을 감추고 추상 타입으로 리턴해 줌으로써 객체를 사용하려는 쪽과 생성된 구체 객체간의 관계가 생성되는 것을 막아준다. 세번째로 객체가 생성되고 나서 사용되기 전에 해줘야 할 작업들을 실행해준다. 대표적으로 초기화 작업이나 이벤트 수신을 위해 생성된 객체를 다른 객체에 등록하는 작업 등이다. 네번째로 객체의 생성에서 사용 전까지 있을 수 있는 동기화 문제를 방지해 주는 역할을 한다. 다섯번째로 객체가 생성되면서 가져야 할 초기 값들을 할당해준다.

조립 책임

현대 소프트웨어처럼 요구사항이 다양화 되기 전까지는 객체를 조립한다는 개념이 두드러지지 않았다. 프레임워크, 즉 어떤 어플리케이션으로 진화하든지 상관 없이 충분한 기능을 지원해 줄 수 있도록 설계된 소프트웨어 플랫폼들에서나 주로 고민하던 부분이다. 하지만 객체지향 언어에서 의존성 관리를 통한 유연성 확보 개념이 널리 퍼지고, 엔터프라이즈 애플리케이션이 더 많은 산업 분야에 적용되고, 같은 기능의 소프트웨어를 대량으로 판매하기 보다는 사용자의 요구에 맞춰 다른 기능들을 제공하는 방식을 더 많이 지원해야 하는 상황에서 이 조립 책임이 대두되었다. 아마도 앞으로 인공지능 분야가 더더욱 발전하고 소프트웨어가 더 많은 데이터를 손쉽게 수집할 수 있는 플랫폼을 개발하는 방향으로 나가게 된다면 이 조립 책임은 더욱더 비중이 커지게 될 것이다.

Spring 프레임워크의 의존성 주입(Dependency Injection), 넷빈즈의 룩업(Lookup) 서비스 객체, 서비스 로케이터(Service Locator) 등과 같이 객체의 조립이라는 과정은 독립적이고 핵심적인 요소이다.

구현 책임

구현의 책임은 구체 객체가 가진 책임이다. 즉 특정 요구사항에 맞게 실제 동작을 수행할 수 있는 메소드를 가지고 있는 것이다. 좀 더 구체적으로 보면 공개 메소드가 구현 책임의 대상이 된다. 이 공개 메소드는 interface나 상위 클래스를 상속함으로써 구현이 강제 되기도 하고, 자체적으로 공개 메소드로 설정하고 제공되기도 한다. 어쨌든 구체 객체는 이들 공개 메소드에 대하여 구현함으로써 실제 작업을 수행하는 책임을 가진다.

계약 책임

계약 책임은 interface에 대한 책임이다. 계약이라는 이야기가 매우 생소할 수도 있다. 하지만 어떤 interface를 구현하는 객체들이 구체적인 구현에 있어서 자율성을 가지고 있다는 사실은 이미 알고 있을 것이다. 객체를 사용하는 쪽에서는 구체적인 객체가 어떤 방식으로 동작하는지에 대해서는 관여하지 않는다. 이를 통해 다형성이 유지되고 유연한 구조의 소프트웨어가 탄생할 수 있다. 하지만 이와 함께 interface는 행위를 강제하는 역할을 한다. 이것을 현실 세계와 비교해 보면 "계약" 관계와 그 유사성을 찾을 수 있다. 계약서를 들이 미는 쪽은 사용자 측이고, 계약을 이행해야 하는 측은 구체적인 구현 객체이다. 그리고 계약서에 해당하는 것은 interface이다. 사용자 측에서는 계약서, 즉 interface를 구현하기만 한다면 구체적인 객체가 어떤 일을 하든지 상관하지 않는다. 대신 계약서(interface)는 반드시 지켜져야만 한다.

이런 개념으로 interface를 이해하면 리스코프 치환의 원칙이 자연스럽게 지켜진다. 사용자 측에서는 언제나 interface를 통해서만 객체를 취급한다. 구체적인 객체들은 interface의 구현을 강제 당하는 셈이지만, 그 방법에 대해서는 최대한 자유를 누릴 수 있다. 이런 특성들을 개념적으로 잘 이해할 수 있도록 하기 위해서 interface에 계약 책임을 부여하고자 한다.

상태 책임

상태 책임은 필드와 필드의 가시성, 그리고 getter/setter에 대한 책임이다. 어떤 객체에 필드를 선언할 때는 메소드를 선언할 때보다 훨씬 신중해야 한다. 필드는 구현을 도와주는 도구가 아니다. 오히려 가능한 한 선언을 피해야 하고, 일단 선언 되어 있다면 가능한 한 가시성을 낮춤으로써 외부로 유출되는 것을 방지해야 한다. 일단 선언된 필드는 필드를 가지고 있는 객체에 책임을 뒤집어 씌운다. 필드는 필드 스스로 연산을 수행할 능력이 없기 때문이다. 다른 객체들은 특정 필드가 선언된 객체에게 해당 필드의 값을 요구하거나(getter), 어떤 필드 값을 저장하라고 시키거나(setter), 그 필드와 연관된 연산을 수행할 것을 요구할 수 있다. 그리고 특히 getter 메소드는 필드의 값을 외부로 누출시켜 필드에 의해 발생하는 로직들(특히 조건문과 같은)을 전파시키는 역할을 하게 된다. 따라서 단순히 필드를 노출시키는 것만 아니라 필드를 통해 다른 객체들이 하고자 하는 일들을 예상하고 그 일들을 미리 구현해야 한다.

GRASP 패턴

어떤 책임이 필요하고, 어떤 객체가 어떤 책임을 질 것인지 정하는 것은 쉽지 않은 일이다.

객체 디자인에서 가장 기본이 되는 것 중의 하나(원칙은 아닐지라도)는 책임을 어디에 둘지를 결정하는 것이다. 나는 십년 이상 객체를 가지고 일했지만 처음 시작할 때는 여전히 적당한 위치를 찾지 못한다. 늘 이런 점이 나를 괴롭혔지만, 이제는 이런 경우에 리팩토링을 사용하면 된다는 것을 알게 되었다.
《Refactoring》
Martin Fowler. (1999)

GRASP 패턴이란 General Responsibility Assignment Software Patterns의 약자로 각 객체에 역할(혹은 책임)을 부여하는 방법을 설명한 것이다.
이는 아래의 9가지로 구성되어 있다.
1. Information Expert: 역할을 수행할 수 있는 정보를 가지고 있는 객체에 역할을 부여하자. 단순해 보이는 이 원칙은 객체지향의 기본 원리 중에 하나이다. 객체는 데이터와 처리로직이 함께 묶여 있는 것이고, 자신의 데이터를 감추고자 하면 오직 자기 자신의 처리 로직에서만 데이터를 처리하고, 외부에는 그 기능(역할)만을 제공해야 하기 때문이다.

  1. Creator: 객체의 생성은 생성되는 객체의 컨텍스트를 알고 있는 다른 객체가 있다면, 컨텍스트를 알고 있는 객체에 부여하자. A 객체와 B 객체의 관계의 관계가 다음 중 하나라면 A의 생성을 B의 역할로 부여하라.

    • B 객체가 A 객체를 포함하고 있다.
    • B 객체가 A 객체의 정보를 기록하고 있다.
    • A 객체가 B 객체의 일부이다.
    • B 객체가 A 객체를 긴밀하게 사용하고 있다.
    • B 객체가 A 객체의 생성에 필요한 정보를 가지고 있다.
  2. Controller: 시스템 이벤트(사용자의 요청)를 처리할 객체를 만들자. 시스템, 서브시스템으로 들어오는 외부 요청을 처리하는 객체를 만들어 사용하라. 만약 어떤 서브시스템안에 있는 각 객체의 기능을 사용할 때, 직접적으로 각 객체에 접근하게 된다면 서브시스템과 외부간의 Coupling이 증가되고, 서브시스템의 어떤 객체를 수정할 경우, 외부에 주는 충격이 크게 된다. 서브시스템을 사용하는 입장에서 보면, 이 Controller 객체만 알고 있으면 되므로 사용하기 쉽다.

  3. Low Coupling: 객체들간, 서브 시스템들간의 상호의존도가 낮게 역할을 부여하자. Object-Oriented 시스템은 각 객체들과 그들 간의 상호작용을 통하여 요구사항을 충족시키는 것을 기본으로 한다. 그러므로, 각 객체들 사이에 Coupling이 존재하지 않을 수는 없다. 이 패턴은 요구사항은 충족시키면서도 각 객체들, 각 서브시스템 간의 Coupling를 낮은 수준으로 유지하는 방향으로 디자인하라고 말하고 있다. Low Coupling은 각 객체, 서브시스템의 재 사용성을 높이고, 시스템 관리에 편하게 한다.

  4. High Cohesion: 각 객체가 밀접하게 연관된 역할들만 가지도록 역할을 부여하자. 이 패턴은 Low Coupling 패턴과 동전의 양면을 이루는 것으로, 한 객체, 한 서브시스템이 자기 자신이 부여받은 역할만을 수행하도록 짜임새 있게 구성되어 있다면, 자신이 부여 받은 역할을 충족시키기 위해 다른 객체나 시스템을 참조하는 일이 적을 것이고, 그것이 곧 Low Coupling이기 때문이다.

  5. Polymorphism: 객체의 종류에 따라 행동양식이 바뀐다면, Polymorphism 기능을 사용하자. Object-Oriented 시스템은 상속과 Polymorphism(다형성)을 지원한다. 만약 객체의 종류에 따라 행동이 바뀐다면 객체의 종류를 체크하는 조건문을 사용하지 말고, Object-Oriented 시스템의 Polymorphism 기능을 사용하라.

  6. Pure Fabrication: Information Expert 패턴을 적용하면 Low Coupling과 High Cohesion의 원칙이 깨어진다면, 기능적인 역할을 별도로 한 곳으로 모으자. 데이터베이스 정보를 저장하거나, 로그 정보를 기록하는 역할에 대해 생각해 보자. 각 정보는 각각의 객체들이 가지고 있을 것이다. 이 때 Information Expert 패턴을 적용하면, 각 객체들이 정보를 저장하고, 로그를 기록하는 역할을 담당해야 하지만, 실제로 그렇게 사용하는 사람들은 없다. 이것은 그 기능들이 시스템 전반적으로 사용되고 있기 때문에 각 객체에 그 기능을 부여하는 것은 각 객체들이 특정 데이터베이스에 종속을 가져오거나, 로그을 기록하는 매커니즘을 수정할 경우, 모든 객체를 수정해야 하는 결과를 가져온다. 즉 Low Coupling의 원칙이 깨어지게 된다. 이럴 경우에는 공통적인 기능을 제공하는 역할을 한 곳으로 모아서 가상의 객체, 서브시스템을 만들어라.

  7. Indirection: 두 객체 사이의 직접적인 Coupling을 피하고 싶으면, 그 사이에 다른 객체를 사용하라. 여기서 말하는 다른 객체란 인터페이스가 될 수 있고, 주로 인터페이스인 경우가 많다. 그런 특별한 경우는 아래에 설명된 Protected Variations 패턴이라고 부를 수 있다.

  8. Protected Variations: 변경될 여지가 있는 곳에 안정된 인터페이스를 정의해서 사용하자. JDBC에 대해서 생각해 보자. JDBC는 일련의 인터페이스들로 구성되어 있으며, 각 데이터베이스 벤더들이 인터페이스를 구현한 Concrete 클래스를 제공하고 있다. 데이터베이스 기능을 사용하는 시스템의 입장에선 각 벤더들이 구현방식을 바꾸었을 때, 자신의 코드를 수정하고 싶지 않을 것이다. 그래서 Driver를 로딩하는 코드를 제외하고는 모두 인터페이스를 사용함으로서 데이터베이스의 변경시에도 Driver 로딩만 바꾸어 주면 되도록 데이터베이스 관련 작업이 필요한 곳에는 안정된 JDBC 인터페이스를 사용한 것이다.

SRP

한 객체가 다른 객체에 의존하는 이유는 의존의 대상이 되는 객체에 어떤 책임이 부여되어 있고, 그 책임을 이용하기 위함이다. 이러한 관점에서 책임이란 기능이라고 생각할 수도 있다. 그리고 그 기능이 있어야 수정, 변경하려는 이유도 생긴다. 아무 기능도 없다면, 다시 말해 책임이 없다면 의존할 필요도 없고, 수정할 필요도 없다.

객체를 변경해야 하는 이유는 단 하나여야 한다.
《Agile Software Development, Principles, Patterns, and Practices》
Martin, Robert C. (2002).

위의 문장은 객체의 책임은 단 하나여야 한다로 해석할 수 있을 것 같다. 한 객체가 여러 책임을 가진다면 변경 가능성이 여러 군데라고 생각할 수 있다. 막연하게 생각해봐도 변경 가능성이 여러 곳에 걸쳐있다면 유지보수하기 쉽지 않을 것 같지 않은가? 그 이유는 여러 책임이 한 객체 전반에 걸쳐 존재할 수 있기 때문이다. 그렇다면 하나의 책임만을 수정하려고 해도 그 책임과 관계없는 다른 책임들이 섞여 있을 수 있고, 처음 목적과는 다른 수정이 될 수 있을 것이다. 변경에 취약한 코드가 되는 것이다.
책임이 분할되는 경우, 중복 요소의 추출이 원활해질 것이다. 책임을 분할해 대상들을 작게 분리한다면 전체 시스템을 이해하기 수월해질 것이다. 또, 작은 단위들은 유사성을 띄는 경우가 많다. 하나로 강하게 결합된 거대한 시스템에서는 동일성을 찾기 쉽지 않지만 작게 나누다보면 같은 것을 발견할 가능성이 높아질 것이다. 조금씩 다르다고 해도 작은 부분들의 목적이 같다면 통일시켜 하나의 요소로 만들기도 쉬울 것이다. 이를 통해 더 많은 유연성과 재사용성 확보가 가능해질 것이다.

참고

GRASP 패턴

GRASP 패턴 : 객체의 책임 구별은 어떻게 할까?

[Effective Programming] 객체지향의 올바른 이해 : 7. 의존(Dependency)과 책임(Responsibility)

0개의 댓글