📝 객체지향 설계 원칙

객체지향 설계의 기반을 이루는 2가지 원칙에 대해 알아보자. 그 전에 먼저 설계(Design) 란 정확히 무엇이고, 설계의 목적이 무엇인지에 대해 짚고 넘어가야 한다.

설계는 코드를 배치하는 방식이다. 영화 예매 시스템을 구현했을 때, 처음에는 데이터를 구현한 코드와 프로세스를 구현한 코드를 서로 다른 분리된 모듈에 위치시켰다. 그리고 나서 프로세스를 구현한 코드를 데이터를 구현한 코드로 조금씩 이동시키면서 코드를 배치하는 방식을 변경함으로써 절차지향에서 객체지향으로 전환했다. 이 2가지 방식은 수행하는 기능은 동일하지만 코드를 배치하는 방식, 즉 설계가 다른 것이다.

설계는 요구 사항이 변경될 때, 코드를 쉽고 안전하게 수정하기 위해 필요하다. 객체지향 설계를 배우는 이유 역시 이와 동일하다. 객체지향 설계를 이해하기 위한 첫 걸음은, 객체지향의 모든 원칙은 “변경” 을 중심에 두고 있다는 사실을 이해하는 것이다.

 

이제 2가지 객체지향 설계 원칙을 알아보자.

  1. 협력에 필요한 행동을 먼저 결정하고, 행동에 적합한 객체를 나중에 선택하는 것이다.

  2. 객체의 행동을 먼저 구현하고, 행동에 필요한 데이터를 나중에 선택하는 것이다.

다시 말해, 어떤 객체가 필요하고, 그 객체가 어떤 데이터를 저장해야 하는지 보다 행동을 먼저 결정하고 객체와 데이터는 나중에 결정해야 한다는 것이다.

 

먼저, 2번 원칙부터 살펴보자. 절차적인 설계에서는 데이터와 프로세스를 독립적인 단위로 나눠서 설계했다. 사용될 문맥을 고려하지 않고 데이터를 설계했기 때문에 모든 경우에 사용 가능하도록 Getter와 Setter 메서드를 추가해야 했다. 이런 추측에 의한 설계 전략이 나쁜 이유는, 겉으로 보기에는 필드를 캡슐화한 것처럼 보이지만 실제로는 Getter와 Setter를 통해 클래스 내부를 그대로 드러내고 있기 때문이다.

위의 그림을 보면 알 수 있었듯이, DiscountPolicy를 사용하는 ReservationService의 경우, DiscountPolicy의 내부 데이터 구조에 강하게 결합되게 된다. 데이터의 구조가 프로세스에 그대로 노출되기 때문에 데이터의 내부 구조가 수정되면 데이터를 사용하는 프로세스도 함께 수정될 수 밖에 없다.

변경으로 인한 문제를 해결하는 가장 좋은 방법은 데이터와 프로세스를 하나의 모듈 안에 함께 담는 것이다. 

ReservationService에서 할인 정책을 참조하는 로직을 DiscountPolicy로 이동시켜서 데이터와 프로세스를 한 클래스 안으로 모아두면 변경의 영향을 지역화시킬 수 있다. 이런 식으로 데이터를 사용하는 로직을 데이터를 구현한 모듈로 이동시키는 작업을 “책임의 이동” 이라고 한다. 이렇게 책임을 이동시키고 나면, 프로세스를 구현한 코드는 더 이상 내부의 데이터 구조에 의존할 필요가 없다.

지금까지 이렇게 코드를 수정하기 더 쉽도록 코드의 배치, 즉 설계를 변경했다. 여기서 중요한 점은 설계를 바꾸기 전과 후를 비교했을 때 ReservationService에 영향을 받는 대상이 달라졌다는 점이다.

바꾸기 전에는 내부 데이터 구조에 의존했고, 바꾼 이후에는 외부에 공개한 메서드에 의존하고 있다. 이처럼 데이터를 자유롭게 수정하기 위해서는 외부 객체가 데이터가 아닌 행동에 의존하도록 만들어야 한다. 그리고 외부 객체가 데이터에 의존하지 않도록 만드는 가장 좋은 방법은 데이터를 결정하기 전에 먼저 “행동부터 결정” 하는 것이다. 데이터가 없는 상태에서 행동을 먼저 결정하면 외부에서는 데이터에 대해 알 수 없기 때문에 행동에 의존할 수 밖에 없을 것이다.

