Post

우아한 객체지향

  1. 우아한 객체지향
  2. 의존성(Dependency)
    1. 개요
    2. 의존성
    3. 클래스 의존성
      1. Association 연관 관계
      2. Dependecy 의존 관계
      3. Inheritance 상속 관계
      4. Realization 실체화 관계
    4. 페키지 의존성
    5. 좋은 의존성을 관리하기 위한 몇 가지 규칙
      1. 양방향 의존성을 피하라
      2. 다중성이 적은 방향을 선택하라
      3. 의존성이 필요없다면 제거하라
      4. 패키지 사이의 의존성 사이클을 제거하라
    6. 의존성과-방향성
      1. 연관관계
      2. 의존관계
  3. 설계 및 개선하기

    1. 문제점 파악하기
    2. 중간 객체를 이용한 의존성 사이클 끊기
    3. 연관 관계 다시 보기
    4. 2차 개선 객체 참조 끊기
      1. 어떤 객체들을 묶고 어떤 객체들을 분리할 것인가
      2. 하지만 컴파일 에러가 발생한다
        1. 여러 객체를 오가면서 로직을 파악하지 않아도 된다
        2. 낮은 응집도의 객체를 높은 응집도의 객체로 변경할 수 있다
        3. 때로는 절차지향이 객체지향보다 좋을 때가 있다
      3. 또 컴파일 에러가 발생한다.(도메인 로직의 순차적 실행)
        1. 절차지향 로직의 서비스를 사용
        2. 도메인 이벤트domain event 사용
    5. 정리
  4. 의존성과 시스템 분리

1. 우아한 객체지향

의존성을 이용해 설계 진화시키기

설계에 대한 핵심은 의존성 이다.
의존성을 어떻게 설정하느냐에 따라서 설계의 모양이 달라지게 된다.

  • 객체지향: 역할, 책임 ⇒ 의존성을 어떻게 관리하느냐가 핵심

2. 의존성(Dependency)

2.1. 개요

설계란, 코드를 어떻게 배치할 것인가에 대한 의사결정이다.
어디에 어떤 코드를 넣느냐에 따라서 설계가 달라질 수 있다.

  • 그렇다면, 어디에 어떤 코드를 넣는 것이 좋은가?

핵심은 변경에 초점을 맞추는 것이다.
같이 변경되면 같이 두어야 하고, 같이 변경되지 않는 코드들은 따로 넣는다.

2.2. 의존성

  • Dependency(의존성) : 변경에 의해서 영향을 받을 수 있는 가능성

의존성이 있다는 의미는 아래와 같다.

  • B가 변경될 때, A도 같이 변경될 수 있다.
  • B가 바뀌었을 때, A가 변경될 가능성이 있다.
  • 의존성이 있다고 해서 무조건 변경되는 것이 아닐 수 있다.(설계를 잘 하면)

Dependency

의존성 구분

  • 클래스 사이의 의존성
  • 패키지 사이의 의존성

2.3. 클래스 의존성

2.3.1. Association 연관 관계

  • 협력하는 시점에 영구적으로 협력하는 관계
  • 코드 상에서 객체 참조가 있다.

2.3.2. Dependency 의존 관계

  • 협력하는 시점에 일시적으로 협력하는 관계
  • 파라미터에 타입이 나온다.
  • 리턴 타입에 타입이 나온다.
  • 메서드 안에서 타입의 인스턴스를 생성한다.

2.3.3. Inheritance 상속 관계

  • 협력하는 시점에 상속하여 계승하는 관계
  • 상위 클래스의 구현을 하위 클래스도 계승한다.
  • 싱위 클래스의 변경은 곧 하위 클래스의 변경으로 이어진다.

2.3.4. Realization 실체화 관계

  • 협력하는 시점에 메시지를 구현하는 관계
  • 인터페이스를 implement하는 관계이다.
  • 인터페이스의 오퍼레이션 시그니쳐가 바뀌면 영향을 받는다.

