[SOLID] SRP에 대해 알아보자

eunduk.log·2024년 5월 9일
1

SOLID는 대학교에서 처음 접해 이론을 공부하고 정보처리기사를 딸 때 한 번 더 접했었다.

이론을 공부하면서 ‘아, 이런 식으로 코드를 짜야 좋은 코드이구나’라고 생각만 했지 실제 코드에 적용해 본 적은 없었다. 원칙 따위 무시하고 기능 구현에만 급급했던..

이번 야곰 리팩토링 아카데미에 참가하면서 SOLID의 중요성을 깊이 알게 되었고 코드에도 적용해보니 꼭 필요한 원칙임을 깨닫게 되었다. (특히 실무같이 큰 프로젝트에 있어서는 더더욱..)

그래서 SOLID의 S인 SRP부터 차근차근 공부해 보려고 한다.


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. 재사용성 향상

역시 말로는 쉬우나 와 닿지는 않으니.. 예시 코드를 짜보면서 이해해보자

1. 가독성 향상

이 친구는 코드를 짜지 않아도 알겠지만 간단하게 함수로 구현해 본다면 아래와 같다.

// 다중 책임
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함수 내부만 건들여주면 된다는 점에서 유지보수성도 향상 됨)

2. 오류/결함 감소

이 내용은 또 응집도와 결합도가 연관되어 있다. (얘네도 포스팅 해야겠네..)

(한 줄로 설명하면 응집도가 높고 결합도가 낮으면, 서로의 프로퍼티와 로직을 모른다. 응집도가 낮고 결합도가 높으면, 서로의 프로퍼티를 바꿀 수 있고, 로직도 공유하며 작용한다.)

한 클래스에 여러 책임이 얽히게 되면 자연스럽게 그 책임들은 서로의 정보나 로직을 공유하게 되고, 결합도가 높아져 좋지 않은 코드가 되는 것이다.

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가 아닌가 싶다.

3. 재사용성 향상

이 내용은 사실 코드에서 뿐만 아니라 우리의 일상에서도 녹아들어 있다.

노트북과 컴퓨터만 비교해봐도 알 수 있는데 예를 들어 노트북의 키보드만 분리하고 다른 컴퓨터에 연결하여 사용하고 싶어도 절대 그렇게 할 수가 없다.

하지만 컴퓨터에 연결하여 사용하는 키보드는 어떤 컴퓨터에도 바꿔가며 재사용 할 수 있다.

다른 예시로 아래 코드를 살펴보자.

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 내용과 같이 포스팅 해봐야겠다.

이번 포스팅 한 줄 결론

여러 개를 한 개로 쪼개고, 그 한 개의 완성도를 높이자!


모든 피드백 감사합니다. (꾸벅)

profile
iOS 개발자

0개의 댓글