SOLID 디자인 원칙1 : SRP
전체 SOLID 디자인 원칙의 링크들
SOLID 디자인 원칙이란?
SOLID는 다음과 같은 디자인 원칙들을 아우르는 약어이다.
- 단일 책임 원칙(Single Responsibility Principle, SRP)
- 열림-닫힘 원칙(Open-Closed Principle, OCP)
- 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
- 인터페이스 분리 원칙(Interface Segregation Principle, ISP)
- 의존성 역전 원칙(Dependency Inversion Principle, DIP)
이 원칙들은 2000년대 초 로버트 마틴에 의해 소개되었다. 사실, 이 다섯 가지 원칙은 로버트의 저서와 블로그에서 소개된 수십 가지 원칙중에서 선정된 것이다. 프로그래밍언어를 시작하거나 공부하는 사람들이라면, 일명 예쁜 코드를 짜기 위해서는 위와 같은 원칙을 따라야 한다고 많은 사람들이 말한다. 그러나 대부분의 사람들이 자세한 이유와 예시 그리고 결과를 보여주지 않고, 그냥 몇줄만 적고 넘어가는 경우가 대부분이다. 그래서 대부분의 사람들은 SOLID 원칙을 중요시 하지 않고 그냥 짜는 경우가 많다. 하지만 SOLID 원칙은 꽤나 중요한 원칙이며, 앞으로 우리가 공부할 디자인 패턴들의 존재 이유에 이 다섯 가지 원칙이 전반적으로 녹아들어가 있다. 따라서 이 SOLID 원칙을 조금 깊게 파고들어가 공부해보자.
SOLID 디자인 원칙의 첫 번째, 단일 책임 원칙(Single Responsibility Principle, SRP)
좋은 아이디어가 생각날 때마다 기록해 두는 메모방을 만들기로 했다고 하자. 이 메모장에는 제목 하나에 여러 항목이 저장될 수 있을 것이다. 이러한 기능은 다음과 같이 구현될 수 있다.
1
2
3
4
5
6
class Journal {
string title;
vector<string> entries;
explicit Journal(const string& _title) : title(_title) {}
};
이제 메모장의 각 항목이 기입된 순서대로 저장되게 하는 기능을 만들어보자.
1
2
3
4
5
static int count;
void add(const string& _entry) {
entries.push_back(to_string(count++) + ": " + _entry);
}
이제 다음과 같이 메모장을 이용할 수 있다.
1
2
3
Journal j("Diary");
j.add("test1");
j.add("test2");
이 함수가 Journal 클래스에 포함되는 것은 매우 자연스럽고 상식적이다. 왜냐하면 각 항목을 기록할 수 있게 하고 관리할 책임이 메모장에 있기 때문이다. 따라서 이러한 책임을 완수하기 위한 함수라면 Journal에 포함되어야만 한다. 이제 메모장에 영구적으로 파일에 저장하는 기능을 만들기로 했다고 하자. 그러한 코드를 다음과 같이 Jounral 클래스에 추가할 수 있다.
1
2
3
4
5
void save(const string& _filename) {
ofstream ofs(_filename);
for (auto& s : entries)
ofs << s << "\n";
}
이러한 방식은 문제가 있을수 있는데, 메모장의 책임은 메모 항목들을 기입/관리하는 것이지 디스크에 쓰는 것이 아니다. 만약 디스크에 파일을 쓰는 기능을 데이터의 기입/관리를 담당하는 클래스가 함께 책임지도록 한다면 데이터 저장 방식이 바뀔 때(예를 들어, 로컬 디스크 대신 원격 클라우드에 저장)마다 그러한 클래스들을 일일이 모두 수정해야 한다.
잠시 요점을 짚고 넘어가자. 작은 수정을 여러 클래스에 걸쳐서 해야 한다면 아키텍처에 뭔가 문제가 있다는 징조이다. 물론 수정할 곳이 수백 군데에 이르더라도 단순히 변수 이름을 일괄적으로 바꾸어야 하는 상황이면 큰 문제가 아니다.(이 문제는 IDE에서 제공하는 변수명 일괄 변경 기능으로 해결할 수 있다.) 하지만, 인터페이스를 완전히 고쳐야 하는상황이라면 대단히 고통스러울 수 있다.
따라서, 이 예에서의 파일 저장 기능은 메모장과 별도로 취급하여 별도의 클래스로 만드는 것이 바람직하다. 예를 들어 다음과 같이 만들 수 있다.
1
2
3
4
5
6
7
class PersistenceManager {
static void save(const Journal& _j, const string& _filename) {
ofstream ofs(_filename);
for (auto& s : _j.entries)
ofs << s << "\n";
}
};
단일 책임 원칙이 의미하는 바가 정확히 바로 이런 것이다. 각 클래스는 단 한가지의 책임을 부여받아, 수정할 이유가 단 한가지여야 한다. Journal 클래스는 기록할 항목에 대해 뭔가 바꿀 것이 있을 때 코드 수정이 되어야 한다. 예를 들어 각 항목이 날짜와 시간을 접두어로 가지게 해야 한다면 add()
함수를 그렇게 하도록 수정해야 한다. 반면에 영구적인 저장 방식을 바꾸어야 한다면 PersistenceManager
를 수정해야 한다.
SRP를 위배하는 안티 패턴의 극단적인 예로 전지전능 객체가 있다. 전지전능 객체는 가능한 많은 기능을 담아 하나의 괴물 같은 클래스를 이룬다. 이러한 객체와 함께 일하는 것은 매우 어렵다.
다행히, 전지전능 객체는 IDE에 의해 쉽게 식별될 수 있다.(멤버 함수의 개수만 헤아리면 된다.) 전지전능 객체를 가능한 한 빠르게 식별하고 바로 잡아야 한다.
SRP의 장점과 단점
SRP원칙을 따라서 구현을 해야하는 것은 분명한 일이다. 그러나 명확하게 SRP를 사용함으로써 생기는 장점과 단점을 파악하는 일은 매우 중요하니 생각해보자.
- 단점
- 코드의 길이가 길어진다.
- 설계 복잡도가 증가한다.
책임을 분산하기 위해 클래스를 여럿 만들다 보면 필연적으로 코드의 길이가 길어질 수 밖에 없다. 또한, 전지전능 객체를 만들면 아무 생각 없이 코딩해도 상관없지만 SRP 원칙에 따라 책임을 지키면서 코딩하는 것은 매우 복잡한 작업이 될 수 있다.
- 장점
- 디버깅이 쉬워진다.
- 기능 변경(수정)을 해야만 할 때, 파급 효과가 매우 적어진다.
- 멀티 쓰레드가 쉬워진다.
책임이 클래스별로 분리 되어 있고, 모든 기능은 개별적인 함수를 통해 작동함으로 중단점을 걸기도 좋아지기 때문에 디버깅이 쉬워진다. 책임을 분리함으로써, 기능 변경을 해야만 할 때, 해당 책임을 지닌 클래스를 수정하면 된다.(즉, 한 기능을 수정하기 위해 모든 코드를 수정해야할 필요가 없다.) 멀티 쓰레드를 사용하기 위해서 lock을 걸 때, 책임이 명확하게 분리되어 있음으로, lock을 걸기가 쉬워진다. 이는 상대적으로 멀티 쓰레드 구현에 도움이 된다.
단점에 비해서 장점으로 얻는 이득이 크기 때문에, 스스로 하는 프로젝트의 규모가 크다 싶으면 이 SRP원칙을 지키기 위해 노력하자.
요약
SRP는 책임을 클래스에 분리하여 구현하는 원칙이다. 이 책임을 분리하는 가장 큰 기준은 기능 변경(수정)이 일어났을 때, 파급효과를 최대한 줄일 것이다. 프로젝트의 규모가 크다면 SRP원칙을 지킴으로써 얻는 이득이 크기 때문에 최대한 SRP원칙을 지키도록 하자.