2.4. 패키지 의존성

  • 패키지에 포함된 클래스 사이의 의존성을 의미한다.

다른 패키지에 있는 클래스에 의존성을 가지면 패키지 의존성이 있다로 생각 하면 된다.
즉, 코드상으로 다른 패키지 클래스를 import 하고 있다면 패키지 의존성이 있다고 볼 수 있다.

2.5. 좋은 의존성을 관리하기 위한 몇 가지 규칙

2.5.1. 양방향 의존성을 피하라

  • A 와 B 가 함께 바뀐다는 것은 하나의 클래스로 볼 수 있는 것을 분리시킨 것일 가능성이 있다.
  • 문제점은 A와 B의 상태를 동기화 시켜주어야 하는 문제점이 생길 수 있다.
  • 성능, 버그(싱크를 맞추는 과정에서 발생) 가능성을 높인다.

가급적이면, 양방향 연관관계를 피할 수 있으면 피하도록 한다.(단방향 설계를 하자)

2.5.2. 다중성이 적은 방향을 선택하라

  • One To Many 관계보다 Many To One 관계로 고려해라
  • A 에서 B Collection(List) 를 가지고 있다면, B 에서 A를 바라보는 방향으로 의존성을 조정하는 것을 추천한다.
  • 다만 이를 유지하고자 한다면 다양한 이슈가 발생한다.(성능이슈, 객체들의 관계를 유지하기 위한 노력들이 필요하다)

가급적이면 다중성이 적은 방향으로 객체를 설계한다.

2.5.3. 의존성이 필요없다면 제거하라

  • 정말 불필요한 의존성이라면 제거하는 것이 좋다.

2.5.4. 패키지 사이의 의존성 사이클을 제거하라

  • 패키지 사이의 양방향 의존성은 반드시 피해야 한다.

2.6. 의존성(관계)과 방향성

개발이 어려운 것은 동적인 구조와 정적인 구조의 간극에서 온다.
객체가 메시지를 주고 받고, 생성되고, 소멸되는 것은
실제 객체가 메모리에 올라와 유기적으로 동작하고 있기 때문이다. 실행 시점에 정적인 코드에 ‘시간’ 이라는 개념이 들어온 것이다.

개발자들은 이런 변화무쌍한 가능성을 정적인 코드로 담아야한다.

동적 구조를 정적 구조로 바꾸려고 한다면, 동적 구조의 행동을 정적 구조의 무엇인가로 매핑해야한다. 메서드, 로직등이 있으며 협력의 입장에서 관계(의존성)가 있다.

Direction

코드상에 선언되는 모든 것들은 관계가 형성된다.
그리고 이러한 관계에는 방향성이 존재한다.
관계는 객체가 어떤 방향으로 협력을 이루는지에 대한 의존성을 나타낸다.

  • 관계의 방향 = 협력의 방향 = 의존성의 방향

이러한 관계에는 2가지 종류가 존재한다.

  1. 연관 관계
  2. 의존 관계

정답은 없으며, 각 비즈니스 논리에 맞추어 관계 설정을 잘 이루어 나가면 된다.

2.6.1. 연관관계

연관관계

  • 협력이 영구적으로 유지되어야 하는 관계이다.
  • 협력을 위한 영구적인 탐색 구조를 가진다.
  • 일반적으로 ‘객체 참조’를 이용해서 구현한다.
  • 연관 관계 = 탐색 가능성(navigability): A를 알면 B를 찾아갈 수 있는 가능성
    • 두 객체 사이에 협력이 필요하고 두 객체의 관계가 영구적이라면 연관 관계를 이용해 탐색 경로 구현한다.

2.6.2. 의존관계

  • 협력을 위해 일시적으로 필요한 의존성
  • 일반적으로 파라미터, 리턴타입, 지역변수 등을 이용해서 구현한다.

3 설계 및 개선하기

