최근 SOLID 원칙에 대해 다시 들ㅇ다 보는 시간을 가지게 된 적이 있는데, 이 때 내가 깨달은 경험을 바탕으로, 단일책임원칙에 대해 말해보려고 한다.
단일책임원칙(SRP : Single Responsibility Principle)은 객체지향을 공부하면서 한 번쯤은 들어봤을 용어일 것이다.
사전적 정의만 놓고 본다면, 단일책임원칙의 의미는
객체는 오직 하나의 책임만을 가진다
라는 정의할 수 있는데, 나는 여기서 "책임"이라는 단어에 대해 확실하게 알고 있어야한다고 생각한다.(왜냐면 내가 이걸 몰랐으니까..)
책임이라는 것은 말 그대로 "객체가 어떤 행동을 수행할 수 있는가"를 정의하는 것을 의미한다. 그런데, 이러한 책임을 부여하는 것에 있어, 과연 우리는 SRP에 맞는 "단일 책임"을 객체에게 부여하고 있을까?
하나의 예시를 살펴보자.
우리는 채팅 서비스를 만들고 있다. 우리의 채팅 서비스는 채팅방에서 아래와 같은 기능들을 제공해준다.
- 사용자 초대
- 채팅방 나가기(혼자 남은 경우엔 채팅방 삭제)
- 채팅방 이름 변경
- 그 외 기능
위와 같은 요구사항이 주어지게 되면, 우리는 해당 기능을 구현하게 위해 우리는 아마 아래와 같은 코드를 작성할 것이다.
final class ChatRoomManager {
final ChatRoomInfo _chatRoom;
final ChatRoomApi _chatRoomApi;
const ChatRoomManager({
required ChatRoomInfo chatRoom,
required ChatRoomApi chatRoomApi,
}) : _chatRoom = chatRoom,
_chatRoomApi = chatRoomApi;
void inviteMember(List<String> userIdList) {
_chatRoomApi.addMembers(userIdList);
}
void leaveChatRoom() {
if (_chatRoom.hasOtherMember()) {
_chatRoomApi.leave(_chatRoom.roomId);
} else {
_chatRoomApi.delete(_chatRoom.roomId);
}
}
void updateChatRoomName(String newName) {
_chatRoomApi.updateName(newName);
}
}
이 예시 코드를 보면 논리적으로 요구사항에 모두 부합하도록 작성되어 있고, 실제로도 그렇게 동작할 것이다.
하지만, 이러한 코드는 SRP에 위배되는 코드로 볼 수 있는데, 그 이유는 "단일 책임"이라는 조건에 충족하지 않기 때문이다.
"하지만, 채팅방 하나만 책임지니까 단일 책임 아닌가요?"
이렇게 물어보는 사람들이 분명히 있을 것이다. 나 또한 그렇게 생각했었으니까.
이 글의 목적이 바로 이러한 질문이 머릿속에 떠오르는 사람들에게 내가 생각하고 있는 바를 공유하고자 작성한 것이기에, 이 부분에 대해 자세하게 알아보도록 하자.
우선, 우리가 놓치고 있는 부분을 바로 알 필요가 있다. 단일책임원칙을 처음으로 언급한 로버트 마틴이 정의한 "책임"에 대해 알아보자.
"A class should have only one reason to change".
- https://en.wikipedia.org/wiki/Single-responsibility_principle
문장에 따르면, 책임이라는 것의 정의는 "변경되어야하는 이유"로 설명할 수 있다.
"변경되어야하는 이유"라는 것이 추상적으로 느껴질 수 있을 텐데, 내 나름대로는 이것을
변경되어야하는 이유 == 데이터에 무엇인가 영향을 주었는가
로 생각하고 있다.
즉, 우리가 작성한 어떠한 코드의 동작이 시스템 내의 데이터에 변경을 일으킨다면, 이것이 곧 하나의 책임이 된다는 말이다.
앞서 작성된 단일 책임의 관점에서, 다시 한 번 ChatRoomManager를 보도록 하자.
ChatRoomManager는 inviteMember, leaveChatRoom, updateChatRoomName이라는 3개의 함수를 가지고 있으며, 각각의 함수는 최소 1회 이상의 데이터 변경을 수행한다.
특히 leaveChatRoom함수의 경우, 함수 내부에서 경우에 따라 데이터 변경을 수행하는 동작에 분기가 있으므로, 총 2가지의 데이터 변경 로직을 가지고 있다.
SRP를 적용하기 위해서는, 이러한 데이터의 변경을 수행하는 동작들을 모두 단일 클래스로 만들어 줄 필요가 있다.
// 채팅방 멤버 초대
final class InviteChatMemberUseCase {
final ChatRoomApi _chatRoomApi;
const InviteChatMemberUseCase({required ChatRoomApi chatRoomApi})
: _chatRoomApi = chatRoomApi;
void execute(List<String> userIdList) {
_chatRoomApi.addMembers(userIdList);
}
}
// 채팅방 나가기
final class LeaveChatRoomUseCase {
final ChatRoomApi _chatRoomApi;
const LeaveChatRoomUseCase({required ChatRoomApi chatRoomApi})
: _chatRoomApi = chatRoomApi;
void execute(String chatRoomId) {
_chatRoomApi.leave(chatRoomId);
}
}
// 채팅방 삭제
final class DeleteChatRoomUseCase {
final ChatRoomApi _chatRoomApi;
const DeleteChatRoomUseCase({required ChatRoomApi chatRoomApi})
: _chatRoomApi = chatRoomApi;
void execute(String chatRoomId) {
_chatRoomApi.delete(chatRoomId);
}
}
// 채팅방 이름 변경
final class UpdateChatRoomNameUseCase {
final ChatRoomApi _chatRoomApi;
const UpdateChatRoomNameUseCase({required ChatRoomApi chatRoomApi})
: _chatRoomApi = chatRoomApi;
void execute(String newName) {
_chatRoomApi.updateName(newName);
}
}
이 4개의 클래스는 모두 단순하게 "API를 호출한다"라는 동작을 수행할 뿐, 그 이상의 무언가를 수행하지 않는다. 즉, 단 하나의 책임만을 가지고 있다는 의미이다.
그러면, 이렇게 단일책임원칙을 적용하면, 이러한 코드를 어떻게 사용해야할까?
이를 반영한 ChatRoomManager 코드가 아래에 작성되어 있으니 확인해보자.
final class ChatRoomManager {
final InviteChatMemberUseCase _inviteChatMemberUseCase;
final LeaveChatRoomUseCase _leaveChatRoomUseCase;
final DeleteChatRoomUseCase _deleteChatRoomUseCase;
final UpdateChatRoomNameUseCase _updateChatRoomNameUseCase;
const ChatRoomManager({
required ChatRoomInfo chatRoom,
required InviteChatMemberUseCase inviteChatMemberUseCase,
required LeaveChatRoomUseCase leaveChatRoomUseCase,
required DeleteChatRoomUseCase deleteChatRoomUseCase,
required UpdateChatRoomNameUseCase updateChatRoomNameUseCase,
}) : _chatRoom = chatRoom,
_inviteChatMemberUseCase = inviteChatMemberUseCase,
_leaveChatRoomUseCase = leaveChatRoomUseCase,
_deleteChatRoomUseCase = deleteChatRoomUseCase,
_updateChatRoomNameUseCase = updateChatRoomNameUseCase;
final ChatRoomInfo _chatRoom;
void inviteMember(List<String> userIdList) {
_inviteChatMemberUseCase.execute(userIdList);
}
void leaveChatRoom() {
_leaveChatRoomUseCase.execute(_chatRoom.roomId);
}
void deleteChatRoom() {
_deleteChatRoomUseCase.execute(_chatRoom.roomId);
}
void updateChatRoomName(String newName) {
_updateChatRoomNameUseCase.execute(newName);
}
}
보자마자 이런 생각이 들 것이다.
"결국 ChatRoomManger는 여러가지 동작을 수행하는 거 아닌가? 그러면 SRP에 위배될텐데?"
맞는 말이라고 생각한다. 결국 SRP라는 것도 절대적으로 지켜야하는 것은 아니다. 그리고 지킬 수도 없다. 결국 시스템은 어느 지점에서는 서로 연결되어야하기 때문이다.
이렇게 SRP에 대해 알아보았다. SOLID 5가지 원칙이 모두 중요하지만, 개인적으로 그 중 가장 중요한 원칙은 바로 단일책임원칙이 아닐까 싶은 생각이다.
단일책임원칙 하나만 지켜도, 객체간 결합도가 많이 낮아지고, 이를 통해 시스템의 유지보수성이 눈에 띄게 좋아지기 때문이다.