DiscountPolicy에 대해 다시 생각해보면, 절차적인 설계에서는 데이터부터 설계했지만, 객체지향에서는 데이터는 무시한 채 객체가 외부에 제공해야 하는 행동만 고민했다. 이 객체가 외부에 어떤 행동을 제공해야 할까? DiscountPolicy로 예를 들면, "할인을 계산" 해야 할 것이다. 할인 금액을 계산하기 위해서는 어떤 메서드를 제공해야 할까? 당연히 할인 금액을 계산하는 메서드(calculateDiscount())를 제공해야 한다.

이렇게 외부에 제공할 행동을 결정했다면 calculateDiscount() 메서드 안에 할인 금액을 계산하는데 필요한 로직을 추가해야 한다. 메서드를 구현하다 보면 고정 할인 금액을 저장할 amount나 비율을 저장할 percent라는 데이터가 필요하다는 사실을 알게 된다. 그렇다면 어떤 행동을 객체에 추가해야 하는걸까? 방금도 왜 DiscountPolicy 클래스 안에 calculateDiscount() 메서드를 추가한거지? 그건 바로 외부의 ReservationService 객체가 할인 금액을 계산하기를 원했기 때문이다. DiscountPolicy 객체는 ReservationService의 요청에 응답하기 위해 할인 금액을 계산하는 행동을 외부에 제공하고 있다.

다시 말해, ReservationService 클래스의 필요성 때문에 DiscountPolicy 클래스에 메서드를 추가했던 것이다. 여기서 1번 원칙을 유도할 수 있다.

여기서 집중해야 할 부분은 클래스를 고립시킨 상태에서 외부에 제공할 행동을 결정해서는 안 된다는 것이다. 객체는 다른 객체가 사용할 필요가 있는 행동을 외부에 제공해야 한다. 객체의 행동은 고립된 상태가 아닌 다른 객체와의 협력이라는 문맥 안에서 결정해야 한다. 클라이언트의 요청을 먼저 결정한 후에 이 요청을 처리할 객체를 나중에 선택해야 한다. 아래 그림과 같이 할인 금액을 계산하라는 요청이 먼저 존재하고, 이 요청을 처리하기에 적합한 객체인 DiscountPolicy를 나중에 선택해야 한다.

결론적으로 행동이 객체를 결정하게 되는 것이다. 이렇게 행동이 결정되면 앞에서 살펴본 원칙에 따라 행동을 부연하는데 적합한 데이터를 선택하면 된다. 이렇게 데이터를 나중에 선택하면 데이터가 변경되더라도 외부에 영향이 미치지 않는다. 이런 식으로 객체가 다른 객체에게 도움을 얻기 위해 요청하고 응답하는 과정을 "협력" 이라고 한다. 위의 ReservationService를 예로 들면, DiscountPolicy와 협력하기 위해 calculateDiscount() 요청을 전송하고 있는 것이다.

지금까지의 내용을 다시 살펴보면, 두 가지 원칙이 객체지향 설계의 순서를 정의한다는 사실을 알 수 있다. 객체지향 설계는 객체 사이의 협력을 설계하고, 그 후에 클래스의 내부 구조를 구현하는 순서로 진행된다. 협력에 필요한 행동을 먼저 결정하고, 행동에 적합한 객체를 나중에 선택하면 객체 사이의 협력이 설계된다. 이렇게 선택된 행동을 구현하면서 필요한 데이터를 나중에 결정하면 클래스의 내부 구현이 완성된다.

 

정리하면, 객체지향 설계는 런타임에 실제로 동작하는 객체 사이의 협력을 먼저 결정한 다음에 이 협력을 실현하는데 필요한 클래스를 나중에 구현하는 순서로 진행된다.

그리고 이렇게 객체들의 협력 관계를 기반으로 애플리케이션을 설계하는 방법을 “책임 주도 설계” 라고 부르는 것이다.

 

💎 책임 주도 설계

객체지향 설계의 흐름을 다시 한번 요약해보면 아래와 같다.

  1. 협력을 위한 문맥 결정

  2. 필요한 책임을 식별

  3. 책임을 수행할 객체를 선택

  4. 객체 안에 책임을 구현

  5. 책임을 구현하는데 필요한 데이터 결정