설계를 잘하는 방법

  1. 메소드를 만들고 메시지를 결정하는 것이 아니라, 메시지를 만들고 메시지를 바탕으로 메소드를 만들어야 한다.
  2. 이 설계가 정말 괜찮은 가를 알기 위해서는 툴보다는 손으로 그림을 그려가면서 의존성의 관계를 확인하는 것이 좋다.
  3. 양방향 연관관계가 있다면 이상하다고 생각해야 한다.

설계를 진화 시키기 위한 출발점은, 코드 작성후 의존성 관점에서 설계를 검토하는 것이다.
이번 세미나에서는 크게 2가지로 의존성과 설계를 개선시키고 있다.

  1. 객체 참조로 인한 결합도 상승
  2. 패키지 의존성 사이클

3.1. 문제점 파악하기

  • 레이어드 아키텍처를 기준으로, 의존성을 확인했을 때 위와 같다.
  • 좋은 아키텍처의 원칙 중 하나로, 상위 레이어는 하위 레이어 의존성을 가지면 안 된다.
  • Shop 이 Order 를 바라보고 있는 상황을 개선해야한다.

3.2. 1차 개선, 중간 객체를 이용한 의존성 사이클 끊기

  • OptionGroup / Option 이라는 중간 클래스를 만들었다.
  • Order 의 OrderOptionGroup 은 OptionGroup 으로 변환시켜 동작한다.
  • 조금 이상해 보일 수 있지만, 우선 문제는 해결했다.
1
2
3
4
5
6
7
추상화에 대한 선입견
흔히들, 추상 클래스나 인터페이스만 추상화의 대상이 될 수 있다고 생각하는 경향이 있다.
그러나 구체적인 구현체 클래스도 추상화의 대상이 될 수 있다.

개발에서 추상화의 의미는 '어떤 것에 비해서, 어떤 것이 잘 변하지 않는 것'을 의미한다.
이 같은 원리를 사용하면 재사용성이 크게 증가하게 된다.
꼭 해야한다는 것은 아니고 의존성을 보면서, 설계의 개선 방향을 생각할 수 있는 것이 중요하다.

3.3. 연관 관계 다시 보기

연관 관계의 코드 구현중 하나인, 객체 참조에는 문제점이 존재한다.

  1. 객체 사이의 결합도가 높아진다.
  2. 성능 문제, 어디까지 조회할 것인가? (Lazy Loding 문제가 나올 수 있다. -> DB 한정이 아니다.)
  3. 수정할 때, 도메인 규칙을 함께 적용해야 하는 객체의 범위(경계)가 모호해진다.
    1. DB, DataMapping에서 이슈가 발생한다.
  4. Long Transaction 으로 물리게 되고, 트랜잭션 경합이 일어나서 성능이 떨어지는 이슈가 발생한다.

더 나아가, 기존 유스케이스 이외의 추가 비즈니스가 생긴다면 어떻게 될까? 예를 들어 주문이 끝난 후 ‘배달’ 이라는 비즈니스를 수행하고자 한다면
주문 후 완료의 트랜잭션 범위는 다음과 같이 구성될 것이다.

위 3개의 도메인에 대한 변경 빈도와 액터는 다르다.
변경 빈도 및 액터가 다르다는 것은 이들의 작업에 대한 동시성 문제를 막기 위해 Lock 이 필요하다는 것을 의미하며 이는 결국 트랜잭션 경합으로 이어져 성능을 저하시킨다.

  • 객체 참조가 정말 필요한가?

객체 참조의 문제점은 모든게 다 연결 되어있다는 것이다.
어떤 객체라도 다 접근 가능하고, 어떤 객체라도 함께 수정 가능하다.
객체 참조는 영구적인 결합이기 때문에 결합도가 가장 높은 의존성이다. 그러므로, 상황에 따라서 객체 참조를 다 끊어주는 게 좋은 경우가 있다.

