📕 목차
1. Introduction
2. Problems with the Existing Approach
3. Direction for Architecture Improvement
4. Refactoring Execution Strategy
5. Real-World Application Case
6. Advantages and Limitations of the Improved Architecture
1. Introduction
📌 The Reason
멀티 모듈을 구성하면서 잘 모르는 개념에 대해선 몸소 체험해보기로 결정했었다.
점차 기존 설계의 결함이 보이기 시작했고, 이를 분석하여 정리해둔 적이 있었다.
하지만 도메인 모듈을 수정하면, 하위 모듈까지 영향이 전파될 것이 두려워 차마 손을 대지 못하고 있었다.
그러다 11.22(금)에 졸업 작품 시연을 마치고, 마음이 붕 뜬 상태에서 신규 개발을 할 맘이 당췌 나질 않았었다.
그래서 다시 머리를 복잡하게 만들 겸, 본격적으로 다중 인프라스트럭처에 대한 책임을 가지고 있는 도메인 모듈을 분리하기로 결정했다.
엄청난 양의 코드 수정 라인이 나올 것이라 예상했고, 실제로 그렇게 되었다.
하지만 졸작 끝나기 전부터 이미 다른 백엔드 인원 둘 다 부트 캠프와 다른 프로젝트로 떠나면서, 눈치 볼 사람도 없었기에 충분히 시도해볼만 한 조건이었다.
📌 Current Architecture
현재 모듈 구성은 다음과 같이 되어있다.
- common 모듈: 아무런 외부 의존성을 가지지 않으며, Type, Util 등을 정의한다.
- infrastructrue 모듈: 외부 액터와의 상호작용 및 시스템 인프라 영역에 대한 코드를 모아두었다.
- domain 모듈: 핵심 비즈니스 로직에 대한 관심사를 가지며, MySQL과 Redis에 대한 의존성을 갖는다.
- application 모듈: 모든 모듈을 의존하며, 서비스 비즈니스 로직에 대한 관심사를 갖는다.
✒️ Core Domain Logic vs. Service Business Logic
1. 핵심 비즈니스 로직
• 비즈니스의 핵심 가치와 규칙을 담고 있는 로직
• 예) 주문 시 재고 검증, 결제 금액 계산, 사용자 권한 검증 등
2. 서비스 비즈니스 로직
• 애플리케이션 기능 구현을 위한 보조적인 로직
• 인프라스트럭처와의 상호작용을 조정하는 로직
• 예) 캐시 정책, 데이터 동기화, 단순 CRUD 등
보다 상세한 예시를 들어보자면, 사용자가 비밀번호가 있는 채팅방에 가입 요청한 경우를 생각해보자.
핵심 비즈니스 로직은 서비스마다 다르겠지만, 일반적으로 다음과 같은 규칙들이 존재할 것이다.
• "채팅방에는 비밀번호가 설정되어 있어야 하며, 입력된 비밀번호와 일치해야 한다."
• "채팅방의 현재 인원이 최대 수용 인원을 초과하면 안 된다."
• "차단된 이력이 있는 사용자는 해당 채팅방에 입장할 수 없다."
• "한 사용자는 동일한 채팅방에 중복 가입할 수 없다."
이를 위해, 서비스 로직의 흐름을 설계해보면 이런 식으로 구성해볼 수 있다.
1. 사전 검증 단계
• "요청한 사용자가 유효한지 확인한다."
• "대상 채팅방이 존재하는지 확인한다."
2. 비즈니스 규칙 검증 단계
• "채팅방의 비밀번호 일치 여부를 확인한다."
• "채팅방의 수용 인원을 확인한다."
• "사용자의 차단 이력을 확인한다."
3. 실행 단계
• "모든 검증이 통과되면 채팅방 멤버로 등록한다."
• "채팅방 입장 이벤트를 발행한다."
• "채팅방 입장 알림을 전송한다."
4. 후처리 단계
• "채팅방 멤버 수를 갱신한다"
• "사용자의 최근 활동 시간을 업데이트 한다."
1번의 채팅방 존재 로직부터는 모두 핵심 비즈니스 규칙이라 봐도 무방하지 않느냐고 볼 수도 있을 것이다.
하지만 엄밀히 따지면, "비밀번호 채팅방의 접근 가능 여부를 결정하는 규칙"과 "채팅방 멤버로서의 상태 변경 규칙"만이 도메인 규칙이다.
(물론 제가 틀렸을 가능성을 배제하고 있지 않습니다. 오류가 있다면 지적해주세요.)
그 외, 채팅방 입장을 위한 기술적 처리 흐름이나 (눈치챘을 진 모르겠지만) 비즈니스 규칙 검증 단계의 원자적 연산을 보장하기 위한 트랜잭션 혹은 락 처리 등은 기술 구현 사항에 해당한다.
한 줄 요약하면 이렇다.
• 핵심 비즈니스 로직: "어떤 조건에서 입장이 가능한가?"라는 비즈니스 규칙 자체에 관심
• 서비스 비즈니스 로직: "입장 처리를 어떻게 안전하게 수행할 것인가?"라는 기술적 처리에 관심
2. Problems with the Existing Approach
📌 Difficulty of Domain Logic Arrangement
백엔드에 처음 입문하는 사람들이 으레 그렇듯, 다들 무지성으로 Entity를 기준으로 Service를 생성하고 시작한다.
그러다 여러 Entity가 복합적으로 얽히는 비즈니스 로직이 등장하기 시작하면, 뭔가 이대로 가면 안 되겠다 싶어서 서비스 계층을 보다 세밀하기 분리하기 시작한다.
이미 여기까진 경험을 해보고 멀티 모듈 아키텍처를 만들었던 터라, 이를 고려하지 못 했던 건 아니다.
하지만 Domain의 정의를 잘못 이해하고만 나머지, 규칙을 다음과 같이 정의했었다.
- application 모듈의 서비스: 복합 entity 처리 및 기술적 인프라 통합 로직을 구현한다.
- domain 모듈의 서비스: 하나의 entity에 대한 책임만을 갖는다.
domain 모듈의 서비스는 고작 해봐야 하나의 Entity에 대해서만 관심사를 가질 수 있었기에,
자연스레 핵심 비즈니스 로직을 표현하는 로직은 모두 하위 모듈로 노출되는 현상이 발생했다.
domain 모듈 서비스에 걸어버린 컨벤션 때문에 나중에 이 규칙을 갈아 엎기도 힘든 상황이었다.
심지어 초기에는 domain 모듈 내 service의 책임이 뭔지 조차 불분명해서 대충 CRUD 및 각종 도우미 메서드를 라우팅 해주는 용도로만 쓰다가,
추후 사태의 심각성을 깨닫고 이를 고려하기 시작하자, 오히려 "대체 이 기능의 비즈니스 로직이 어디 있는 거야??"라며 해메일 정도로 코드의 응집성이 급격히 저하되기 시작했다.
📌 Violation of Domain Purify
💡 이상적인 도메인 모델은 비즈니스 로직 외에, 실제로 그 데이터가 오는지는 관심을 가질 필요가 없다.
처음에는 Data Source에 따른 Entity를 조정할 필요도 없었다. (어떨 땐 RDB에서 불러오고, 어떨 땐 Cache에서 불러오는 등)
명확히 사용 DB 관점에서 둘을 분리시킬 수 있었고, 아예 redis를 사용하는 Entity는 common 패키지 하위로 격리시켜버렸었다.
그러나 추후 write-back & write-allocate 캐시 정책으로 관리하는 Entity가 등장하면서 문제는 복잡해지기 시작했다.
설계 원칙 관점으로 보면 Repository 영역에서 추상화 시키면 되는 문제지만, Spring의 자동 빈 스캔이 자꾸 일을 복잡하게 만들었다.
public interface ChatMessageStatusRepository extends JpaRepository<ChatMessageStatus, Long>, ChatMessageStatusCacheCustomRepository {
...
}
이를 해결하려면 Repository Interface에서 적절히 처리해야만 했다.
그리고 서비스 로직에서 다음과 같이 표현하는 것이다.
/**
* 마지막으로 읽은 메시지 ID를 조회합니다.
*
* @return 마지막으로 읽은 메시지 ID가 없을 경우 0을 반환합니다.
*/
@Transactional(readOnly = true)
public Long readLastReadMessageId(Long userId, Long chatRoomId) {
return chatMessageStatusRepository.findLastReadMessageId(userId, chatRoomId)
.orElseGet(() -> chatMessageStatusRepository
.findByUserIdAndChatRoomId(userId, chatRoomId) // 캐시에서 정보 조회
.map(status -> {
// Cache Miss 나면, RDB에서 조회해서 캐시 갱신
})
.orElse(0L));
}
그러나 지금 생각해보면 이건 반쪽짜리 시도였다고 생각한다.
보다 Data Source를 명확하게 감추고 싶었다면 JpaRepository를 사용하는 것조차 포기하고, Entity Manager를 사용하면 완벽하게 캐시 정책까지 숨길 수 있다.
물론 Repository 구현체의 복잡도 증가는 알아서 감당해야 할 문제.
📌 Increased Test Complexity
Data Source는 어떻게 잘 감출 수 있다 쳐도, 다중 인프라스트럭처 구조가 본격적으로 테스트 케이스로 전파되면서 상황을 골치아프게 만들었다.
- JPA만을 대상으로 삼던 domains 패키지 하위에 RedisTemplate 의존성 추가로 인해, JPA 자동 스캔 과정에서 충돌 발생
- JPA의 조잡한 Repository 네이빙 컨벤션(Custom*, *Impl)으로 임시로 해결했으나, 임시 방편에 불과함.
하지만 여전히 기존의 DAO 계층 테스트에서 에러가 발생하고 있었다.
@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
@ContextConfiguration(classes = JpaConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestJpaConfig.class)
@ActiveProfiles("test")
public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig {
...
}
정말 어처구니 없는 이유여서 아직도 기억한다.
위 테스트는 분명히 잘 동작하고 있었는데, 갑자기 빈 주입에 실패한다면서 빌드 도중에 죽어버렸다.
이유는 Data Jpa Test를 위해 domains 패키지를 스캔하면서, 여기에 은근슬쩍 끼어든 RedisTemplate 의존성이 올바르게 주입되지 않고 있었기 때문이었다.
(RedisTemplate 빈은 RedisConfig.class에 포함되어 있기 때문)
이를 회피하기 위해, TestJpaConfig에 RedisTemplate 빈을 설정해야 해주어야만 했다.
@TestConfiguration
public class TestJpaConfig {
...
@Bean
@ConditionalOnMissingBean
public RedisTemplate<String, ?> testRedisTemplate() {
return null; // Redis와 무관한 테스트에도 빈 설정 필요
}
}
결국 하나의 도메인 모듈이 다중 인프라스트럭처에 대한 의존성을 가지게 되면서, 다음과 같은 문제가 전파되고 있는 것이다.
- JPA 단위 테스트를 위해 domains 패키지 스캔 과정에서 의도치 않은 Redis 의존성 함께 로드
- 특정 데이터 소스 테스트를 위한 설정이 다른 데이터 소스 설정까지 요구하는 상황
- 결과적으로 단순한 단위 테스트조차, 불필요한 설정을 포함해줘야 하는 상황에 직면
만약, 더 이상 MySQL이 아닌 다른 RDB를 사용하게 된다면?
추가적인 인프라스트럭처 의존성이 생긴다면?
상황은 겉잡을 수 없이 시궁창으로 내리꽂을 것이 분명한 상황이었다.
3. Direction for Architecture Improvement
📌 Module Separation Based on the Single Responsibility Principle
우선 우리가 필요로 하는 조건들은 다음과 같다.
- 핵심 비즈니스 영역이 domain source를 몰라야 한다.
- 결론을 스포하자면, 이건 반쪽짜리 결과물을 낳았다. 오히려 불가능해졌다.
- 여러 엔티티에 걸쳐 핵심 비즈니스 로직을 표현할 영역이 필요하다.
- 인프라스트럭처 확장성에 유연한 구조를 가져야 한다.
- 인프라의 변화가 테스트에 영향을 주지 않아야 한다.
위 조건들을 만족시키기 위한 다양한 방법이 있지만,
이전에 고민한 내용을 기반으로 우아한 기술 블로그에서 소개한 방법을 따라해보고자 했다.
(네이버였나..? 어디에선 domain 모듈이 infrastructure 모듈에 대한 의존성을 가지도록 만들었던 것으로 기억한다.)
이러한 구조에서 각 모듈의 책임은 다음과 같다.
- domain-service
- 핵심 비즈니스 로직의 허브 역할 (여기서 최종 핵심 도메인 규칙이 구현)
- 여러 도메인 간의 상호작용 조율
- domain-rdb, domain-redis에 대한 직접적인 의존
- 어차피 핵심 비즈니스 로직의 일부이므로, Entity 구조를 공유해도 무방하다.
- domain-rdb
- RDB 연동에 대한 책임을 갖는다.
- Entity 정의 및 JPA 구현
- 단일 저장소에 대한 트랜잭션과 불변식, 상태 관리
- domain-redis
- Redis 연동 책임
- 분산 락과 같은 Redis 특화 기능 관리 (분산 락은 infrastructure로 아예 빼는 게 좋을 거 같긴 하다)
- domain-cache로 짓지 않은 이유는 내가 redis를 단순히 cache로만 사용하지 않고 있다는 문제에 기반한다. 이로 인해, cache 라는 범용적인 이름을 짓기엔 내 프로젝트에선 부담이 있었다.
참고로 batch 애플리케이션처럼 대부분 핵심 도메인 규칙이 아닌 비즈니스 로직은 그냥 하위 모듈에서 구현하도록 만드는 것이 낫다.
따라서 domain-service 모듈을 의존하지 않고, 곧장 domain-rdb, domain-redis 모듈을 의존하도록 설정한 것이다.
📝 Trade-off
- ✅ 각 인프라스트럭처 모듈의 명확한 책임 분리
- ✅ 테스트 단순화 (각 저장소별 독립적 테스트 가능)
- ✅ 인프라스트럭처 확장 용이성
- ❌ 완벽한 도메인 순수성 달성 실패 (Entity 의존성 등)
- ❌ domain-service의 다중 저장소 의존
📌 Centralization of Domain Logic
서비스 계층의 역할을 다시 한 번 조정할 때가 되었다.
기존에는 domain 모듈의 서비스가 온전히 핵심 비즈니스 규칙을 표현하지 못 한다는 제약으로 인해, 비즈니스 규칙이 하위 모듈로 새어나간다는 치명적인 단점이 존재했다.
- 도메인 서비스가 단일 저장소에 종속되어 있다.
- 도메인 서비스가 단일 Entity에 종속되어 있다.
이러한 사혼의 구슬마냥 흩어져버린, 나의 마마잃은중천공 핵심 비즈니스 규칙을 한 곳에 모아둘 필요가 있다.
🟡 DDD 관점에서의 이점
- Aggregate Root 중심의 도메인 로직 구현
- 여러 Entity를 포함하는 Tx 경계를 자연스럽게 표현할 수 있다.
- Aggregate 간의 일관성 있는 상호작용을 보장
- Domain Rule이 Storage 기술에 종속되지 않고 순수하게 표현될 수 있다.
- 물론, RDB냐 Cache냐 이런 건 숨기지 못했지만, (나처럼 모듈명에 Redis를 때려넣지만 않았다면) 저장소의 변화가 domain service에 영향을 전파하진 않는다.
- Bounded Context의 명확한 구분
- 각 도메인 컨텍스트별로 독립적인 서비스를 구성할 수 있다. (더 이상 단일 Entity 규칙에 종속되지 않아도 된다!)
- Context 간의 명확한 경계와 관계를 설정하기 용이해졌다.
✒️ Context 간의 경계를 명확히 표현할 수 있게 된 이유
1. 명확한 단일 의존 지점
• 여러 Context가 상호작용해야 할 때, domain-service가 단일 접점 역할을 수행한다
• 각 Context 간의 의존성이 domain-service를 통해 명시적으로 표현 가능
2. 도메인 로직 가시성
• "채팅방 입장 시, 사용자 상태를 확인하고 알림을 보낸다"는 Chat, User, Notification 간의 암시적 의존 관계가 발생할 수 있음.
• 하지만, 이제는 domain-service 내에서 명시적으로 관계를 조율할 수 있다.
🟡 도메인 규칙 응집도 향상
- 비즈니스 규칙의 중앙화
- 응집되지 않고 흩어져있던 도메인 규칙들을 하나의 서비스로 통합
- 규칙 변경 시, 수정 지점 최소화
- 도메인 정책의 일관성 확보
- 도메인 지식의 명확한 표현
- 기술적 구현 세부사항과 분리된 순수한 도메인 규칙
- 비즈니스 요구사항과 구현 코드 간의 간극 감소
- 유지보수성 향상
- 도메인 규칙 변경 시 영향 범위 예측에 용이
- 새로운 요구사항 반영 시, 적절한 위치 파악 용이
📌 Defining Dependency Directions
완전 초기에는 위와 같이 의존성을 관리하고 싶었다.
- domain-service 모듈에서 rdbService, redisService 인터페이스를 정의
- rdb-module과 domain-redis에서 각각 인터페이스를 구현
하지만 이내 위 방식은 불가능하다는 이유를 금방 떠올릴 수 있었다.
- 하위 애플리케이션은 domain-service가 아닌, domain-rdb, domain-redis 모듈을 의존해야만 모든 빈을 올바르게 컨텍스트에 올릴 수 있다.
- Entity의 존재를 domain-service 모듈마저 알 수 없게 되므로, 핵심 비즈니스 규칙을 온전히 표현할 수 없게 된다.
- 만약 이렇게 한다고 해도, 엄청난 대규모 리팩토링 작업을 수반한다. (하위 애플리케이션의 모든 기능에 대한, 모든 로직을 수정해야 할 수도 있다.)
- 함수 파라미터로 설정한 모든 Entity를 제거하고, 별도의 DTO를 모두 작성해야 하며, 중복 검증 문제는 추가로 따라붙는다.
이러한 이유로 domain-service 모듈이 각 인프라스트럭처에 대한 의존성을 가지는 것이 옳다고 생각했다.
🤔 Data Source를 완전히 감추는 방법?
domain 모듈이 infrastructure 모듈을 의존하게 만드는 것만큼은 죽어도 싫다면, 한 가지 (제정신이 아닐 수 있는) 아이디어가 있긴 하다.
바로 domain-service 모듈이 domain-rdb, domain-redis를 직접 의존하는 것이 아니라, domain-repository 모듈을 한 단계 더 거치도록 만드는 것이다.
분명 깔끔해지긴 할 것이다.
하지만 패키지와 모듈과 계층을 분리하면 분리할 수록, 단순히 클래스 3~4개 만들면 끝났을 CRUD 기능조차 훨씬 많은 양의 클래스를 요구하게 된다는 사실을 기억해야 한다.
4. Refactoring Execution Strategy
📌 Strategy for Separating Configuration Files
1️⃣ 모듈별 독립적 설정 구성
- domain-rdb: JPA, QueryDsl 관련 설정
- domain-redis: Redis, Redisson 관련 설정
- domain-service: 통합 설정 관리
2️⃣ 프로필 기반 설정 분리
- application-domain.yml을 다음과 같이 분리
- application-domain-rdb.yml
- application-domain-redis.yml
- application-domain-service.yml
- 각 환경(local, dev)별 프로필 그룹으로 통합 관리
📌 Gradual Migration Plan
한 번에 완벽하게 마이그레이션을 진행하려고 하면, 상당한 작업 시간을 필요로 하고 동작에 결함이 생길 우려가 높아진다.
따라서 정확히 어디서부터 어디까지 진행할 지 범위를 지정하고, 나머지는 점진적으로 개선하기로 했다.
이번 마이그레이션의 목표는 그저 구조적 개선만을 포커싱한다.
1️⃣ Phase 1: 모듈 재구성 (이번 작업)
- 기존 도메인 로직은 현상 유지. (비록 DDD를 어기더라도 감안한다)
- 새로운 모듈 구조 확립
- domain 모듈 분리와 모듈 의존 관계 재설정
여기까지만 작업해놔도, 적어도 신규 기능에 대해서는 새로운 구조에 맞게 개발할 수 있다.
2️⃣ Phase 2: 점진적 도메인 로직 이전 (추후 작업)
- "하위 모듈은 도메인 비즈니스 로직을 몰라야 한다"라는 규칙에 의거한 점진적 마이그레이션 과정
- 기능별로 로직을 domain service로 옮기면서 개선한다.
📌 Minimizing Impact on Existing Code
1️⃣ 상향식 리팩토링 접근
- 의존성 전파를 최소화하는 순서로 진행한다.
- [domain-redis, domain-rdb → domain-service → 하위 모듈] 순으로 수정한다.
- 마찬가지로 Helper → Service → DTO → Mapper → UseCase → Controller 순으로, 변경 지점을 확인하고 수정한다.
- 이렇게 하지 않으면, 의존하고 있는 클래스가 수정되지 않아서 계속 컴파일 에러 상태가 유지된다.
- 자칫 무분별한 리팩토링을 수행하면서 놓치고 지나가는 부분이 많아질 수 있고, 작업 진행률을 파악하기 어려워진다.
2️⃣ 실용적 접근 방식
- domain service 모듈은 기존 서비스 로직을 유지한다. (무리하게 수정하려 하지 말자)
- 위에서 언급했듯, 흩어진 도메인 규칙까지 통합하려는 작업은 실수를 늘릴 확률을 증가시킬 뿐이다.
- 하위 모듈의 변경 지점을 최소화, 가능하다면 패키지 경로만 수정해도 될 정도로 만드는 것이 중요하다.
- 현재로썬 하위 모듈이 domain-service 모듈만을 의존하도록 강제할 수 없다.
- 이미 너무 많이 Entity가 노출되어 있는 상황이라, 어쩔 수 없이 domain-rdb와 domain-redis 모듈까지 의존하도록 구성해두어야 한다.
5. Real-World Application Case
모든 코드는 위 Repo에서 확인 가능합니다.
📌 Example of Module Separation
더 나은 모듈명이 있겠지만, 일단은 가장 단순하게 가져가기로 했다.
더이상 pennyway-domain의 src 패키지와 build, bin 디렉토리는 필요가 없으니, 마지막에 제거하면 끝.
하위 모듈에선 이런 식으로 의존성을 설정할 수 있다.
dependencies {
implementation project(':pennyway-common')
implementation project(':pennyway-domain:domain-service')
implementation project(':pennyway-domain:domain-rdb')
implementation project(':pennyway-domain:domain-redis')
implementation project(':pennyway-infra')
}
📌 Configuration File Setup
각 모듈마다 자신의 책임에 맞는 설정을 표현 가능하도록 application 파일을 분리했다.
하위 모듈에서 위 설정을 불러오려면, 다음과 같이 적용하면 된다.
spring:
profiles:
group:
local: common, domain-service, domain-rdb, domain-redis, infra
dev: common, domain-service, domain-rdb, domain-redis, infra
📌 Implementation of Service Layer
도메인 서비스는 최대한 기존 형태를 유지했다.
그래서 ChatService, ChatMemberService, ChatMessageService 같은 클래스를 확인해보면, 여전히 단일 Entity에 대한 책임을 가지고 있다.
하지만 기존의 서비스 로직 중, 두 가지 클래스만이 이질적인 형태를 가지고 있었다.
- ChatMessageStatusService: RDB와 Redis의 동일 도메인 조율
- ChatNotificationCoordinatorService: 여러 도메인 서비스의 조합
1️⃣ 단일 도메인, 다중 인프라
@Slf4j
@DomainService
@RequiredArgsConstructor
public class ChatMessageStatusService {
private final ChatMessageStatusRdbService rdbService;
private final ChatMessageStatusRedisService redisService;
...
}
- RdbService와 RedisService를 각각 주입하여, 내부적으로 캐시 정책을 표현한다.
- 세부 기술 규칙은 각 인프라스트럭처 Service에서 책임을 갖는다.
- RdbService
- Entity 생명주기 관리
- 영속성 컨텍스트 관리
- 벌크 연산 처리
- RedisService
- 데이터 저장 방식 (Hash, Set, SortedSet 등)
- TTL 정책
- RdbService
- 이를 통해, 도메인 서비스는 순수한 비즈니스 규칙에만 집중하도록 돕는다.
2️⃣ 도메인 간 협력 서비스
@Slf4j
@DomainService
@RequiredArgsConstructor
public class ChatNotificationCoordinatorService {
private final UserRdbService userRdbService; // User 도메인
private final ChatMemberRdbService chatMemberRdbService; // Chat 도메인
private final DeviceTokenRdbService deviceTokenRdbService; // Device 도메인
private final UserSessionRedisService userSessionRedisService; // Session 도메인
...
}
- 도메인 간 경계 관리
- 각 도메인의 독립성 보장
- 명시적 의존 관계 표현
- 도메인 간 결합도 최소화
- 복잡한 비즈니스 시나리오 캡슐화
- "채팅 메시지 푸시 알림을 보낼 사용자 판별"이라는 복잡한 고수준 정책을 한 곳에서 표현 가능
- "뛰어난 도구, 멍청한 사용자"에 기반하여, 더 이상 도메인 규칙이 하위 모듈로 노출되지 않는다.
📌 Test Strategy by Layer
이 구조의 가장 큰 장점은 테스트 케이스 작성을 할 때, "무엇"에 포커싱해야 하는 지 매우 명확해졌다는 점.
- Domain Service Test
- 핵심 비즈니스 규칙에 대한 단위 테스트
- 로우 레벨 영역은 DAO 테스트로 검증되었음을 가정하고, 비즈니스 규칙에 대해서만 테스트하면 된다.
- DAO Test
- domain-rdb, domain-redis 등에서 각자 저장소 연동 테스트 수행
- 저장소별 독립적인 테스트 환경을 구축할 수 있으며, 더 이상 기존의 jpa 스캔 대상에 redis가 올라가는 등의 현상이 발생하지 않는다.
- Service Logic Test
- api 모듈이라면, Controller 단위 테스트와 UseCase 단위 테스트를 수행
- 마지막으로 통합 테스트로 최종 동작에 대한 검증을 수행
6. Advantages and Limitations of the Improved Architecture
📌 Achieved Objectives
처음부터 완벽하지 않은 결과일 것임을 예상했고, 실제로도 그렇다.
이제 막 리팩토링을 끝낸 참이라 아직은 뭐가 더 좋아졌다고 말하기도 어려운 상황이지만, 적어도 수정을 하면서 많은 것들을 느낄 수 있었다.
(과거의 나는 테스트 케이스를 왜 그따구로 작성했을까...)
- DAO 계층에 대한 테스트가 명확하게 쉬워졌음을 느낄 수 있다. 더 이상 Redis, Jpa 테스트를 추가할 때마다, 두 녀석의 충돌을 고려할 이유가 없어졌다는 게 너무 행복하다.
- "이 로직이 왜 여기 있지?", "이 규칙은 어디 정의해야 하지?"라는 의문은 전부터 들었고, 나름대로 개선은 하고 있었으나, 이를 구조적으로 완벽하게 표현할 수 있는 기반을 마련했다는 게 가장 뿌듯하다. (사혼의 도메인 규칙들아~ 이제는 싸우지 말고 개발자 말 잘 들어야 한다~)
- 사실 지금도 규칙이 추가될 때 수정 위치가 상당히 명확한 편인데, 이렇게까지 함으로써 유지/보수 관점에선 상당히 향상될 거라 추측할 수 있다. 다만, 새로운 기능을 구현할 때는 좀 귀찮아졌다.
📌 Current Limitations
처음 리팩토링 전략을 수립할 때 이야기했듯, 아직은 Phase1까지밖에 진행하지 않았다.
하지만 그 외에도 몇 가지 문제는 더 남아있다.
- 구조만 수정했을 뿐, 여전히 핵심 비즈니스 로직은 하위 모듈에 유출된 상태로 남아있다. 언젠가는 해결해야 할 숙제.
- Domain Logic에게서 Data Source를 완전히 감추지는 못 했다. 이걸 하고 싶으면, 차라리 Infrastructure 모듈을 의존하도록 만드는 게 나을 것이다.
📌 Future Development Directions
뭐, 점진적 개선이야 꾸준히 하면 되는데, 하다가 몇 가지 재밌는 아이디어들을 떠올렸다.
우선, 정말 단순하기 그지없는 CRUD 기능은 오히려 domain-service 모듈이 추가되면서, 구조만 더 복잡해진 꼴이 된 상황이다.
하지만 이는 지금까지 이유도 모른 채, "양 쪽의 모듈 모두 service를 가지고 있어야 한다"라는 강박 때문이었을지 모른다.
이로 인해, application 모듈과 domain 모듈 양 측 모두 무기력한 도메인 모델 상태에 빠져버린 경우가 한두 개가 아니다.
그러나, "현재 상황에서 봤을 때" 매우 단순한 기능이라면, domain-service 모듈에서만 서비스를 구현하고
이를 application의 UseCase에서 바로 의존하도록 만들어도 크게 상관이 없지 않나? 싶다.
어차피 파사드 패턴으로 전부 분리해놓기도 했고, 나중에 더 세밀한 제어가 필요할 때 수정해도 아무런 영향을 전파하지 않는다.
즉, 이것이야 말로 "결정되지 않은 아키텍처를 최대화하라"라는 말과 일맥상통하는 이야기가 아닐까 싶다. (그저 내 생각이긴 하다만..)
여튼 이번 리팩토링을 간략하게 말하고 끝내자면,
이전에 싱글 모듈에서 멀티 모듈로 마이그레이션 할 때는 관심사에 대해 전혀 고려하지 않고 코드를 작성해둔데다, 무턱대고 마이그레이션을 시도하는 바람에 거의 일주일이라는 시간을 허비했었다.
그러나 이번에는 기존에 어느정도 유연성을 갖춘 구조를 마련하고, 체계적인 접근법으로 시도하면서 고작 3일(최근 취준으로 바빠서 시간으로 따시면 2일도 채 안 됐다)만에 마이그레이션을 끝내버렸다. ㅋㅋ
domain 규칙을 표현하기 위한 구조적 개선도 개선이지만, 지금까지 설계를 열심히 고려하며 작업한 것이 상당히 유의미했던 것 같아서 뿌듯~~