객체지향에서는 협력에 참여하기 위해 한 객체가 다른 객체에게 제공하는 행동을 "책임" 이라고 부른다.

위의 그림에서 DiscountPolicy 객체는 "할인 요금을 계산" 하는 책임을 수행하고 있고, DiscountCondition 객체는 "할인 여부를 판단" 하는 책임을 수행하고 있다.

두 객체가 자신에게 할당된 책임을 수행함으로써 다른 객체와 협력하게 된다는 사실을 눈여겨 봐야 한다. 이런 식으로 객체가 수행할 책임을 기반으로 객체와 객체 사이의 협력을 설계하는 방식을 "책임 주도 설계" 라고 하는 것이다.

 

객체가 외부에 제공해야 하는 책임에는 2가지 카테고리가 있다.

하나는 "하는 것(Doing)" 이고, 다른 하나는 "아는 것(Knowing)" 이다. 각각의 범주에 어떤 것들이 포함되는지는 아래 항목을 참고하자.

  • 하는 것(Doing)

    • 객체를 생성하거나 계산을 하는 등의 스스로 하는 것
    • 다른 객체(협력자)의 행동을 시작시키는 것
    • 다른 객체(협력자)의 활동을 제어하고 조절하는 것
  • 아는 것(Knowing)

    • private로 캡슐화된 상태(데이터)에 관해 아는 것
    • 관련된 객체(협력자)에 관하여 아는 것
    • 자신이 유도하거나 계산할 수 있는 것(상태와 협력자)에 관하여 아는 것

여기서 "책임""행동" 과 관련 있다는 사실이 중요하다. 종종 아는 것(Knowing)과 관련된 책임을 내부에 해당하는 데이터를 저장해야 한다는 것으로 오해하는 경우가 있다. 책임은 행동 관점이기 때문에 어떤 것을 아는 책임을 할당 받았다는 것은 해당 데이터를 저장해야 한다는 것이 아니라 "정보에 대해 대답할 수 있어야 한다는 것" 을 의미한다.

객체지향에서 책임이 중요한 이유는 객체의 구현이 아니라 객체 사이의 협력에 초점을 맞출 수 있도록 해주기 때문이다. 책임 관점에서 객체를 설계하면 객체 내부의 세부 사항에 대한 결정은 뒤로 미루고, 외부에 제공해야 하는 행동을 먼저 결정할 수 있게 된다.

이제 협력 바깥으로 눈을 돌려보자. 협력이 책임을 설계하는데 필요한 문맥을 제공한다면, 이 협력을 설계하는데 필요한 문맥은 어디서 얻는거지?

결론부터 말하자면, 객체지향에서는 시스템이 외부에 제공해야 하는 기능을 협력을 위한 문맥으로 사용한다. 예를 들어, 영화 예매 시스템의 경우에는 "사용자에게 영화를 예매할 수 있는 기능" 을 제공해야 한다. 따라서 영화 예매 시스템을 객체지향적으로 설계한다는 이야기는 영화 예매 기능을 객체들 사이의 협력으로 구현하고, 협력에 필요한 행동을 수행하는데 필요한 객체들을 선택한다는 것을 의미한다. 객체지향 설계에서는 시스템이 제공해야 하는 기능을 시스템에 전달된 요청으로 간주한다.

영화 예매의 경우, 실제로 예매할 대상은 "영화" 가 아니라 "상영" 이기 때문에 요청의 이름을 "상영을 예매하라" 로 수정했다.

그리고 이 요청을 수행할 적합한 객체를 결정하는 일을 시작으로 기능을 구현하는 데 필요한 객체들의 협력을 설계하도록 하자. 여기서 핵심은 애플리케이션의 기능을 기반으로 객체들 사이의 협력을 설계한다는 사실이다.

이제 책임 주도 설계의 전제 과정을 정리해보자.

  1. 객체의 협력을 구현하기 위해 애플리케이션이 외부에 제공해야 하는 기능을 파악한다. 그리고 이 기능을 협력을 설계하기 위한 문맥으로 활용한다.

  2. 기능이 식별됐으면, 이 기능을 시스템이 외부에 제공해야 하는 책임으로 간주한다.

  3. 이어서 외부에 제공해야 하는 시스템의 책임을 시스템 내부에 존재하는 객체의 책임으로 변환한다. 이제 설계의 문맥은 시스템 외부에 제공할 기능에서 시스템 내부에 존재하는 객체 사이의 협력으로 바뀌게 된다.

  4. 책임을 결정했다면, 책임을 수행할 적합한 객체를 선택해야 한다.

  5. 객체가 책임을 수행하는 도중, 스스로 처리할 수 없는 로직이 식별될 수도 있다. 이 경우에 객체는 자신이 처리할 수 없는 로직을 누군가 처리해주기를 기대하면서 외부에 도움을 요청한다.

  6. 이 요청이 새로운 책임으로 변환된다. 이제 또 다시 새로운 책임을 수행할 적합한 객체를 선택해야 할 것이다.

