ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SOLID] SRP에 대해 알아보자
    Refactoring 2024. 10. 8. 20:42

    (기존 벨로그 링크)

     

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

    이번 포스팅 한 줄 결론

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


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

    'Refactoring' 카테고리의 다른 글

    [SOLID] DIP에 대해 알아보자  (1) 2024.10.08
    [SOLID] LSP와 ISP에 대해 알아보자  (1) 2024.10.08
    [SOLID] OCP에 대해 알아보자  (2) 2024.10.08
Designed by Tistory.