루비로 배우는 객체지향 디자인 | 2장 단일 책임 원칙을 따르는 클래스 디자인 하기

루비로 배우는 객체지향 디자인 | 2장 단일 책임 원칙을 따르는 클래스 디자인 하기

날짜
May 8, 2024
태그
OOP
설명
단일 책임 원칙을 따르는 클래스 디자인 하기
 

서론

객체는 클래스로 만들어져 있고, 각 개체는 메시지를 주고 받으며 상호작용한다. 알고 있지만 항상 어렵다.
  • 어떤 클래스를 만들어야 할까
  • 몇 개나 만들어야 할까
  • 어떤 행동을 구현해야 할까
  • 하나의 클래스는 다른 클래스에 대해 얼마나 알고 있어야 할까
  • 다른 클래스에 대해 얼마나 열려 있어야 할까
 
우리는 이러한 질문에 압도당하곤 한다. 모든 결정은 위험하고 한 번 결정한 것은 돌이킬 수 없을 것만 같다. 하지만 두려워하지 말자. 우리가 해야할 것은 클래스는 단순해야 한다는 말은 가슴깊이 새기고, 지금 당장 해야 할 일을 하고, 나중에도 쉽게 수정이 가능한 클래스를 모델링하는 것이다.
 
 

무엇을 클래스에 넣어야 할까

결국은 문제를 해결하는 것은 기술에 대한 지식보다는 코드를 구성하고 배치하는 일, 즉 모델링이다.
 
객체지향 언어에서는 클래스와 클래스를 구성하는 메소드로 구성된다. 클래스는 우리가 애플리케이션에 대해 어떻게 생각하는지에 영향을 끼친다. 클래스를 만든다는 것은 주어진 상상의 범위 밖에서는 사고하기 힘든 하나의 박스를 만드는 것이다.
 
프로젝트의 초기 단계에서는 메서드를 제대로 묶어 내기란 어렵다. 당장 주어진 정보가 부족하기 때문이다. 처음부터 완벽할 수는 없다. 디자인이란 완벽함을 추구하는 행위라기보다 코드의 수정가능성을 보존하는 기술이다.
 

수정하기 쉬운 코드를 구성하기

말이 쉽다. 수정하기 쉬운 코드를 구성한다는 표현은 애매하다. 이를 정의한다면 아래와 같다.
  • 수정이 예상치 못한 부작용을 낳지 않는다.
  • 요구사항이 변했을때 연관된 코드를 소폭 수정하면 된다.
  • 현재 코드를 재사용할 수 있다.
  • 코드를 수정하는 가장 쉬운 방법은 기존의 코드에 새로운 코드를 추가하는 방법이다.
 
결국 우리가 만드는 수정이 쉬운 코드는 이러한 특징을 가지고 있어야 한다.
  • 투명성: 수정된 코드와 연관된 코드에서 수정의 결과가 뚜렷하게 나타난다.
  • 적절성: 수정 비용은 수정 결과를 통해 얻은 이득에 비례하다.
  • 사용가능성: 예상치 못한 상황에서도 현재 코드를 사용할 수 있다.
  • 모범성: 나중에 수정하는 사람이 위의 특징을 이어갈 수 있다.
 
첫 단추는 모든 클래스들이 하나의 잘 정의된 책임을 갖도록 하는 일이다.
 

하나의 책임만을 지는 클래스 만들기

하나의 클래스는 최대한 작으면서도 유용한 것이어야 한다. 다시 말해, 하나의 책임만을 가져야 한다.
 

애플리케이션 만들기: 자전거와 기어

 
chainring = 52 # 앞 톱니 cog = 11 # 뒷 톱니 ratio = chainring / cog # 기어비 print(ratio) # 4.73 chainring = 30 cog = 27 ratio = chainring / cog print(ratio)
앞 톱니 바퀴의 톱니가 52개, 뒤 톱니가 11개인 기어의 기어비(52 x 11)는 약 4.73이다. 즉 페달을 한 번 돌릴때마다 바퀴는 5번 정도 돈다.
 
우리의 직관은 자전거가 하나의 클래스라고 말해준다. 하지만 위에는 자전거의 행동을 설명하는 내용이 없다. 그러므로 지금 필요한 것은 자전거 클래스가 아니다. 반면 기어는 앞 톱니, 뒷 톱니, 기어비를 가지고 있다. 다시 말해, 데이터와 행동 둘 다 가지고 있다. 기어는 클래스가 되기에 충분하다.
 
