[SOLID] 단일 책임 원칙 (SRP)

김법우·2022년 6월 4일
0

SOLID

목록 보기
2/2
post-thumbnail

SRP 란?

정의

딱딱한 SRP 의 정의?!

객체 지향 프로그래밍에서 모든 클래스는 하나의 책임만을 가지며 클래스는 그 책임을 완전히 캡슐화 해야 함을 일컫는다. 클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다.
캡슐화는 데이터와 데이터를 처리하는 행위를 묶어 외부에는 행위를 보여주지 않는 것을 의미한다. (캡슐화에 대한 자세한 내용은 따로 정리한다.)

위의 정의를 계속 보다 보면 몇가지 의문이 남는다. 책임은 맡아서 행하는 임무를 의미하는데, 클래스가 하나의 책임을 가진다는 것은 어떤 의미일까? 무엇에 대한 책임인 걸까?
이러한 의문들을 로버트 마틴의 클린 아키텍처의 내용을 통해 조금이나마 해소 할 수 있었다.

로버트 마틴이 말하는 SRP 의 정의

로버트 마틴의 클린 아키텍쳐에서는 단일 책임 원칙을 다음과 같이 정의한다. “하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.” 여기서 로버트 마틴이 말하는 “모듈"이란 소스 파일, 함수와 데이터 구조로 응집된 집합을 의미한다. 이 정의는 모듈의 응집도를 높이는 것에서 기인한다.

모듈의 응집도란 한 모듈 내부의 처리 요소들간의 기능적 연관도를 의미한다. 즉, 단일 액터를 책임지는 코드들을 묶어 응집도를 높이는 것이 단일 책임 원칙을 준수함을 통해 얻고자 하는 것이다.

위에서 생긴 의문에 대한 해답을 내놓자면, SRP 에서 말하는 책임은 액터에 대한 책임을 의미한다. 하나의 액터에 대해 하나의 책임을 지는 객체를 정의하고 사용하라는 것이다.

출처 : https://yoongrammer.tistory.com/96

액터는 시스템과 상호작용하는 시스템 외부의 어떤 존재를 일컫는 단어이며 시스템의 이해 관계자로 정의 할 수 있다. 배달의 민족을 통해 주문을 하는 나는 사용자로서 액터이고, 쿠팡 앱의 입장에서 각종 카드 결제 대행 서비스 또한 외부 시스템으로서 액터이다.

조금 더 자세히 고찰 해보자

다시, 그렇다면 책임은 무엇일까? 왜 책임을 져야 하는 것일까? 단일 객체가 단일 액터에 대해서만 관심사를 가지고 대응 하도록 설계해야하는 이유를 곱씹어보면 해답을 찾을 수 있다.

책임은 변경에 대한 책임이다.
소프트웨어는 끊임 없이 변경된다. 위에서 언급한 액터들의 사용 행태에 따라, 요구 사항에 따라 변화한다. 햇빛이 잘 안들어온다는 이유로 1년이 걸려 지은 아파트 단지를 10cm 서쪽으로 옮기는 것은 불가능하다. 하지만 계좌 이체로만 인앱 결제를 하던 고객들이 카드 결제로 결제 방식을 변경해달라고 하는 요구를 반영하는 것은 소프트웨어에서 만큼은 가능하다.

소프트웨어는 변경에 상시 열려있어야 한다. 따라서 단순히 “열려있음" 보다는 “쉽게 변경이 가능함” 이 훨씬 좋은 선택지임은 명백하다. 즉, SRP 는 단일 이해 관계자에 대해 단일 객체만이 변경에 대한 이유를 가져야 함을 의미한다.

SRP 를 지켜야 하는 이유

그래서, 어떻게 코드를 짜라는 건데?

직접 만들어보자!