이런 식으로 객체에게 요청을 전송하고 적합한 객체를 선택하는 과정을 반복하면서 시스템의 기능을 설계하는 방식을 "책임 주도 설계" 라고 부른다.

책임 주도 설계에서 가장 중요하면서도 가장 어려운 일은 책임을 할당할 적합한 객체를 선택하는 일이다. 앞에서 설명한 것처럼, 객체지향 설계는 런타임에 실제로 동작하는 객체 사이의 협력을 먼저 결정한 다음에 이 협력을 실현하는데 필요한 클래스를 구현하는 순서로 진행된다. 여기서 책임을 결정하고 책임에 적합한 객체를 선택하는 단계는 런타임에 객체 협력을 설계하는 과정에 해당한다고 생각하면 된다.

요약하면 책임 주도 설계에서는 2가지 문맥을 사용한다는 것이다.

하나는 협력을 설계하기 위해 사용하는 애플리케이션의 기능이고, 다른 하나는 객체에게 책임을 할당하기 위해 사용하는 협력이라는 문맥이다.

 

📉 표현적 차이 줄이기

절차적인 방식에서는 알고리즘을 하나의 클래스 안에 실행 순서대로 배치하는데 반해, 객체지향에서는 알고리즘을 어떤 원칙에 따라 여러 개의 객체로 분배한다.

이렇게 알고리즘을 구성하는 로직을 적합한 객체에게 분배하는 일을 객체지향에서는 "책임 할당" 이라고 부른다. 그리고 훌륭한 객체지향 설계를 만들기 위해 익혀야 하는 가장 중요한 기법이 바로 책임 할당 방법이다.

객체지향 설계에서는 협력이라는 문맥 안에서 책임을 먼저 결정하고, 이 책임을 수행하기에 적합한 객체를 나중에 선택한다고 했다. 그리고 시스템 외부에 제공할 기능이 협력을 설계하기 위한 문맥을 제공한다고 했다.

협력은 애플리케이션 기능이라는 문맥 안에서 얻고, 책임은 협력이라는 문맥 안에서 얻는다면... 책임을 맡길 객체는 어디에서 얻을 수 있을까?

영화의 도메인 안에는 할인 여부를 판단하는 규칙을 정의하는 "할인 조건" 이라는 도메인 개념이 존재한다. 따라서 할인 여부를 판단하는 책임을 맡게 될 객체에게 "할인 조건" 이라는 이름을 붙였던 것이다. 이렇게 도메인 안에 존재하는 중요한 개념과 관계를 모아서 이해하기 쉽게 추상화 시킨 것을 도메인 모델이라고 한다. 여기서 추상화는 필요에 맞는 부분만 취하고, 필요 없는 부분은 생략해서 단순화 시킨다는 것을 의미한다.

도메인 모델은 도메인 안에서 객체 설계를 하는데 적합한 부분만 남겨두고, 나머지 불필요한 세부 사항은 과감하게 생략해버린 결과물이다. 그리고 객체지향 설계는 이렇게 추상화시킨 도메인 모델 안에 포함된 용어와 관계를 반영해서 객체와 관계를 설계한다. 도메인 모델 안에 포함된 개념과 관계는 객체지향 설계에서 책임을 할당 받을 객체의 이름과 관계에 대한 중요한 힌트를 제공한다. 따라서 책임을 할당할 객체를 찾을 때 제일 먼저 참고할 수 있는 재료가 바로 "도메인 모델" 이 된다. 도메인 모델을 객체를 설계할 때 참고할 수 있는 지도라고 생각하면 편하다.

 

그렇다면 도메인 모델을 참고하는 이유는 무엇일까?