기어비를 계산하는 애플리케이션을 작성해보자.
class Gear: def __init__(self, chainring, cog): self.chainring = chainring self.cog = cog def ratio(self): return self.chainring / float(self.cog) print(Gear(52, 11).ratio()) print(Gear(30, 27).ratio())
Gear 클래스는 단순하다. 앞, 뒤 톱니 수를 인자로 넘겨 인스턴스를 만든다. Gear 인스턴스는 세개의 메서드를 구현한다. 앞 톱니, 뒤 톱니, 기어비. Gear 클래스는 스스로 구현한 메서드와 상위 클래스로부터 상속받은 메서드로 구성되어 있기 때문에 Gear 인스턴스가 할 수 있는 행동들의 묶음, 다시 말해 기어 인스턴스가 이해할 수 있는 메시지는 상당히 많다. 애플리케이션 디자인에서 상속 관계는 중요한 고려사항이다.
 
변경 사항의 적용
기어비를 계산하기에 용이해졌다. 더 발전시켜 기어는 동일하지만 자전거마다 바퀴의 크기가 다르다. 이를 표현하기 위해 기어 인치라는 개념을 추가해야 한다.
💡
기어 인치 = 바퀴 지름 * 기어비
💡
바퀴 지름 = 바퀴테 지름 + 타이어 높이의 2배
 
자, Gear 클래스에 새로운 행동을 추가하자
class Gear: def __init__(self, chainring, cog, rim, tire) self.chainring = chainring self.cog = cog self.rim = rim self.tire = tire def ratio(self): return self.chainring / float(cog) def gear_inches(self): return self.ratio * (self.rim + (tire * 2)) print(Gear(52, 11, 26, 1,5).gear_inch)
print(Gear(52, 11).ratio) # 인자 값의 변화로 기존 코드가 동작하지 않는다.
이제 Gear 클래스의 기본 골격이 완성되었다. 현재의 모습이 코드를 최선으로 배치하는 방법일까? 더 효율적으로 발전하려면 코드는 수정하기 쉬워야 한다.
 

단일 책임 원칙이 왜 중요한가

쉽게 수정할 수 있는 애플리케이션은 재사용하기 쉬운 클래스로 구성되어 있다. 쉽게 가져다 쓸 수 있는 코드(pluggable unit)은 복잡하게 얽혀 있지 않고 잘 정의되어 있는 행동의 묶음이다. 잘 짜여진 클래스는 마치 레고 블록으로 가득찬 박스와 같다.
 

클래스가 하나의 책임을 가지는지 알아보기

클래스를 인격이 있는 존재처럼 질문을 던져본다.
# 이해가 잘 안돼서 Chat GPT에게 물었다. # 답변 객체 지향 디자인에서 클래스의 설계는 중요한 부분을 차지합니다. 잘 설계된 클래스는 응집력이 있고, 외부에서의 접근이 최소화되며, 확장성이 좋아야 합니다. 제시하신 예제를 통해 설명을 드리겠습니다. ** OK: Gear, What's your ratio? ratio는 Gear 클래스의 주요 기능 중 하나로 보입니다. chainring과 cog 값에 기반하여 계산되므로, 이는 클래스의 핵심 책임 중 하나입니다. 이러한 핵심 책임이 클래스에 속하는 것은 자연스럽습니다. ** NO: Gear, What's your gear_inches? gear_inches는 ratio와 타이어의 크기를 통해 계산됩니다. 그런데 이 값이 Gear 클래스의 핵심 책임인지는 논란의 여지가 있습니다. 타이어의 크기는 Gear보다는 다른 클래스(예: Wheel 클래스)의 책임일 수 있습니다. 만약 Gear 클래스가 이런 계산까지 책임져야 한다면, 그것은 클래스의 책임이 과도하게 많아진다는 의미가 될 수 있습니다. ** NO: Gear, What's your tire height? 타이어의 높이는 Gear 클래스의 핵심 책임이 아닙니다. 타이어와 관련된 특성이나 연산은 다른 클래스(예: Wheel 클래스)에서 다루어져야 합니다. Gear 클래스가 타이어의 높이에 대한 정보를 알고 있어야 하는 경우, 그것은 객체 지향 설계 원칙 중 하나인 '단일 책임 원칙'을 위반할 수 있습니다. 이렇게 설계를 할 때 각 클래스의 책임과 역할을 명확히 하는 것이 중요합니다. 클래스가 책임져야 할 핵심 역할과 책임만을 수행하도록 설계하는 것이 좋습니다. 이렇게 하면 클래스의 응집도가 높아지고 유지보수 및 확장이 용이해집니다.
 
