좋은 구현이란?
- 비즈니스 가치를 명확히 충족시켜야 한다
- 잘 읽혀야 한다
- 테스트 코드 작성이 쉬워야 한다
- 변경에 유연해야 한다
- 요구사항은 언제든지 추가되고 바뀔 수 있다
- 따라서 코드 구현과 설계는 요구사항 변경에 유연하도록 작성되어야 한다
- 객체지향 설계 원칙
좋은 객체 지향 설계의 5가지 원칙 (SOLID) 😏
[디자인 패턴의 아름다움] 3. 설계 원칙1) 단일 책임 원칙클래스와 모듈은 하나의 책임 또는 기능만을 가지고 있어야 한다. 즉, 거대하고 포괄적인 클래스를 설계하는 대신, 작은 단위와 단일 기
progfrog.tistory.com
도메인 주도 설계
- 비즈니스 도메인 중심으로 서비스를 모델링하고 구현하기
- 각각의 복잡한 도메인을 모델링하고 표현력 있게 설계하는 것을 도메인 주도 설계, DDD라고 한다
프로젝트의 Layer 구조
- 레이어 간의 참조 관계에서는 단방향 의존을 유지하고 계층 간 호출에서는 인터페이스를 통한 호출이 되도록 한다
Layer 별 특징과 역할
Layer | 설명 | 주요 객체 |
사용자 인터페이스 (interfaces) | 사용자에게 정보를 보여주고 사용자의 명령을 해석하는 책임을 진다. | Controller, Dto, Mapper(Converter) |
응용 계층 (application) | 수행할 작업을 정의하고 표현력있는 도메인 객체가 문제를 해결하게 한다. 이 계층에서 책임지는 작업은 업무상 중요하거나 다른 시스템의 응용 계층과 상호 작용하는 데 필요한 것들이다. 이 계층은 얇게 유지되고, 오직 작업을 조정하고 아래에 위치한 계층에 포함된 도메인 객체의 협력자에게 작업을 위임한다. |
Facade |
도메인 계층 (domain) | 업무 개념과 업무 상황에 대한 정보, 업무 규칙을 표현하는 일을 책임진다. 이 계층에서는 업무 상황을 반영하는 상태를 제어하고 사용하며, 그와 같은 상태 저장과 관련된 기술적인 세부사항은 인프라 스트럭쳐에 위임한다. 이 계층이 업무용 소프트웨어의 핵심이다. |
Entity, Service, Command, Criteria, Info, Reader, Store, Executor, Factory(interfaces) |
인프라 스트럭쳐 계층 (infrastructure) | 상위 계층을 지원하는 일반화된 기술적 기능을 제공한다. 이러한 기능에는 애플리케이션에 대한 메시지 전송, 도메인 영속화, UI에 위젯을 그리는 것 등이 있다. | low level 구현체(ReaderImpl, StoreImpl, Spring JPA, RedisConnector, ...) |
Layer 간의 참조 관계
- Layer 간의 참조 관계에서 application과 Infrastructure는 domain layer를 바라보게 하고, 양방향 참조는 허용하지 않게 한다.
- domain layer는 low level의 기술에 상관없이 독립적으로 존재할 수 있어야 한다.
- 이를 위해 대부분의 주요 로직은 추상화되고, runtime 시에는 DIP 개념을 활용하여 실제 구현체가 동작하게 한다.
Layer 별 구현 상세
Domain Layer
DDD에서 말하는 domain layer의 역할은 다음과 같다
- 업무 개념과 업무 상황에 대한 정보, 업무 규칙을 표현하는 일을 책임진다
- 이 계층에서는 업무 상황을 반영하는 상태를 제어하고 사용하며 그와 같은 상태 저장과 관련된 기술적인 세부사항은 인프라 스트럭쳐에 위임한다
- 이 계층이 업무용 소프트웨어의 핵심이다
DDD에서 도메인 모델을 정의하고 구현하는 layer는 domain layer이기 때문에 DDD에서는 domain layer가 핵심이다
여기에서 domain layer의 표준 구현은 다음과 같다
- domain layer에서의 Service에서는 해당 도메인의 전체 흐름을 파악할 수 있도록 구현되어야 한다
- 이를 위해서는 추상화 레벨을 많이 높여야 한다
- 도메인 업무는 적절한 interface를 사용하여 추상화하고 실제 구현은 다른 layer에 맡기는 게 맞다
- 세세한 기술 구현은 Service가 아니라 Infrastructure의 implements 클래스에 위임하고, Service에서는 이를 활용하기 위한 interface를 선언하고 사용한다
- DIP를 활용하여 도메인이 사용하는 interface의 실제 구현체를 주입받아 사용할 수 있도록 한다
- 영속화된 객체를 로딩하기 위해 Spring JPA를 사용할 수도 있지만, MyBatis를 사용할 수도 있다. domain layer에서는 객체를 로딩하기 위한 추상화된 interface를 사용하고, 실제 동작은 하위 layer의 기술 구현체에 맡긴다는 것이 핵심이다
- 이런 식의 구현을 가져가면
- service의 메서드를 읽기만 해도 업무 도메인의 흐름을 대략적으로 파악이 가능하고
- interface로 추상화된 실제 구현 기술은 언제든지 원하는 것으로 교체가 가능하게 된다
- 도메인을 대표하는 하나의 Service가 존재하게 하고, 해당 Service에는 @Service를 붙인다
- 해당 제안을 규약으로 가져가면, 다른 개발자들이 해당 도메인을 파악할 때 엔트리 포인트가 되는 로직을 빠르게 찾을 수 있을 것이라 기대한다
- 이를 위해서는 추상화 레벨을 많이 높여야 한다
- domain layer에서의 모든 클래스명이 XxxService로 선언될 필요는 없다
- 하나의 도메인 패키지 내에 수많은 Service 클래스가 존재하게 되면, 도메인 전체의 흐름을 컨트롤하는 Service가 무엇인지 알기 어렵다
- 주요 도메인의 흐름을 관리하는 Service는 하나로 유지하고, 이를 위한 support 역할을 하는 클래스는 Service 이외의 네이밍을 가져가는 것이 좋다
- 또한 하나의 책임을 가져가는 각각의 구현체는 그 책임과 역할에 맞는 네이밍으로 선언하는 것이 가독성에 좋다
- 아래와 같은 네이밍이 적절한 예시가 될 것이다
- XxxReader
- XxxStore
- XxxExecutor
- XxxFactory
- XxxAggregator
- 다만 해당 구현체는 domain layer에서는 interface로 추상화하고, 실제 구현체는 Infrastructure layer에서 구현한다
- 즉, domain layer에서는 도메인 로직의 흐름을 표현하고 구현하는 Service와 ServiceImpl이 있지만 그 외의 상세한 구현은 Reader, Store, Executor 같은 interface를 선언하여 사용하고 이에 대한 실제 구현체는 Infrastructure layer에 두고 활용한다 (DIP)
- 아래와 같은 네이밍이 적절한 예시가 될 것이다
- Service 간에는 참조 관계를 가지지 않도록 한다
- Service 로직을 구현하다 보면 좀 더 상위 레벨의 Service와 하위 레벨의 Service가 도출되기 마련인데, 이런 구조를 허용하게 되면 상위 레벨의 Service가 하위 레벨의 Service를 다수 참조하게 되면서 로직이 구성된다
- 시간이 지날수록 특정 Service가 참조하는 하위 Service가 점점 늘어날 수 있다
- 이는 테스트 코드 작성을 어렵게 하고 가독성도 많이 떨어지게 된다
- Service 간에는 참조 관계를 가지지 않도록 원칙을 세우는 것이 좋다
- Service 내의 로직은 추상화 수준을 높게 가져가고
- 각 추상화의 실제 구현체는 잘게 쪼개어 만들면
- 도메인의 전체 흐름이 파악되면서도 로직이 간결하게 유지되는 코드를 가져갈 수 있다
- Service 로직을 구현하다 보면 좀 더 상위 레벨의 Service와 하위 레벨의 Service가 도출되기 마련인데, 이런 구조를 허용하게 되면 상위 레벨의 Service가 하위 레벨의 Service를 다수 참조하게 되면서 로직이 구성된다
Infrastructure Layer
- domain layer에 선언되고 사용되는 추상화된 interface를 실제로 구현하여, runtime 시에는 실제로 로직이 동작하게 한다
- DIP 개념을 활용한다
- 세세한 기술 스택을 활용하여 domain의 추상화된 interface를 구현하는 것이므로 비교적 구현에서의 자유도를 높게 가져갈 수 있다
- Serive 간의 참조 관계는 막았지만, infrastructure layer에서의 구현체 간에는 참조 관계를 허용한다
- Infrastructure에서의 구현체는 domain layer에 선언된 interface를 구현하는 경우가 대부분이므로 Service에 비해 의존성을 많이 가지지 않게 된다
- 로직의 재활용을 위해 Infrastructure 내의 구현체를 의존 관계로 활용해도 된다
- @Component를 활용한다
- Spring 내의 동일한 bean이라도 @Service와 @Component를 구분하여 선언하여 명시적인 의미를 부여하고자 한다
- Spring에서 @Service와 @Component는 동일하게 class를 bean으로 등록하고 큰 차이는 없으나, annotation을 통해 해당 class의 의미를 부여할 수 있다
Application Layer
- 비즈니스 규칙은 포함하지 않으며, 작업을 조정하고, 다음 하위 계층에서 도메인 객체의 협력을 위해 업무를 위임한다
- 그렇기 때문에 해당 Layer는 얇게 유지된다
- 작업을 조정하기만 하고 도메인 상태를 가지면 안 된다
여기서의 application layer 표준 구현은 다음과 같다
- application layer에서는
- transaction으로 묶여야 하는 도메인 로직과
- 그 외의 로직을 aggregation 하는 역할로 한정 짓는다
- 그러므로 해당 로직이 두꺼워질 요소는 없다
- 해당 layer의 클래스 네이밍은 XxxFacade로 정한다
- Facade의 개념은 복잡한 여러 개의 API를 하나의 인터페이스로 aggregation 하는 역할지미나
- 여기서 정의하는 application layer 내의 Facade는 서비스 간의 조합으로 하나의 요구사항을 처리하는 클래스로 정의하였다.
- 실제적인 요구사항을 예시로 하여 Facade 구현을 정의해 보면 다음과 같다
- "예매 완료 후 유저에게 카카오톡으로 예매 성공 알림이 전달된다"라는 요구사항이 있다고 해보자
- 예매 처리 과정에서의 모든 도메인 로직은 하나의 transaction으로 묶여야 정합성에 이슈가 없다
- 그러나 예매 완료 직후 카카오톡 알림 발송이 실패하더라도, 예매 로직이 전체 롤백될 필요는 없다
- 카카오톡 알림 발송이 실패했더라도 유저는 메인 서비스를 통해서 예매 완료를 확인할 수 있기 때문에
- 이런 맥락을 기반으로 Facade 내에 예매 완료 메서드를 구현하면 다음과 같다
- "예매 완료 후 유저에게 카카오톡으로 예매 성공 알림이 전달된다"라는 요구사항이 있다고 해보자
public String completeOrder(OrderCommand.RegisterBook registerOrder) {
var orderToken = orderService.completeOrder(registerOrder);
notificationService.sendKakao("ORDER_COMPLETE", "content");
return orderToken;
}
- Facade 안의 completeOrder 메서드에는 transaction을 선언하지 않는다
- orderService.completeOrder(registerOrder) 내에는 transaction이 선언되어 있고, 예매 완료 처리 중에 예외가 발생하면 Order Aggregate 전체 데이터가 rollback이 된다 (정합성이 지켜진다)
- orderService.completeOrder(registerOrder)가 성공하고 notificationService.sendKakao()가 실패하더라도, 예매 완료 처리는 rollback 되지 않는다
- Order Aggregate의 정합성은 지키면서도, 주요 도메인 로직에는 포함되지 않는 외부 서비스 call (여기에서는 카카오톡 알림 발송)은 성공/실패에 민감하지 않게 요구사항을 처리하게 된다.
Interfaces Layer
- API를 설계할 때에는 없어도 되는 Request Parameter는 제거하고, 외부에 리턴하는 Response도 최소한을 유지하도록 노력하자
- 요구하는 Request Parameter가 많다는 것은 관련된 메서드나 객체에서 처리해야 하는 로직이 많다는 것을 의미하고, 이는 관련된 객체가 생각보다 많은 역할을 하고 있다는 신호일 수 있다
- Response의 경우도 불필요한 응답을 제공하고 있고, 이를 가져다 쓰는 외부 로직이 있다면 추후 Response에서 특정 프로퍼티는 제거하기 어렵게 될 수 있다
- API는 한 번 외부에 오픈하면 바꿀 수 없는 것이라고 생각하자! 처음부터 제한적으로 설계하고 구현해야 한다
- http, gRPC, 비동기 메시징과 같은 서비스 간 통신 기술은 Interfaces Layer에서만 사용되도록 하자
- 가령 json 처리 관련 로직이나 http cookie 파싱 로직 등이 Domain Layer에서 사용되는 식의 구현은 피해야 한다
- 그렇게 하지 않으면 언제든지 교체될 수 있는 외부 통신 기술로 인해 domain 로직까지 변경되어야 하는 상황이 발생한다
반응형