3.4. 2차 개선, 객체 참조 끊기

Repository를 통한 탐색(약한 결합도)을 사용한다.

  1. 별도의 Repository 를 구현하여 객체가 아닌 ID 를 통한 조회로 사용한다.
    1. Repository에 들어갈 인터페이스는 연관 관계를 구현할 수 있는 Operation이 들어가 주어야 한다.
    2. 파라미터로 받은 타입을 가지고 이 객체를 찾을 수 있다는 의미임.
  2. 사실 이 같은 방식도 쉽게 깨지는데 사용자/어드민 조회 로직이 들어가면서이다.
  3. 비즈니스 로직 측면에서는 다 연관관계를 구현하기 위한 것이 들어가야 함.(도메인 자체로는 문제가 없다.)

3.4.1. 어떤 객체들을 묶고 어떤 객체들을 분리할 것인가?

  1. 함께 생성되고함께 삭제되는 객체들을 함께 묶어라
  2. 도메인 제약사항을 공유하는 객체들을 함께 묶어라
  3. 가능하면 분리하라

예를 들면, 장바구니가 있고 장바구니 항목이 있다. 장바구니와 장바구니 항목은 하나의 단위로 묶을 것인가?
=> 장바구니가 생성되는 시점과 장바구니 항목을 넣는 시점은 다르다.

결국 결정하는 것은 도메인 룰/비즈니스 룰이다.
도메인 관점에서 어떤 데이터를 같이 처리해야할 지 결정을 해주어야 한다.

1
2
3
일반적인 E-Commerce 는 장바구니 사이에 Constraints 가 거의 없다.
배달의 민족의 장바구니는 동일한 상점의 상품만 넣을 수 있는 Constraints 가 있다.
업계 비즈니스 모델에 따라 각 도메인 모델은 달라질 수 있다.

  • 경계 안의 객체는 같이 묶어주는 편이 좋다.

  • 경계 밖의 객체는 ID를 통해 접근 할 수 있도록 한다.
  • 트랜잭션의 단위도 명확하게 보인다.(Command)
  • 조회 단위도 명확하게 보인다.(Query) => 퍼포먼스 튜닝 경계가 보이기 시작한다.(Lazy or Eager)
  • 참고로, RDB 뿐만 아니라 mongoDB 와 같은 다른 저장소에 저장하는 단위가 바로 이 경계이다.

필자가 느끼기에는 Aggregate 와 BoundedContext 와도 비슷하다. 결국 도메인 모델이라는 것은 비즈니스에 따라 ‘도메인 경계’가 존재하고 이 ‘도메인 경계’는 주로 연관된 객체들의 ‘라이프 사이클’을 기준으로 판단할 수 있다.

  • 즉, 그룹(도메인 경계)은 트랜잭션/조회/비즈니스 제약의 단위이다.
1
2
3
4
왜 책이나 강의에서는 객체 참조로 구현되어 있는가?
객체 참조로 설명하는 편이 객체간의 메시지를 통한 협력을 표현하기 좋기 때문이다.
실무에서는 성능 이슈 등의 현실적인 제약으로 분리를 하는 것이 더 좋다.
Lazy Loding과 Eager Loding은 실질적으로 이 경계에서 결정하면 된다.

3.4.2. 하지만 컴파일 에러가 발생한다.

해결하는 방법은 바로 객체를 직접 참조하는 로직을 다른 객체로 옮기는 것이다. 이렇게 객체를 직접 참조하는 로직을 다른 객체로 옮기면서 생겨난 이점은 2가지가 있다.

  1. 여러 객체를 오가면서 로직을 파악하지 않아도 된다.
  2. 낮은 응집도의 객체를 높은 응집도의 객체로 변경할 수 있다.

3.4.2.1. 여러 객체를 오가면서 로직을 파악하지 않아도 된다.

