단기 집중개발로 개인 프로젝트를 마무리 후 미흡했던 부분을 보완하거나 개선하는 작업을 진행하고 있다. 테스트 코드 작성이나 프론트엔드 구현 등 작업들이 남아있지만, 가장 하고싶었던건 프로젝트를 레이어 별 멀티모듈로 분리하는 작업이었다. 최근 강의와 유튜브 영상에서 멀티 모듈로 나눈 코드로 시연하는걸 보고 나도 한 번 해보고 싶은게 컸다. 마침 프로젝트 개선 사항에도 부합했기 때문에 진행하기로 결정했고 분리했던 과정을 간략히 기록했다.
1. 배경 및 동기
현재 프로젝트는 단일 모듈로 구성해 기능과 역할별 패키지를 분리했다. 각 레이어간 역할 및 구조를 명확히 분리하기 위해 레이어 별 멀티 모듈 형태로 구조를 변경했다.
// 기존 구조
arcademarket(proejct root)
├─ docs(문서 모음)
├─ ... // 소스코드 외 기타 파일들
│
└─ src.io.github.arcademarket.main.java
├─ common // (유틸, 설정 등)
├─ domain
│ ├─ member
│ │ ├─ domain (JPA 엔티티, repository, 예외)
│ │ ├─ service (서비스 클래스)
│ │ └─ presesntation (RestController)
│ ├─ marketitem
│ ├─ // ...
└─ support // (JPA 설정, 웹 필터, 컨버터 등)
가장 큰 동기는 레이어별 멀티모듈 아키텍처를 직접 설계하고 구현 과정을 경험해보고 싶었던 것과, 프로젝트 구조를 일관성 있게 유지하고 싶었기 때문이다.
물리적인 모듈 분리를 통해 의존성 방향을 자동으로 관리할 수 있고, 각 레이어의 책임을 모듈 단위로 명확히 구분할 수 있다고 판단했다. 또한 계층별로 독립적인 테스트가 용이하며, 향후 추가 기능을 개발할 때도 유리하다는 장점이 있다.
2. 모듈 구조 설계
최종 구조는 아래와 같은 형태를 구성했다.
├─arcademarket
│ ├─boot
│ │ └─api (애플리케이션 진입점 및 외부 인터페이스)
│ ├─core
│ │ ├─application (유즈케이스 구현 및 비즈니스 흐름 제어)
│ │ ├─common (전역 공통 요소)
│ │ └─domain (핵심 비즈니스 모델 및 규칙)
│ └─infrastructure
│ └─db (데이터 영속성 구현)
주요 설계 원칙은 다음과 같다.
- 의존성은 항상 내부(core)를 향해야한다.
- 도메인(domain)은 순수 도메인 로직으로 구성하며 어디에도 의존하지 않는다.
- 외부 모듈들은 단방향으로 domain을 의존한다.
- Port-Adapter 패턴을 통한 의존성 역전
- Port(인터페이스)는 application, domain에 정의한다.
- Adapter(구현체)는 infrastructure에 위치한다.
- infrastructure는 실제 구현 기술을 담당한다.
그리고 각 계층별로 모듈을 분리하여 역할을 명확히 구분했다.
- boot:api - 외부 세계와의 접점으로 모든 모듈을 조립해 애플리케이션 실행 (Controller, Config, Filter 등)
- core:application - 유즈케이스 구현 (Service, DTO, Port 인터페이스 등)
- core:domain - 비즈니스 핵심 모델 및 로직 (Entity, Domain Service, Enum 등)
- infrastructure:db - 영속성 구현 (JPA Adapter, Port 구현체 등)
- core:common - 공통 요소 (ErrorCode, Utils 등)
3. 작업 과정
기존 단일 모듈로 작성했던 코드도 기능별로 계층형 패키지 구조를 구성하고 있어서 기능별 계층을 적절한 모듈로 이동해주는 작업을 진행했다. 기존에도 모듈과 의존성을 잘 나누어서 작업했다고 생각했는데 막상 모듈별로 분리하다보니 고민하게 되는 부분이 몇몇 있었다.
JPA 엔티티를 도메인 모델로 사용
기존 코드에서 이미 JPA 엔티티를 도메인 모델로 사용하고 있었어서 core:domain 모듈에 spring-data-jpa 를 포함해야 했다. core:domain 은 순수 도메인 로직만 작성하기 위해 최대한 다른 의존성을 가지지 않도록 만들고 싶었지만, spring-data-jpa 를 유지하고 기존 그대로 JPA 엔티티를 도메인 모델로 사용했다.
- 별도 도메인 모델 클래스를 작성해야하며 추가로 Mapper 작업 시 보일러플레이트 증가한다.
- 개인 프로젝트인 만큼 실용적이 선택이 필요했고 이미 구현된 동작하는 코드에 큰 문제가 없다면 변경하고 싶지 않았다.
본 프로젝트에서는 QueryDSL 도 사용하고 있었는데, Q 클래스 생성이나 관련 의존성은 infrastructure:db 모듈에 포함시켜 core:domain 모듈에 더 이상 다른 모듈을 추가하지 않도록 구성했다.
조회 모델을 Application 모듈에 위치
영속성 관련 인터페이스는 Port-Adapter로 만들어 구성하고 있었다. 서비스 클래스에서는 Port 인터페이스를 사용해 조회 및 수정을 수행했다.
// 포트 정의
public interface MarketItemPort {
@NonNull
MarketItem save(@NonNull MarketItem marketItem);
Optional<MarketItem> findByIdWithLock(@NonNull Long id);
PagingResponse<MarketItemListDto> findMarketItems(@NonNull SearchRequest request);
}
@Repository
@RequiredArgsConstructor
class MarketItemPersistenceAdapter implements MarketItemPort {
private final MarketItemJpaRepository marketItemRepository;
// ... 구현
MarketItem save(@NonNull MarketItem marketItem){}
Optional<MarketItem> findByIdWithLock(@NonNull Long id){}
PagingResponse<MarketItemListDto> findMarketItems(@NonNull SearchRequest request){}
}
하지만 화면에 표시하기 위한 조회 모델들의 위치가 고민되었다. MarketItemListDto의 경우 목록을 표시하기 위한 모델인데, MarketItem과 ItemMaster의 조회 결과를 조합해 만들어야한다. 이런 조회용 DTO들의 core:domain , core:application 중 어디가 적합할지 고민했다.
- 조회 모델은 화면 요구사항에 맞춰진 모델이기 때문에 도메인보다는 애플리케이션의 책임이라고 생각했다.
- 화면 구성이 변경되더라도 도메인 계층은 상관 없이 application 계층을 수정하고 데이터를 어떻게 가져올지는 infrastructure에 작업하면 된다.
- 현재는 변경/조회 모두 하나의 기술(RDB)를 사용하지만 성능을 위해 조회용 별도의 DB가 필요할 경우 쉽게 변경하고 추가할 수 있을 것이다.
// core:domain
// - 단순 조회 및 도메인
public interface MarketItemPort {
@NonNull
MarketItem save(@NonNull MarketItem marketItem);
Optional<MarketItem> findByIdWithLock(@NonNull Long id)
}
// core:application
// - 조회용 DTO와
public class MarketItemListDto {
}
// - 조회용 Port를 정의한다.
public interface MarketItemQueryPort {
PagingItems<MarketItemListDto> findMarketItems(@NonNull SearchRequest request);
}
// infrastructure:db
@Repository
@RequiredArgsConstructor
class MarketItemPersistenceAdapter implements MarketItemPort, MarketItemQueryPort {
private final MarketItemJpaRepository marketItemRepository;
// ... 구현
MarketItem save(@NonNull MarketItem marketItem){}
Optional<MarketItem> findByIdWithLock(@NonNull Long id){}
PagingResponse<MarketItemListDto> findMarketItems(@NonNull SearchRequest request){}
}
4. 결과
멀티 모듈로 분리한 결과, 프로젝트 구조의 명확성과 유지보수성이 크게 향상되었다.
계층별 책임 분리의 명확화
각 모듈의 역할이 물리적으로 분리되면서 "이 코드는 어디에 작성해야 하는가?"에 대한 답이 명확해졌다.
- Domain: 새로운 Entity나 비즈니스 규칙을 추가할 때
- Application: 새로운 UseCase를 구현할 때
- Infrastructure: 새로운 기술을 추가하거나 외부 시스템과 통합할 때
- API: 새로운 엔드포인트를 노출할 때
단일 모듈이었을 때는 "이게 domain 패키지에 들어가야 하나, service 패키지에 들어가야 하나?" 같은 고민이 잦았다. 하지만 모듈로 분리하고 나니 물리적인 경계가 생겨 이러한 고민이 현저히 줄어들었다.
의존성 방향 강제를 통한 아키텍처 일관성
모듈 간 의존성이 Gradle 설정으로 관리되면서, 잘못된 의존성 추가 시 컴파일 타임에 바로 발견할 수 있게 되었다. 예를 들어, core:domain에서 infrastructure:db를 참조하려고 하면 빌드 자체가 실패한다. 이는 팀 프로젝트에서 특히 유용할 것으로 보인다. 코드 리뷰에서 의존성 방향을 지적하는 대신, 빌드 단계에서 자동으로 검증되기 때문이다.
테스트 격리 및 독립성 확보
각 모듈이 독립적으로 테스트 가능해졌다는 점도 큰 장점이다. core:application의 서비스 로직을 테스트할 때 Port 인터페이스만 Mock으로 대체하면 되고, 실제 DB 연결이나 API 설정 없이도 비즈니스 로직을 검증할 수 있다. 이는 테스트 속도 향상과 함께 테스트의 목적을 더욱 명확하게 만들어준다.
개발 속도 향상
처음 프로젝트를 시작할 때는 "이 코드 어디에 넣지?"를 고민하는 시간이 의외로 많이 소요되었다. 하지만 모듈 구조가 확립된 지금은 기능 추가 시 해당 레이어로 바로 이동해서 작업할 수 있다. 예를 들어, 새로운 아이템 거래 기능을 추가한다면:
- core:domain에 Trade 엔티티 정의
- core:application에 TradeService와 TradePort 인터페이스 작성
- infrastructure:db에 TradePersistenceAdapter 구현
- boot:api에 TradeController 추가
이런 흐름이 자연스럽게 체화되어, 구현보다는 로직 자체에 집중할 수 있게 되었다.
향후 확장성 확보
현재는 단일 애플리케이션이지만, 향후 마이크로서비스로 분리하거나 배치 애플리케이션을 추가할 때도 유리한 구조다. core:domain과 core:application은 그대로 재사용하고, 새로운 boot:batch 모듈을 추가하는 식으로 확장이 가능하다. 또한 조회 성능 개선을 위해 별도의 조회 전용 DB를 추가한다면, infrastructure:db-read 같은 모듈을 추가하여 기존 코드 수정 없이 확장할 수 있다.
아쉬운 점과 개선 방향
완벽하지는 않다. JPA 엔티티를 도메인 모델로 사용하면서 core:domain에 spring-data-jpa 의존성을 포함한 부분은 순수성 측면에서 아쉬움이 남는다. 하지만 개인 프로젝트라는 현실적 제약과 실용성을 고려했을 때 합리적인 선택이었다고 생각한다. 향후 프로젝트가 성장하면 별도의 도메인 모델을 분리하는 것도 고려해볼 수 있을 것이다.
이번 작업을 통해 "구조가 잘 잡혀있으면 개발이 편하다"는 말을 몸소 체험했다. 이전 프로젝트에서 코드 구현보다 "어디에 무슨 클래스를 넣어야 할지" 고민하는 데 시간을 많이 썼던 기억이 있다. 확실히 아무것도 없는 백지 상태보다는, 명확한 구조가 갖춰진 상태에서 생각하는 게 훨씬 효율적이다. 다음 프로젝트를 시작한다면 이번 경험을 토대로 초기 구조 설계에 더 많은 시간을 투자할 것 같다. 지금은 멀티 모듈을 사용해봤는데, 다음에는 Spring Modulith를 적용해보는 것도 한 번 시도해보고 싶다.