루비로 배우는 객체지향 디자인
21년도에 공부한 내용을 하나로 합쳤습니다.
1. 객체지향 디자인
이 세상을 객체지향적으로 본다는건 세상을 이미 정해진 절차들의 묶음으로 생각하지 않고 객체가 서로 주고 받는 메세지들의 연쇄로 생각하고 봐야한다.
객체지향 어플리케이션은 각 객체가 주고 받는 메세지를 통해 구성되어 있는데 이러한 발신과 수신을 통해 객체가 서로에 대해 어느정도 알게 되는데 이 관계가 의존성을 만들어내 하나의 객체를 수정하면 그와 의존성이 묶인 객체들을 수정해야하고 이때문에 어플리케이션의 수정이 어려워진다.
따라서 객체지향 디자인은 이러한 의존성을 관리하는 것이 주 목표다.
관리에 대한 원칙으로 디자인 원칙이라는 것이 있는데 아래와 같다.
- 단일 책임
- 개방-폐쇄
- 리스코프 치환
- 인터페이스 분리
- 의존성 역전
하지만 잊어서는 안되는 부분이 존재한다.
객체지향 디자인은 필요할 수도 필요하지 않을 수도 있다.
만약 여기에 평생 바뀌지 않는 소프트웨어가 있다면 객체지향 디자인은 사실상 필요가 없다. 그것이 소프트웨어를 개발하기 위한 최적의 방법이 아닐 수 있기 때문이다.
그럼에도 객체지향 디자인을 알아야 하는 이유는 미래를 대비해 가능한 여러가지 선택지를 만들어 놓기 위해서다.
한번에 모든 항목을 디자인 할 수는 없다. 작은 코드 조각을 보여줌으로써 처음 고객이 원했던 소프트웨어와 다른 진정으로 원하는 소프트웨어의 모습이 만들어지고 이때 객체지향 디자인으로 만들어둔 여러가지 선택지는 개발에 주어진 시간 안에서 최적의 구현 비용을 만드는데 도움을 주기 때문에 객제지향 디자인은 필요하다.
객체지향 디자인을 통해 만들어진 객체지향 어플리케이션에 대한 수량화를 하기 위한 노력들이 존재했었는데 이때의 기준은 “전체적인 클래스의 크기, 클래스가 다른 클래스와 얽혀있는 정도, 상속 관계의 높이와 너비, 메세지 전송이 유발하는 실행 횟수 등"이 있다. (ruby metrics)
흔히 알려진 디자인 패턴은 “객체지향 소프트웨어 디자인에서 명확한 문제를 처리하는 간단하고도 우아한 해결책"이라고 보면 좋다. 하지만 이것도 단점이 존재하는데 디자인 패턴에 대한 오용이 있다.
디자인 패턴에 대한 오용은 간단하게 해결되어야 했을 문제를 복잡하고 혼란스럽게 만들 수 있다.
객체지향 디자인에서도 실패하는 경우가 있는데 이는 2가지 케이스를 볼 수 있다
- 디자인 자체가 부족한 경우, 어플리케이션을 못만드는건 아니지만 붕괴의 씨앗을 품은 어플리케이션이 만들어진다.
- 지나치게 디자인 하는 경우, 나쁜 의도로 지나치게 디자인 하는 일은 없다. 좋은 의도로 디자인 했는데 그 디자인이 지나치게 많을 뿐인 경우에는 자신이 만들어둔 혹은 팀원이 만들어둔 디자인 속에서 나오기 힘들어진다.
마지막으로 구현에 타협을 해서 하드 코딩을 하거나 뒤를 생각하지 않고 구현한다면 이는 결국 기술적으로 빚을 지는 것이고 이 기술적 부채는 언젠가 이자와 함께 묵직한 빚이 되어 돌아오게 된다.
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 클래스는 디자인 의도를 잘못 전달하고 있고 여러 개의 책임을 가지고 있는 상태인데 이를 다른 개발자가 참고하면 안되기 때문이죠.
완벽한 디자인은 없기 때문에 당장의 필요와 미래의 가능성 사이에서 심사숙고해 개선비용을 최소화 할 수 있는 방법, 이를 변화를 받아들일 수 있는 코드를 작성하는 방법을 알아봅시다.
이에는 몇가지 기술이 있습니다.
-
데이터가 아니라 행동에 기반한 코드 작성
행동은 메서드 속에 담겨 있고 메세지를 보내는 행위를 통해 실행됩니다. 하나의 책임만 지는 클래스를 만들면 각각의 작은 행동들은 단 한 곳에만 존재한다. DRY 한 코드는 변화를 잘 견뎌내는데 이는 클래스의 행동을 수정하기 위해 단 한 부분만 수정하면 되기 때문입니다.
-
인스턴스 변수 숨기기
변수를 직접 참고하기 보다는 언제나 엑세서 메서드를 통해 변수에 접근하는 것이 좋습니다.
Gear 클래스의 cog (톱니)를 예시로 들어보면 Gear 클래스에서 cog는 여러 곳에서 참조하는 데이터지만 cog 라는 메서드를 구현함으로 단 한 번만 정의된 행동으로 바뀌게 됩니다. 이는 나중에 데이터 였을 때의 cog 변수를 참고하고 있는 코드가 10곳 이라면 10곳의 코드를 바꿔줘야 하지만 cog가 메서드로 구현되어 있다면 cog 메서드의 구현체만 바꿔주면 되기 떄문에 코드 수정이 쉽고 코드가 지저분해지지 않습니다.
데이터를 메서드 형태의 객체로 취급하면 두가지 이슈가 생깁니다.
- 가시성 이슈가 생겨 퍼블릭 메서드로 감싸 외부에서 접근이 가능하도록 할지 프라이빗 메서드로 만들어 외부에서 접근 할 수 없게 만들지를 선택해야 하는데 이는 [4장 유연한 인터페이스 만들기]에서 다룬다고 합니다.
- 데이터와 객체의 구분이 무의미 해진다. 사실 저는 이부분에 대해서는 항상 객체인 편이 좋다고 동의하고 있기 때문에 어떤 문제가 발생하는지에 대해서는 잘 모르겠습니다. 그리고 개발자도 데이터의 모든 행동을 다 알고 있지 못한 경우가 있기 때문에 감춰두는편이 좋습니다.
-
데이터 구조 숨기기
코딩을 하다보면 다음처럼 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 문법을 사용해서 흔히 말하는 객체 형태로 변형했습니다.
-
-
모든 곳에 단일 책임 원칙을 강제
-
메서드에서 추가적인 책임 뽑아내기
클래스처럼 메서드 역시 단일 책임만을 지도록 구현 할 수 있습니다.
위의 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 로 분리된 메소드는 타이어의 지름을 계산하는 메서드가 추가로 생긴거고 이는 어딘가에서 지름을 계산하는 코드를 따로 작성하지 않고 재사용 할 수 있게 됩니다.
-
다른 클래스로 옮기기 쉽다
디자인에 필요한 정보를 새로 얻었을 때 코드를 수정하기로 마음 먹었을 때 작은 메서드는 이동이 쉽습니다.
-
-
클래스의 추가적인 책임들을 격리시키기
이전 예제인 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 코드를 분리해서 클래스로 만들면 됩니다.
-
수정하기 쉽고 유지보수하기 쉬운 객체지향 소프트웨어를 만들어가는 방법은 하나의 책임을 지는 클래스를 만드는 것부터 시작합니다.
3. 의존성 관리하기
의존성 관리에 대해서 이야기하기 전에 먼저 한가지 알아두고 가야 할 것이 있습니다.
객체지향에서 어떤 행동 (우리가 원하는 결과라고도 볼 수 있겠네요.) 을 유발 할 때는 객체가 그 행동을 이미 알고 있거나 상속 받았거나 또는 그 행동을 알고 있는 다른 객체에 대해 알아야만 합니다.
2장에서는 그 행동을 알고 있을 때, 즉 자체적으로 구현된 행동에 대해서 이야기를 했다면 상속에 대해서는 6장에서 살펴볼 것이고 이번 장에서는 다른 객체에 의해 구현된 행동에 접근하는 법에 대해서 이야기를 하려고 합니다.
의존성은 곧 다른 객체에게 의존적일 경우 의존성이 있다고 하는데 이러한 의존성은 다음과 같은 상황에서 발생합니다. 하나의 객체를 수정 했을 때 다른 객체들을 뒤따라 수정해야 할 경우, 후자는 전자에게 의존적이라고 할 수 있습니다.
객체간의 메세저 전송을 통해 행동하는 객체지향 에서 이러한 의존성은 어쩔 수 없이 생깁니다. 왜냐면 메세지 전송을 위해서는 받는쪽도 보내는쪽도 서로에 대해 알아야 할 필요성이 있기 때문입니다. 다만 이러한 의존성을 관리한다면 좀 더 변경에 유연한 코드를 작성 할 수 있고 하나의 객체가 변경 되었더라도 최소한의 변경으로 코드를 관리 할 수 있게 됩니다.
결합이라는 용어에 대해 간단하게 이야기를 하고 넘어가겠습니다. 서로 다른 객체가 있는데 이 두 객체가 서로에 대해 알아야 하는 부분이 많으면 많아질수록 객체들은 서로 떨어질 수 없게 되고 이를 결합이라고 합니다.
다음은 불필요한 의존성으로 불리는 4가지 체크사항 입니다.
- 다른 클래스의 이름
- 자기 자신을 제외한 다른 객체에 전송할 메세지의 이름
- 메세지가 필요로 하는 인자들
- 인자들을 전달하는 순서
만약 4가지 중에 해당하는 부분이 있다면 불필요한 의존성, 객체 하나를 수정 했을때 결합된 객체를 수정해야만 하는 의존성 을 가지고 있게 됩니다.
이러한 의존성을 관리하는 방법은 아래와 같습니다.
각 클래스가 자신이 해야하는 일을 하기 위한 최소한의 지식만을 알고 그 외에는 아무것도 모르도록 하는 것
다음은 베드 케이스로 소개된 사례입니다.
- 하나의 객체가 다른 객체에 대해 알고 있는데 이 객체가 무언가를 알고 있는 다른 객체를 알고 있는 경우
- 테스트 코드에 대한 의존성
이 두가지 케이스는 첫째는 하나의 변경점이 생겼을 때 모든 객체를 수정 해야할 필요가 생기고 이러한 행동은 흔히 자주 보는 하나를 수정했더니 다른곳에서 문제가 터져버렸다!!! 할 때 자주 쓰이는 것 같네요.
두번째는 테스트 코드가 실제 코드와 너무 결합되어 있으면 테스트 코드 역시도 계속 수정을 해줘야 하는 문제를 낳게 된다는 이야기 입니다. 이 부분에 대해서는 저도 어느정도 공감하고 있었는데 9장, 비용-효율적인 테스트 디자인하기 에서 알려준다고 하네요.
의존성을 관리하는 방법에는 크게 2가지가 있지만 세세하게는 여러가지가 더 있습니다.
- 약하게 결합된 코드 작성하기
- 의존성 주입하기
- 의존성 격리시키기
- 인자 순서에 대한 의존성 제거하기
- 의존성 방향 관리하기
- 의존성의 방향 바꾸기
- 의존성의 방향 결정하기
의존성 주입하기는 한가지 중요한 점을 기억하면 좋습니다.
해당 행동을 할 때 객체의 클래스가 무엇인지 보다 우리가 전송하는 메세지가 무엇인지에 대해서 아는 것, 이것이 중요합니다.
이전의 Gear 클래스를 가져오겠습니다.
class Gear
attr_reader :chainring, :cog, :rim, :tire
def 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 * Wheel.new(rime, tire).diameter
end
end
이 코드의 가장 큰 문제점은 gear_inches 가 오직 Wheel 인스턴스의 기어인치만을 계산하게 되어 있다는 점입니다. 이 부분 때문에 gear 와 wheel 은 강하게 결합되어 있다고 이야기 할 수 있습니다.
여기서 gear 는 사실 diameter 만 알고 있으면 기어 인치를 계산 할 수 있습니다.
gear 가 사실상 알고 있어야 하는게 뭔지 알았으니 Wheel 에 대한 의존성을 줄이는 방식으로 코드를 다시 작성해보겠습니다.
class Gear
attr_reader :chainring, :wheel
def def initialize(chainring, cog, wheel)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
이렇게 되면 gear 에서는 wheel 의 diameter 가 있다는 사실만 알고 있으면 되고 diameter 을 가지고 있는 객체 @wheel 만 전달되면 어떤 것이든 기어 인치를 구할 수 있게 됩니다.
이런 기술을 의존성 주입 (dependency injection) DI 라고 부릅니다.
의존성 격리시키기는 기술적으로 불필요한 모든 의존성을 현실적으로 제거하지 못할 경우에 사용하면 좋습니다.
이번에는 이전의 단일 책임 원칙을 따르는 클래스 디자인하기 에서 적당한 시점이 왔을 때 어디를 수정하고 무엇을 제거 해야하는지 쉽게 알 수 있도록 추가적인 책임을 고립 시키는 방법에서 착안해 나눠놓는 방법을 이야기 합니다.
- 인스턴스 생성을 격리시키기
- 외부로 전송하는 메세지 중 위험한 것들을 격리시키기
gear 객체에 의존성 주입을 할 수 없을 때, Wheel 객체를 생성하는 과정을 gear 내부에 격리시켜보도록 하겠습니다.
class Gear
attr_reader :chainring, :cog, :rim, :tire
def def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@wheel = Wheel.new(rime, tire)
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
class Gear
attr_reader :chainring, :cog, :rim, :tire
def 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 * wheel.diameter
end
def wheel
@wheel ||= Wheel.new(rime, tire)
end
end
첫번째 코드는 생성자 내부에 wheel 인스턴스 생성을 함으로 gear 인스턴스를 생성 할때 무족건 wheel 인스턴스를 생성한다는 의존성을 뚜렷하게 나타낼 수 있습니다. 두번째 코드는 wheel 인스턴스가 필요한 순간이 왔을 때 wheel 객체를 생성하도록 구현되어 있습니다.
완전히 의존성이 줄어들지는 않았지만 그래도 wheel 에 의존하고 있다는 사실을 좀 더 뚜렷하게 나타 낼 수 있고 오히려 이런 부분을 보임으로 재사용과 추후 코드 수정이 용이하게 되었습니다.
먼저 외부로 전송되는 메세지는 ‘나 자신이 아닌 객체에게 보내는 메세지’ 입니다.
gear_inches 메서드의 diameter 는 wheel 객체에 보내는 메세지 입니다. 지금은 gear_inches 메서드가 간단하지만 중간 과정에서 많은 연산이 필요해진다면? gear 가 wheel 에게 의존한다는 사실이 쉽게 보이지 않게 될 수 있습니다.
따라서 wheel.diameter 를 캡슐화 했습니다.
def gear_inches
ratio * diameter
end
def diameter
wheel.diameter
end
물론 처음부터 코드를 DRY 하게 유지하기 위해 diameter 메서드를 만들 수도 있었지만 타이밍이 다릅니다.
모든 외부 메서드 호출에 대해 꼭 이렇게 대응할 필요는 없지만 코드를 꼼꼼히 살펴보고 가장 위태로운 의존성을 찾아내 내부 메서드로 감싸는 작업은 시도해 볼만 합니다.
그런데 이런 문제를 제거하는 방법에는 시작 지점으로 돌아가 의존성의 방향을 반대로 돌려버리는 방법이 있습니다.
하지만 그전에 인자 순서에 대한 의존성을 제거하는 방법을 간단하게 설명하겠습니다.
기존의 생성 메서드를 보면 순서를 맞춰야만 가능한데 이를 해시로 변경하는겁니다.
def def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@rim = args[:rim]
@tire = args[:tire]
end
코드가 길어진것처럼 보이지만 나중에 코드를 관리하는 측면에서는 훨씬 이득인 부분이 많습니다.
여기에 기본 값을 설정하는 방법을 취할텐데 해당 해쉬 키 값이 없을 경우에도 사용 할 수 있는 fetch 라는 루비 메서드를 사용하도록 하겠습니다.
def initialize(args)
@chainring = args.fetch(:chainring, 40)
@cog = args.fetch(:cog, 18)
@rim = args.fetch(:rim, 11)
@tire = args.fetch(:tire, 21)
end
마지막으로 메서드를 직접 수정 할 수 없는 고정된 인자값을 가진 메서드를 사용할 때의 방법으로 Wrapper 모듈을 사용하는 방법 입니다.
외부에 있어 코드를 변경 할 수 없는 객체를 생성해야 할 때가 생겼다고 가정하겠습니다.
module SomeFramework
class Gear
attr_reader :chainring, :cog, :wheel
def def initialize(chainring, cog, wheel=nil)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
end
gear 객체의 생성 부분만 감싸주는 Wrapper 모듈을 작성해보겠습니다.
module GearWrapper
def self.gear(args)
SomeFramework::Gear.new(args[:chainring], args[:cog], args[:wheel])
end
end
이런 기술은 변경할 수 없는 외부 인터페이스에 의존 해야하는 경우에 쓰기 좋은 방법입니다.
이제 의존성의 방향을 관리하는 방법을 알아볼텐데 그전에 의존성의 방향이 중요한 이유에 대해서 알아보려고 합니다.
왜 의존성의 방향이 중요한가 생각을 해보면 어떤 행동을 할 때, 코드를 작성할 때 수정은 불가피합니다. 다만 수정이 많은 클래스와 덜 수정하는 클래스가 있을 때 어느쪽을 의존할까 방향을 정할때는 덜 수정하는 클래스쪽으로 의존하는게 좋습니다. 이에 대한 내용을 좀 더 다듬으면 다음과 같습니다.
- 어떤 클래스는 다른 클래스에 비해 요구사항이 더 자주 바뀐다.
- 구현 클래스는 추상 클래스보다 수정해야하는 경우가 번번히 발생한다.
- 의존성이 높은 클래스를 변경하는 것은 코드의 여러 곳에 영향을 미친다.
그렇다면 변경 가능성이 높다는건 어떻게 판단하는게 좋을지 정리해보겠습니다.
어떤 코드를 작성할 때 이 코드는 루비로 만들어져 있으며 특정 루비 프레임워크를 사용한 로직이 담겨있습니다. 가장 변경 가능성이 낮은 부분부터 순위를 매겨보겠습니다.
- 루비 베이스 클래스
- 프레임워크 클래스
- 우리가 작성하는 클래스
수정은 이루어지지만 비교적 적은 수정이 이루어진다는 점을 참고해서 보시면 이해 하실 수 있습니다. 다만 프레임워크의 경우에는 조금 다를 수 있는게 생긴지 얼마 안된 프레임워크의 경우에는 수정이 번번하게 일어날 수 있습니다.
다음으로 고민할 것은 구현 클래스와 추상 클래스로 나뉘는 부분을 어떻게 고려할지 입니다.
의존성 주입으로 만들어진 gear 클래스는 diameter 메세지에 반응하는 객체를 필요로 하게 됩니다. 이 객체는 어떤 객체인지 모르지만 diameter 을 가지고 있어야 한다는 생각을 하고 구현되어 있습니다. 이러한 어떤 객체인지 모르겠지만에 해당 하는 부분이 추상적인 생각을 코드로 구현한 부분입니다.
책에는 의존성이 높은 클래스를 만들었을때의 높은 대가 지불에 대한 것과 문제가 되는 의존성을 찾는 방법에 대해서 더 설명하고 있지만 내용 정리는 여기서 마무리 해도 될 것 같습니다.
4. 유연한 인터페이스 만들기
이전의 챕터들을 통해 객체지향 어플리케이션에서는 클래스로 모든걸 다 해결 할 수 있을 것 같아보이고 클래스의 책임과 의존성을 중심으로 디자인이 논의 되고 끝나는 경향이 생길 수 있지만 실제 객체지향 어플리케이션은 각 객체간의 메세지를 통해 동작하기 때문에 이를 주의해야 합니다.
지금까지 내용을 봤을 때 객체지향 디자인에서 중요시 해야하는 부분은 객체의 책임, 의존성, 그리고 어떻게 소통하는지가 됩니다.
그리고 이런 객체간의 소통은 인터페이스를 통해 이루어집니다.
인터페이스 이해하기
위에서 객체간의 소통을 인터페이스를 통해 이루어진다고 했습니다.
인터페이스는 크게 2가지로 분류가 되는데 클래스로부터 독립된 인터페이스와 객체 내부에 구현된 인터페이스가 있습니다.
객체 내부에 구현된 인터페이스는 외부로 노출되는 퍼블릭 인터페이스와 내부에서만 공유되는 프라이빗 인터페이스로 구분이 됩니다. 클래스로부터 독립된 인터페이스는 메세지의 묶음으로 표현되기도 합니다.
이번 4장에서는 이 중에서도 클래스내의 메서드를 다루는데 그 중에서도 어떤 부분을 노출 할지를 다루고 이후 5장에서는 클래스로부터 독립된 인터페이스, 메세지의 묶음을 알아보도록 하겠습니다.
인터페이스 정의하기
여기서는 예시를 통해 퍼블릭 인터페이스와 프라이빗 인터페이스를 나누는 기준과 정의를 하는 방법을 알아 볼건데 예시가 간단해 쉽게 이해할 수 있었습니다.
레스토랑의 부엌을 봤을 때, 손님은 메뉴판을 보고 음식을 주문합니다.
그러면 손님의 주문은 홀과 부엌을 지나 부엌으로 전달되고 음식이 나오게 됩니다.
- 퍼블릭 인터페이스 : 메뉴판
- 프라이빗 인터페이스 : 부엌에서 일어나는 일들
조금 더 각각의 특징을 정리해보겠습니다.
퍼블릭 인터페이스
- 클래스의 핵심 책임을 드러낸다.
- 다른 객체에 의해 호출될 수 있다.
- 쉽게 변경되지 않는다.
- 다른 객체가 안정적으로 의존할 수 있다.
- 테스트를 통해 꼼꼼하게 문서화되어 있다.
프라이빗 인터페이스
- 세부적인 구현을 담당한다.
- 다른 객체에 의해 호출되지 않는다.
- 필요에 따라 언제든 변경될 수 있다.
- 다른 객체가 의존하기에는 위험하다.
- 테스트에서 다루지 않을 수도 있다.
실제로 프라이빗 인터페이스의 경우에는 테스트 코드를 작성하지 않는 경우도 있었습니다.
퍼블릭 인터페이스를 설계할 때 비교적 싼 비용으로 설계에 도움을 주는 방법이 있습니다. 시퀸스 다이어그램이라고 부르는 UML 로 메세지 전달과 그 메세지를 요청하고 받는 객체에 대한 정의를 할 수 있습니다.
그리고 제일 중요한 부분인데 이러한 시퀀스 다이어그램을 그리거나 다른 방법으로 객체와 메세지를 설계할 때 기존의 객체 기반 디자인에서 메세지 기반 디자인으로 설계 방법을 바꾼다면 좀 더 유연한 어플리케이션을 만들도록 도움을 줍니다.
메세지 기반 디자인에서 메세지를 얻기 위해 어떻게 해야 하는지 보다는 어떤 메세지를 요청 할 지를 디자인 한다면 의존성 관리 측면에서 도움이 됩니다.
마지막으로 객체지향 디자인에 있어 객체가 있기 때문에 메세지를 보내는게 아닌 메세지를 보내기 때문에 객체를 갖게 되었다는 점을 기억하면 좋습니다.
인터페이스를 만들떄 주의 해야하는 점
- 명시적인 인터페이스 만들기 (퍼블릭, 프라이빗 구분)
- 다른 이의 퍼블릭 인터페이스 존중하기
- 프라이빗 인터페이스에 의존할때는 주의를 기울인다
- 최소한의 맥락 속에 위치시키기 (다른 객체와의 연계 부분을 최소화 하기)
지금까지 객체의 책임, 의존성, 인터페이스에 대해서 알아보았습니다.
책에서는 이후 데메테르의 원칙에 대해 설명하고 있는데 이는 좀 더 자세히 알아보고 정리하고 싶어 다음으로 미루겠습니다.
5. 오리 타입으로 비용 줄이기
오리 타입, Duck Typing 은 예전부터 굉장히 많이 나오고 실제로 사용하고 있던 방법 중에 하나 입니다.
그런데 이번에 글을 읽을때 어떻게 설명이 되는지 굉장히 기대가 되었는데 기대 이상으로 좋은 내용이였습니다.
먼저 오리 타입, 덕 타이핑을 사용하는 큰 이유는 “특정 클래스에 종속 되지 않은 퍼블릭 인터페이스” 이기 때문에 클래스에 대한 의존을 메세지에 대한 의존으로 대치 시킵니다. 이는 덕 타이핑 객체가 클래스 보다는 행동에 의해 규정되기 때문입니다. 행동에 의해 규정 된다는건 덕 타이핑의 이름대로 “오리처럼 꽥꽥대고 오리처럼 걷는다면 이 객체는 오리가 맞다.” 를 의미합니다.
이런 오리타입이 나오게 되는 생각의 전환점은 어떤 객체가 하나의 인터페이스에만 반응할 수 있다는 생각입니다.
사람마다 생각이 다르듯 객체를 사용하는 사람에 따라 그 객체의 쓰임은 달라질 수 있습니다. 그리고 객체를 사용하는 사람은 객체의 클래스가 무엇인지를 신경 쓸 필요가 없습니다. 클래스는 객체가 퍼블릭 인터페이스를 갖기 위한 하나의 수단일 뿐입니다. 진짜 중요한 것은 객체가 무언기가가 아니라 어떻게 행동 하는가 입니다.
이전 예제에서 여행을 준비할때 정비공에게 여행에 사용할 자전거를 준비하는 코드가 있었습니다.
class Trip
attr_reader :bicycles
def prepare(mechanic)
mechanic.prepare_bicycles(bicycles)
end
end
여기서 여행에 필요한 준비 요소가 늘었다고 생각이 되면 기존의 코드를 같은 방식으로 더 추가하게 됩니다.
class Trip
attr_reader :bicycles, :vehicle
def prepare(preparers)
preparers.each do |preparer|
case preparer
when Mechanic
preparer.prepare_bicycles(bicycles)
when Driver
parparer.gas_up(vehicle)
parparer.fill_water_tank(vehicle)
end
end
end
end
지금은 하나만 추가 되었지만 상황에 따라서는 점점 코드가 길고 복잡해지고 점점 코드 수정 비용이 늘어날 것 입니다.
이때 Trip 의 prepare 의 목적에 대해서 생각해봅니다. prepare 는 사실 여행을 준비하기 위한 메서드 입니다.
각각의 Mechanic 와 Driver 이 prepare 의 목적에 맞는 메서드를 구현할거라고 믿고 코드를 작성해보겠습니다.
class Trip
attr_reader :bicycles, :vehicle
def prepare(preparers)
preparers.each do |preparer|
preparer.prepare_trip(self)
end
end
end
여기서 얻을 수 있는 이점이 있습니다.
첫번째 코드는 구체적이기 때문에 이해하기 쉽지만 확장에는 위험합니다. 하지만 두번째 코드는 추상적이라 이해하기는 쉽지 않지만 손쉬운 확장을 제공합니다.
코드의 수정 비용이 중요한 포인트라면 이처럼 코드의 덕 타이핑이 가능한 시점을 찾아내 수정하면 앞으로의 코드 수정 비용에는 확실히 도움이 될겁니다.
객체지향 디자인에서 구체적인 코드를 작성하는 비용과 추상적인 코드를 작성하는 비용 사이의 고민에서는 결코 자유로울 수 없기 때문에 상황에 따라 잘 선택 할 수 있도록 다양한 방법을 이해하고 익혀두는게 중요하다고 생각이 듭니다.
6. 상속을 이용해 새로운 행동 얻기
기존에 알아보았던 방식 외에도 코드를 공유 할 수 있는 방법으로는 상속이 있습니다.
고전적 상속
- 자동화된 메세지 전달 시스템
- 특정 객체가 이해할 수 없는 메세지를 전달 받을 경우 그 객체는 이 메세지를 다른 객체에게 전달하는데 이런 전달의 관계를 만드는 것
- 하위 클래스를 만드는 것을 통해 정의
상속에서의 메세지는 하위 클래스에서 상위 클래스로 전달되게 됩니다.
지금은 안티패턴이라고 불리지만 자바스크립트의 프로토타입 상속이나 루비의 모듈이 이러한 기능을 제공합니다.
그렇다면 상속은 어떤 상황에서 필요할까요?
하나의 클래스가 여러 개의 서로 다른, 하지만 연관된 타입을 가지고 있다면 이는 상속을 통해 해결 할 수 있는 문제이기 때문에 상속을 사용합니다.
상속 관계를 만드는 과정에서 주의할 점이 몇가지 있습니다. 이 주의점은 상속의 기본 원칙이며 이를 훼손하면 안됩니다.
- 모델링 하는 객체들이 명확하게 일반-특수 관계를 따라야 합니다.
- 올바른 코딩 기술을 사용해야 합니다.
첫번째의 일반-특수 관계의 예시로는 자전거-로드자전거 의 관계를 들면 좋습니다. 즉, 하위 클래스는 상위 클래스의 특수한 형태입니다. 하위 클래스는 상위 클래스의 모든 행동을 갖추고 추가적인 행동을 가지게 되는겁니다.
추상화된 상위 클래스를 작성하는 방식에서 언어마다 특정 클래스를 명시적으로 추상 클래스 선언 해주는 문법을 지원하는 경우가 있는데 대표적으로는 자바의 abstract 키워드가 있습니다. 루비에서는 그런 문법은 아쉽게도 없습니다.
이러한 추상 클래스를 작성할 때 좋은 방법 중 하나로 “추상적인 행동을 위로 올리기"가 있습니다.
이 방법은 기존에 상속이 필요하지 않았을때 정상적으로 동작하던 코드에서 상속이 필요한 상황이 오고 그때 코드를 작성하는 방법 입니다.
상속을 사용하기로 결정되었다면 먼저 상위 클래스를 만들고 하위 클래스들을 작성합니다. 그런데 기존에 있던 코드는 이름만 변경하고 상위 클래스와의 상속 관계를 정의 합니다.
이후 추상적인 부분만을 상위 클래스로 코드를 옮깁니다. 이 과정이 유효한 이유는 기존의 코드에서 추상화 할 부분과 구체화 하는 부분을 쉽게 분류 할 수 있고 기존의 구체화 된 코드를 상위 클래스에 두는게 아닌 하위 클래스에 그대로 둠으로 추상화 되어야 할 상위 클래스에 구체적인 코드가 남지 않게 되는 이점이 있기 때문입니다.
우리가 이런 혹시 모를 문제를 대비해야하는 이유는 내가 실수했을 때의 어플리케이션의 추가 작성 비용을 고려해야만 하기 때문입니다.
이 책의 좋은 점 중에 하나는 이런 수정 사항이 생겼을 때 새로 개발하는 것이 아닌 기존의 코드에서 수정시 비용까지 고려를 항상 해야한다는걸 알려주기 때문에 좋습니다.
7. 모듈을 통한 역할 공유
이전에 배운 상속의 기술을 이용해 역할을 공유하는 다른 방법을 알아보는 장입니다.
여기서는 루비의 모듈을 이용해 공통의 역할을 구현하려고 합니다.
이 공동의 역할은 고전적 상속이 만들어내는 상위/하위 클래스 관계가 아닌 주어진 역할을 누군가를 위해 수행하면서 이 누군가와 관계를 맺게 됩니다.
여기서 루비 모듈은 다른 객체지향 언어에서 지원하는 메서드의 묶음에 이름을 부여하고 관리하는 방법 중에 하나 입니다.
객체가 모듈을 include 하면 객체는 이 모듈이 정의하고 있는 메서드를 자동화된 위임을 통해 모두 사용 할 수 있게 되는데 이 부분은 코드를 보겠습니다.
module A
def pr
puts "a - pr"
end
end
class Person
include A
end
p = Person.new
p.pr
저는 이 모듈과 설명하는 내용을 보면 Java 의 추상 클래스 같은 느낌이 아닐까 싶었습니다. 인터페이스는 아니라고 생각했던건 인터페이스는 추상화된 코드만 있어야 해서 제외하게 되었습니다. 완전히 추상 클래스는 또 아니지만 그나마 제일 비슷한게 아닐까 싶었습니다.
그리고 모듈을 include 했을 때 이 모듈의 메서드들은 상속을 통해 얻은 메서드와 같은 메서드 탐색을 거치게 됩니다.
마지막으로 인상 깊었던 내용을 추가로 정리하겠습니다.
객체가 다른 곳에서 정의된 행동을 가져왔다면 다른 곳이 상위 클래스든 아니면 모듈이든 이 객체는 상위 타입은 자신의 하위 타입으로 치환될 수 있습니다.
이는 리스코프 치환 원칙으로 설명이 가능합니다.
- SOLID 디자인의 원칙 중 ‘L’이 리스코프 치환 원칙을 뜻합니다.
- q(x) 를 자료형 T 의 객체 x에 대해 증명할 수 있는 속성이라 하자, 그렇다면 S가 T의 하위형이라면 q(y) 는 자료형 S의 객체 y에 대해 증멸 할 수 있어야 한다.
이는 타입 시스템이 정상적으로 작동하려면 상위타입은 자신의 하위타입으로 치환될 수 있어야 합니다.
8. 조합을 이용해 객체 통합하기
객체지향에서의 조합은 독립적인 객체를 보다 크고 복합적인 것으로 통합하는것을 의미합니다. 이는 좀 더 큰 객체가 다른 작은 객체들을 가지고 있는 has-a 관계를 가진다고 이야기 할 수 있습니다.
예시에서는 상속으로 구현했던 클래스를 조합으로 구현하는 방식을 설명했습니다.
따라서 상속과 조합 각각의 이점에 대해서 정리해보려고 합니다.
“상속은 특수화이다.” - 클래스의 손길: 객체와 계약을 통해 재대로 프로그래밍 배우기
“상속은 이미 존재하는 클래스들에 새로운 기능을 추가할 때 가장 잘 어울린다. 기존 코드의 대부분을 계속 사용하면서 상대적으로 적은 양의 새로운 코드를 추가하는 상황에 어울린다,” - 디자인 패턴: 재사용성을 지닌 객체지향 소프트웨어의 핵심 요소
“주어진 행동이 자신의 부분들의 총합 이상일 때 조합을 사용하라” - 객체지향 분석과 디자인
- is-a 관계에서 상속 사용하기
- behaves-link-a 관계에는 오리 타입 사용하기
- has-a 관계에서 조합 사용하기
조합보다 어떤 것을 사용해야 할지에 대한 이야기를 더 많이 한것 같지만 조합 자체는 정말 객체 내부에 어떤 객체를 가지고 있는 형태를 설명하고 있어 크게 정리할 내용이 없었습니다. 그리고 무엇보다 중요한건 어떤 상황이 생겼을때 각각에 맞는 디자인을 잘 선택해 사용하는게 중요하기 때문에 이 부분을 좀 더 중점적으로 적었습니다.