객체지향은 처음엔 로직을 파악하기 쉬웠지만 다른 사람이 처음 볼 때에는 여러 객체를 오가야 한다. 반면 위와 같이 설계를 하면, Validation 관련하여 한 눈에 파악할 수 있는 구조가 되었다.

3.4.2.2. 낮은 응집도의 객체를 높은 응집도의 객체로 변경할 수 있다.

응집도는 관련된 책임의 집합을 의미한다. 응집도는 같이 변경되는 것이 같이 있을 때 높아지고, 같이 변경되지 않는 것이 같이 있을 때 낮아진다. 변경은 객체의 상태가 바뀌는 것이 아니라, 코드의 수정을 의미한다.

사실 기존 코드의 경우 Validation 이라는 책임과 Order 라는 2가지 책임을 가지고 있다.
즉, Validation 이라는 변경 지점과 Order 라는 변경 지점이 공조하기에 응집도가 낮다고 볼 수 있다.

반면 앞서 별도의 Validation 을 분리한 경우, Order 는 주문하다에 대한 역할/책임만 가지게 된다. 변경 지점은 1개가 되는 것이고 이는 응집도가 높은 설계라고 볼 수 있다.

3.4.2.3. 때로는 절차지향이 객체지향보다 좋을 때가 있다.

객체의 상태를 Validation 하기 위해서 여러 객체를 가지고 있는 것은 결합도는 높이고 응집도를 떨어뜨린다. 때로는 절차지향이 객체지향보다 좋을 때가 있다. 필자가 생각하기에는, 이는 Domain Service 와 매우 비슷한 형태임을 알 수 있다. Domain Service 는 각 Aggregate 루트의 관계성이 필요할 때 사용하는 클래스이다.

Order 검증 로직을 기준으로 보자면, Shop 이라는 Aggregate Root 와 Menu Aggregate Root 가 필요하다. 이들을 객체 참조를 통해 가져가기 보다는, 별도의 서비스를 통해 트랜잭션 스크립트 방식을 사용하는 것도 하나의 방법이다. 참고로 검증과 같은 요소들은 정책적으로 언제든지 바뀔 수 있다고 생각하기에,
순수한 Order 도메인을 더럽히지 않는 차원에서 별도로 분리되어도 좋다고 생각한다.

참고로 단순 객체의 Validation 을 하는 경우 내부 적으로 가지고 있는 것이 맞다. 위 예시에서는 Order 의 경계 범위 밖에서의 검증이 필요하기에 별도의 Validator 클래스를 사용한 것이다. 결론은 객체지향이 다 정답은 아니다. 이에 대한 Trade-off를 잘 해야 한다.

3.4.3. 또 컴파일 에러가 발생한다.(도메인 로직의 순차적 실행)

위 컴파일 에러는 A라는게 실행 되었을 때, B/C가 순차적으로 실행해야하는 상황에 발생한 문제이다.

  • 본질: 도메인 로직의 순차적 실행

2가지 해결방법이 있다.

  1. 절차지향 로직의 서비스를 사용
  2. 도메인 이벤트(Domain Event) 사용

3.4.3.1. 절차지향 로직의 서비스를 사용

어떤 비즈니스 플로우 자체가 한 눈에 보이게 된다. 객체 참조는 줄여서 객체간의 결합도는 낮추고, 로직의 응집도를 높아졌다.

그러나…의존성 싸이클이 발생했다. 이를 해결하기 위해서 인터페이스를 이용해서 의존성을 역전시키는 방법을 고민해볼 수 있다.

의존성 역전 원칙을 통해 한결 나아진 모습을 볼 수 있다.
단, Delivery 가 Shop 을 의존하는 형태가 되어 그다지 맘에 들지 않는다.

이처럼, 패키지간에 싸이클이 돌 때 2가지 방법을 고려해볼 수 있다.(사실 더 많다)

  • 좀 더 추상적인 중간 객체를 만들어서 변환한다.
  • 인터페이스등을 통해서 추상화를 넣어서 의존성을 역전시킨다.

