💡 해당 포스트는 필자의 빈약한 이해 지식을 기반으로 한 프로젝트 멀티 모듈화입니다.
개발이 진행됨에 따라 추후 지속적으로 내용이 변경될 수 있으며, 혹시나 본인의 프로젝트에도 반영을 하실 거라면 아래 첨부해둔 영상과 포스팅들을 보시는 게 훨씬 도움이 됩니다.
일시 | 설명 |
`24.02.03 | • 포스팅 작성 |
`24.03.15 | • jwt 모듈 변경 |
`24.04.02 | • 다른 진행 중인 프로젝트 링크 업로드 • @SpringBootApplication 테스트 환경 충돌 제어 |
`24.04.30 | • Soft Delete 정책과 @SQLRestriction, 멀티 모듈 |
`24.06.04 | • 더 고민해야 할 것들: Domain 모듈의 OpenAPI 의존 문제 • 더 고민해야 할 것들: UseCase와 파사드 패턴의 적용 |
`24.09.05 | • "가장 보호받아야 하는 영역"의 정의 |
`24.10.11 | • ConfigImportSelector, 하위 모듈에서 Config 의존성을 제어할 수 있게 하는 방법의 중요성 |
`24.11.05 | • 도메인 비즈니스 규칙과 멀티 모듈 아키텍처 설계 |
`24.11.26 | • 다중 인프라에 관심을 갖는 도메인 모듈의 분리 |
`24.12.03 | • 효과적인 도메인 모델 전략 구상 |
📕 목차
1. 서론
2. 어떻게 모듈을 분리할 것인가?
3. 멀티 모듈화
4. Refactoring
5. 더 고민해야할 것들
6. 알게 된 점
1. 서론
최근 진행 중인 프로젝트에서 하나의 모듈에 모든 기능을 작성하고 있었다.
비록 백엔드 1인 개발이지만, 개발이 진행됨에 따라 점차 규모가 커지고 추후 유지 보수 관점까지 고려했을 때 '내가 이걸 손댈 수 있을까?'라는 불확실성이 자리잡기 시작했다.
기능이 얼마 구현되지 않았을 때는 디렉토리 구조만 잘 신경써도 해결이 됐었지만 지금은 아니다.
혼자서 모든 코드를 작성하므로 그나마 아직까진 버틸만 하지만 이 코드를 당장 일주일 후의 내가 다시 본다면, 지금처럼 모든 위치를 파악하고 찾아낼 수 있을까?
지금 당장은 쉽고 빠르게 개발이 진행되고 있지만, 점차 스파게트 코드가 되어가는 것을 내가 막을 수 있을까?
당연히 불가능하다는 판단에 이르렀고, 개발이 더 진행되기 전에 책임과 역할에 따른 모듈 분리 작업을 시작하기로 결심했다.
물론 MSA는 아니고, 모놀리식 아키텍처는 그대로 유지할 생각이다.
계기는 코드 유지보수에서 비롯했지만, 점점 기능과 아키텍처에 대해 생각해볼 수 있었다.
- 숲 - 아키텍처
- 나무 - 기능
- 잎 - 코드
내가 그러했듯, 도움이 필요해 이 포스팅을 보게 되실 분들 또한 별 도움이 안 되실 거라는 거 알지만
최대한 마이그레이션 하면서 생각한 고민 과정과 나름의 차선책들을 열심히 써볼 테니, 한 명이라도 도움이 되길 바랍니다. ㅎㅎ
자자, 이제 험난한 여정을 떠나봅시다.
작업 사항은 아래에서 확인 가능합니다.
위 프로젝트는 디자이너의 UX 욕구를 충족시켜주느라 정책을 완전히 갈아 엎게 생겨서...한동안 개발 진행이 없을 예정.
대신 다른 프로젝트에서 멀티 모듈화를 진행하고 있다.
아예 처음부터 설계를 하고 시작해서 훨씬 깔끔하다(고 믿고 있다).
2. 어떻게 모듈을 분리할 것인가?
• 도입
• 핵심 포인트들 정리 (주관적 생각들)
• 우아한 module 규칙
📌 도입
처음엔 정말 많은 블로그들을 찾아봤는데, 대부분 멀티 모듈화를 하는 방법에 대해 기술되어 있었다.
물론 그것도 도움이 되지만, 내가 가장 궁금했던 건 "어떤 기준으로 모듈화를 진행할 것이냐"였다.
아래는 내가 참고한 영상과 게시글들을 간략하게 정리해보았다.
그런데 보다보니 대부분 MSA 아키텍처로 진행하시는 것 같아서, 어느정도 구분해서 들어야 할 필요가 있다.
내가 정리한 건 매우매우 간결하게 한 것이므로, 꼭 영상 한 번씩 시청해보는 것을 권장한다.
🟡 실전! 멀티 모듈 프로젝트 구조와 설계 (2022 인프콘, 네이버 김대성님)
- 멀티 모듈 프로젝트란?
- 서비스 도메인 기반 구조 (X)
- 기술 베이스 기반 구조 (O)
- 하지만 기술 베이스 구조로 가면 모듈 간 종속성 문제가 발생하게 됨
- Core, Common 모듈 일단 다 삭제하고 시작하라
- 일부 코드 중복을 허용하는 것이, 허용하지 않았을 때의 잠재적 위험성보다 크다.
- DDD Bounded Context
- Context 문맥 하에서 완전한 의미를 나눈 기준으로 나눠야 한다.
- 예시
- BOOT(Server)
- batch, admin, api
- 잦은 변화
- DATA(Domain)
- meta, user, chart
- Server 모듈과 매우 밀접
- 연동모듈(Infrastructure)
- and, vod, photo, billing
- 유관 부서 및 업체 연결
- 구현할 때는 괜찮은데, 버전업 될 때 큰 변화
- Cloud(System)
- config, gateway, discovery
- aws, gcp, azure
- 서버 관리를 위한 그룹 (컨테이너 환경, 트래픽 관리)
- 변화 적음
- BOOT(Server)
- gradle에서 그룹 폴더명을 기준으로 정의할 기술들을 일괄적으로 선언할 수 있다.
- 모듈을 한 번 나누고, 모듈 특성에 맞게 더 분리해보면 build 시간 감소와 인터페이스 분리가 명확해진다.
- Service Layer는 어디에 위치해야 하는가?
- BOOT와 DATA 양 쪽에 모두 존재해야 한다.
- BOOT의 service에서 Event가 발생했을 때, DATA의 service가 저장해야 한다.
- 단, boot-meta와 data-meta 프로젝트 간에 Survlet Request와 같은 웹 서버 의존적인 객체를 전달해서는 안 된다. (R&R 위배)
- data-meta에서 웹 개발 의존성이 강하게 주입되면, TDD할 때 웹 서버 관련 라이브러리가 모두 필요해지므로 data-meta라는 의미가 상실된다.
- 요약
- "왜" 멀티 모듈 프로젝트 구조가 중요할까?
- 잘못 구성되면 나중에 변경하기 고통스럽다
- 프로젝트 초기에 이루어져야 하는 일련의 설계 과정이다.
- 개발 생산성에 막대한 영향을 미친다.
- "무엇"을 기준으로 멀티 모듈 프로젝트 구조를 나누어야 하는가?
- 경계 안에서 의미를 가질 수 있는 그룹을 나누는 것이 가장 중요하다. (Bounded Context)
- 역할, 책임, 협력 관계가 올바른지 다시 생각한다.
- "어떻게" 실전 모듈 프로젝트를 구현해야 하는가?
- 프로젝트가 커지고 있다면 다시 경계를 나누고 그 기준으로 소스 저장소를 분리한다.
- INFRA(외부) 라이브러리에는 DATA 관련 구현을 지향한다.
- Service 구현은 각자 역할에 맞게 각각 구현될 수 있다. (공통으로 한 쪽에만 구현하지 마라)
- 시스템 레벨 구현이 실제 서비스 애플리케이션과 밀접하게 연관되지 않도록 격리하거나 전환하라.
- "왜" 멀티 모듈 프로젝트 구조가 중요할까?
🟡 우리는 이렇게 모듈을 나눴어요: 멀티 모듈을 설계하는 또 다른 관점 (2023 인프콘, 네이버 클라우드 조민규님)
- Common 모듈의 문제점
- As-is. 의존성 관리, Entity, Application, Service 계층을 다루고 있었음.
- 작업 수행 및 히스토리 파악 어려움
- 서비스 분리 어려움
- SRP 위반하기 쉬움
- Common의 변경 어려움 및 배포 의존성
- 개발을 시작할 때 무엇부터 해야할까?
- 테이블 설계부터 시작? 막상 해보니 구조가 안 맞아서 엎어본 적 있지 않은가?
⇒ 테이블 설계부터 하는 게 과연 올바른 절차였을까? - 데이터베이스, 테이블, 캐시 AOP 등 중요하지만 가장 중요한 것은 아닐 수도 있다.
⇒ 세부 사항에 가깝다. - 핵심 비지니스 로직과 유즈 케이스가 중요하다. (자세한 내용은 위의 영상이나 블로그를 통해 :) )
- 핵심 비지니스 로직: 시스템 유/무와 상관없이 존재하는 비지니스 로직. 핵심 비지니스 데이터만을 필요로 한다. (둘을 묶어서 Domain Entity라고 부를 수 있다.)
- 유즈케이스: 시스템이 있어야 유효한 로직
- 테이블 설계부터 시작? 막상 해보니 구조가 안 맞아서 엎어본 적 있지 않은가?
- global-utils: 도메인에 무관한 코드는 거의 없으므로, "변경이 거의 없는 모듈"에 해당한다.
- appstore-core
- Domain과 Infra 영역은 다른 수준(Level) = 다른 변경 속도
- Level: 입/출력까지의 거리
- 입력: HTTP, 웹소켓
- 출력: 캐시, 데이터베이스 등 - 고수준
- 입력과 출력으로부터 먼 것
- 비지니스 요구사항에 의해 수시로 변경되는 부분
- Domain 영역에 해당한다. - 저수준
- 입력과 출력으로부터 가까운 것
- 새로운 환경 또는 보안 취약점 등에 의해 특정 시점에 변경
- DB Migration 등의 작업을 할 때 변경
- Use case가 공통 모듈에 존재하면 side effect가 따른다.
- SRP 위배로 인한 문제
- 모두 제외했을 때 중복인 코드가 보인다면 우발적 중복(Accidental Duplication, 실제 Actor가 다르지만 중복처럼 보이는 코드)인지 의심해보라.
- 진짜 중복인 코드들이 보인다면, 중복을 허용하거나 공통 API를 만들어서 처리해라
- 절대로 코드를 공유하지 마라.
- ComponentScan
- Spring Boot는 Default로 사용하지도 않는 하나의 클래스가 존재하기만 하더라도 Component를 등록시켜버린다.
- Enable 어노테이션을 사용하여 동적 구성 요소 선택을 하여 인프라 구성을 제어할 수 있다.
- 외부에서 쓸 일이 없는 package는 package private로 막아버려라.
🟡 멀티 모듈 설계 이야기 with Spring, Gradle (우아한 테크 세미나, 우아한 형제들 권용근님)
위에서 했던 이야기들과 비슷한데 Common으로 인한 문제점을 보다 명확하게 설명해주셨다.
특히 모듈 간의 제약을 두어 의존성 방향을 시각적으로 표현해주셔서 정말 너무너무 도움이 된 자료.
📌 핵심 포인트들 정리 (주관적 생각들)
A good architecture allows you to defer critical decisions.
(좋은 아키텍트는 결정되지 않은 사항들을 최대화한다.)
- Robert C. Martin -
영상을 보면서 이해가 가는 부분도 있었고, 당췌 무슨 소린지 모르겠는 부분도 있었고, 지금은 모르겠지만 이해하고 있다고 착각하고 있는 부분들도 많을 것이다.
그런데 저런 실력자 분들도 모두 시행착오를 통해 이런 결과를 도출해내셨는데, 내가 뭐라고 한 번에 완벽한 성공을 바라겠는가.
최대한 이해한 내용들을 간추려서 바로 설계와 분리 작업을 하고 프로젝트에 반영하러 가자!!
머리 너무 많이 깨져봐서 이제 무섭지도 않다.
- 서비스 도메인 구조가 아닌 기술 베이스 구조 기반으로 설계
- 대부분 프로젝트를 시작할 때 domains를 나열하는데, 이게 아니라 AOP 관점으로 보는 것이 좋다.
- 코어 기능: 서비스 기능. 사용자가 관심이 많은 부분들
- 크로스 기능: 공통으로 쓰이는 기능. 개발자가 관심이 많은 부분들
- 중복을 일부 허용
- Common 모듈 이름 들었을 때부터 마음에 안 들었던 게, 현재도 모듈 내 common 패키지가 있지만 사실상 "잡동사니를 쌓아놓는 곳"으로 전락하고 있다.
- 이렇게 모듈화를 해버리면 사용하지도 않으면서, common에 의존적인 모든 모듈이 라이브러리를 빌드하게 된다.
- DB 관련 설정까지 있을 경우, 쓸 데 없이 connection pool을 차지하면서 서비스 이용에 지장이 생길 수도 있다.
- 따라서 우발 중복인지 확인하고, 실제 중복이라 하더라도 되도록 중복을 허용하는 편이 낫다.
- 하지만 Common을 아예 다 없애는 게 과연 효율적일까? 어떤 외부 라이브러리에 의존하지 않고, 특정 도메인에도 의존적이지 않은 DateUtil과 같은 경우엔 Common에 두어도 좋다고 생각한다. (단, 매우 제한적이어야 한다.)
- Service Layer
- 예전에 Service Layer를 Facade Pattern으로 분리했었는데, 여기서 상위 서비스 이름을 UseCase로 바꾸고 하위 서비스를 SRP에 맞게 분리하려고 한다.
- 현재 내 프로젝트에선 상위 서비스의 이름이 하위 서비스의 이름과 동일한 메서드가 빈번한데, 이 참에 전부 뜯어고칠 예정
- UseCase마저 전부 기능 별로 분리한 경우(ex. LoginUseCare, SignUpUseCase)를 봤는데, 모두 권한과 밀접한 기능들이므로 AuthService에 메서드명으로 분리해주는 게 나을 것이라 생각된다.
- Use case는 application 모듈에, 하위 서비스 계층은 domain 모듈로 옮기려고 한다.
- README
- 잘 분리했다면 모든 Module은 독립적인 기능을 가져야 한다.
- 따라서 module 마다 README를 작성하여, 모듈의 설명과 사용법을 명시하라.
✒️ Domain: 가장 보호받아야 하는 영역이란
멀티 모듈을 공부할 때 가장 잘못 이해하고 있던 말이, Domain 영역은 견고하고 가장 보호받아야 한다는 것이었다.
보호받아야 한다는 게 무슨 말일까? 나는 이걸 보안 관점에서 이야기하는 줄 알았다. 😂
우리의 데이터는 너무 소중하니까, 이 값들이 함부로 노출되거나 해서는 안 되기 때문에 외부 노출을 줄여야 한다고 이해했는데 막상 지키려니 뭘 어떻게 보호하라는 건지 알 수가 없었다.
그러나 클린 아키텍처를 다시 공부하면서, "가장 보호받아야 한다"는 말은 곧 "외부에 의존하는 곳이 없어야 함"을 의미한다는 것을 알게 되었다.
예를 들어, A클래스가 구체 클래스 B를 의존할 때, B의 수정이 A에게 영향을 줄 수 있다.
단적인 예로 아무런 아키텍처를 적용하지 않은 우리 iOS팀의 코드는 의존도가 "View → ViewModel → Repository → API"로 흘러가는데, 덕분에 서버의 변경 사항이 iOS 팀의 View 코드 수정까지 변경이 전파되는 현상이 일어난다.
시스템의 전체 상태를 표현하는 Domain 영역이 외부 Actor(DB, 혹은 다른 API)에 의존한다면 어떻게 될까?
자칫하면, 정책은 변하지 않았음에도 MySQL의 스펙이 수정되거나, 외부 API 스펙의 변경이 Domain 영역에 전파되어 수정을 강제할 수도 있다.
이러한 관점에서 의존성의 방향을 모두 Domain 방향으로 향하도록 설계해야 하며, 유일하게 의존하는 Common 모듈에 매우 엄격한 규칙을 적용한 것이 이러한 이유 때문이다.
(Common이 외부 API 등에 의존하게 되면, 추이 종속성에 의해 결국 Domain에 변경이 전파될 수 있다.)
📌 우아한 module 규칙
내 프로젝트는 아직 Infra 관련 설정은 커녕, Batch조차 없기 때문에 김대성님의 아이디어가 매우 감명깊긴 했지만 내 프로젝트에 적용하기엔 너무 아리송하다는 문제가 있었다.
따라서 권용근님의 아이디어를 메인으로 분리하고, 다른 영상에서 나온 방법들을 믹싱해보려 한다.
우아한 테크 세미나에서 이야기한 모듈의 규칙은 다음과 같다.
- 독립 모듈 계층(independently available)
- 시스템과 무관하게 어디서나 사용 가능한 라이브러리 성격의 모듈
- 자체로서 독립적인 역할을 갖는다. (어떤 프로젝트에서도 의존 관계를 두어선 안 된다.)
- 사실상 나는 쓸 일이 없다. ㅎㅎ
- 공통 모듈 계층(system core)
- 하나의 프로젝트에서 모든 모듈에서 사용될 수밖에 없는 것들
- Type, Util 등을 정의한다.
- 가능하면 사용하지 않는다.
- 프로젝트 내 어떠한 모듈도 의존해서는 안 된다. (웬만하면 외부도)
- 도메인 모듈 계층(system domain)
- 서비스 비지니스를 모른다.
- 하나의 모듈은 최대 하나의 Infrastructure에 대한 책임만을 갖는다.
- 도메인 모듈을 조합한 더 큰 단위의 도메인 모듈이 존재할 수 있다.
- 단일 인프라스트럭처 사용 모듈 (나는 다중이 아니므로 단일만)
- Domain
- Java Class로 표현된 도메인 Class들 - Repository
- 도메인 조회, 저장, 수정, 삭제
- 시스템에서 가장 보호받아야 하고 견고해야 한다.
- 구현하려는 기능이 중심 역할이라면 도메인 모듈, 아니라면 사용하는 측에서 작성하도록 만드는 것이 좋다. - Domain Service
- Domain의 비지니스 책임
- Domain의 비지니스가 단순하면 생기지 않을 수도 있다.
- 트랜잭션의 단위, 요청 데이터 검증, 이벤트 발생 등의 비지니스로 사용
- Domain
- 내부 모듈 계층(in system available)
- 저장소, 도메인 외 시스템에서 필요한 모듈들
- 애플리케이션, 도메인 비지니스를 모른다.
- 전체적인 시스템 서포트를 위한 기능 모듈이 만들어질 수 있다.
- web, client, event-publisher 등을 처리할 때 사용 (scheduling을 할 때 여기에 정의하면 되려나???)
- 애플리케이션 모듈 계층(application)
- batch, worker, internal-api, external-api 등의 모듈이 존재
- 사용성에 따라 다른 모든 계층에 의존성을 추가하여 사용할 수 있다.
📌 최종 멀티 모듈 구조
내가 생각한 멀티 모듈은 다음과 같다.
참고로 프로젝트 진행하면서 수정 작업을 너무 많이 해서 잡소리가 길다.
- {project-name}-app-external-api
- 모든 모듈에 의존
- 웹 및 security 관련 라이브러리 의존성 주입
⇒ 다른 분들은 config 설정을 위한 security, 혹은jwt 라이브러리를 common에서 가져오던데 '굳이?' 싶어서 app 모듈로 모두 끌고 왔다.
=> 그런데 하다보니 oauth 때문에 infra에서 jwt를 사용한다.. - Swagger 라이브러리 의존성 주입
- 일반적으로 많이 알고 있는 controller, 요청에 대한 dto, helper 클래스 등이 위치한다. service는 상위 컴포넌트의 이름을 UseCase로 수정하여 메서드 명을 더 Client 입장에서 명확하도록 만든다.
- security도 여기에 넣어버릴 생각
- {project-name}-domain
- common 모듈만을 의존
- mysql과 jpa, queryDsl 의존성 주입 + redis 관련 유틸
- 기존에 만들어둔 커스텀 예외 클래스가 web 라이브러리의 HttpStatus를 사용해서 변경이 필요하다.
- Entity 클래스들과 핵심 비지니스 로직에 관여하지 않는 모듈 Service들을 정의한다.
⇒ 이게 정말 좋은 방법일까 많이 고민해봤는데, repository는 서비스 내에서 가장 견고해야 하기 때문에 protected 범위로 제한해버리고 외부 모듈은 반드시 모듈 Service만을 의존하도록 만드는 게 좋다고 생각했다.
- {project-name}-infra
- common 모듈만을 의존
- 여기에 당췌 뭘 넣어야 할 지 모르겠어서, 일단 외부 api에 요청을 보내거나,
redis 관련 라이브러리의존성 주입
⇒ 많은 생각을 해봤는데, redis도 어쨌든 cache memory에 저장하기 위한 domain이 존재하고 보호받아야 한다고 생각했다. infra에 넣기엔 다소 영역이 부적절한 것 같아서 domain으로 변경했다. jackson 관련 라이브러리 주입 (외부 요청을 infra에서 다 보낼 것 같으면, 다른 곳에선 필요 없음)뭔가 infra 모듈 내에서 또 client와 redis로 나누어 보면 좋을 거 같긴 한데, 이게 맞나..😅
- {project-name}-common
- 어떠한 모듈도 의존하지 않음
- 전역적으로 많이 사용하는 jackson 라이브러리 주입
- 하나의 모듈에서 관리할 때와 달리 예외 처리 클래스를 다루기가 매우 까다로워졌다. 각 모듈 별로 따로 관리하려 했으나, 너무 변수가 많아져서 예외 인터페이스를 정의하여 추상화 시키는 게 좋을 듯 하다.
- jackson -> common으로 이사왔습니다.
위에서 그렇게 연구해놓고, 결과를 보니 띠용스럽긴 하지만 이런 결과가 도출된 이유는 다음과 같다.
- 여러 강의에서 이야기하던 아키텍처를 내 프로젝트에 그대로 적용시키기엔 너무 과하다.
- 추후 batch를 적용할 걸 감안하여 application 모듈 하위에 external-api를 넣는 방법도 있겠지만, 내가 멀티 모듈화가 처음이라 기본 세팅조차 익숙하질 않다.
- 나는 MSA가 아닐 뿐더러 단순하기 그지 없는 프로젝트라 internal-api 또한 필요하지 않아서 최상단에 external-api 모듈을 두었다. (그래도 추후 확장을 대비하여 네이밍은 external-api라고 지었다.)
3. 멀티 모듈화
• 모듈 생성과 설정
• 하위 모듈 build.gradle 설정 (As-is)
다음 주제를 보면 아시겠지만, 문제가 많은 세팅입니다.
참고로만 봐주세요
📌 모듈 생성과 설정
이미 모듈을 생성한 상태라 노란줄이 뜨긴 하는데, 여튼 최상위 모듈에 등록시킬 모듈들을 차례로 생성한다.
여기서 Parent를 최상위 모듈로 지정을 해주면 된다. (안 해줬으면 밑에서 나올 파일에서 직접 작성해주면 된다.)
// settings.gradle
rootProject.name = 'fitapet'
include 'fitapet-app-external-api'
include 'fitapet-domain'
include 'fitapet-infra'
include 'fitapet-common'
root 디렉토리에 생성한 모듈을 include 하는 구문이 알아서 추가된다.
없으면 직접 작성하면 된다.
buildscript {
repositories {
mavenCentral()
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
}
bootJar {enabled = false}
jar {enabled = true}
allprojects {
group = 'kr.co'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
}
subprojects {
apply plugin: "java"
apply plugin: 'java-library'
apply plugin: "io.spring.dependency-management"
apply plugin: "org.springframework.boot"
repositories {
mavenCentral()
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
}
- subprojects: setting.gradle에서 include된 모든 프로젝트에 공통적으로 적용할 설정
- bootJar(dependencies와 클래스 파일을 모두 묶어서 빌드) 설정은 꺼주고, jar(클래스만 묶어서 빌드)만을 허용해준다.
📌 하위 모듈 build.gradle 설정 (As-is)
✒️ api vs. implementation
상위 모듈에서 주입한 라이브러리가 하위 모듈의 의존성에 등록되지 않는 문제가 발생했다.
이유를 찾아보니 api로 외부 라이브러리의 의존성을 받아야, 해당 모듈을 의존하는 하위 모듈에서도 라이브러리 함수에 접근이 가능하다.
Implementation은 컴파일 시간에 종속 항목에게 누출되는 것을 제한하기 때문이다.
(런타임에는 결국 모두 올라가게 되어있다.)
다만 이렇게 되면 의도하지 않은 하위 모듈에서도 외부 라이브러리에 접근 가능해지므로 코드 의존성이 생긴다.
따라서 상위 모듈에 api 키워드를 사용할 때는 하위 모듈에 의존성이 생기더라도 괜찮은 지 신중하게 검토하고, 안 된다면 따로 implementation을 사용하는 게 낫다고 생각한다.
해당 블로그에서 아주 잘 설명해주고 있으니 읽어보면 이해가 될 것이다.
🟡 app-external-api
plugins {
id 'java'
}
bootJar {enabled = true}
jar {enabled = false}
group = 'kr.co'
version = 'unspecified'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
/* jwt */
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
implementation project(':fitapet-common')
implementation project(':fitapet-domain')
implementation project(':fitapet-infra')
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
🟡 domain
bootJar {enabled = false}
jar {enabled = true}
dependencies {
api 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java:8.0.28'
/* QueryDSL */
implementation 'com.querydsl:querydsl-core'
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
/* redis */
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
/* jackson */
implementation 'com.fasterxml.jackson.core:jackson-core:2.13.5'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5'
/* Swagger */
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
implementation project(':fitapet-common')
}
test {
useJUnitPlatform()
}
def querydslDir = 'src/main/generated'
sourceSets {
main.java.srcDirs += [querydslDir]
}
configurations {
querydsl.extendsFrom compileClasspath
}
tasks.withType(JavaCompile).configureEach {
options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}
clean.doLast {
file(querydslDir).deleteDir()
}
🟡 infra
bootJar {enabled = false}
jar {enabled = true}
dependencies {
testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
/* jwt */
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
/* httpclient */
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'
implementation 'org.apache.httpcomponents:httpcore:4.4.14'
/* redis */
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
/* feign */
implementation platform("org.springframework.cloud:spring-cloud-dependencies:2022.0.3")
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'io.github.openfeign:feign-okhttp'
/* jackson */
implementation 'com.fasterxml.jackson.core:jackson-core:2.13.5'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.5'
implementation project(':fitapet-common')
}
🟡 common
bootJar {enabled = false}
jar {enabled = true}
dependencies {
// implementation 'org.springframework.boot:spring-boot-starter-parent:2.7.10'
implementation 'org.springframework.boot:spring-boot-starter-aop'
/* Jackson */
api 'com.fasterxml.jackson.core:jackson-annotations:2.10.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
}
📌 실행
# 전체 빌드 (test 제외)
./gradlew build --stacktrace --info -x test
# 특정 모듈 빌드
# main 없는 경우 (common, domain, infra)
./gradlew :{project-name}-domain:build
# main 있는 경우 (api)
./gradlew :{project-name}-domain:bootJar
4. Refactoring
📌 ErrorCode
🟡 As-is
public interface StatusCode {
HttpStatus getHttpStatus();
String getMessage();
String getName();
}
기존의 ErrorCode 포맷 응답을 통일 시키기 위해 위와 같은 인터페이스를 사용했다.
문제는 HttpStatus가 web 라이브러리에 속하기 때문에 common 모듈에 넣자마자 컴파일 문제가 발생했다.
모듈 간에 web 관련 라이브러리가 의존하는 데이터가 오고가면, 모든 모듈에 web 라이브러리에 의존성이 발생하기 때문에 멀티 모듈화의 의미가 퇴색되기 때문에 해당 인터페이스를 모두 수정해줄 필요가 있었다.
🟡 To-be
public record CausedBy(
int code,
String name,
String message
) {
public static CausedBy of(int code, String name, String message) {
return new CausedBy(code, name, message);
}
}
public interface BaseErrorCode {
CausedBy causedBy();
String getExplainError() throws NoSuchFieldError;
}
@Getter
public class GlobalCodeException extends RuntimeException {
private final BaseErrorCode baseErrorCode;
public GlobalCodeException(BaseErrorCode baseErrorCode) {
super(baseErrorCode.causedBy().message());
this.baseErrorCode = baseErrorCode;
}
public CausedBy causedBy() {
return baseErrorCode.causedBy();
}
}
전역적으로 많이 사용하는 기본 에러 코드 상수를 enum 타입에 정의하고, 예외 클래스를 보다 직관적으로 처리했다.
📌 SpringBoot data를 api가 의존해도 되는가?
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthorAwareAudit implements AuditorAware<Member> {
private final EntityManager em;
@Override
public Optional<Member> getCurrentAuditor() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) {
return Optional.empty();
}
Long userId = ((CustomUserDetails) authentication.getPrincipal()).getUserId();
return Optional.ofNullable(em.getReference(Member.class, userId));
}
}
위 코드는 Table에 작성자, 수정자 필드를 자동으로 채우기 위한 springboot.data.domain의 AuditorAware를 구현한 클래스다.
당연히 Security는 Api에서 처리하고 있으니 Domain에서 호출할 수 없었고, 그렇다고 Api로 내리자니 Domain의 springboot.data를 의존하지 못 하도록 막아버린 상태여서 어느 곳에서도 사용할 수 없는 현상이 발생했다.
단순하게 생각하면 해당 로직을 API로 내리고, Domain에서 data-jpa 라이브러리를 implementation이 아니라 api로 수정하면 된다.
하지만 처음 원했던 방식은 DB에 접속하는 기능은 모두 Domain으로만 처리하고 싶었기에 api 모듈에게도 의존성을 주입해주는 것이 옳은지에 대한 고민을 많이 했다.
아쉽게도 명쾌한 답을 구하지는 못 했다.
결과적으로는 api 키워드로 하위 모듈에서도 data-jpa 의존성을 주입받도록 만들었고,
ComponentScan 방식을 사용하지 않는다면 DB connection full 문제는 해결 가능할 것이라는 희망으로 작업하고 있다.
📌 Domain 모듈과 Redis와 API 모듈의 jwt
@Slf4j
@Service
public class RefreshTokenServiceImpl implements RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProvider jwtAccessTokenProvider;
private final JwtProvider jwtRefreshTokenProvider;
private final Duration refreshTokenExpireTime;
public RefreshTokenServiceImpl(
RefreshTokenRepository refreshTokenRepository,
@AccessTokenQualifier JwtProvider jwtAccessTokenProvider,
@RefreshTokenQualifier JwtProvider jwtRefreshTokenProvider,
@Value("${jwt.token.refresh-expiration-time}") Duration refreshTokenExpireTime)
{
this.refreshTokenRepository = refreshTokenRepository;
this.jwtAccessTokenProvider = jwtAccessTokenProvider;
this.jwtRefreshTokenProvider = jwtRefreshTokenProvider;
this.refreshTokenExpireTime = refreshTokenExpireTime;
}
@Override
public String issueRefreshToken(String accessToken) throws AuthErrorException {
JwtSubInfo subs = jwtAccessTokenProvider.getSubInfoFromToken(accessToken);
final var refreshToken = RefreshToken.builder()
.userId(subs.id())
.token(makeRefreshToken(subs))
.ttl(getExpireTime())
.build();
refreshTokenRepository.save(refreshToken);
log.debug("refresh token issued. : {}", refreshToken);
return refreshToken.getToken();
}
...
}
기존 싱글 모듈에서는 RefreshTokenProvider을 호출하고 Redis에 저장하는 방식이 아닌, RefreshTokenService에서 알아서 AT를 받으면 RT를 발급해주었다.
당연히 jwt를 API 모듈로 내려버렸기에 더이상 Domain 모듈의 RefreshTokenService는 해당 정보를 참조할 수 없게 되었다.
그렇다면 방법은 둘 중 하나였다. 분리하던가 jwt 의존성을 상위 모듈로 옮기던가.
1️⃣ jwtProvider을 Common 모듈로 이관?
Infra 영역에서도 jwt 라이브러리 의존성을 가지고 있었기에, 현재로썬 모든 모듈에 의존 관계가 필요하므로 합당하다다만, common 모듈 영역은 기본적으로 '단독적으로 오픈 소스로 배포가 가능한 정도'의 독립성을 유지하는 것을 Convention으로 정의했었다.그렇다면 jwt 라이브러리에 의존하는 JwtProvider를 Common 모듈로 옮기는 것이 옳은가? 실용성을 위해 이상을 다소 포기할 것인가?
- jjwt 의존성을 common이 갖는 것은 옳지 않다고 생각했다. 왜냐면 common은 모든 모듈이 의존하는 최상위 모듈에 해당하는데, Domain 모듈이 jjwt에 관심사를 두어야 할 필요가 없다고 생각했기 때문이다.
- 그렇다고 api 모듈에만 jjwt 의존성을 주입하는 것도 무리가 있는데, OIDC 정책을 사용하기에 infra 모듈에서도 jjwt 의존성을 주입해주어야 하며, 다수의 api 모듈마다 jwtProvider를 중복 정의하는 것도 필요 이상의 중복 코드를 허용하게 된다.
- 따라서 시스템 내에서 사용할 jwtProvider 인터페이스를 infra 모듈에 정의하고, 각 구현체를 하위 api 모듈에서 구현하도록 만들었다.
2️⃣ Service Layer로 부터 jwt 의존성을 분리
- RefreshTokenService가 애초에 jwt를 의존하는 컴포넌트를 사용하지 않으면 간단하게 해결된다.
- issueRefreshToken()이 아니라 다른 곳에서 생성된 RT를 저장하는 메서드만 지원하게 된다.
- 하위 모듈에서 RT 생성 로직이 위임되며, Client의 요청에 의존하므로 발급한 AT를 기반으로 RT를 생성한다는 일관성이 깨질 수 있다는 잠재적 위험성이 존재한다.
3️⃣ Infra에 jwt 기능을 만들고, domain 모듈이 infra를 의존
- 객체지향의 순수성을 따지자면 domain 모듈이 infra를 알아선 안된다.
- 언제든지 infra 구현체를 바꿀 수 있어야 하는 상태를 유지해야, 아름다운 도메인 구성이 완성된다.
- 하지만 Infrastructure는 기본적으로 특정 시점을 제외하고 변화가 크지 않은 영역에 속한다.
- 그리고 Infra와 domain이 서로 모르는 상태로 존재하면 domain 영역에 수많은 인터페이스를 재정의해야 한다.
- jwt는 정말 많이 쓰인다. 추후 back-office를 구성한다고 해도 jwt가 쓰이긴 할 것이다. 하지만 그렇다고 common에 정의하기엔 분명히 쓰이지 않는 곳도 존재한다.
- 그렇다면 infra에서 jwt 환경을 구성하고, "하나의 모듈 최대 하나의 infrastructure에 대한 책임을 갖는다"라는 조건을 명시하여 domain 모듈이 infra를 의존한다면 어떨까?
3번 방식을 적용하면 모듈 간 의존성은 위와 같은 형태를 갖는다.
만약 추후 Infra 모듈 내의 기능들을 더 세분화하여 모듈화한다면, 위에서 이야기한 domain 영역이 최대 하나의 infrastructure에 대한 책임을 갖도록 만들 수 있을 것이다.
🤔 내가 선택한 방법
모든 방법에 명확한 장단점이 존재했다.
Api 모듈에 token 생성을 위임하려 했으나, 다른 api 모듈이 생긴다고 가정하면 결국 모든 api 모듈에서 JwtProvider 코드를 중복 정의해야 한다는 문제가 발생했다.
물론 어느 정도의 중복은 허용한다지만, 앞으로 api 모듈이 어떻게 확장될 줄 알고 감히 그런 결정을 내릴 수 있을까가 관건이었다.
infra 모듈로 옮기기엔 domain이 고작 jwt 의존하겠답시고 infra 모듈에 의존 관계가 생기는 게 탐탁치 않았다.
순수성이 아닌 실용성을 따져봐도, 과연 domain이 infra 모듈 전체에 의존성이 발생하는 것이 과연 합당한가?
infra를 더 세분화하여 모듈화한다면 나쁘지 않을지도 모르지만, 현재 시점에서 이 이상 구조가 복잡해지는 것은 오버 엔지니어링이라고 밖에 생각할 수 없다.
common에 정의하기엔 또 다른 문제가 있었는데, jwt의 secret key를 분리하기 위하여 인터페이스로 추상화하는 방식을 채택하고 있는 기존 정책이 문제였다.
Access Token, Refresh Token, Sms Token, Oauth Token 총 4가지 종류의 타입이 존재하며, 이를 추상화하기 위해 매개변수로 받는 타입을 아래와 같이 정의해두었었다.
public interface JwtSubInfo {
Long id();
String oauthId();
RoleType role();
String phoneNumber();
}
RoleType은 Domain 영역에 속해있었고, 다른 매개변수는 Token 종류에 따라 메서드를 막아두거나 하는 방식을 적용했는데 그렇게 되면 고수준 모듈이 하위 수준 모듈의 기능에 종속된다는 심각한 오류가 발생했다.
그래서 결국 기존 방식대로 Api 모듈에 Provider를 명시하되, RefreshTokenService 단에서 Domain에 정의된 Refresh 객체 타입으로 매개변수를 받도록 수정하였다.
처음 고민대로 확장성을 고려한다면 분명 옳지 않겠지만, 해당 서비스가 과연 그렇게까지 모듈화를 할 정도로 확장성이 큰가를 따졌을 때 그렇지 않다라는 판단을 했기 때문이다.
📌 DTO 중복 문제
Client로부터 요청을 받고, 응답을 보낼 Dto를 평소에는 아무 생각 없이 관리했었기에 Service가 받거나 반환하는 중간 Dto가 따로 존재할 수도 있고 아닌 경우도 있었다.
특히 QueryDsl을 사용한 응답은 대부분 그 자체로 반환값으로 돌려보내는 경우가 대부분이었기 때문에 Repository의 결과값이 그대로 API의 응답이 되는 경우도 존재했다.
여기서 발생하는 문제는 다음과 같다.
애초에 Domain 영역의 모듈이 하위 모듈의 Repository의 직접적인 접근을 막기 위함이었으므로, Dto를 따로 두는 것 자체는 괜찮다고 생각을 한다.
문제는 Swagger 문서를 위한 @Schema 어노테이션이 API 모듈에 주입되고 있다는 점이다.
그리고 더 나아가 Valid 유효성 검사나 타입 변환을 위한 커스텀 작업을 Domain에서 수행하게 될 텐데 이러한 확장성을 고려해봤을 때, 지금 상태로는 결코 모듈화의 이점을 취할 수 없을 것이라 생각했다.
1️⃣ Domain 모듈에서 OpenApi 의존성을 compileonly로 주입
- 가장 간단한 해결책이지만, 모듈화의 이점 또한 손쉽게 부셔버릴 수 있다.
- Domain의 R&R에 명백히 벗어난 설계에 해당한다.
2️⃣ Swagger 문서화 퀄리티 다운그레이드
- 최악의 선택...멀티 모듈화를 위해서 협업을 무시하는 이기적인 행동이라 생각한다.
3️⃣ Dto 중복 정의
- Domain의 동일한 명세의 Dto를 api 모듈에도 작성한다.
- 일부 기능에 대해서는 어느정도 타협해도 괜찮다고 생각한다.
- 현재 모든 Dto를 중복한다고 치면 관리해야 할 클래스가 너무 많아지고, Domain의 Service 명세가 바뀌면 수정해야 할 코드가 증가한다.
4️⃣ Module Service를 api 모듈로 이관
- 기존의 하위 모듈로부터 repository를 보호한다라는 원칙을 철폐하고, Domain 모듈의 module service도 하위 service로 옮기는 방법
- findById와 같은 범용적으로 사용되는 로직이 api 마다 중복 정의되어야 하며, 네이버 김대성님께서 말씀하신 "공통으로 한 쪽에 service를 구현하지 말라"는 원칙을 어겨야 한다.
5️⃣ API 모듈에서 Repository 빈 주입 허용
- (4)의 방법처럼 기존 convention을 위배하되, 역할을 api 모듈로 넘기는 방법
- 생각해보면 아무리 module service라고 해도, 다양한 api에 대한 요구사항을 매번 새롭게 반영하려면 Domain 모듈의 책임이 과하게 무거워질 수 있다.
- 범용적인 요구사항 외에, 특정 api에서만 요구하는 특수 비지니스 로직에 대해서는 api에서 구현하도록 책임을 분배할 수 있다.
🤔 내가 선택한 방법
(5)의 방법을 채택하되, 조금 더 고민을 해봤다.
따지고 보면, Query의 결과를 Dto로 받고 그대로 응답으로 보내는 건 특수한 경우라고 보는 것이 더 맞다고 생각했다.
그 말은 즉슨 해당 로직을 Domain 모듈에서 관리하는 것이 오히려 적합하지 않은 방법이라 볼 수 있다.
UserService는 Domain 혹인 기본값 그 자체를 반환할 수 있을 정도로 비지니스 로직에 관여하지 않는 메서드만을 남겨두고, 그 외의 작업은 Adapter 클래스를 두어 하위 모듈에서 다루도록 만들었다.
아니, 아닌 것 같다. 역시 repository를 하위 모듈에서 접근 가능하도록 하는 건 리스크가 너무 크다.
애초에 Dto를 사용해서 쿼리 응답을 받아야 한다는 상황 자체에 의문을 가져야 하는 게 옳은 접근인 것 같다.
N+1 문제를 해결하기 위해 Join이 너무 복잡하지 않도록 적당하게 쿼리를 나누어서 DB로부터 결과값을 생성하다보니 QueryDsl의 응답을 Dto로 받고 있다.
API를 분리하여 Domain 단위로 받을 수 있다면 좋기야 하겠지만, Client 입장에서 비동기 작업으로 해결 불가능한 순차 과정이 생기는 순간 Response Time을 보장할 수 없기 때문에 Join을 하긴 해야 한다.
그렇다면 어떻게 해야 하나 한참을 고민하다가 문득 떠올린 게, "openApi 라이브러리가 진짜 web 관련 라이브러리에 의존성을 가지고 있는 걸까?"라는 생각이 들었다.
의존성을 추가할 때 webmvc라는 단어가 들어가는 걸 보고 지레짐작하여 domain 모듈에서 제외하긴 했으나, 아무리 뒤져봐도 web과 관련된 의존성이 추가된 것을 확인할 수 없었다.
그렇다면 문서화를 위해 Domain 모듈에 추가하고, 하위 모듈이 될 app 모듈에서도 의존 가능하도록 만들어도 상관 없지 않을까????????
일단 여기까지가 내 생각. 더 좋은 생각이 나오면 다시 업데이트하러,,,,
당연한 거지만 나도 이 방법이 절대 옳지 못 하다는 것은 인지하고 있으나, 그렇다고 마땅한 해결책이 떠오르질 않았다.
적어도 이렇게 하면 당장의 문제를 제거하면서, 나중에 더 좋은 방법이 떠올랐을 때 리팩토링하기 가장 쉬운 형태로 적용했을 뿐이다.
⚠️ web 전이 종속
openapi가 web을 의존하지 않을리가 ㅎㅎ...막상 모듈화 다 해놓고 찝찝해서 다시 확인해보니 아니나 다를까 경고가 들어와 있었다.
일단 지금 당장 우아한 해결책이 떠오르질 않으니, 추후 다시 리팩토링 해야겠다.
📌 application.yml 주입
상위 application 설정이 하위 모듈에도 적용이 되어야 하는데, 자꾸 값이 제대로 들어가지 않는 경우가 발생했다.
일단 내가 겪은 경우는 다음과 같았다.
1️⃣ application.yml 파일 이름
모든 모듈에서 application.yml 이라고 써놨더니 문제가 발생한다.
이렇게 되면 가장 마지막에 찾은 api 모듈의 application만 반영된다고 한다.
2️⃣ profile과 include
이건 나랑 다른 문제긴 했지만, 계속 실행이 안 되다가 profile이 계속 default로 설정되는 걸 보고 문제가 있음을 알 수 있었다. 간접적으로 도움이 됐던 블로그.
📌 테스트 실행 시, @SpringBootApplication 충돌 이슈
- 하다보니 정말 중요한 개념들을 되짚어 볼 수 있는 기회긴 했지만, 내용이 너무 길어질 거 같아서 별도 포스팅으로 분리할 예정
- 우선 PR에 간략하게 정리한 내용들을 첨부해두었다.
📌 ConfigImportSelector의 필요성
네이버 개발자, 조민규님의 블로그를 확인해보면 ConfigImportSelector를 사용하여, 하위 모듈에서 인프라 구성에 대한 제어권을 획득할 수 있도록 만든 코드를 올려주셨다.
당시만 해도 좋은 건 알겠는데, 팀원들이 이해를 어려워해서 선뜻 반영하기 꺼려졌던 부분이다.
나도 그저 막연히 좋겠구나 싶었지, 구체적으로 필요한 상황까지는 떠올리기 힘들었다.
그러나 팀원들에게 충분한 설득을 하고, (그냥 사용 방법만 익히는 건 전혀 어렵지 않으므로) 무작정 프로젝트에 적용을 했었다.
그리고 이 선택을 하길 정말정말 잘 했다는 생각을 하고 있다.
싱글 레포, 모놀리식 멀티 모듈의 특징은 모든 하위 모듈이 공통의 infra 모듈을 의존하게 된다.
하위 모듈의 공통적으로 모두 HTTP 통신을 하는 애플리케이션이고, 복잡하지 않은 서비스라면 큰 이점을 찾기 힘들다.
그러나 만약, 여기에 socket 모듈까지 올리겠다고 하기 시작한다면?
그리고 socket 모듈에서 외부 브로커(ex. kafka, rabbitmq) 연결을 필요로 한다면?
예를 들어, 각 하위 모듈이 필요로 하는 infra 모듈 내 configuration이 다음과 같다고 하자.
- external-api : FCM, OAuth, GUID generator, RDB, NoSQL
- batch : FCM, RDB
- socket : MessageBrocker, GUID generator, NoSQL
위 설정들은 모두 infra 모듈에 위치해 있고, @Configuration 방식으로 자동 빈 스캔을 설정해버리면
socket 모듈은 필요도 없는 FCM, Oauth 등의 설정까지 의존한다.
external-api는 필요도 없는 MessageBroker와 연결을 시도해야 하고,
socket은 RDB의 커넥션 풀을 잡아먹게 된다.
그리고 이게..테스트를 진짜 귀찮게 만드는데, 하위에서 infra 구성을 제어할 수 있게 해줬을 때의 이점이 매우 커서 유용하게 쓰고 있다.
5. 더 고민해야 할 것들
💡 하다가 해결되는 것들도 갱신하고, 모르는 게 추가되도 갱신할 예정입니다.
📌 Domain 모듈의 QueryDsl, Redis 종속성
- 현재 Domain 모듈이 Jpa, QueryDsl, Redis을 모두 주입받고 있어서 책임이 과하다.
- 이를 더 잘게 쪼개어 상위에 DomainService 모듈을 두고, 하위에 각각 모듈로 분리하기도 한다고 들었다.
- 다만, 너무 한꺼번에 많은 양을 작업하려다 보니 모듈화 하는데만 시간을 너무 쏟아서 추후에 다시 적용해볼 계획이다.
🟡 `24.11.26 추가
드디어 ㅋㅋ Domain 모듈 쪼개기를 시도해보았다.
점진적인 마이그레이션 과정 도중이고, 우선은 구조적인 설계만 개선해서 반쪽 짜리 결과지만 유의미한 결과를 얻을 수 있었다.
📌 Domain 모듈의 OpenAPI 의존성 제거
- 가장 심각한 문제다. Swagger 문서 만든답시고 Domain module에 해당 의존성을 주입해둔 게 도저히 용납이 안 된다.
- 하지만 그렇다고 여전히 마땅한 해결책이 떠오르지도 않는다는 것이 가장 큰 문제라고 생각한다. 틈틈이 고민하면서 좋은 방법이 떠오르면 바로 아래에 추가해둘 예정
🟡 해결책
- 대부분의 경우엔 Entity 자체를 반환하도록 만들었다.
- 데이터의 크기가 너무 크거나, 특수한 경우를 위해 가공한 데이터를 반환하고 싶을 때는 domain 모듈의 DTO를 통해 응답을 받도록 수정했다.
- 하위 모듈에서 해당 DTO를 그대로 반환할 수도 있긴 하겠지만, domain 모듈에 정의한 dto 스펙이 어떻게 바뀔지 알 수 없는 법이며, 문서를 남기기도 어려우므로 어느정도 중복을 허용하기로 했다.
📌 Usecase, Helper, Mapper
- Componet Service 명을 UseCase로 바꾸고, Module Service 계층을 DomainService라 호칭을 변경하였다.
- 이번에 작업하면서 느낀 건데, 내 예전 코드가 너무너무 지저분하다. Mapper, Helper, Adapter 클래스로 리팩토링 과정을 진행할 예정이다.
🟡 UseCase에 대해서
처음에 참고할 때 위와 같은 구조를 갖는 프로젝트가 많았다.
하지만 Controller에서 주입하는 빈이 너무 많아지기 때문에 단위 테스트 시에 하나하나 @MockBean을 걸어주는 것도 일이고, 관리해야 할 클래스가 불필요할 정도로 많아지는 것 같았다.
그래서 하나의 UseCase로 묶고 메서드 명을 signUp(), signIn(), signOut()이라고 제공하면 충분히 직관적이고, 관리할 클래스도 줄어들기 때문에 효율적이라 생각했다.
UseCase 메서드 내의 중복 코드를 제거하기도 용이하고, 좀 더 세부적인 비지니스 로직을 처리할 때는 UseCase와 DomainService 사이에 Service를 하나 추가하거나, Helper, Util 등을 적극 활용했다.
하지만 위 방법은 Service Layer 단위 테스트를 할 때 진짜 지옥을 맛볼 수 있다.
그리고 도중에 우발적 중복을 중복으로 오인하여 묶었다가 수정하느라 고생한 적도 있었다.
만약 다른 프로젝트를 하게되면 위 방식을 사용하지 않을까 싶다.
UseCase는 적절한 Service를 호출하는 라우터 역할만을 수행한하는 파사드 패턴을 철저하게 수행함으로써, Controller와 Service 계층의 단위 테스트가 매우 편해진다는 장점을 가질 수 있다.
(Service 응답에 대한 Mapper도 UseCase에서 처리해주면 좀 더 좋으려나? 이건 해봐야 할 것 같다.)
클래스가 처음 방식보다 더 많아지긴 하지만, 확장성이나 코드 관리 측면에서 오히려 훨씬 큰 이점을 얻을 수 있을 것 같다고 느낀다.
📌 멀티 모듈과 @SQLRestriction
Domain 모듈의 Entity에 @SQLRestriction을 걸면 무슨 일이 발생하는가에 대한 고찰입니다.
📌 도메인 비즈니스 규칙과 멀티 모듈 아키텍처 설계
이 글을 작성하던 당시엔 당췌 이해하지 못 하고 넘어갔던 부분들을 다시 한 번 되짚어 보았다.
미숙하지만 DDD 관련 내용이 듬뿍 들어있으므로, 머리가 너무 복잡해질 것 같아면 안 읽는 게 차라리 나을 수 있습니다. 허허
🟡 `24.12.03 추가
도메인 규칙을 어떻게 서비스 로직, Entity, Repository에 표현할 것인지에 대한 고찰을 다룬 포스팅입니다.
6. 알게 된 점
예전에 클린 코드의 시스템 아키텍처 파트로 넘어가면서 정말 호되게 당한 적이 있었다.
아무리 봐도 내용이 이해가 안 가길래, 처음으로 북리딩을 중도 포기한 초유의 사태였다.
그게 너무 분해서 틈만 나면 다시 읽어보지만, 읽어볼때마다 새로운 챕터였다.
이번에 멀티 모듈화에 알게 되고 깊게 공부해보면서 시야가 넓어졌다는 것이 느껴졌다.
항상 클린 코드를 추구했지만, 내가 지금까지 추구하던 클린 코드는 고작 잎 정도에 불과했다.
기능과 아키텍처, 그리고 고수준과 저수준의 의미를 지레짐작만 하다가 직접 모듈을 분리하면서 더 나은 설계에 대해 끊임없이 고민해볼 수 있었다.
주위에 이 문제에 대해 상의할 사람이 없어서 정말 힘들긴 했지만, 매우 유익한 경험이었다.
지금 내 모듈화 결과물이 결코 만족스럽진 않지만 정답이 없는 문제를 감히 한 번에 완벽하게 할 수 있을 거라 기대하지도 않았다.
앞으로도 리팩토링 열심히 해봐야지.