바로 "표현적 차이" 를 줄이기 위해서다. 표현적 차이는 도메인을 바라보는 모습과 소프트웨어를 구현한 모습 사이의 차이를 의미한다. 가령, 도메인의 모습과 코드의 모습이 유사하다면 표현적 차이가 작다고 얘기한다. 일반적으로 객체지향적으로 작성된 코드는 표현적 차이가 작다.

그러면 표현적 차이를 줄여야 하는 이유는 무엇일까? 표현적 차이 역시 "변경" 과 관련이 있다. 표현적 차이를 줄이면 변경하기 쉽고 유연한 설계를 얻을 수 있기 때문이다. 비즈니스의 본질이 크게 바뀌지 않는다면 도메인의 개념과 관계없이 크게 바뀌지 않는다. 따라서 비즈니스의 본질이 유지되는 상태에서 요구사항이 바뀌는 정도라면 세부적인 내용이 영향을 받을 수는 있겠지만, 도메인의 본질적인 개념과 관계는 그대로 유지가 될 것이다. 이렇게 요구사항 변경이 안정적인 도메인의 구조에 기반해서 코드의 구조를 설계하면 요구사항이 변경되더라도 코드의 전체적인 구조는 크게 바뀌지 않을 것이다. 따라서 요구사항이 변경되더라도 쉽고 안정적으로 코드를 수정할 수 있게 될 것이다.

표현적 차이를 줄이기 위해서는 객체가 도메인 개념을 은유하도록 만들어야 한다. 은유는 유사한 다른 개념을 이용해서 어떤 개념을 서술하는 방식이다. 은유의 장점은 한 가지 개념을 이해하고 나면 유사한 다른 개념을 이 개념을 통해서 쉽게 이해할 수 있게 된다는 점이다. 객체지향 설계에서 은유는 어떤 책임을 수행하기에 적합한 객체를 우리에게 익숙한 도메인 개념과 연결시켜준다.

예를 들어, 할인 여부를 판단할 책임을 수행할 객체 이름으로 뭐가 적절할까? 지금 도메인 안에서 할인 여부를 판단하기 위한 규칙을 "할인 조건" 이라는 이름으로 부르고 있기 때문에 할인 조건에 해당하는 DiscountCondition을 객체 이름으로 사용하면 이 객체가 할인 여부를 판단하는 책임을 수행한다는 사실을 쉽게 떠올릴 수 있을 것이다.

객체지향은 명사를 중심으로 동사들을 명사 안으로 모은다. 일단 명사를 이용해서 동사를 묶고 나면 동사의 위치를 찾기 위해 명사를 이용할 수 있다. 그리고 이 명사를 객체의 이름으로 동사를 객체의 책임으로 대응시키고 나면, 요구사항이 변결될 때 어떤 책임을 수정해야 하고 이 책임이 수정될 때 어떤 클래스를 수정해야 하는지를 쉽게 찾을 수 있게 된다. 예를 들어, 할인 여부를 판단하는 책임이 변경된다면 도메인 개념인 할인 조건이 변경된 것이기 때문에 DiscountCondition 클래스를 수정하면 된다는 사실을 쉽게 알 수 있을 것이다.

다행히도 이미 선배님들께서 책임을 할당하는 훌륭한 방법들을 정리해 놓았다. 그 중 GRASP(General Responsibility Assignment Software Patterns)은 일반적인 책임 할당을 위한 소프트웨어 패턴을 의미한다. GRASP에는 9가지 패턴이 포함되어 있다. 이 중에서 6가지 패턴을 먼저 살펴볼 예정이다.

협력에 참여할 객체와 객체가 수행할 책임은 CRC 카드를 이용해서 표현하도록 하겠다. CRC는 Candidate, Responsibility, Collaborator의 약자로 CRC 카드 한 장이 객체 하나를 의미한다고 생각하면 된다.

여기서 Candidate 항목에는 객체 또는 역할을 적는다. Responsibility 항목에는 객체가 수행할 책임들을 기입하고, Collaborator 항목에는 협력할 다른 객체들을 기입한다. CRC 카드는 책임을 다루기 때문에 런타임에 객체 협력을 설계하는 용도로 사용된다. 따라서 CRC 카드가 클래스와 유사해 보이더라도 CRC 카드는 정적인 클래스가 아니라 런타임에 실제로 행동을 수행하는 동적인 객체를 나타낸다는 사실을 기억해야 한다.

0개의 댓글