JoungSik/루비로 배우는 객체지향 디자인 2장

Created Fri, 15 Oct 2021 05:27:36 +0900 Modified Thu, 05 Jan 2023 23:24:26 +0900

2. 단일 책임 원칙을 따르는 클래스 디자인하기

먼저 단일 책임 원칙을 따르는 클래스는 다음과 같은 특징을 가진다.

  • 단순하다
  • 지금 당장 해야 할 일을 할 줄 안다.
  • 나중에도 쉽게 수정 할 수 있다.

여기서 쉽게 수정한다는 말이 애매하기 때문에 좀 더 구체적으로 서술한다.

  • 수정이 예상치 못한 부작용을 낳지 않는다.
  • 요구사항이 조금 변했을 때는 연관된 코드를 조금 수정하면 된다.
  • 현재 코드를 다시 사용하기 쉽다. (DRY, Don’t repeat yourself)

그리고 코드를 수정하기 가장 쉬운 방법은 이미 수정하기 쉬운 코드에 새로운 코드를 추가하는 것이다.

이런 수정하기 쉬운 코드는 다음과 같은 특징을 가진다.

  • 수정의 결과가 뚜렷하다
  • 모든 수정 비용은 이득에 비례한다. 지금 작성한 코드가 오늘 저녁에 작성하는 코드에 도움이 된다면 이득이 있다.
  • 예상치 못한 상황에서도 코드를 사용 할 수 있다.
  • 이후 코드를 작성하는 사람도 이 특징을 이어 갈 수 있다.

결국 디자인에 있어서의 중요한점은 기술에 대한 지식이 아닌 코드를 어떻게 구성하고 배치하느냐라고 볼 수 있다.

마지막으로 디자인은 완벽함이 아닌 코드의 수정 가능성을 계속 보존하는 것이다.

예시로 자전거와 기어에 관련된 개발자와 고객의 이야기와 구현체를 보면 좋을 것 같다.


자전거에서 기어는 앞 뒤 톱니바퀴의 톱니 수를 비율로 표시해 페달을 한번 밟을때 바퀴가 몇바퀴 도는지를 알 수 있는데 이를 기어비라고 한다.

자전거의 기어에 관심이 많은 사람들을 위해 기어비를 계산하는 어플리케이션을 만들어보자

class Gear
    attr_reader :chainring, :cog 
    def initialize(chainring, cog)
        @chainring = chainring
        @cog = cog
    end

    def ratio
        chainring / cog.to_f
    end
end

puts Gear.new(52, 11).ratio # 4.7272727272727275
puts Gear.new(30, 27).ratio # 1.1111111111111112

가장 초기의 모델로 chainring 은 앞바퀴를 의미하고 cog 는 뒷바퀴를 의미하고 ratio 는 기어비를 계산하는 로직이 들어가 있는 클래스 입니다.

만들어진 어플리케이션을 고객에게 보여주었더니 고객은 좀 더 발전해서 바퀴 사이즈가 다른 자전거에서도 사용 할 수 있는 어플리케이션을 원했습니다.

미국에서는 기어도 다르고 바퀴 크기도 다른 자전거를 비교하기 위해 기어 인치 라는 단위를 사용한다고 합니다. 기어 인치의 계산 식은 다음과 같습니다.

기어 인치 = 바퀴 지름 X 기어비

바퀴 지름 = 바퀴테 지름 + 타이어 높이의 두배

이제 Gear 클래스를 수정합니다.

class Gear
    attr_reader :chainring, :cog, :rim, :tire
    def initialize(chainring, cog, rim, tire)
        @chainring = chainring
        @cog = cog
        @rim = rim
        @tire = tire
    end

    def ratio
        chainring / cog.to_f
    end

    def gear_inches
        ratio * (rim + (tire * 2))
    end
end

puts Gear.new(52, 11, 26, 1.5).gear_inches  # 137.0909090909091
puts Gear.new(52, 11, 24, 1.25).gear_inches # 125.27272727272728