그렇다면, Gear 클래스의 단 하나의 책임을 어떻게 표현하면 좋을까?
‘자전거에 기어가 미치는 영향을 계산한다’ 라고 정의한다. 그렇다면 gear_inch가 Gear 에 속하는 것은 어색하지 않다. 하지만 여전히 tire는 어색하다고 말할 수 있다.
 

언제 디자인은 결정하면 좋을까

클래스에 문제가 있다고 느끼는 순간은 생각보다 꽤 자주 온다.
현재도 코드를 보면 Gear 클래스에서 rim(바퀴테)와 tire(타이어)를 인자로 받는다. 어색하지 않나? 잘 생각해보면 이건 Gear가 아니라 Bicycle 이라고 표현 해야 맞을 것 같다.
 
하지만 우리는 어떤 기능이 필요할지 처음부터 알 수 없다. 방금과 같은 상황을 반복적으로 겪으면서 수정해나가는 것이다.
 
우리는 잘못된 상황을 깨닫고, 다시 디자인을 고민한다. 그러다 문득 Wheel 이라는 객체가 필요함을 알게된다.
 
하지만 지금 수정해야할까? 에 대해서는 두 가지 견해가 존재한다. Gear 클래스는 이미 투명하고 적절하다. 큰 의존성을 가지고 있지도 않기에 수정함으로써 얻는 이점이 없다. 만약 새로운 객체를 만들어 의존성이 생긴다면, 오히려 Gear 클래스는 투명함고 적절성을 잃게 될 것이다.
 
디자인 결정은 꼭 필요한 순간에, 그 순간이 제공하는 정보들을 가지고 해야 한다. 그대로 두는 것은 꽤 설득력이 있다. 반면 지금 수정해야 한다는 주장도 설득력이 있다.
 
현재의 클래스 구조는 미래의 개발자에게 보내는 메시지이다. 미래의 개발자는 지금 구현해놓은 디자인 패턴을 참조하게 될 것이다. Gear 클래스는 현재 잘못된 디자인 의도를 담고 있다. 여러가지 책임을 가지고 있기 때문에 재사용하면 안되는 코드이다. 우리가 신경쓰지 못하는 사이 해당 코드는 여러 곳에 참조되고 있는 끔찍한 일을 겪게 될 수도 있다.
 
‘지금 당장 개선하기’와 ‘나중에 개선하기’ 사이에는 언제나 긴장감이 흐른다. 완벽하게 디자인된 애플리케이션은 없다. 모든 결정에는 대가가 따른다. 좋은 디자이너(개발자)는 이 긴장을 이해하고 당장의 필요와 미래의 가능성 사이에서 고민하고 개선 비용을 최소화해야 한다.
 

변화를 받아들이는 코드 작성하기

쉽게 수정될 수 있는 코드를 작성하는 몇 가지 잘 알려진 기술이 있다.
 
데이터가 아니라 행동에 기반한 코드를 작성하자
행동은 메서드 속에 담겨 있고, 메시지를 보내는 행위를 통해 실행된다. 하나의 책임만 지는 클래스를 만들면 각각의 작은 행동들은 단 한 곳에만 존재한다. DRY는 이런 아이디어를 보여주고 있다. DRY한 코드는 변화를 잘 견뎌내는데, 클래스의 행동을 수정하기 위해 코드의 오직 한 부분만 수정하면 되기 때문이다.
 
객체는 행동과 함께 데이터를 갖는다. 데이터는 객체의 인스턴스 변수 속에 있는데 문자열 부터 해시까지 다양한 형태를 갖는다. 이 데이터에 접근하는 방법은 두 가지가 있다. 인스턴스 변수를 직접 참조하거나 인스턴스 변수를 감싸는 엑세서 메서드를 만들어 접근하는 방법이다.
class Gear: def __init__(self, chainring, cog): self._cog = cog self.chainring = chainring self.cog = cog @property def cog(self): return self._cog @cog.setter def cog(self, value): self._cog = cog gear = Gear(10, 12) # 인스턴스 생성 gear.chainring # 인스턴스 변수에 직접 접근 gear.cog = 13 # 내부적으로 @cog.setter 변수에 접근한다. # _cog 속성은 private처럼 동작하며, 외부에서 직접적인 변경을 원치 않을 때 사용합니다. # cog은 property를 사용하여 _cog에 대한 getter와 setter를 제공합니다. # 이렇게하면 객체 내부의 데이터에 안전하게 접근하고 수정할 수 있습니다.
 

댓글

guest