금쪽같은 내 객체

우리는 현업 프로젝트 상에서 일하다 보면 동료들이 작성한 다양한 객체들을 마주치게 되는데요.

물론 객체설계에는 정답은 없지만 가끔 마주치게 되는 아주 금쪽?같이 어려운 객체들이 있는데 주로 다음과 같은 객체들이 있었어요.

  • 욕심쟁이: 한 객체가 너무 많은 일을 하고 있어요. A 역할도 하기도하고 B역할도 하며 심지어 C역할까지하는 아주 욕심많은 객체인데 심지어 이곳 저곳에서 많은 역할을 하는 아주 무시무시한 객체죠. 우리는 이 객체를 Master 객체라고도 부르기도 하며 Massive하다고 표현하기도 해요.
  • 지킬 & 하이드: 어떤 고리가 있는데 코에 붙으면 코걸이, 귀에 붙으면 귀걸이 듯이 둘로 확실히 나눠지면 좋을꺼 같은데 아주 지킬&하이드 같이 프로젝트 위에서 가끔 사이드 이펙트가 터지면서 버그를 만들어 내는 객체 무시무시한 객체에요. 또한 이미 저질러놓은 상황에서 분리하는것도 참 고달픈일이에요.
  • 시한폭탄: 조건을 많이 따지면서 분기문 마다 하는 역할이나 동작이 많은 메서드를 보유한 객체에요. 어떤 역할을 수행하는데 상태값도 많고 조건도 많이 따지면서 내부에 분기문이 수두룩하고 이미 이 객체를 사용하는 객체들이 많은 상황이라 어디 잘못 건드렸다간 무슨일이 터질지 모르는 아주 시한폭탄 같은 객체인거죠. 이미 동작하고 있는 기능에 실험명분으로 분기문을 더 추가하기도 해요. 참고로 위와 같이 별명을 준 건 친해지고 싶지 않겠지만 어려운 문제가 아닌 누구나 쉽게 접근하고 해결할 수 있는 문제라는 걸 전달해주고 싶었어요.

욕심쟁이, 지킬&하이드, 시한폭탄등 이러한 빌런들을 어떻게 하면 개과천선 시키고 구제할 수 있을까요?

그 정답은 객체지향 개발 5대 원리(SOILD)를 기반으로 단일책임과 추상화에 집중하면 사전에 이러한 빌런 탄생을 막을 수도 있고 문제를 해결해 나갈 수도 있어요.

자 그럼 빌런들을 하나 하나씩 레이드 해보아요!

욕심쟁이

어떤 교실에서 어린 욕심쟁이 꼬마아이가 친구들의 장난감, 사탕, 게임기를 빼앗아 욕심을 부리고 있다면, 반드시 선생님이 다가와서 욕심쟁이 꼬마애 머리에 꿀밤을 먹여주고 하나씩 다시 친구들에게 돌려줄 꺼에요.

만약 그러지 않는다면 이 욕심쟁이는 장난감, 사탕, 게임기뿐만 아니라 다른 것도 친구들로 부터 빼앗아올 가능성이 매우높겠죠?

위 내용에서 볼 수 있듯 이런 경우엔 하나씩 역할을 분리해 나가는게 정답인 걸 뻔히 알 수 있어요.

이러한 욕심쟁이를 분리하는 방법은 다음과 같이 코드로 표현할 수 있어요.

AS-IS

class ClassRoom {
  let grabber: Grabber
}
class Grabber {
 func playToy() { ... }
 func playGame() { ... }
 func eatCandy() { ... }
}

TO-BE

class ClassRoom {
  let toy: Toy
  let game: Game
  let candy: Candy
}
class Toy {
  func play() { ... }
}
class Game {
  func play() { ... }
}
class Candy {
  func eat() { ... }
}