puts Gear.new(52, 11).ratio #Error gear.rb:3:in `initialize': wrong number of arguments (given 2, expected 4)
puts Gear.new(30, 27).ratio 

하지만 기존의 코드에서 에러가 생겼습니다. 인자의 수가 늘어나면서 해당 메서드를 호출하는 모든 곳에서 문제가 생기게 된 것 입니다. 당장 수정해야 할 큰 문제지만 당장은 무시합니다.

현재의 구현체가 과연 최선의 방법일까요?

정답은 “상황에 따라 다르다” 입니다. 이 구현체 그대로 바뀌지 않는다면 여기서 멈춰도 되지만 현재 구현체로는 부족한 상황이 올 수도 있다면 더 효율적으로 발전할 수 있도록 코드의 수정이 쉬워야 합니다.

수정하기 쉬운 코드를 작성하기 위해선 이 구현체가 단일 책임을 가지고 있는 구현체 인지를 확인해야 합니다.

단일 책임을 가지고 있는지를 알아보기 위해 좋은 방법은 구현체의 책임을 한 문장으로 만들어 보는 것입니다.

여기서 그리고 혹은 또는 이라는 단어를 가지고 있다면 구현체는 하나 이상의 책임 혹은 서로 연관되지도 않은 둘 이상의 책임을 가지고 있다는 뜻이 되니 구현체에 대한 수정이 필요한 시점이라고 볼 수 있습니다.

단일 책임 원칙은 협소한 한 가지 역할만 해야 하는건 아닙니다. 클래스 안의 모든 것들이 하나의 핵심 목표와 연관이 있는 상태, 강하게 응집 되어 있는 상태가 되어 있다면 됩니다. 이에 대해서는 책임 주도 디자인 (RDD)에 그 기원을 두고 있으니 찾아보면 좋습니다.

이제 Gear 클래스를 다시 살펴봅니다.

앞, 뒤 톱니바퀴 상이의 기어비를 계산한다 -> 그에 비해 현재의 Gear 클래스는 너무 많은 일을 하고 있습니다.

자전거에 기어가 미치는 영향을 계산한다 -> 그렇다면 gear_inches 가 있는건 맞지만 타이어의 높이는 애매합니다.

결국 Gear 클래스에는 하나 이상의 책임을 가지고 있지만 어떻게 수정 해야할지는 명확하지 않습니다.

이제 디자인을 선택해야 할까요? 아직 아닙니다.

우리는 미래에 어떤 디자인이 필요한지에 대한 정보가 없습니다. 하지만 주어진 시간은 정해져있기 때문에 여러 디자인 중에서 하나를 선택 할테지만 그 선택은 올바르지 않은 선택일 가능성이 높습니다. 그렇다고 아무것도 하지 않는다면 나중에 분명 큰 대가를 지불해야 할겁니다…

우선 Gear 클래스는 현재 코드가 투명하고 적절하지만 디자인이 훌륭하지는 않다. 그리고 의존성이 없기 때문에 코드를 수정해도 특별한 문제가 생기지 않지만 의존성이 생긴다면 투명함과 적절함을 잃게 될 것이다. 즉, 의존성이 생기는 시점이 코드를 재구성 해야할 시점이고 이 의존성은 좋은 디자인을 결정하기 위한 정보를 제공합니다.

하지만 언제까지 정보를 기다리고 있을 수는 없습니다. 왜냐면 Gear 클래스는 디자인 의도를 잘못 전달하고 있고 여러 개의 책임을 가지고 있는 상태인데 이를 다른 개발자가 참고하면 안되기 때문이죠.

완벽한 디자인은 없기 때문에 당장의 필요와 미래의 가능성 사이에서 심사숙고해 개선비용을 최소화 할 수 있는 방법, 이를 변화를 받아들일 수 있는 코드를 작성하는 방법을 알아봅시다.

