새로운 프로젝트를 또 혼자 개발해야하니 아키텍처를 정해야겠다. 기존 프로젝트들은 모두 모놀리식 아키텍처로 계층형도 있고 도메인 중심 패키지 구조로 되어있는 것도 있다. 이번 프로젝트에서는 멀티 모듈도 고려해볼 참이라 또 아키텍처에 대해 찾아보고 정리를 해보았다. 늘 고민이 되는 부분이다. 클로드를 사수 삼아 함께 논의했다.
1. 계층형 아키텍처
계층형 아키텍처는 레이어 별로 패키지 관리한다.
com.example
├── ExampleApplication.java
├── config/ # 스프링 설정 클래스
├── domain/ # 도메인 엔티티
├── repository/ # JPA 레포지토리
├── service/ # 비즈니스 로직
├── controller/ # REST API 컨트롤러
├── dto/ # 데이터 전송 객체
├── exception/ # 예외 처리
├── security/ # Spring Security, JWT 설정
├── util/ # 유틸리티 클래스
└── external/ # 외부 API 연동
장점
- 이해하기 쉽고 친숙한 구조
- 빠르게 개발 시작 가능
단점
- 비즈니스 기능이 여러 계층으로 분산 되어 응집도가 낮음
- 규모가 커지면 기능 추적이 어려워짐
- 변경 시 모든 계층 수정 필요
- 도메인 간 경계가 불분명해 의존성 관리가 어려움
생각
매우 익숙한 구조로 어떤 패키지에 넣지?? 에 대한 고민 없이 개발이 가능하다.
변경을 할 때 이 도메인의 컨트롤러가 어디에 있었지.. 하고 한참 찾아야 한다.
검색을 통해 바로 찾을 수 있긴 하지만 클래스명을 모르면 검색하기 어렵고 처음 프로젝트에 대해 파악하기가 어렵다.
같은 패키지에 있으니 서비스 간 의존성에 대한 깊은 고민 없이 마구잡이로 참조하다보면 어느새 스파게티 코드가 되어버린다.
2. 도메인 중심 패키지 구조
도메인(기능)별로 패키지 분리
com.example
├── ExampleApplication.java
├── global/
│ ├── config/ # 전역 설정 (Security, Web 등)
│ ├── error/ # 예외 처리
│ ├── util/ # 공통 유틸리티
│ └── dto/ # 공유 DTO 클래스
│
├── domain/
│ ├── user/ # 사용자 관련 기능
│ │ ├── controller/
│ │ ├── service/
│ │ ├── repository/
│ │ ├── entity/
│ │ └── dto/
│ │
│ ├── car/ # 차량 관련 기능
│ │ └── ...
│ │
│ ├── favorite/ # 즐겨찾기 기능
│ │ └── ...
│ │
│ └── search/ # 검색 기능
│ └── ...
장점
- 기능별 응집도가 높음
- 도메인 로직의 경계가 명확
- 패키지 단위 개발 및 유지보수 용이
- 확장성이 좋음
- 협업할 때 작업 분담 용이
단점
- 도메인 간 의존성 관리 필요
- 공통 코드 관리가 복잡
- 모듈 간 결합도 관리가 명시적이지 않음
생각
이 기능은 어떤 패키지에 넣어야할까..? 에 대한 고민이 끝도 없이 이어질 때가 있다.
도메인 별로 정리되어있어서 역할과 책임에 대한 고민을 하게 된다.
의존성과 모듈 간 결합도를 고려하여 개발을 하다보면 어느새 조금이나마 확장과 변경에 용이한 코드에 대해서 생각하게 된다.
협업을 할 때 한 기능별로 작업을 나눌 수 있어 용이했고, 공통 코드 관리가 좀 어려웠다.
3. 모듈러 모놀리스(멀티 모듈)
물리적으로 도메인 별 모듈을 분리
example/
├── example-app/
├── example-user-module/
├── example-car-module/
└── example-common/
장점
- 물리적으로 분리된 모듈 구조로 경계가 명확
- 모듈 간 의존성이 명시적으로 관리
- 독립적인 테스트와 배포 가능
- 마이크로서비스로 전환 용이
- 대규모 팀 작업에 적합
단점
- 초기 설정 복잡
- 빌드 시간 문제
- 모듈 간 인터페이스 설계에 많은 노력 필요
번외
기능 중심 모듈 분리
- 기능별로 인프라, 도메인(데이터), 서버 등으로 모듈을 분리한다.
- 장점: 유사한 변경 주기를 가진 코드를 묶어 배포 관리에 용이하다.
- 단점: 도메인 로직이 여러 모듈에 분산될 가능성이 있다.
그리고 코어, user api, admin api 등으로 공통 코어 모듈을 의존하여 각 사용자별로 모듈을 구성하는 것도 있었다.
이 구성은 코어 모듈이 비대칭적으로 너무 커지는 문제가 있다고 한다.
생각
마이크로서비스에서 모듈러 모놀리식으로 회귀하는 사례를 보았다.
뭐든 어떤 기준으로 모듈을 나눌건지(패키지를 나눌건지)가 가장 고민되는 부분인 거 같다.
4. 마이크로서비스 아키텍처
각 도메인별로 완전히 독립된 서비스로 분리
장점
- 서비스별 독립적 확장 가능
- 각 서비스별 다른 기술 스택으로 개발 가능
- 장애 격리
- 대규모 조직에 적합
단점
- 분산 시스템 복잡성
- 인프라 관리 부담
- 개발 및 테스트 복잡성 증가
- 서비스 간 통신 오버헤드
- 초기 개발 속도 저하
생각
소규모 조직, 작은 프로젝트에서 바로 적용을 하기에는 무리가 있다.
일단 기본에 충실해보자.
추후에 서비스가 잘되고 조직이 커져 MSA로 전환해야하는 때가 오면 그나마 쉽게 할 수 있도록 코드를 짜두고 싶다.
비교
| 초기 개발 속도 | 계층형 > 도메인 중심 > 모듈러 > 마이크로서비스 |
| 유지보수성 | 모듈러 > 도메인 중심 > 마이크로서비스 > 계층형 |
| 확장성 | 마이크로서비스 == 모듈러 > 도메인 중심 > 계층형 |
| 배포 복잡성 | 마이크로서비스 > 모듈러 > 도메인 중심 == 계층형 |
| 팀 협업 용이성 | 모듈러 > 마이크로서비스 == 도메인 중심 > 계층형 |
| 학습 곡선 | 마이크로서비스 > 모듈러 > 도메인 중심 > 계층형 |
| 도메인 경계 명확성 | 마이크로서비스 == 모듈러 > 도메인 중심 > 계층형 |
| 자원 효율성 | 도메인 중심 == 계층형 > 모듈러 > 마이크로서비스 |
| 테스트 용이성 | 모듈러 > 도메인 중심 > 마이크로서비스 == 계층형 |
| 장애 격리 | 마이크로서비스 > 모듈러 > 도메인 중심 == 계층형 |
최종으로 결정한 아키텍처 - 도메인 중심 패키지 구조
- 어느정도 익숙한 방식으로 빠르게 구현 가능
- 소규모 조직
- 초기 단계라 적은 사용자
- 이후 모듈러 모놀리스나 마이크로서비스로 전환이 용이
- 단순하게 시작하여 점진적으로 도메인 경계를 명확히 하며, 의존성을 관리하는 방향으로 발전
- 도메인 간 의존성에 주의하고 응집도를 높이자
주의할 점
1. 도메인 경계 기준
- 명확한 도메인 정의 - 초기에는 핵심 도메인으로 시작하고 점진적으로 세분화
- 요구사항을 기반으로 책임과 기능이 명확한 도메인부터 분리
- 도메인 간 중복 코드 방지 - 공통 기능은 global 패키지로 분리
- 예) 파일 업로드, 날짜 처리, 외부 API 공통 로직 등
2. 도메인 간 의존성 관리
- 순환 의존성 방지- 의존성 방향을 단방향으로
- 도메인 간 통신 시 인터페이스 활용
- 공통 모듈에 의존하되, 공통 모듈이 도메인에 의존하지 않도록
- 의존성 최소화 - 도메인 간 지나친 결합 주의
- 최소 지식 원칙(Law of Demeter) 적용
- 필요한 정보만 DTO로 주고 받기
- 특정 도메인에만 필요한 메서드는 내부용으로 제한
3. 일관된 패키지 구조 유지
- 도메인 내부 표준화 - 모든 도메인 패키지에 일관된 하위 구조 적용
domain.user/
├── controller/ // API 엔드포인트
├── service/ // 비즈니스 로직
│ ├── api/ // 외부 도메인용 인터페이스
│ └── impl/ // 구현체
├── repository/ // 데이터 액세스
├── entity/ // JPA 엔티티
└── dto/ // 데이터 전송 객체
- 명확한 네이밍 컨벤션 - 일관된 네이밍 컨벤션 수립 및 적용
- 예) XxxController, XxxService, XxxRepository 등
4. 도메인 내부 응집도 강화
- 도메인 로직 캡슐화 - 핵심 비즈니스 로직은 도메인 모델(엔티티)에 캡슐화
// 나쁜 예: 비즈니스 로직이 서비스에 있음
@Service
public class CarServiceImpl {
public void updateCarPrice(Long carId, int newPrice) {
Car car = carRepository.findById(carId).orElseThrow();
// 검증 로직이 서비스에 존재
if (newPrice < 100000) {
throw new InvalidPriceException("가격이 너무 낮습니다");
}
car.setPrice(newPrice);
carRepository.save(car);
}
}
// 좋은 예: 비즈니스 로직이 엔티티에 캡슐화됨
@Entity
public class Car {
private int price;
public void updatePrice(int newPrice) {
validatePrice(newPrice);
this.price = newPrice;
}
private void validatePrice(int price) {
if (price < 100000) {
throw new InvalidPriceException("가격이 너무 낮습니다");
}
}
}
- 서비스 계층 책임 명확화 - 서비스를 목적에 따라 분리
5. 공통 컴포넌트 관리
- global 패키지 구성
- 공통 컴포넌트가 무분별하게 증가하면 관리 어려움 - 기능별로 구분된 global 하위 패키지 구성
global/
├── config/ // 스프링 설정
├── security/ // 보안 관련
├── error/ // 예외 처리
├── util/ // 유틸리티
├── external/ // 외부 API 연동
└── dto/ // 공통 DTO
- 공통 예외 처리 - 글로벌 예외 핸들러와 도메인별 커스텀 예외 정의
// global/error/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CarNotFoundException.class)
public ResponseEntity<ErrorResponse> handleCarNotFound(CarNotFoundException e) {
// 처리 로직
}
}
// domain/car/exception/CarNotFoundException.java
public class CarNotFoundException extends RuntimeException {
// 구현
}
6. 테스트 전략
- 도메인 별 테스트 구성 - 도메인 단위로 테스트 격리, 의존 도메인은 Mock 처리
- 통합 테스트 전략 - 도메인 간 상호작용 테스트 필요 → 주요 시나리오에 대한 통합 테스트 구성
7. 프로젝트 진행 시 주의점
- 처음부터 완벽한 구조를 만들려고 하지 말고 핵심 도메인부터 시작하여 점진적으로 구조 개선
- 필요에 따라 리팩토링을 통해 도메인 경계 조정
- 도메인 모델 및 의존성 다이어그램 문서화
+ 추가
구현하면서 도메인 경계는 어떻게 설정하고 의존성을 최소화할지, 서비스의 역할과 트랜잭션 처리는 어떻게 해야할지에 대해 고민해봐야겠다. 그리고 문서화!
++
헥사고날 아키텍처에 대해서
모듈러 모놀리스는 물리적으로 프로젝트를 나누는 것 - 멀티 모듈
도메인 중심 패키지는 논리적으로 어떻게 구성할 것인가 - 패키지 구조
헥사고날 아키텍처는 의존성 방향을 어떻게 관리할 것인가 - 계층 구조
이렇게 각 다른 레벨에서 동작한다.
'개발 > Spring' 카테고리의 다른 글
| Spring Boot 서버 모니터링(Actuator + Prometheus + Grafana) (2) | 2025.11.26 |
|---|---|
| 리팩토링 - 패키지를 넘나드는 캐시 (0) | 2025.02.12 |
| [Spring] Caffeine Cache 적용 (1) | 2025.02.11 |
| 스프링 내장 캐시 사용하기 (2) | 2025.02.07 |
| Spring 서버 로깅 1 - 로그 레벨 (0) | 2024.11.04 |