SOLID는 대학교에서 처음 접해 이론을 공부하고 정보처리기사를 딸 때 한 번 더 접했었다.
이론을 공부하면서 ‘아, 이런 식으로 코드를 짜야 좋은 코드이구나’라고 생각만 했지 실제 코드에 적용해 본 적은 없었다. 원칙 따위 무시하고 기능 구현에만 급급했던..
이번 야곰 리팩토링 아카데미에 참가하면서 SOLID의 중요성을 깊이 알게 되었고 코드에도 적용해보니 꼭 필요한 원칙임을 깨닫게 되었다. (특히 실무같이 큰 프로젝트에 있어서는 더더욱..)
그래서 SOLID의 S인 SRP부터 차근차근 공부해 보려고 한다.
Single Responsibility Principle, 단일 책임 원칙
한 클래스는 하나의 책임만 가져야 한다!
여기서는 클래스라고 나와 있지만 함수도 포함하여 생각하면 좋을 듯! (이왕 생각한 거 변수, 상수도..?)
아무튼 쉽게 말하면 주어진 일만 해라!!!
사실 당연한 말 같으면서도 뭔 말인가 싶다.. 그래서 코드를 짜 보며 이해해 보려고 한다.
계산기 프로그램을 짠다고 생각하고 아주 쉽게 구현해 보았다.
class Calculator {
func plus() {
// 덧셈 로직
}
func minus() {
// 뺄셈 로직
}
func multiple() {
// 곱셈 로직
}
func divide() {
// 나눗셈 로직
}
// 그 이외의 많은 연산들
}
코드만 보면 문제가 없어 보이지만, 나는 위 코드가 SRP를 위반했다고 생각한다.
계산기의 단일 책임은 무엇일까? 더하기 기능? 빼기 기능?
나는 계산기의 단일 책임은 단지 ‘계산하는 기능’이 전부라고 생각한다.
더하기, 빼기 이런 건 모르겠고~ 그냥 계산 그 잡채만 해주면 계산기의 책임을 다한 것이 아닐까? (개인적인 생각입니다ㅎ)
뭔가 말이 어려울 수 있는데 이렇게 생각해 보자.
plus 함수가 잘 못 되어 더하기한 값이 엉터리로 나왔다고 가정해 보자. 그렇다면 그 엉터리 값에 대한 책임은 누가 물어줘야 하지? 라고 생각해 본다면 당연히 덧셈 로직을 가지고 있는 Calculator클래스일 것이다.
이렇게 생각하면 결국 Calculator클래스는 1개의 책임이 아닌 모든 연산에 대한 책임을 가지고 있다고 볼 수 있는 것이다.
그렇다면 어떻게 리팩토링하여 SRP원칙을 충족할 수 있을까?
아래 코드를 살펴보자. (의존성 주입은 개나 줘버린 코드입니다.)
class Calculator {
private let plusCalculator: PlusCalculator = PlusCalculator()
private let minusCalculator: MinusCalculator = MinusCalculator()
private let multipleCalculator: MultipleCalculator = MultipleCalculator()
private let divideCalculator: DivideCalculator = DivideCalculator()
func plus() {
plusCalculator.plus()
}
func minus() {
minusCalculator.minus()
}
func multiple() {
multipleCalculator.multiple()
}
func divide() {
divideCalculator.divide()
}
}
class PlusCalculator {
func plus() {
// 덧셈 로직
}
}
class MinusCalculator {
func minus() {
// 뺄셈 로직
}
}
class MultipleCalculator {
func multiple() {
// 곱셈 로직
}
}
class DivideCalculator {
func divide() {
// 나눗셈 로직
}
}
각각의 연산의 책임을 갖는 클래스를 4개 만들어주었고, Calculator클래스는 연산 클래스들의 연산을 가져와 사용하였다.
Calculator클래스는 원래 코드와 동일하게 plus, minus, multiple, divide함수를 가지고 있지만 과연 여러 개의 책임을 가진다고 말할 수 있을까?
그럼 아까의 가정을 다시 가져와 비교해보자.
PlusCalculator클래스의 plus함수가 잘 못 되어 더하기한 값이 엉터리로 나왔다고 가정했을 때, 덧셈이 엉터리로 된 것에 대한 1개의 책임은 덧셈 로직을 갖고 있는 PlusCalculator클래스가 갖게 되고 Calculator클래스는 계산이 잘 못된 것에 대한 1개의 책임만 갖게 되는 것이다.
따라서, 각 클래스들은 각자 1개씩의 단일 책임을 갖고 있고 이 것은 SRP를 만족했다고 볼 수 있는 것이다.
아 오케이! 한 클래스가 1개의 책임을 어떻게 가져야하는지는 알겠어.
근데 대체 왜 1개의 책임을 가져야 하는건데?
1개의 책임을 캡슐화하게 되면 얻는 이점은 3가지 정도 있는 것 같다.
(갑자기 캡슐화가 나왔는데 이건 다음에 포스팅 하는 걸로..)
1. 가독성 향상
2. 오류/결함 감소
3. 재사용성 향상
역시 말로는 쉬우나 와 닿지는 않으니.. 예시 코드를 짜보면서 이해해보자
이 친구는 코드를 짜지 않아도 알겠지만 간단하게 함수로 구현해 본다면 아래와 같다.
// 다중 책임
func calculate(value1:Int, value2:Int, op:String) -> Int {
switch op {
case "+":
return value1 + value2
case "-":
return value1 - value2
default:
return 0
}
}
// 단일 책임
func plusCalculate(value1:Int, value2:Int) -> Int {
return value1 + value2
}
func minusCalculate(value1:Int, value2:Int) -> Int {
return value1 - value2
}
지금은 매우 간단한 코드이기 때문에 둘 다 가독성이 좋아 보이지만, 큰 프로젝트라 생각해본다면 calculate 함수의 switch문은 점점 길고 무거워져 나중에는 이해하기 매우 힘들 것이다. 그리고 plus기능을 이해하려면 calculate함수의 필요 없는 부분도 분석해 나가며 이해해야하기 때문에 가독성이 떨어진다고 할 수 있다.
반면에 단일 책임을 갖는 plusCalculate는 직관적이고 사용하는 곳에서 덧셈 로직을 알 필요 없이 함수를 갖다 쓰기만 하면 된다. (사용하는 곳에서 가독성 향상)
(덧셈 로직이 수정될 때 plusCalculate함수 내부만 건들여주면 된다는 점에서 유지보수성도 향상 됨)
이 내용은 또 응집도와 결합도가 연관되어 있다. (얘네도 포스팅 해야겠네..)
(한 줄로 설명하면 응집도가 높고 결합도가 낮으면, 서로의 프로퍼티와 로직을 모른다. 응집도가 낮고 결합도가 높으면, 서로의 프로퍼티를 바꿀 수 있고, 로직도 공유하며 작용한다.)
한 클래스에 여러 책임이 얽히게 되면 자연스럽게 그 책임들은 서로의 정보나 로직을 공유하게 되고, 결합도가 높아져 좋지 않은 코드가 되는 것이다.
SRP를 적용하면 응집도는 높아지고 결합도는 낮아지기 때문에 오류의 확률을 낮출 수 있다.
아래 코드를 살펴보자.
class Car {
private var engine: Bool = false
func depart() {
if engine == true {
print("출발!")
}
}
func engineStart() {
self.engine = true
// 시동이 켜질 때 작동하는 추가 로직들
}
}
시동을 키는 책임과 출발을 하는 책임 두가지를 가지고 있는 클래스가 있고 출발을 하려면 시동은 꼭 켜져있어야 한다.
이 때, 출발 함수가 비정상적인 작동으로 인해 시동 플래그를 true로 조작하고 출발하게 된다면 분명 결함이 생길 것이다. engineStart라는 함수를 통해 정상적으로 시동을 건 것이 아니기 때문이다.
class Car {
private var engine: Bool = false
func depart() {
self.engine = true
if engine == true {
print("출발!")
}
}
func engineStart() {
self.engine = true
// 시동이 켜질 때 작동하는 추가 로직들
}
}
위 예시와 같이 시동 플래그를 억지로 true로 바꾸고 억지로 출발 시킬 수 있게 된다.
근데 예시는 억지로 플래그를 바꿔서 그런거고 실제 프로젝트에서는 저렇게 하지 않으면 되지 않냐? 라고 할 수 있는데 맞는 말이지만 실제로 큰 프로젝트에서 여러 개의 책임이 겹쳐 있으면 나 또는 내 동료가 서로 간섭하는 코드를 짤 가능성이 있고 발생한 오류들을 살펴보면 실제로 그런 것들이 원인인 경우가 빈번하다.
따라서 그런 가능성 조차 없앨 수 있는 방법이 SRP가 아닌가 싶다.
이 내용은 사실 코드에서 뿐만 아니라 우리의 일상에서도 녹아들어 있다.
노트북과 컴퓨터만 비교해봐도 알 수 있는데 예를 들어 노트북의 키보드만 분리하고 다른 컴퓨터에 연결하여 사용하고 싶어도 절대 그렇게 할 수가 없다.
하지만 컴퓨터에 연결하여 사용하는 키보드는 어떤 컴퓨터에도 바꿔가며 재사용 할 수 있다.
다른 예시로 아래 코드를 살펴보자.
class Car {
func engineStart() {
print("2기통 엔진 활성화 완료")
}
func depart() {
engineStart()
print("출발!")
}
}
// 2기통 엔진 자동차만 사용 가능
let car = Car()
car.depart()
이 코드는 엔진의 활성화와 출발의 로직을 담고 있어 2개의 책임을 가지고 있다.
Car클래스가 위처럼 구현되어 있다면 재사용성은 떨어지게 된다.
2기통 엔진이 박혀있는 차량의 틀이기 때문에 다른 엔진이 달린 차량은 만들 수 없다.
그렇다면 엔진의 책임을 분리하여 리팩토링 해보자.
protocol Engine {
func engineStart()
}
class Car {
private let engine: Engine
init(engine: Engine) {
self.engine = engine
}
func depart() {
engine.engineStart()
print("출발!")
}
}
class Engine2: Engine {
func engineStart() {
print("2기통 엔진 활성화 완료")
}
}
class Engine4: Engine {
func engineStart() {
print("4기통 엔진 활성화 완료")
}
}
// 2기통 엔진 자동차 사용
let car2 = Car(engine: Engine2())
car2.depart()
// 4기통 엔진 자동차 사용
let car4 = Car(engine: Engine4())
car4.depart()
엔진을 Engine프로토콜이라는 단일 책임으로 분리하고 Car클래스에서 Engine을 주입 받아 사용하고 있는데 위와같이 2기통 엔진 자동차도 사용할 수 있고, 4기통 엔진 자동차도 사용할 수 있는 것을 볼 수 있다.
(짜다보니 다른 원칙들이 적용되고 있는 것 같은 기분이..의존성 주입이 이렇게 좋아..)
이 뿐만 아니라 실무에서 팝업을 띄우는 함수가 있을 때 단일 책임을 부여해야 재사용성이 높아질 수 있다.
예를 들어 어떤 조건을 검사하고 팝업을 띄우면 2개의 책임을 갖기 때문에 재사용성이 현저히 떨어질 수 있다.
어떤 조건을 검사하는 책임과, 팝업을 띄우는 책임으로 분리하여 사용하면 재사용성이 향상될 수 있다.
여기까지 SRP를 예시를 통해 공부하고 이해해보았는데 하면 할 수록 캡슐화와 큰 관련이 있는 원칙이 아닌가 싶다.
사실 SRP의 여러 정의 중에도 이런 정의가 있다.
모든 타입은 하나의 책임만 가지며, 타입은 그 책임을 완전히 캡슐화해야한다.
이처럼 SRP는 단일 책임을 가지게 하는 것뿐만 아니라 그 책임을 캡슐화 하는 것 까지가 완성이지 않나 싶다. (아무래도 포스팅 순서가 캡슐화가 먼저였어야 했다..)
나중에 캡슐화를 자세히 공부하고 SRP 내용과 같이 포스팅 해봐야겠다.
여러 개를 한 개로 쪼개고, 그 한 개의 완성도를 높이자!
모든 피드백 감사합니다. (꾸벅)