이에는 몇가지 기술이 있습니다.

  1. 데이터가 아니라 행동에 기반한 코드 작성

    행동은 메서드 속에 담겨 있고 메세지를 보내는 행위를 통해 실행됩니다. 하나의 책임만 지는 클래스를 만들면 각각의 작은 행동들은 단 한 곳에만 존재한다. DRY 한 코드는 변화를 잘 견뎌내는데 이는 클래스의 행동을 수정하기 위해 단 한 부분만 수정하면 되기 때문입니다.

    1. 인스턴스 변수 숨기기

      변수를 직접 참고하기 보다는 언제나 엑세서 메서드를 통해 변수에 접근하는 것이 좋습니다.

      Gear 클래스의 cog (톱니)를 예시로 들어보면 Gear 클래스에서 cog는 여러 곳에서 참조하는 데이터지만 cog 라는 메서드를 구현함으로 단 한 번만 정의된 행동으로 바뀌게 됩니다. 이는 나중에 데이터 였을 때의 cog 변수를 참고하고 있는 코드가 10곳 이라면 10곳의 코드를 바꿔줘야 하지만 cog가 메서드로 구현되어 있다면 cog 메서드의 구현체만 바꿔주면 되기 떄문에 코드 수정이 쉽고 코드가 지저분해지지 않습니다.

      데이터를 메서드 형태의 객체로 취급하면 두가지 이슈가 생깁니다.

      1. 가시성 이슈가 생겨 퍼블릭 메서드로 감싸 외부에서 접근이 가능하도록 할지 프라이빗 메서드로 만들어 외부에서 접근 할 수 없게 만들지를 선택해야 하는데 이는 [4장 유연한 인터페이스 만들기]에서 다룬다고 합니다.
      2. 데이터와 객체의 구분이 무의미 해진다. 사실 저는 이부분에 대해서는 항상 객체인 편이 좋다고 동의하고 있기 때문에 어떤 문제가 발생하는지에 대해서는 잘 모르겠습니다. 그리고 개발자도 데이터의 모든 행동을 다 알고 있지 못한 경우가 있기 때문에 감춰두는편이 좋습니다.
    2. 데이터 구조 숨기기

      코딩을 하다보면 다음처럼 2차원 배열과 같은 인자를 받을 때가 있습니다. 해쉬로 받으면 좋지만 항상 그렇게 좋게 받을 수 있는 경우가 아니기 때문에 이 경우에도 방법을 찾아야합니다.

      @data = [ [622, 20], [622, 23], [622, 35], [559, 30], [559, 40]]
      

      이런 복잡한 구조의 데이터는 [0] 에는 어떤 값이 들어가 있는지 [1] 에는 어떤 값이 들어가 있는지를 정확하게 알고 있어야만 하는 문제가 생깁니다. 그런데 만약 이 배열을 참조하는 코드가 있다면 중간의 수정이 있을 경우 모든 데이터가 바뀌어야 합니다.

      아래 예시 메소드 diameters 를 보시죠.

      class Sample
          attr_reader :data
          def initialize(data)
              @data = data
          end
      
          def diameters
              data.collect { |cell|
                  cell[0] + (cell[1] * 2)
              }
          end
      end
      

      원래라면 저런 데이터 구조를 분리해서 볼 수 있는 코드를 작성해야 하지만 오늘은 루비의 특징을 살려서 코드를 작성해보도록 하겠습니다.

      class Sample
          attr_reader :wheels
          def initialize(data)
              @wheels = wheelify(data)
          end
      
          def diameters
              wheels.collect { |wheel|
                  wheel.rim + (wheel.tire * 2)
              }
          end
      
          Wheel = Struct.new(:rim, :tire)
          def wheelify(data)
              data.collect { |cell| Wheel.new(cell[0], cell[1]) }
          end
      end
      

      Struct 문법을 사용해서 흔히 말하는 객체 형태로 변형했습니다.

  2. 모든 곳에 단일 책임 원칙을 강제

    1. 메서드에서 추가적인 책임 뽑아내기

      클래스처럼 메서드 역시 단일 책임만을 지도록 구현 할 수 있습니다.

      위의 diameters 메서드는 2개의 책임을 가지고 있습니다. 하나는 여러 개의 바퀴를 하나씩 루프 하는 책임과 바퀴 하나의 지름을 계산하는 책임을 가지고 있습니다. 여기서 지름을 계산하는 책임을 분리시켜 봅시다.

      class Sample
          attr_reader :wheels
          def initialize(data)
              @wheels = wheelify(data)
          end
      
          def diameters
              wheels.collect { |wheel| diameter(wheel) }
          end
      
          def diameter(wheel)
              wheel.rim + (wheel.tire * 2)
          end
      
          Wheel = Struct.new(:rim, :tire)
          def wheelify(data)
              data.collect { |cell| Wheel.new(cell[0], cell[1]) }
          end
      end
      

      이렇게 메서드가 하나의 책임을 질 때 비로소 보이는 것들이 있습니다.

      • 예전에는 몰랐던 특성이 드러난다

        모든 메서드가 하나의 책임을 지게되면 클래스 자체가 명확하게 들어납니다. 각각의 메서드가 하는 일들이 단일한 목적을 가지면 클래스가 하는 일이 무엇인지 드러나는거죠.

      • 주석을 넣어야 할 필요가 없어진다

        만약 메서드 속의 특정 코드 조각에 주석을 달아야한다면 그 코드를 별도의 메서드로 뽑아낸다면 메서드의 이름이 곧 주석의 역활을 하게 됩니다.

      • 재사용을 유도한다

        이런 습관은 다른 프로그래머들이 봤을 때도 같은 코드를 중복으로 사용하지 않게 되는 모범이 됩니다.

        diameter 로 분리된 메소드는 타이어의 지름을 계산하는 메서드가 추가로 생긴거고 이는 어딘가에서 지름을 계산하는 코드를 따로 작성하지 않고 재사용 할 수 있게 됩니다.

      • 다른 클래스로 옮기기 쉽다

        디자인에 필요한 정보를 새로 얻었을 때 코드를 수정하기로 마음 먹었을 때 작은 메서드는 이동이 쉽습니다.

    2. 클래스의 추가적인 책임들을 격리시키기

      이전 예제인 Gear 클래스는 바퀴로 보이는 행동을 가지고 있습니다. 하지만 당장에 Wheel 클래스를 만드는건 아직 확정된 사실이 아닙니다.

      따라서 Gear 클래스에 있으면서 Gear 클래스가 아니도록 코드를 수정할 필요가 있습니다.

      Wheel 클래스를 생성하는 디자인을 결정하는건 미루고 미뤄도 괜찮습니다.

      루비에서 사용하는 코드 블록으로 분리를 해봅니다.

      class Gear
          attr_reader :chainring, :cog, :wheel
          def initialize(chainring, cog, rim, tire)
              @chainring = chainring
              @cog = cog
              @wheel = Wheel.new(rim, tire)
          end
      
          def ratio
              chainring / cog.to_f
          end
      
          def gear_inches
              ratio * wheel.diameter
          end
      
          Wheel = Struct.new(:rim, :tire) do
              def diameter
                  rim + (tire * 2)
              end
          end
      end
      

      이로서 Gear 클래스를 사용할 때는 Wheel 이 항상 같이 붙는다는걸 볼 수 있습니다.

      그러다 후에 Wheel 은 분리가 되어야 한다는 정보를 얻게 되면 Wheel 코드를 분리해서 클래스로 만들면 됩니다.

수정하기 쉽고 유지보수하기 쉬운 객체지향 소프트웨어를 만들어가는 방법은 하나의 책임을 지는 클래스를 만드는 것부터 시작합니다.

원래 2021-10-14 에 올려야 했지만 늦어졌습니다.