이렇게 나누게 된다면 Toy/Game/Candy의 객체가 늘어나더라도 동료들이 욕심쟁이에 기능을 덧대지 않고 역할에 맞는 객체를 추가하거나 적절한 객체에 필요한 스펙을 추가할 수 있어요.

그리고 우리는 어떤 새로운 기능을 어떤 객체에 위임해야 할지에 대한 건전한 커뮤니케이션을 자연스럽게 할 수도 있어요.

지킬 & 하이드

클린아키텍쳐에 따르면 핵심객체들간에 소통하는데 주고받는 Payload 및 데이터정보에 대한 내용을 다루는 객체들이 있는데 이를 DTO(Data Transfer Object)/VO(Value Object), DAO(Data Access Object), Entity, ViewData/ViewModel 등이 있어요.

간헐적으로 우리가 마주칠 수 있는 지킬&하이드는 위와 같은 객체에서 자주 만나게 되는데 주로 이런 친구들이에요.

서버와 통신한 결과값에 대한 Response DTO인데 Entity처럼 쓰이는 구조체 Entity인데 View를 위한 데이터 구조체(ViewModel/ViewData)처럼 쓰이는 구조체 위와 같이 지킬 & 하이드 같은 빌런들을 방치했다간 아래와 같은 고생을 하게 되더라구요.

ViewModel/ViewData나 Networking DTO와 같은 객체들은 서비스의 변화에 따라서 변경이 잦은 객체들인데 변경이 잦아질 때마다 공유하고 있던 다른 stable한 기능들에도 영향을 주게 되어 예기치 못한 문제들을 만들기가 쉬워요. ViewModel/ViewData과 Entity가 합쳐진 빌런의 경우 Entity에 presentation logic을 자연스럽게 추가하게 되면서 설계를 망가트리게 되고 이 객체는 자연스럽게 욕심이 많은 지킬&하이드가 되기 쉬워요. 이렇게 주고받는 객체도 송/수신되는 단말간에 역할도 하나의 역할이기 때문에 이 또한 역할에 맞춰서 나누는게 바람직하다고봐요.

따라서 아래의 코드와 같이 간단하게 풀어갈 수 있어요.

AS-IS

struct JekyllAndHyde: Decodable {
  let title: String
  let description: String
  ...
}

TO-BE

// Entity
struct JekyllAndHyde {
  let title: String
  let description: String
  ...
}
// Networking DTO
struct JekyllAndHydeDetailResponse: Decodable {
  let title: String
  let description: String
  ...
}
struct JekyllAndHydeSummaryResponse: Decodable {
  let title: String
  let description: String
  ...
}
// ViewModel/ViewData
struct JekyllAndHydeItemViewModel {
  let title: String
  ...
}

시한폭탄

우선 아래와 같은 코드가 있다고 가정해보아요.

func order() -> Food {
 if isNeedDrink { return .coke }
 else if isNeedDrink && isNeedSideMenu { return .side }
 else { return .setMenu }
}

여기서 말은 안되겠지만 특정 A지역에서는 음료를 제공하지 않는다고 분기문을 가져간다면 아래와 같이 작업을 하게 될꺼에요.

func order() -> Food {
  if isRegionA {
    if isNeedSideMenu { return .side }
    else { return .setMenu }
  } else {
    if isNeedDrink { return .coke }
    else if isNeedDrink && isNeedSideMenu { return .side }
    else { return .setMenu }
  }
}

약간의 복잡도가 이전보다 상승할걸 볼 수 있고 unit test를 작성한다면 아래와 같이 케이스가 많아짐을 알 수 있어요.

  • isRegionA True 일 때
    • isNeedSideMenu가 true일 때
    • isNeedSideMenu가 false 일 때
  • isRegionA False 일 때
    • isNeedDrink가 true이며 isNeedSideMenu가 true 인경우
    • isNeedDrink가 true이며 isNeedSideMenu가 false 인경우
    • isNeedDrink가 false이며 isNeedSideMenu가 true 인경우
    • isNeedDrink가 false이며 isNeedSideMenu가 false 인경우

