단일 책임 원칙(SRP)

단일 책임 원칙이란 ?

  • 클래스는 단 한 개의 책임을 가져야 한다.
  • 클래스를 변경하는 이유는 단 한가지여야 한다.

‘단일 책임 원칙’을 사용하는 이유

변경의 이유가 두 가지라면 여러가지 기능을 수정해야 할 때, 한 코드를 두 팀에서 두 가지 서로 다른 이유로 작업할 지도 모른다. 각 클래스의 수정 이유가 단 한가지라는 뜻은, 특정 기능을 수정할 때 클래스를 하나만 수정하면 되는 것이다. 만약 단일 책임 원칙을 지키지 않으면 특정 기능을 수정할 때 여러 클래스를 수정해야 하는 경우가 생긴다. 따라서 단일 책임 원칙을 지키게 되면 유지보수가 편리해진다. 즉, 기능을 변경하기가 쉬워진다.

‘단일 책임’에 대한 오해

잘못된 판단 기준

  • 클래스가 여러가지의 (public) 메소드를 가진다면, 복수의 책임을 갖는가?
  • 클래스가 다중상속 (혹은 다중구현)을 한다면, 복수의 책임을 갖는가?
  • 해당 클래스를 의존하는 사용자(클라이언트)가 여럿이라면 변경되는 이유는 여러가지가 되는가?

SRP는 위와 같은 질문에 단편적으로 답변할 수 없다. 즉, 판단의 기준이 될 수 없는 것이다.

‘단일 책임’은 절대적으로 측정할 수 있는 개념이 아니다.

매우 상대적인 개념이다. 어느 정도의 수준으로 추상화를 하냐에 따라서 ‘단일’로 볼 수도 있고 아닐 수도 있는 것이다. 하지만 단일 책임 원칙에서 말하는 ‘단일’은 객체들 간의 협력과 책임을 고려해서 되도록 작은 범위를 잡을 것을 권고하는 의미이다.

메서드의 개수 ≠ 책임의 개수

class Employee {
    public void calculatePay() { ... }
    public void calculateDeduction() { ... }
    public void calculateSalary() { ... }
}

하나의 클래스에 메서드가 3개라고 책임이 3개인 것이 아니다. 위 Employee 클래스의 책임은 “Employee에 관련된 정보를 계산하는 것”으로 1개의 책임을 가지고 있는 것이다. 즉, 하나의 책임에 같은 부류의 메서드들이 모여있는 것을 보고 단일 책임 원칙을 지켰다고 얘기하는 것이다.

‘액터’, ‘책임’의 정의

액터

‘서비스를 사용하는 주체자’의 관점에서 바라보는 것이 중요하다. 이러한 주체자들이 기능의 변경을 요청한다는 뜻이 곧 클래스 변경이 될 것이다. 예를 들면 특정 서비스에 대한 주체자들은 다음과 같다.

  • ‘지속성 모듈’ 서비스 : DBA, 소프트웨어 아키텍터
  • ‘회계’ 서비스 : 점원, 회계사, 운영자
  • ‘급여 시스템의 결제 계산’ 서비스 : 변호사, 관리자, 회계사
  • ‘도서관 관리 시스템’ 서비스 : 사서, 도서관 이용자

→ 이와 같은 서비스를 이용하는 주체자를 ‘액터(actor)’라고 하자.

책임

단일 책임 원칙에서 말하는 ‘책임’이란, 하나의 특정 액터(actor)를 위한 기능 집합이다.

책임에 대한 변경

액터(actor)가 니즈(사용하고자 하는 기능)이 바뀔 경우, 특정 기능 집합인 클래스의 코드 또한 액터의 니즈에 맞게 바뀌어야만 한다. 어떤 책임에 대해 액터는 해당 책임에 대해 유일한 변경의 원천이다.

예제

예 1)

‘책’이라는 개념과 ‘책의 기능’을 캡슐화한 Book이라는 클래스가 있다고 가정하자.

단일 책임 원칙을 지키지 않은 코드

class Book {
    // 책 제목 조회
    function getTitle() {
        return "A Great Book";
    }
		
    // 책 저자 조회
    function getAuthor() {
        return "John Doe";
    }
	
    // 페이지 넘기기 기능
    function turnPage() {
        // pointer to next page
    }
 
    // 현재 페이지를 화면에 출력
    function printCurrentPage() {
        echo "current page content";
    }
}

Book 객체를 운용하는 액터(실제 기능들을 사용하는 주체)는 누가 될 수 있을까? ‘책을 조회해서 도서관에 책을 정리하는 사람‘이나 ‘UI로 렌더링시키는 시스템‘이 될 것이다. 이 둘은 서로 아주 다른 액터이다. 그래서 밑의 코드로 변경하는 것이 ‘단일 책임 원칙’을 준수한 코드가 되는 것이다.

단일 책임 원칙을 지킨 코드

// 책을 조회해서 도서관에 책을 정리하는 사람이 사용하는 기능들
class Book {
    function getTitle() {
        return "A Great Book";
    }
 
    function getAuthor() {
        return "John Doe";
    }
 
    function turnPage() {
        // pointer to next page
    }
 
    function getCurrentPage() {
        return "current page content";
    }
}
 
// UI로 렌더링시키는 시스템이 사용하는 기능들
interface Printer {
    function printPage($page);
}
 
class PlainTextPrinter implements Printer {
    function printPage($page) {
        echo $page;
    }
}
 
class HtmlPrinter implements Printer {
    function printPage($page) {
        echo '<div style="single-page">' . $page . '</div>';
    }
}