우리는 SRP 의 정의에서 말하는 단어 하나 하나를 곱씹어 보며, SRP 를 지키는 목적이 결국 소프트웨어를 구성하는 모듈의 응집도를 높여 변경에 대한 비용을 낮추기 위함이며 이를 이루기 위해 단일 액터에 대해서만 책임 지는 클래스를 설계해야 함을 알게 되었다.

정의 자체는 이해가 되었는데, 만약 SRP 를 준수하지 않은 설계가 어떻게 변경 비용을 증가시키는지 혹은 어떠한 문제를 야기하는지에 대해 코드로 직접 알아보고자 한다.

자, 나는 도서관의 도서 관리 시스템을 만드는 개발자라고 상상해보자. 출근을 하고, 도서관 관계자분들과의 기나긴 회의를 끝마쳐 요구사항을 수집하고, 컨펌하여 아래와 같은 유즈 케이스 다이어그램을 도출하였다.

출처 : https://jow1025.tistory.com/255

나는 위의 유즈 케이스 중 대여, 연체 관리, 연체금 표시를 묶어 하나의 rental 이라는 클래스에 기능을 구현하였다.회원은 연체금이 10000원이 넘어가면 더 이상 책을 대여 할 수 없다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class RentalService {
  async userRentalBook() {
    // 책 대여
  }

  async getUserArrears() {
    // 연체금 표시
  }

  ...
}

위의 두 함수, 책을 대여하는 함수와 연체금을 표시하는 함수는 책의 연체금을 계산하는 로직을 공유한다고 가정하다. 책의 연체금을 계산해야 대여가 가능한지에 대해 판단 할 수 있고, 연체금을 계산해야 연체금을 표시 할 수 있기 때문이다.

지금까지는 위의 소프트웨어는 제 기능을 잘 하고 있다. 동일한 로직을 공유하기는 하지만 적절히 연체금을 계산해 회원 액터가 책을 대여할 수 있는지 확인하고, 책을 대여할 수 있는 기능을 구현하였다.

마찬가지로 사서 액터에 대해서도 회원 별로 연체금을 확인하고 연체금을 추가하거나 삭제 할 수 있게 되었다.

그런데 어느날 책의 종류에 따라 연체금을 다르게 매기는 정책을 도서관에 적용하였다. 원래는 모든 책이 연체일 하루당 1000원이었지만 사람들이 많이 읽는 토익 관련 도서는 하루당 1500원, 사람들이 잘 읽지 않는 고전 도서는 하루당 500원으로 내려갔다.

벌써부터 머리가 아프다. 회원이 책 대여 요청을 할 때마다 책의 종류에 따라 연체금을 계산하여 연체금이 10000이 넘는지 확인해야 한다. 관리자는 이제 연체금을 표시 할 때 책의 종류에 따라 연체금을 계산해 보여줘야 올바르게 연체금을 확인 할 수 있게 되었다.

즉, 코드 상에서 두 함수를 모두 수정해야 하는 것이다.

원인은 ?

두 함수를 수정 하지 않아도 요구사항을 반영 할 수 있는 설계는 없었던 것일까? 왜 이렇게 두 함수를 수정해야 하는 것일까.

이유는, 하나의 클래스에 속한 두 함수는 연체금 계산이라는 로직을 공유하지만 이 두 함수가 제공하는 기능의 책임이 다른 곳에 있기 때문이다.

책 대여 기능은 회원 액터의 요구사항에 대한 책임이 있고, 연체금 표시는 사서 액터의 요구사항에 대한 책임이 있다. 그럼에도 하나의 클래스내에서 두 기능을 같이 제공하기에 rental 이라는 클래스는 두 액터에 대한 책임을 동시에 지게 되었다. 이로 인해 변경사항을 반영해야 하는 범위가 넓어진 것이다.

해결책 !

우리가 먼저 유즈 케이스 다이어그램을 보았기에, 해결책은 손쉽게 도출된다. 대여는 회원 액터만이 사용하는 유즈 케이스이고 연체금 표시는 사서 액터만이 사용하는 유즈 케이스이다.

