설계에 대한 핵심은 의존성이다.
역할, 책임, 협력에 대한 이야기를 주로 하지만, 그 세 가지가 필요한 이유도 의존성 때문이다.
의존성 관리에 따라 설계가 어떻게 바뀌는지 단계별로 보여주려고 한다.
Part1 - 의존성이란?
설계가 뭔가요?
-> 코드를 어떻게 배치하는지에 대한 의사결정
어떤 클래스에, 어떤 패키지에, 어떤 프로젝트에 코드를 넣을 것인가.
배치하는 기준은 변경이 되어야 하고, 이는 즉 의존성을 의미함.
의존성이란 -> 변경에 의해 영향을 받을 수 있는 가능성.
그러나 설계를 잘하면 영향을 받지 않을 수도 있음.
의존성은 1. 클래스 간, 2. 패키지 간 의존성으로 크게 두 가지로 나뉨.
클래스 의존성
클래스 의존성은 총 4가지가 존재함.
- 연관 관계: 객체 참조가 영구적으로 존재하는 경우.
- 의존 관계: 코드 상에서 파라미터로 해당 타입이 나오거나, 리턴 타입이거나, 인스턴스를 생성하거나 하는 경우. 즉, 일시적인 협력.
- 상속 관계: 부모 클래스의 구현이 바뀌면 자식 클래스도 영향을 받기 때문.
- 실체화 관계: 인터페이스를 implements하는 경우. 시그니처가 바뀌면 영향을 받기 때문.
패키지 의존성
어떤 패키지 안에 있는 클래스가 다른 패키지 안에 있는 어떤 클래스에 의존성이 있다면 패키지 의존성이 존재한다.
간단하게 클래스 코드를 열었는데 import 문이 존재한다면 패키지 의존성이 존재하는 것.
양방향 의존성을 피하라
A <-> B 의존성이 존재하면 어떻게 될까?
의존성의 정의에 의하면, A가 바뀌면 B가 바뀌고, B가 바뀌면 A가 바뀌므로 A, B는 하나의 클래스라고 봐야 함.
성능 이슈도 많이 발생하고, 동기화를 항상 시켜야 하기 때문에 문제가 됨.
피할 수 있으면 단방향 연관관계로 바꿔라.
class A {
private B b;
public void setA(B b) {
this.b = b;
this.b.setA(this);
}
// A의 변경이 B의 변경을 유발하기 때문에
// 동기화를 항상 시켜줘야 함
}
class B {
private A a;
public void setA(A a) {
this.a = a;
}
}
다중성이 적은 방향을 선택하라
리스트나 컬렉션을 가지면 데이터를 유지하기 위한 많은 노력이 필요함.
일대다 보다는 다대일 관계를 선택하라.
class A {
private Collection<B> list;
}
class B {
}
// 위 방식보다는 아래 방식이 좋다.
class A {
}
class B {
private A a;
}
의존성이 필요없다면 제거하라
가장 좋은 시나리오는 의존성을 제거하는 것.
불필요한 의존성은 제거하라.
패키지 사이의 의존성 사이클을 제거하라
패키지 사이에서도 양방향 의존성이 존재하면 안된다.
A -> B -> C -> A 의존성이 있으면 피해야 함.
패키지 3개가 하나의 패키지라는 의미와 같음. 같이 변경되니까.
Part2 - 예제 살펴보기
주문 플로우를 생각해보자.
가게선택 -> 메뉴선택 -> 장바구니담기 -> 주문완료 사이클을 거친다.
이런 개념들이 런타임에 어떻게 적용되는지?
문제점이 몇 가지 존재한다. 뭘까?
(문제점) 메뉴 선택
사장님이 '1인 세트'를 메뉴에 등록해뒀다고 해보자.
사용자가 이를 고른 다음에 장바구니에 담았다.
그러나 장바구니 데이터는 핸드폰 로컬 데이터에 저장된다.
서버에 장바구니 정보를 저장하는 것이 아님.
하지만 로컬에 담긴 사이에 사장님이 정보를 바꿀 수도 있다.
즉, 로컬 장바구니 메뉴와 사장님이 판매하는 메뉴의 불일치가 발생한다.
따라서 주문했을 때 전송되는 데이터랑 사장님이 가지고 있는 데이터랑 같은지를 항상 검증해야 함.
그러면 주문 과정에서 무엇을 검증하는가?
협력 설계하기
이제 이 플로우를 한번 이리저리 바꿔볼 것이다.
바꿀 때 trade-off가 어떻게 되는지도 따져볼 것임.
방금 전 구조를 클래스 다이어그램으로 바꾸면 아래와 같이 바뀐다.
이제 의존성의 관점에서 설계를 바꿔보자.
객체 간에는 관계가 존재함.
이는 런타임에 어떤 식으로든 협력이 이루어진다는 것을 암시함.
관계라는 것은, 방향성이 필요함. 이는 곧 의존성과 같다.
관계의 방향 = 협력의 방향 = 의존성의 방향.
클래스 간 의존성은 4가지가 있다고 했음.
그런데 실체화 관계, 상속 관계는 명확하기 때문에 우리는 두 가지만 고려하면 됨.
연관 관계, 의존 관계.
연관 관계 -> 영구적인 탐색 구조를 잡아야 할 때. 빈번하게 해당 객체로 메세지를 보내야할 때.
의존 관계 -> 일시적인 탐색 구조를 잡아야 할 때. 파라미터나 리턴타입, 지역변수 등. 협력을 위해 일시적으로 필요한 의존성.
연관 관계
연관 관계의 정의는 '탐색 가능성'이다.
어떤 객체가 있는데, 이 객체를 알면 다른 객체를 찾아갈 수 있다면 연관관계가 성립한다.
연관관계를 잡는 기준은 두 객체 사이의 협력이 필요하고 관계가 영구적인 경우.
일반적으로 연관관계를 구현하는 방법은 객체 참조를 사용하는 것.
그러나 연관관계를 구현하는 방법은 되게 많음. 객체 참조와 1 : 1 관계가 아니다.
연관관계를 구현하는 가장 대표적인 방법 중 하나가 객체 참조.
class A {
private B b; // 객체 참조를 통해 연관관계 설정
public void sendA() {
b.sendB(); // 연관관계를 통한 협력
}
}
class B {
public void sendB() {
// do something
}
}
아래는 이런 연관관계, 의존관계를 이용해 실제 코드 레벨에서의 객체 협력을 구성한 모습이다.
여태까지 이야기했던 개념들, 코드들은 Layered Architecture 상에서 Domain Layer에 속한다.
실제로 코드를 구현하려고 하면 Request도 받아야 하고, DB에 저장도 해야 하니 Service, Infra 영역까지 구현해보겠음.
order.place()를 통해 예외가 발생하는지를 판단하고, 아니라면 Repository에 저장함.
이것이 지금까지 만들었던 '주문하기' flow의 전체적인 코드이다.
Part3 - 설계 개선하기
이제 설계를 개선해보자.
그러나 설계 개선 작업은 클래스, 코드 레벨에서 하는 것이 아니라 협력, 코드 배치의 레벨에서 살펴봐야 한다.
핵심은 의존성을 보라는 것.
조영호님은 코드를 짜면 항상 의존성을 종이에다가 그려 봄.
의존성에 이상이 있으면 코드 자체에도 이상이 있는 경우가 많다.
즉, 설계를 진화시키기 위해서는 코드를 일단 작성하고 의존성 관점에서 검토해라.
그 검토 방법 중 두 가지만 여기서 이야기하려고 한다.
- 객체 참조로 인한 결합도 상승
- 패키지 의존성 사이클
이전 코드 의존성 살펴보기
문제점은 무엇인가?
의존성 사이클이 존재한다는 것.
왜 이런 문제가 발생할까?
Order -> Shop 으로의 의존성이 존재했었음.
그리고 OptionGroupSpecification -> OrderOptionGroup 의존성도 존재했었음.
이렇게 검증을 위한 절차가 패키지 간 의존성 사이클을 생성시킨 것.
따라서 양쪽의 패키지 중 어느 한쪽의 변경이 발생하더라도 둘 모두를 같이 변경해야 하는 상황이 되었다.
이제 총 3가지 해결 방법을 알아보자.
1. 중간 객체를 이용해서 의존성 사이클 끊기
이상해보일 순 있다. 굳이 이렇게까지 해야하나?
일단 추상화라는 것을 알아보자.
추상화는 추상 클래스나 인터페이스만 의미하지 않는다.
OptionGroup, Option은 필요한 정보만 들고 있는 추상적인 객체이다.
따라서 DIP의 변형이라고 볼 수 있음. 구체적인 것이 아닌 추상적인 것에 의존한다고 봐도 되기 때문.
2. 연관관계 다시 살펴보기
연관관계라는 것은 탐색 가능성을 의미한다고 했다.
객체 참조로 연관관계를 구현했는데, 객체 참조로 구현하면 다양한 이슈가 막 발생함.
- 성능 문제 - 어디까지 조회할 것인가? 연관관계라면 탐색이 다 가능한데... ORM이나 DB로 매핑을 한다면 연관관계가 있는 순간부터는 문제가 발생할 수 있음. lazy loading 이슈가 발생할 수도 있음. 쿼리도 길어지는 단점도 존재함. 객체가 다 연결되어 있다보니.
- 어디서부터 어디까지 수정해야 할지 모름 - 객체가 모두 연관관계로 연결되어 있다면 트랜잭션의 경계도 굉장히 모호해진다. 요구사항이 바뀌고 비즈니스 로직이 추가될수록 트랜잭션이 굉장히 길어진다. 하나의 트랜잭션에서 여러 개의 객체를 다루게 되고, 각 객체마다 변경되는 주기가 다르다면 트랜잭션 경합때문에 성능의 저하로 이어짐.
그렇다면 객체참조가 꼭 필요할까?
객체참조의 문제점 -> 모든 것을 다 연결시켜버림. 어떤 객체라도 접근 가능. 어떤 객체라도 함께 수정 가능.
객체 참조는 결합도가 가장 높은 의존성이다.
따라서 필요한 경우 객체 참조를 끊어버려라!
그렇다면 결합도를 낮추면서 연관관계를 구성하는 방법에는 무엇이 있을까?
-> Repository를 통한 탐색.
이렇게 비즈니스 로직은 단방향으로 깔끔하게 만들 수 있는데, 조회 로직 등이 들어가면 양방향이 생기긴 한다.
모든 객체 참조가 다 불필요한가?
아니다. 어떤 객체들은 같이 묶어도 됨.
모든 객체 참조를 끊으라는 이야기가 아니다.
본질적으로 결합도가 높은 객체들은 참조를 쓰는게 맞고,
굳이 참조를 쓰지 않더라도 Repository를 통해 탐색 가능한 애들은 쓰지 않는게 좋다.
이것을 구분해야 하는데, 구분하는 가장 중요한 규칙은 도메인 Rule이다.
어떤 데이터들을 같이 처리해야 하는지 등의 도메인적인 관점을 통해 묶어야 함.
같이 생성되고 같이 삭제된다면 하나의 단위로 움직이는 것이기 때문에 묶어주고, (객체 참조를 사용하고)
그렇지 않은 경우에는 가능하면 분리하라.
또한 도메인적으로 제약사항을 같이 공유한다면 묶어라.
예를 들어서, 장바구니와 장바구니 항목은 묶어야 하는가?
두 개념의 라이프사이클은 완전 다르다. 그리고 두 개념 사이의 제약조건은 전혀 다른 경우가 많다.
따라서 둘은 묶일 필요가 없다.
그런 관점에서 보면 아래와 같이 객체들을 묶을 수 있다.
경계 안 객체는 연관관계로, 경계 밖 객체는 ID를 통해 Repository로 탐색.
비즈니스 룰에 따라 객체 그룹을 잘 나눠서 설계를 해야한다.
즉, 연관관계와 ID 개념을 적절히 혼합해서 다시 짠 구조는 아래와 같게 된다.
객체참조가 전통적인 객체지향을 설명하기는 편한데, 실무 레벨에서는 어디서 묶어야 하고 어디서 끊어야 하는지가 중요해짐.
이렇게 끊고 나면, 묶인 객체들 단위로 트랜잭션을 관리하면 된다. 트랜잭션 경계가 명확해진다. 조회도 마찬가지.
이렇게 구성하면 그룹 단위의 영속성 저장소를 변경할 수도 있다.
검증 로직의 위치
검증 로직은 해당하는 객체에만 위치해야 할까?
오해이다. 때로는 절차지향이 객체지향보다 좋을 수 있다.
객체의 상태를 validation하기 위해 여러 객체가 필요하다면 응집도가 확 떨어진다.
왜? 검증 로직과 비즈니스 처리 로직의 변경 주기가 다르니까.
따라서 trade-off를 결정해 검증 로직을 한 군데 몰아넣을 수도 있다.
이렇게 몰아넣게 되면 핵심 도메인 객체에는 중요한 비즈니스 로직만 남게 된다.
즉, 응집도가 높아진다.
물론 테스트 등에서 어려움이 있을 수는 있지만 말이다.
객체지향이 항상 정답은 아닐 수 있다.
두 객체가 서로 연관관계가 아닐 때 같이 바꾸려면
대표적으로 두 가지 방법이 있음.
- 절차지향적으로 로직 모으기
- 도메인 이벤트 퍼블리싱
비즈니스 플로우를 한 눈에 볼 수 있게 된다.
1. 절차지향적으로 로직 모으기
절차지향적으로 작성하는 1번 방법에 대해 더 생각해보자.
이렇게 수정하고 나서 의존성을 한번 더 그려보자.
그러면, 아까는 중간 객체를 만들어서 이 문제를 해결했었는데 다른 방법인 '인터페이스를 이용'해서 의존성을 역전시키자.
인터페이스는 order 쪽에, 그것을 실제로 실행하는 쪽은 delivery 쪽에 있음.
이렇게 단방향 의존성을 만들 수 있다.
이처럼 패키지 간 의존성 사이클이 생길 때
- 중간 객체를 사용하거나
- 인터페이스 등을 통해 DIP를 활용하거나
방법을 사용해볼 수 있다.
2. 도메인 이벤트 퍼블리싱
이번에는 도메인 이벤트 퍼블리싱을 통해 두 객체를 함께 바꾸는 방법을 생각해보자.
도메인 이벤트는 serivce의 개념과는 반대로 A -> B, C 가 실행되는 것을 원하긴 하지만 느슨하게 만들고 싶다면 사용한다.
OrderEvent를 발행해서 상태를 바꾼다. 그리고 각각의 EventHandler가 요청을 받아 처리한다.
DB 커밋이 갔을 때 이벤트가 발행되고, 받을 때도 마찬가지로 SpringEventListener를 쓰면 된다.
이렇게 이벤트 핸들러를 구성했는데 또 문제가 뭐냐?
의존성 사이클이 한번 더 생긴다는 것.
파라미터로 이벤트를 받고 있으므로.
앞서 이를 해결하는 두 가지 방법을 소개했었다.
1. 중간 객체를 사용하거나, 2. 인터페이스를 사용하거나.
이제는 세번째 방법을 사용해보려고 함.
문제의 근원은 뭐냐? EventHandler가 shop 패키지에 있다는 것이다.
따라서 패키지를 분리한다.
패키지를 분리하고 생각해보니, 정산의 개념과 shop의 개념이 한 군데 있었다는게 어색했다는 걸 알 수 있음.
그렇기 때문에 의존성 사이클이 생긴 것이고.
패키지를 찢을 때에는 도메인적으로 명확한 의사 결정에 의해 찢게 된다.
이처럼 의존성을 쫒다 보면 도메인에 대한 관점이 바뀔 수 있다.
패키지 사이클 의존성을 제거하는 3가지 방법
3가지 방법에 대해 모두 소개했다.
- 중간객체 생성
- 의존성 역전
- 새로운 패키지 추가
세가지 중 어떤 것을 선택할지는 trade-off의 관점에서.
이렇게 의존성을 제거하다 보면 도메인을 모듈화할 수 있고 시스템 분리의 기반이 된다.
참고자료
'객체지향' 카테고리의 다른 글
Layered Architecture란 (0) | 2023.04.21 |
---|---|
왜 상태와 행위를 한 곳에서 관리해야할까? (4) | 2023.03.19 |
[객체지향의 사실과 오해] 6장: 객체 지도 (0) | 2023.02.23 |
[객체지향의 사실과 오해] 5장: 책임과 메세지 (0) | 2023.01.14 |
[객체지향의 사실과 오해] 4장: 역할, 책임, 협력 (0) | 2023.01.13 |