우아한테크세미나 - 우아한 객체지향 (feat. 조영호님)
이번 포스팅은 객체지향 설계에 대해 고민하고 있을 때 좋은 참고가 된 세미나 내용에 대한 정리입니다.
유튜브에 1시간 40분 정도 길이의 영상이 있는데, 사실 이 영상을 3번째 보고 이제야 이해가 되어 포스팅을 하게 되었습니다.
개인 프로젝트와 여러 개념에 대해 더 알게 되면서 저도 성장했는지 매번 볼 때마다 이해도가 달랐습니다.
책도 그렇고 영상도 그렇고 사람은 아는 만큼 보인다고 볼 때마다 느끼는 점이 다른게 참 재미있는 것 같습니다.
이번 포스팅은 단순히 제가 중요하다고 생각한 포인트를 정리한 것입니다.
강의의 핵심인 의존성을 이용해 설계를 발전시키는 것은 담기가 힘듭니다. 꼭 영상을 한번 보는 것을 추천드립니다.
목차
- 세미나 목표
- 클래스 의존성의 종류
- 의존성 사이클
- 협력 설계하기
- Aggregate와 Repository로 객체 참조 끊기
- Aggregate를 나누는 간단한 규칙
- Aggregate로 나누면서 생기는 컴파일 에러 처리방법
- 패키지 의존성 사이클 없애는 방법 3가지
- Domain Event를 사용하면서 얻을 수 있는 이점
세미나 목표
OOP의 구성요소는 역할
, 책임
, 협력
입니다. 하지만 OOP 만으로 설계를 하면 객체 탐색 그래프가 굉장히 커집니다. 거기에 비즈니스
가 더해지면서 서비스 특성에 따라 TPS가 다르고 문제가 발생 할 수 있죠.
좋은 설계를 하기 위해서는 무언가 더 필요합니다.
개발자는 동적인 프로세스를 정적인 코드로 담아내는 사람입니다.
그러기 위해서는 의존성을 관리하여 예상치 못한 상황이 발생하는 것을 막아줘야 합니다.
세미나의 목표는 의존성을 이용해 설계를 진화 시키는 방법을 배웁니다.
클래스 의존성의 종류
우선 의존성의 종류에 대해 알아야 합니다. 클래스 의존성은 4가지가 있습니다.
- 연관 관계
가장 강한 결합도를 갖는 의존성입니다. 클래스가 필드로 레퍼런스를 가지고 있기 때문에 메모리 상에서 항상 같이 존재합니다. - 의존 관계
일시적으로 결합을 하는 비교적 낮은 의존성입니다. 보통 메서드 내에서 파라미터로 주입되거나 리턴타입, 지역변수로 사용됩니다. - 상속 관계
extends
키워드로 상속된 관계를 의미합니다.
컴파일 시점에서 보면 결합도가 낮아도 상속 관계는 필드를 공유할 수 있기 때문에 런타임 시점에 실제로 맺는 관계는 서로 강한 결합을 갖을 수 있습니다.
코드의 복잡성도 높일 수 있어서 주의해서 사용해야 합니다. - 실체화 관계
interface
키워드로 만들어진 클래스와implementes
키워드로 만들어진 구현 클래스 사이의 관계를 의미 합니다.
런타임 시점에 실제로 맺는 관계는 서로 오퍼레이션 명(메서드 이름)만 공유하기 때문에 가장 낮은 의존성을 갖습니다.
구현부가 없기 때문에 가장 변하지 않는 것이라고 하기도 합니다.
의존성 사이클
클래스 의존성 사이클
코드에서 객체가 서로를 의존하고 있는 상태입니다.
위의 코드 예시는 연관관계로 예를 들었지만, 관계의 종류가 중요한 것이 아니라 방향이 양방향(또는 사이클) 이라는 것이 중요합니다.
앞에서 말했지만 개발자는 동적인 프로세스를 정적인 코드에 담아내는 사람이라는 것을 생각해야합니다.
양방향 의존성은 개발은 편하게 해도 런타임 시점에 예상하지 못한 에러를 발생 시킬 수 있습니다.
- 객체 간의 데이터를 동기화 시켜줘야 합니다.
- JPA를 사용하고 있다면 순환 참조로 무한 대기 상태에 빠질 수 있습니다. (ex. 직렬화 과정)
- 객체의 수명 주기가 달라서 참조 시 NPE가 발생할 수 있습니다.
- 그 밖의 성능 이슈 등 여러 문제가 발생할 수 있습니다.
유지보수성 관점에서도 문제가 있습니다. 의존성이란 결국 같이 변할 수 있다는 사실을 내포하고 있습니다.
클래스 A가 B를 의존(A -> B) 하고 있다는 것은 B가 변할 때 A도 같이 변할 수 있다는 의미입니다.
그런데 양방향 의존성을 갖게 되면 서로 같이 변한다는 것인데 아무리 생각해도 의존성을 해결할 수 없다면 클래스를 잘못 설계했을 가능성이 있습니다.
무조건 사용하지 말라는 것이 아니라 이러한 문제를 알고 있어야하고, 꼭 사용한다면 발생 가능성과 범위를 최소화 시켜야 합니다.
패키지 사이의 의존성 사이클
패키지 간의 의존성 사이클이 생긴다는 것은 사실은 두 패키지가 같은 이유로 같이 바뀐다는 의미입니다.
즉 사실은 하나의 패키지를 잘못 나눴을 가능성이 있습니다.
패키지 사이의 의존성 사이클은 도메인 모듈화가 필요하다면 반드시 없애야 합니다.
의존성을 이용하여 설계 진화시키기
협력 설계하기
관계의 방향(의존성 방향)은 협력
의 방향입니다. 런타임에 객체들의 협력관계를 그려보고 방향성을 잡는 것이 중요합니다.
처음부터 완벽한 설계를 하기는 힘듭니다.
설계를 진화 시키기 위한 출발점은 협력을 설계하는 것입니다.
협력 설계의 결과입니다. 이것은 출발점일 뿐이고, 다음의 단순한 과정을 반복해 설계를 발전시킵니다.
- 의존성이 어떻게 흐를지 봐라. 봐도 모르겠다면 일단 코드를 작성하라.
- 코드 작성 후 의존성을 다시 그려보고 의존성 관점에서 설계를 다시 검토하라.
이 과정을 거치면 처음에는 안보이던 힌트가 보이는 경우가 굉장히 많다고 합니다. 협력관점에서 관계의 의미는 다음과 같습니다.
- 연관관계 : 협력을 위해 필요한 영구적인 탐색 구조.
- 의존관계 : 협력을 위해 일시적으로 필요한 의존성.
물론 연관 관계는 데이터 설계에 영향을 많이 받습니다. 중요한 것은 연관관계가 영구적인 객체 탐색의 다리 역할을 한다는 것이죠.
Aggregate와 Repository로 객체 참조 끊기
객체 참조를 연결하는 것은 굉장히 쉽습니다. 그래서 많은 개발자들이 편한 개발을 위해 연관관계를 맺는 것을 아주 쉽게 결정하기도 합니다.
그렇게 완성된 설계의 의존성을 그려보면 모든 객체들이 연결되어 있는 모양이 됩니다.
무분별한 객체 참조는 다음의 문제가 있습니다.
- 객체 그룹의 조회 경계가 모호해집니다.
객체들이 모두 연결되어 있어서 메모리상에서는 문제가 없지만, JPA를 사용한다면 LazyLoading으로 성능 이슈가 발생할 수 있습니다. - 수정 시 도메인 규칙을 함께 적용할 경계가 모호해진다.
다른 말로 하면 트랜잭션의 경계는 어디까지인지 알기가 어렵습니다.
트랜잭션은 테이블의 락과 연관되기 때문에 트래픽이 큰 서비스일 수록 트랜잭션 경합으로 인한 성능 저하가 발생할 확률이 높습니다.
객체 참조(연관 관계)는 결합도가 가장 높은 의존성입니다. 그래서 객체 참조는 꼭 필요할 때가 아니면 없는 것이 더 유연한 설계를 만들 수 있습니다.
그러면 객체 참조를 사용하지 않고 어떻게 객체끼리 참조할 수 있을까요? 방법은 간단합니다.
- 객체들의 그룹을 나눈다.
- 그룹 간의 연관 관계를 끊고 해당 객체의 id를 갖는다.
- 객체 탐색이 필요하다면 갖고 있는 id를 통해 Repository로 조회한다.
이해를 돕기 위해 그림을 같이보겠습니다.
객체들의 그룹
을 나누고 그룹의 핵심 객체들이 조회가 필요한 객체의 id를 갖고 있습니다.
만약 Order 객체와 Shop 객체가 필요하다면?
Shop shop = shopRepository.findById(order.getShopId()); // Order 객체가 가진 shopId를 통해 Service Layer에서 Repository를 사용해 조회 한다.
이렇게 나누어진 객체 그룹
을 DDD(Domain-driven Design)에서는 Aggregate
라고 부릅니다.
Aggregate는 다음의 기준이 될 수 있습니다.
- 트랜잭션의 단위
- 조회 경계
- 비즈니스 제약의 단위
또한 Aggregate 단위로 영속성 저장소를 변경 가능하고 마이크로 서비스로 만들기도 수월합니다.
Aggregate를 나누는 간단한 규칙
- 함께 생성되고 함께 삭제 되는 객체가 있다면 함께 묶어라. (라이프 사이클 공유)
- 도메인 제약사항을 공유하는 객체들을 함께 묶어라.
- 가능하면 분리하라.
예제를 통해 연습해보겠습니다.
1. 사용자의 장바구니(Cart)를 서버에서 저장합니다.
2. 장바구니에는 어떤 물건(CartItem)이든지 담을 수 있습니다.
이 둘은 함께 묶어야 할까요? 이름을 공유하니까 당연히 묶어야 된다고 생각할 수 있습니다.
하지만 좀 더 생각해 볼 필요가 있습니다.
우선 Cart
와 CartItem
은 생성 시점이 다릅니다. 사용자는 항상 Cart
를 가지고 있고 CartItem
은 사용자가 담았을 때만 생성 됩니다.
또 Cart
와 CartItem
은 도메인 제약 사항을 공유하지 않습니다. 어떤 CartItem
을 추가해도 Cart
의 제약사항(규칙)에 영향을 주지 않습니다. 반대로 Cart
가 CartItem
의 제약사항에 영향을 주지도 않습니다.
따라서 Aggregate를 분리가 가능합니다. Aggregate로 묶는 것도 결합도를 높이기 때문에 가능하면 분리하는 것이 좋습니다.
Aggregate로 나누면서 생기는 컴파일 에러 처리방법
Aggregate로 나누면 객체 참조를 id로 바꾸니 여러 클래스에서 컴파일 에러가 발생할 것입니다.
세미나에서 사용한 예제에서는 다음과 같은 코드에서 발생 했습니다. 이번 세미나에서는 2가지 방법을 제시했습니다.
1. OOP를 깨고 절차지향으로 한 군데에 로직을 모으자.
세미나 예제에서는 각 객체에 있는 Validation 로직에서 컴파일 에러가 발생했습니다.
이 코드들을 OrderValidator
객체의 validate()
에 모으고 필요한 객체들은 파라미터로 주입 받아서 사용합니다.
이렇게 한 군데에 모아놓으면 다음과 같은 장점이 있습니다.
- 하나의 관심사에 대한 로직을 한눈에 볼 수 있다.
- 비즈니스 플로우가 한눈에 보인다.
사실 Validation 로직은 비즈니스 로직과 관심사가 다릅니다.Order
안에 비즈니스 로직과 Validation 로직이 같이 있어도 두 로직의 변경 주기는 다릅니다.
따라서 Validation이라는 관심사는 분리하여 절차지향으로 관리하면 읽기도 쉽고 편하게 관리할 수 있습니다.
2. 도메인 이벤트를 사용하여 Aggregate 간의 상태를 동기화하자.
세미나 예제에서는 주문 배달 로직에서 Order
의 주문 상태가 변할 때 Order
가 직접 Shop
과 Delivery
의 상태도 변경해줬었는데 참조가 사라지면서 컴파일 에러가 발생했습니다.
한마디로 말하면 도메인 로직이 순차적으로 실행되어야 하는 상태입니다.
Domain Event
란 Aggregate
간의 상태를 동기화 시킬 때 사용하는 이벤트입니다.Order
가 변할 때 이벤트를 발행하고 순차 실행되어야하는 도메인 Aggregate에서 이벤트를 받아 처리하면 됩니다. 이해를 돕기위해 그림을 같이 보겠습니다.
Spring Data 에서는 AbstractAggregateRoot<>
를 제공합니다. 다음과 같이 사용할 수 있습니다.
Domain Event를 사용하면 도메인 패키지 간의 결합을 아주 느슨하게 연결할 수 있다는 장점이 있습니다.
패키지 의존성 사이클 없애는 방법 3가지
지금 까지 협력 설계부터 시작해서 Aggregate 분리를 하면서 Refactoring의 방법을 배웠다.
Refactoring을 하면서 의존성을 그려보면 패키지 의존성이 생기는 경우가 많다.
실제로 세미나의 예제에서도 설계를 진화시키는 과정에서 패키지 의존성을 해결하면서 진화시켰다.
세미나에서는 3가지 방법을 제시했다.
1. 중간 객체를 이용하여 의존성 사이클 없애기
협력 설계 이후의 생긴 패키지 의존성 사이클을 변경한 설계이다.
꼭 Interface
나 abstract
키워드가 있어야 추상 클래스가 아니다.
추상 클래스의 핵심은 잘 변하지 않는 속성을 갖고 있다는 것이다.
OptionGroup
과 Option
은 중간 객체로서 잘 변하지 않는 속성을 뽑아 만든 클래스이다.
이 방법은 재사용성이 증가한다는 장점이 있다.
만약 Cart
라는 패키지가 있다고 가정한다면, Cart도 CartOptionGroup
과 CartOption
을 가질 것이고 OptionGroup과 Option 객체로 변환이 가능할 것이다.
2. 인터페이스를 이용하여 의존성 사이클 없애기 (DIP)
Aggregate를 나누면서 생긴 컴파일 에러를 절차지향 로직으로 모으는 방법으로 해결하다가 생긴 패키지 의존 사이클이다.Order
의 주문 배달 상태를 바꾸면 Shop
과 Delivery
의 상태를 연쇄적으로 바꿔야 하기 때문에 의존성 방향이 저렇게 되었다.
이번에는 중간 객체가 아니라 인터페이스를 의존하게하여 의존성을 역전 시키는 방법이다. DIP원칙
을 이용한 것이다.
3. 클래스를 나누고 패키지를 분리하기
Aggregate를 나누면서 생긴 컴파일 에러를 Domain Event로 해결하다가 생긴 패키지 의존 사이클이다.Shop
의 Billing 로직을 호출하는 이벤트 핸들러와 Delivery
의 배달완료 로직을 호출하는 핸들러가 있고 둘 다 이벤트 객체를 참조한다.
이번 해결방법은 Shop
에서 Billing에 관련된 데이터를 찢어서 새로운 클래스 Billing
을 만들고 패키지를 분리하는 것이다.
생각해보면 Billing
과 Shop
은 분리되는 것이 옳다. 이렇게 설계를 진화시키다 보면 처음에는 발견하지 못했던 도메인 경계를 더 확실하게 알게되기도 한다.
Domain Event를 사용하면서 얻을 수 있는 이점
위와 같이 Domain Event를 사용하여 패키지를 나누면 패키지 의존성도 해결하면서 도메인을 단위로 모듈로 나눌 수 있다.
도메인 단위 모듈은 시스템 분리의 기반이 된다. 즉 다음과 같이 시스템을 분리하고 MQ 프로토콜을 통해 시스템 간의 협력을 하는 방법도 가능해진다.
시스템을 분리하면 어떤 장점이 있나?
기존의 하나의 시스템으로 이루어진 아키텍처를 Monolith(모놀리스)
라고 한다. 이 방식의 문제점은 하나의 서비스가 죽으면 다른 서비스도 연쇄적으로 죽는다는 것입니다.
특히나 대용량 트래픽을 처리할 때는 각 도메인들을 동기적으로 처리하면 모든 서비스가 다운 될 확률이 높기 때문에 트래픽이 높은 서비스를 분리하여 따로 관리하기도 합니다.
이렇게 서비스 단위로 시스템을 분리하는 것을 Microservice(마이크로서비스)
아키텍처라고 하고 줄여서 MSA
라고 합니다.
MSA
는 전체 서비스가 다운되는 것을 막는 것 말고도 장점이 더 있습니다.
- 서비스 단위로 배포를 독립적으로 할 수 있습니다.
- 단위가 작기 때문에 빠른 빌드와 테스트를 통해 빠른 배포가 가능합니다.
- 하나의 서비스를 위한 전담 팀을 만들어 효율적인 개발이 가능합니다.
- 서비스 별로 트래픽에 따라 인프라를 늘리거나 하는 행위가 가능합니다.
- 서비스 별로 필요에 따라 다른 기술을 적용할 수 있습니다.
- Circuit Breaker를 사용한다면, 하나의 마이크로 서비스가 장애가 일어났을 때 다른 마이크로 서비스를 통해 장애를 복구할 수 있습니다.
마무리
객체지향에대해 관심이 생겼을 때 우연히 보게된 세미나 영상인데 개인 프로젝트를 하면서 설계가 굉장히 어렵다고 느낀 저에게 굉장히 많은 도움을 주었습니다.
질 좋은 강의 영상을 이렇게 멀리서도 볼 수 있다는게 정말 행운 인 것 같습니다. 직접 세미나에 참여한다면 더 좋을 것 같아서 기회가 있을 때 참여해 볼 생각입니다.
좋은 설계란 무엇인가에 대해 하나의 관점을 알게 해주는 유익한 세미나였습니다.
서두에 적었지만 강의의 핵심인 의존성을 이용해 설계를 발전시키는 것이 핵심인데 글로는 담기가 힘듭니다.
짧지는 않은 영상이지만 꼭 보시는 것을 추천드립니다.
긴 글 읽어주셔서 감사합니다.