하지만 여기서 그치지 않고 RegionA에서 IceCream을 order에 추가하고 실험한다고 한다면 점점 관리하기 힘들어지기 시작하게 될텐데요.

여기서 위 코드를 작성한 엔지니어가 간과한 사실을 우리는 알 수 있어요.

  • RegionA에서는 다른 Region의 코드를 알 필요가 없어요.
  • RegionA에서 실험군일 때만 IceCream을 분기하고 마찬가지로 대조군의 코드를 알 필요는 없어요.
  • 다른 지역은 RegionA의 분기로직과 IceCream실험에 대해서 알 필요가 없어요.

이런 상황에서 시한폭탄같은 빌런을 제거하기 위해서 우리는 추상화와 모듈 분리를 함으로써 이 문제를 아래의 코드와 같이 돌파해 나갈 수 있어요.

AS-IS

func order() -> Food {
  if isRegionA {
    if isIceCreamExperimentEnabled {
      if isNeedIceCream { return .iceCream } 
      else if isNeedSideMenu && isNeedIceCream { return .side }
      else { return .setMenu }
    }
    else { 
      if isNeedSideMenu { return .side }
      else { return .setMenu }
    }
  } else {
    if isNeedDrink { return .coke }
    else if isNeedDrink && isNeedSideMenu { return .side }
    else { return .setMenu }
  }
}

TO-BE

protocol OrderUsecase {
  func order() -> Food
}
// RegionA Order Usecase
class RegionAOrderUsecase: OrderUsecase {
  func order() -> Food {
    if isNeedSideMenu { return .side }
    else { return .setMenu }
  }
}
// RegionA IceCream Experiment Order Usecase
class RegionAIceCreamExperimentOrderUsecase: OrderUsecase {
  func order() -> Food {
    if isNeedIceCream { return .iceCream } 
    else if isNeedSideMenu && isNeedIceCream { return .side }
    else { return .setMenu }
  }
}
// Default Order Usecase
class DefaultOrderUsecase: OrderUsecase {
  func order() -> Food {
    if isNeedDrink { return .coke }
    else if isNeedDrink && isNeedSideMenu { return .side }
    else { return .setMenu }
  }
}
  • OrderUsecase interface(protocol)을 생성해요.
  • RegionA, RegionA(Ice cream exeperiment), Default로 구현체를 나눠요.
  • 상위 객체에서 RegionA인지 RegionA이며 IceCream실험중상태인지, RegionA가 아닌지역인지에 대해 상태에 맞춰서 적재적소 상황에 알맞는 객체를 사용해요.

물론 이렇게 풀어간다고 유닛테스트가 줄어들진 않지만 이전 코드에 비하면 제품을 안정적으로 운용할 수 있고 다양한 실험에도 언제든 열려있는 것을 볼 수 있어요.

마무리

물론 위 내용들은 객체지향 설계하는 엔지니어들에게 있어서 아주 원초적이고 기본적인 내용인건 사실이지만 이러한 이론을 의식적 해가면서 제품을 개발하는건 연차와 관계없이 대부분의 엔지니어분들에게 쉬운일은 아닌것도 사실이에요.

당장의 구현보다는 자신이 이러한 구현을 했을 때 정말 역할에 충실하게 개발했는지 객체가 욕심쟁이가 될 수 있는 여지를 남기진 않았는지? 그리고 언제든 대체할 수 있도록 적절히 추상화를 하였는지? 현재만 보지않고 내가 심은 객체라는 씨앗이 미래에 어떤 나무가 되어 있을지 예측을 해보는 행동을 하다보면 어느순간 동료와 인류의 평화를 위한 객체를 설계하는 자기자신을 보게 될 것이라 믿어 의심치 않아요!

이 글이 설계 고민을 하는 어느 엔지니어에게 단비가 되길 기대하며 글을 마무리 해보아요. :)