위 코드를 잘 보면, ‘비즈니스 로직’과 ‘UI 관련 로직’을 분리한 것의 관점으로도 볼 수도 있다. 즉, MVC에서 View와 Model의 코드를 분리해했다는 측면에서 MVC는 ‘단일 책임 원칙’을 지키려고 하는 디자인 패턴임을 알 수 있다.

예 2)

단일 책임 원칙을 지키지 않은 코드

class Book {
    // 책 제목 조회
    function getTitle() {
        return "A Great Book";
    }
		
    // 책 저자 조회
    function getAuthor() {
        return "John Doe";
    }
	
    // 페이지 넘기기 기능
    function turnPage() {
        // pointer to next page
    }
 
    // 현재 페이지 내용 조회
    function getCurrentPage() {
        return "current page content";
    }
 
		// 책을 DB에 저장
    function save() {
        $filename = '/documents/'. $this->getTitle(). ' - ' . $this->getAuthor();
        file_put_contents($filename, serialize($this));
    }
}

Book 객체를 운용하는 액터(실제 서비스를 사용하는 사람)는 누가 될 수 있을까? ‘책을 조회해서 도서관에 책을 정리하는 사람‘이나 ‘책을 DB에 저장 및 관리하는 사용자‘이 될 것이다. 이 둘은 서로 아주 다른 액터이다. 그래서 밑의 코드로 변경하는 것이 ‘단일 책임 원칙’을 준수한 코드가 되는 것이다.

단일 책임 원칙을 지킨 코드

// 사서가 사용하는 기능
class Book {
 
    function getTitle() {
        return "A Great Book";
    }
 
    function getAuthor() {
        return "John Doe";
    }
 
    function turnPage() {
        // pointer to next page
    }
 
    function getCurrentPage() {
        return "current page content";
    }
}

class SimpleFilePersistence {
    function save(Book $book) {
        $filename = '/documents/' . $book->getTitle() . ' - ' . $book->getAuthor();
        file_put_contents($filename, serialize($book));
    }
}

예 3)

제가 쓴 이전 글에서는 하위 수준에서 볼 수 있는 고수준 아키텍처 스키마에 관해 자주 언급하고 제시했습니다.

https://cdn.tutsplus.com/net/uploads/2013/12/HighLevelDesign.png

이 스키마를 분석해 보면 단일 책임 원칙을 어떻게 준수하는지 볼 수 있습니다. 객체 생성은 오른쪽에 있는 팩터리(Factories)와 애플리케이션의 주요 진입점에 나눠져 있으며 한 액터가 한 가지 책임을 맡고 있습니다. 지속성(Persistence, DB를 주로 의미함) 또한 아래 부분에서 처리되고 있습니다. 각 모듈은 서로 다른 다른 책임을 맡습니다. 마지막으로 왼쪽 편에서는 MVC나 다른 UI 형식으로 프레젠테이션과 전달 메커니즘(Delivery Mechanism)을 갖추고 있습니다. 보다시피 SRP가 준수되고 있습니다. 이제 비즈니스 로직에서 어떤 일을 하는지 파악하기만 하면 됩니다.

예 4) 기능을 사용하는 현실 문맥에 따라서 ‘책임의 분리’ 기준이 달라진다.

단일 책임 원칙을 지키지 않은 코드

class Book {
 
    // 책 제목 조회
	  function getTitle() {
        return "A Great Book";
    }
		
    // 책 저자 조회
    function getAuthor() {
        return "John Doe";
    }
	
    // 페이지 넘기기 기능
    function turnPage() {
        // pointer to next page
    }
 
    // 현재 페이지 내용 조회
    function getCurrentPage() {
        return "current page content";
    }
 
    // 책 위치 조회
    function getLocation() {
        // returns the position in the library
        // ie. shelf number & room number
    }
 
}

서비스의 요구사항을 보니 Book 객체를 사용하는 액터가 ‘온라인으로 책을 주문하는 사용자‘와 ‘온라인으로 주문을 받은 오프라인 서점 주인‘이라고 가정하자. 그럼 ‘온라인으로 책을 주문하는 사용자’는 책 위치를 조회하는 기능인 getLocation()이라는 메서드가 필요없을 것이다. 하지만 ‘온라인으로 주문을 받은 오프라인 서점 주인’은 getLocation()이라는 메서드가 필요할 것이다.

만약 ‘온라인으로 책을 주문하는 사용자’가 위의 Book 객체를 사용한다면, ‘온라인으로 책을 주문하는 데 필요로 하는 기능들’ 이외에 ‘책 위치를 조회하는 기능’이라는 2가지의 책임을 가지게 되어서, SRP를 위반하게 되는 것이다.

단일 책임 원칙을 지킨 코드

class Book {
 
    function getTitle() {
        return "A Great Book";
    }
 
    function getAuthor() {
        return "John Doe";
    }
 
    function turnPage() {
        // pointer to next page
    }
 
    function getCurrentPage() {
        return "current page content";
    }
 
}
 
class BookLocator {
 
    function locate(Book $book) {
        // returns the position in the library
        // ie. shelf number & room number
        $libraryMap->findBookBy($book->getTitle(), $book->getAuthor());
    }
}

BookLocator라는 클래스를 새로 만듦으로써, ‘온라인으로 책을 주문하는 사용자’는 Book 객체만을 사용할 것이고, ‘온라인으로 주문을 받은 오프라인 서점 주인’은 BookLocator 객체가 Book 객체를 인자로 받아서 사용하기 떄문에, BookLocator 객체만 사용하게 될 것이다.

단일 책임 원칙을 지키기 위한 설계 순서

  1. 먼저 요구사항을 분석해 액터를 정의한다.
  2. 각 액터에게 제공해야 할 책임을 파악한다.
  3. 함수와 클래스 각각이 단 하나의 책임만 할당받도록 함수, 클래스를 그룹화한다.

References