따라서 rental 클래스에 있는 두 메소드를 완전히 분리하면 해결 할 수 있다. 대여에 대한 소스코드는 rental 에게 위임한다. rental 클래스는 연체금 계산 방식에 대해 알지 못한다. 단순히 대여 가능 여부만을 알 수 있다.

연체금 관리 및 연체금 표시에 대한 소스코드는 Arrears 클래스에게 위임한다. Arrears 클래스는 누가 대여하는지 알 지 못한다. 단순히 연체일에 따라 연체금을 표시한다.

@Injectable()
export class ArrearsService {
  async calcUserArrears() {
    // 연체금 계산
  }

  async getUserArrears() {
    // 연체금 표시
  }

  ...
}

@Injectable()
export class RentalService {
async userRentalBook() {
    // 책 대여
    }
    
  ...
}

이렇게 작성된 코드가 있을 때 아까와 동일한 도서관의 요구가 들어왔다고 상상해보자. 책의 종류에 따라 연체금 방식이 바뀌었다면, ArrearsService 클래스의 연체금 계산 방식이 조금 바뀔 것이다. 조건문이 들어갈 수도 있고 추가적인 외부 클래스를 주입받아 계산에 사용 할 수도 있다.

하지만 중요한 것은 대여 함수는 전혀 수정 될 필요가 없단 것이다. 어떻게 계산이 되든 간에 10000원이 넘어가는 연체금이라면 대여 할 수 없다. 이 기능은 변하지 않았다. 위의 수정사항은 연체금에 대한 수정사항이었고 사서 액터로 부터 야기된 수정사항이다.

따라서, SRP 를 따르는 코드 작성은 위와 유사한 경우에서 유지보수의 비용 절감에 영향을 주게 된다.

결론

확신은 들지 않지만 …

이번에 SRP 를 공부하며 가장 크게 바뀐 고정관념은 책임에 대한 정의이다. 책임이라는 단어가 무엇에 대한 책임인지가 모호하였고, 뜻이 모호하니 구현 자체에도 모호함이 섞여 나왔다.

어쩌다보니 SRP 를 만족할려면 Get 메소드에 대한 Service 메소드는 따로 묶는 것이 맞는가? 에 대한 생각까지 하게 되었다. SRP 를 잘못 이해하여 하나의 객체가 하나의 책임을 진다는 의미를 그냥 하나의 일만 한다는 뜻으로 받아들였기 때문일 것이다.

공부를 하긴 했지만, 위에서 만들어낸 예시가 SRP 위반 및 해결에 대한 아주 적절한 예시였는가에 대해서는 확신하지 못하겠다. 하지만 실무를 하면서 기능 변경이나 추가 개발을 할 때 내가 만드는 클래스가 SRP 를 준수하는지에 대해서는 조금 더 쉽게 판단 할 수 있을 것 같다는 생각이 들었다.

또 다시 생긴 의문들??

예시를 생각하고 글을 적으며 드는 몇가지 의문점들이 있었다. 위의 예시에서도 사실 실제 구현을 할 때 데이터적인 측면에서는 “대여” 와 “연체금"은 큰 연관 관계가 존재 할 수 있다.

아마 내가 위의 시스템에 대해 데이터베이스를 구축한다면, 책과 관련된 정보를 저장하는 책, 책 카테고리 등등의 테이블들을 정의하고 회원과 관련된 정보를 저장하는 회원 정보, 대여 기록 테이블들을 정의하고 대여 기록 테이블과 연관을 가지는 반납 관련 테이블, 연체관련 테이블을 만들 것 같다.

이렇게 주어진 데이터 구조는 사실 회원 - 대여 기록 - 연체 기록 and 반납 기록 쯤으로 구현 될 것 같은데, 이를 보면 대여 기록 ← 연체 의 형태로 연체 관련 데이터들이 대여 기록을 참조하게 될 것이다.

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글