Advertisement
  1. Code
  2. PHP

SOLID: 1부 - 단일 책임 원칙

Scroll to top
Read Time: 9 min
This post is part of a series called The SOLID Principles.
SOLID: Part 2 - The Open/Closed Principle

() translation by (you can also view the original English article)

단일 책임(SRP; Single Responsibility), 개방/폐쇄(Open/Close), 리스코프 치환(Liskov's Substitution), 인터페이스 분리(Interface Segregation), 의존성 주입(Dependency Inversion). 코드를 작성할 때마다 여러분을 안내해줄 다섯 가지 애자일 원칙.

정의

클래스를 변경하는 이유는 단 한 가지여야 한다.

이 원칙은 로버트 C. 마틴(Robert C. Martin)은 자신의 책 Agile Software Development, Principles, Patterns, and Practices와 그후에 출간된 C# 버전의 책 Agile Principles, Patterns, and Practices in C#에서 정의했으며, 다섯 가지 SOLID 애자일 원칙 중 하나입니다. 이 원칙이 말하고자 하는 바는 아주 단순하지만 그러한 단순함을 달성하기란 매우 까다로울 수 있습니다. 클래스를 변경하는 이유는 단 한 가지여야 합니다.

그런데 그 이유는 무엇일까요? 변경의 이유가 단 하나여야 한다는 것이 중요한 이유는 무엇일까요?

정적 타입 및 컴파일 언어에서는 여러 가지 이유로 갖가지 원치 않은 재배포가 일어날 수 있습니다. 변경의 이유가 두 가지라면 한 코드를 두 팀에서 두 가지 서로 다른 이유로 작업할지도 모릅니다. 각자 자체적인 솔루션을 배포해야 할 테고, 컴파일 언어(C++, C#, 자바 같은)의 경우 다른 팀이나 애플리케이션의 다른 부분과 호환되지 않는 모듈이 만들어질 수 있습니다.

컴파일 언어를 사용하지 않는 경우에도 한 클래스나 모듈을 서로 다른 이유로 다시 테스트해야 할지도 모릅니다. 결국 QA 업무나 시간, 노력이 더 많이 들게 됩니다.

청중

클래스나 모듈이 단 한 가지 책임을 가져야 할지 결정하는 것은 체크리스트를 살펴보는 것보다 훨씬 더 복잡합니다. 예를 들어, 변경의 이유를 찾는 한 가지 단서는 클래스의 청중을 분석하는 것입니다. 우리가 개발하는 애플리케이션이나 시스템의 사용자, 즉 특정 모듈를 사용하는 사람들이 해당 모듈의 변경을 요청하는 사람들일 것입니다. 그런 사람들이 변경을 요청할 것입니다. 다음은 몇 가지 모듈과 그러한 모듈 청중의 예입니다.

  • 지속성 모듈 - DBA나 소프트웨어 아키텍트
  • 보고서 모듈 - 점원, 회계사, 운영자
  • 급여 시스템의 결제 계산 모듈 - 변호사, 관리자, 회계사
  • 도서관 관리 시스템의 도서 검색 모듈 - 사서 및/또는 도서관 이용자

역할과 액터

구체적인 인물을 이러한 모든 역할과 연관시키는 것은 쉽지 않을 수 있습니다. 규모가 작은 회사에서는 단 한 사람이 여러 역할을 맡아야 할 수도 있는 반면 규모가 큰 회사에서는 단 하나의 역할에 여러 명이 배정될 수도 있습니다. 따라서 역할에 관해 생각하는 편이 훨씬 더 적절해 보입니다. 하지만 역할 자체는 정의하기가 상당히 까다롭습니다. 역할이란 무엇일까요? 역할을 어떻게 찾아야 할까요? 그러한 역할을 수행하는 액터(actor)와 그러한 액터와 청중을 연관시키는 것을 상상하는 편이 훨씬 더 쉽습니다.

따라서 사용자가 변경의 이유를 정의하면 액터가 청중을 정의합니다. 이렇게 하면 "아키텍트 존"이나 "관계자 매리" 같은 구체적인 인물을 각각 아키텍트나 운영자로 만드는 데 크게 도움됩니다.

따라서 책임은 하나의 특정 액터를 위한 기능 집합이다. (로버트 C. 마틴)

변경의 원천

이렇게 추론하는 맥락에서 액터는 자신을 위한 기능 집합을 변경하는 원천이 됩니다. 액터의 니즈가 바뀔 경우 특정 기능 집합 또한 액터의 니즈에 맞게 바뀌어야만 합니다.

어떤 책임에 대해 액터는 해당 책임에 대한 유일한 변경의 원천이다. (로버트 C. 마틴)

고전적인 예제

자기 자신을 "출력"할 수 있는 객체

책이라는 개념과 책의 기능을 캡슐화한 Book이라는 클래스가 있다고 해봅시다.

1
class Book {
2
3
  function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function printCurrentPage() {
16
		echo "current page content";
17
	}
18
}

이 클래스는 대체로 다음과 같은 형태일지도 모릅니다. 책은 저자와 제목을 반환할 수 있고, 페이지를 넘길 수 있습니다. 마지막으로 현재 페이지를 화면에 출력할 수도 있습니다. 그런데 여기에 자그마한 문제가 하나 있습니다. Book 객체를 운용하는 액터는 누가 될 수 있을까요? 여기서 두 부류의 액터를 어렵지 않게 떠올릴 수 있습니다. 바로 도서 관리(사서 같은)와 책 표현 메커니즘(책의 내용을 사용자에게 전달하는 방식, 즉 화면이나 그래픽 UI, 텍스트 전용 UI, 인쇄 같은)입니다. 이 둘은 아주 다른 액터입니다.

비즈니스 로직을 프레젠테이션과 섞는 것은 단일 책임 원칙(SRP; Single Responsibility Principle)에 위배되기에 바람직하지 않습니다. 다음 코드를 살펴봅시다.

1
class Book {
2
3
	function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function getCurrentPage() {
16
		return "current page content";
17
	}
18
19
}
20
21
interface Printer {
22
23
	function printPage($page);
24
}
25
26
class PlainTextPrinter implements Printer {
27
28
	function printPage($page) {
29
		echo $page;
30
	}
31
32
}
33
34
class HtmlPrinter implements Printer {
35
36
	function printPage($page) {
37
		echo '<div style="single-page">' . $page . '</div>';
38
	}
39
40
}

이처럼 아주 기초적인 예제에서도 비즈니스 로직으로부터 프레젠테이션을 분리하고 SRP를 준수하는 방법을 보여주며, 이는 설계의 유연성에 큰 이점을 줍니다.

자기 자신을 "저장"할 수 있는 객체

위 예제와 비슷한 예제로 한 객체가 프레젠테이션에서 자기 자신을 저장하고 조회할 수 있는 것이 있습니다.

1
class Book {
2
3
	function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function getCurrentPage() {
16
		return "current page content";
17
	}
18
19
	function save() {
20
		$filename = '/documents/'. $this->getTitle(). ' - ' . $this->getAuthor();
21
		file_put_contents($filename, serialize($this));
22
	}
23
24
}

여기서는 다시 한 번 도서 관리 시스템이나 지속성 같은 여러 액터를 확인할 수 있습니다. 지속성을 변경하고 싶을 때마다 이 클래스를 변경해야 합니다. 한 페이지를 다음 페이지로 넘기는 방법을 바꾸고 싶을 때마다 이 클래스를 변경해야 합니다. 이 클래스에는 변경의 축이 여러 개 있습니다.

1
class Book {
2
3
	function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function getCurrentPage() {
16
		return "current page content";
17
	}
18
19
}
20
21
class SimpleFilePersistence {
22
23
	function save(Book $book) {
24
		$filename = '/documents/' . $book->getTitle() . ' - ' . $book->getAuthor();
25
		file_put_contents($filename, serialize($book));
26
	}
27
28
}

지속성 연산을 다른 클래스로 옮기면 책임이 명확하게 나눠지고 Book 클래스에 영향을 주지 않고도 지속성 수단을 마음껏 바꿀 수 있습니다. 예를 들어, DatabasePersistence 클래스를 구현하는 것이 수월해질 것이고 책 관련 연산을 기반으로 하는 비즈니스 로직도 바뀌지 않을 것입니다. 

고수준 관점

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

HighLevelDesignHighLevelDesignHighLevelDesign

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

소프트웨어 설계 고려사항

앞으로 작성해야 할 소프트웨어에 관해 생각할 경우 여러 가지 측면을 분석할 수 있습니다. 예를 들어, 한 클래스가 여러 요구사항의 영향을 받는다면 변경의 축을 나타내는 것일지도 모릅니다. 이러한 변경의 축은 단일 책임 원칙의 단서일 수도 있습니다. 동일한 기능 그룹에 영향을 주는 요구사항 그룹은 애초부터 변경하거나 구체화해야 할 확률이 높습니다.

소프트웨어의 일차적인 가치는 변경하기 쉽다는 것입니다. 두 번째는 기능성인데, 요구사항을 최대한 충족시켜 사용자의 니즈를 충족시킨다는 것을 의미합니다. 하지만 이차적인 가치를 높게 달성하기 위해서는 일차적인 가치가 반드시 지켜져야 합니다. 일차적인 가치를 높게 유지하기 위해서는 변경하거나 확장하기 쉽고 새로운 기능을 적용하고 SRP를 준수하기 쉬운 설계를 갖춰야 합니다.

이를 다음과 같이 단계별 방식으로 추론할 수 있습니다.

  1. 일차적 가치는 이윽고 높은 이차적 가치로 이어집니다.
  2. 이차적 가치는 사용자 니즈를 의미합니다.
  3. 사용자 니즈는 액터의 니즈를 의미합니다.
  4. 액터의 니즈는 이러한 액터의 변경 니즈를 결정합니다.
  5. 액터의 변경 니즈는 우리의 책임을 규정합니다.

따라서 소프트웨어를 설계할 때는 다음을 준수해야 합니다.

  1. 액터를 찾아 정의한다.
  2. 그러한 액터에게 제공해야 할 책임을 파악한다.
  3. 함수와 클래스 각각이 단 하나의 책임만 할당받도록 함수를 클래스를 그룹화한다.

좀 더 불분명한 예제

1
class Book {
2
3
	function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function getCurrentPage() {
16
		return "current page content";
17
	}
18
19
	function getLocation() {
20
		// returns the position in the library

21
		// ie. shelf number & room number

22
	}
23
24
}

이제는 이것이 매우 합리적인 것처럼 보일지도 모릅니다. 여기엔 지속성이나 프레젠테이션을 처리하는 메서드가 없습니다. 대신 turnPage() 기능과 책에 관한 다양한 정보를 제공하는 몇 가지 메서드가 있습니다. 하지만 여기엔 문제가 있을지도 모릅니다. 문제를 찾기 위해서는 애플리케이션을 분석하고 싶을지도 모릅니다. getLocation() 함수가 문제일지도 모릅니다.

Book 클래스의 모든 메서드는 비즈니스 로직에 관한 것입니다. 그래서 우리는 비즈니스 관점에서 바라봐야만 합니다. 우리가 만든 애플리케이션이 책을 검색하고 실제 책을 제공하는 실제 사서가 사용하도록 작성된 것이라면 SRP가 위반됐을지도 모릅니다.

우리는 액터 연산이 getTitle()getAuthor(), getLocation() 메서드와 관련된 것이라는 사실을 추론할 수 있습니다. 도서관 이용자도 애플리케이션에 접근해 책을 선택한 후 책에 관해 살펴보기 위해 처음 몇 페이지를 읽고 책이 필요한지 여부를 판단할 수 있을지도 모릅니다. 따라서 독자 액터는 getLocations() 메서드를 제외한 모든 메서드에 관심이 있을 수 있습니다. 평범한 도서관 이용자는 책이 도서관의 어디에 보관돼 있는지 신경 쓰지 않습니다. 책은 사서에 의해 도서관 이용자에게 전달될 것입니다. 그래서 실제로 SRP를 위반하고 있는 것입니다.

1
class Book {
2
3
	function getTitle() {
4
		return "A Great Book";
5
	}
6
7
	function getAuthor() {
8
		return "John Doe";
9
	}
10
11
	function turnPage() {
12
		// pointer to next page

13
	}
14
15
	function getCurrentPage() {
16
		return "current page content";
17
	}
18
19
}
20
21
class BookLocator {
22
23
	function locate(Book $book) {
24
		// returns the position in the library

25
		// ie. shelf number & room number

26
		$libraryMap->findBookBy($book->getTitle(), $book->getAuthor());
27
	}
28
29
}

BookLocator를 도입하면 사서가 BookLocator에 관심을 가질 것입니다. 도서관 이용자는 Book에만 관심을 가질 것입니다. 물론 BookLocator를 구현하는 방법에는 여러 가지가 있습니다. 저자와 제목 또는 책 객체를 사용해 Book에서 필요한 정보를 얻을 수 있습니다. BookLocator는 언제나 비즈니스에 의존합니다. 중요한 점은 라이브러리가 변경되고 사서가 다른 방식으로 정리된 서고에서 책을 찾아야 할 경우에도 Book 객체는 영향을 받지 않는다는 점입니다. 같은 방식으로 독자가 페이지를 열람하는 대신 미리 만들어진 요약본을 제공하기로 했더라도 사서 또는 책이 놓여있는 선반을 찾는 과정에 영향을 주지 않습니다.

하지만 사서를 없애고 도서관에 셀프 서비스 메커니즘을 만들기로 했다면 첫 번째 예제에서 SRP가 지켜진다고 여길 수도 있습니다. 이 경우 독자가 사서이기도 하므로 직접 책을 찾아 자동화된 시스템에서 책을 대출해야 합니다. 이 또한 가능성 있는 일입니다. 여기서 기억해야 할 중요한 부분은 언제나 비즈니스를 신중하게 고려해야 한다는 점입니다.

결론

단일 책임 원칙은 코드를 작성할 때 항상 고려해야 합니다. 단일  책임 원칙은 클래스와 모듈 설계에 지대한 영향을 미치고, 이 원칙이 잘 지켜지면 의존성이 적고 가벼운, 결합도가 낮은 설계로 이어집니다. 하지만 동전의 양면처럼 여기에도 양면성이 있습니다. 처음부터 SRP를 염두에 두고 애플리케이션을 설계하고 싶을 것입니다. 그뿐만 아니라 원하거나 필요한 만큼 여러 액터를 식별하고 싶기도 할 것입니다. 하지만 설계 관점에서 봤을 때 이처럼 처음부터 모든 당사자를 생각해내려는 것은 사실 위험합니다. SRP에 대한 과도한 고려는 성급한 최적화로 이어질 수 있고, 더 나은 설계 대신 클래스나 모듈의 책임을 명확하게 이해하기 힘들 수 있는 산발적인 설계로 이어질 수 있습니다.

따라서 클래스나 모듈이 서로 다른 이유로 변경되기 시작하면 주저하지 말고 SRP를 준수하는 데 필요한 조치를 취합니다. 그러나 성급한 최적화가 여러분을 곤경에 처하게 할 수 있으므로 과도하게 해서는 안 됩니다.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.