3.4.3.2. 도메인 이벤트(Domain Event) 사용

A에 대한 이벤트를 B/C가 구독하여 이를 느슨하게 결합되어 실행하는 것을 의미한다. A 는 이벤트를 발행하기만 할 뿐이지, 이벤트가 필요한 어느 모듈이 이를 구독(수신)하여 사용하는 것이다.

  • 도메인 이벤트를 도입함으로써, Shop 이랑 Delivery 가 Order 를 의존하는 형태가 되었다.

  • 하지만, 기존 Order 는 Shop 에 대한 의존 방향성을 가지고 있었다.
  • 즉, Domain Event 를 적용하면서 Shop 도 Order 에 대한 방향성을 가지게 되었고 싸이클이 발생했다.
  • 의존성 싸이클이 발생한 이유는 Eevent Handler 가 Shop 패키지에 존재하기 때문이다.

  • Eevent Handler 를 별도의 Billing 이라는 새로운 도메인 모델의 패키지로 분리한다.
  • 패키지를 분리할 때는 도메인적으로 새로운 모델이 도출될 때도 있다.

  • Eevent Handler 에서는 기존 Shop 관련 코드를 ShopId 를 활용하여 참조한다.

  • 결과적으로 위와 같은 패키지 의존성이 그려졌다.

3.5. 정리(패키지 의존성 사이크를 제거하는 3가지 방법)

  1. 중간 객체 만들기(새로운 객체로 변환)

  1. 의존성을 인터페이스나 추상 클래스를 통해서 의존성 역전

  1. 새로운 패키지 추가

3가지 중 어떤 것을 고를 것이냐는 판단에 따라 다르다.(Trade-off 가 필요하다) 이를 검증하는 방법은 그려서 확인하는 법이다. 그려보면 의존성 역전을 적용할 부분이 보일 수도 있다. 패키지 간의 의존성 사이클이 돌 때, 추상화를 적용한다.

  1. 좀 더 추상적인 중간 객체를 만들고 이것으로 변환하는 로직을 사용한다.
  2. 인터페이스를 만들고 이를 구현하는 것으로 변경한다.
  3. 패키지를 분리해버린다.

그리고 패키지를 분리하면 얻는 이점은 도메인 로직이 확실히 분리가 된다는 점이다.


4. 의존성과 시스템 분리

의존성 관리를 하다보면, 시스템을 쉽게 분리할 수 있게 된다.

절차지향 로직의 서비스인 도메인 이벤트를 사용하기 전의 의존성이다. 여태까지 서비스나 도메인으로 레이어 단위로 분리하면서 사용했다. 시스템은 서비스나 도메인이라는 기술적인 관점에서 패키지 안으로 밀어넣었다.

왜냐하면 앞선 절차지향 로직의 서비스 방식으로 분리하면 의존성 사이클이 존재하기 때문이다.

하지만, 도메인 이벤트를 사용해서 도메인 단위를 분리하게 된다면, 의존성 사이클이 제거된다.

각각의 도메인 영역은 도메인 이벤트를 통해서 협력을 하고 있는 구조가 되었다.

이런식으로 의존성이 분리되어 있다면, 시스템적으로 물리적으로 분리하기도 쉬워지고 시스템끼리는 비동기적인 메시지 통신을 ㅌ오해 커뮤니케이션을 할 수 있다.

참고로, 아래와 같은 용어를 사용한다.

  • 시스템 내부적 == 인터널 이벤트, 메시징 통해서 외부 == 익스터널 이벤트
  • 내부에서 쓰임 == 도메인 이벤트, 외부에서 쓰이는 것 == 시스템 이벤트

인터널 이벤트를 익스터널 이벤트로 시스템간에 통신을 시킬 수 있다.
의존성에 따라 시스템을 진화시키자

This post is licensed under CC BY 4.0 by the author.