항공권 발권 시스템: Spring Modullith 적용 검토서
Executive Summary
핵심 결론
“마이크로서비스는 문제의 해결책이 아니라, 성공의 결과물이다.” — Martin Fowler
신규 항공권 발권 시스템에 Spring Modullith로 시작할 것을 권장합니다.
이유:
- 초기 개발 속도: MSA 6-12개월 vs Modullith 2-3개월
- 운영 복잡도: MSA 5개 서비스 관리 vs Modullith 단일 애플리케이션
- 성능: 모듈 간 호출 <1ms (MSA는 10-50ms)
- 확장성: 필요 시 점진적 마이크로서비스 전환 가능
3단계 전환 전략
graph LR A[Phase 1<br/>0-6개월<br/>Modullith MVP] -->|모니터링| B[Phase 2<br/>6-12개월<br/>성능 최적화] B -->|데이터 분석| C[Phase 3<br/>12개월+<br/>선택적 분리] style A fill:#90EE90 style B fill:#87CEEB style C fill:#FFD700
Phase 1: 비즈니스 로직 개발에 집중
Phase 2: 실제 트래픽 데이터로 병목 파악
Phase 3: 검증된 병목만 독립 서비스로 분리
목차
예상 독서 시간: 20-30분
Spring Modullith 소개
정의
모놀리식 애플리케이션을 논리적 모듈로 구조화하여, 마이크로서비스의 장점(모듈화)은 가져가되 운영 복잡도는 낮춘 아키텍처입니다.
핵심 개념
Modular Monolith = 깔끔한 모듈 구조 + 단일 배포
주요 특징
- 패키지 기반 모듈화 (Java/Kotlin 패키지로 경계 정의)
- 이벤트 기반 모듈 간 통신
- 모듈 경계 자동 검증
- 필요 시 마이크로서비스 전환 가능
탄생 배경
철학: “완벽한 모듈화를 먼저, 분산은 필요할 때”
대부분의 조직은 마이크로서비스의 복잡도를 감당하기 어렵습니다. Modullith는 모듈화된 코드 구조는 유지하되, 분산 시스템 복잡도는 회피하는 현실적 접근입니다.
현재 MSA 구조 분석
항공 시스템 서비스 구성
graph TD Client[Client] Search[Search Service<br/>항공권 검색 병렬호출<br/>결과 캐싱] GDS[GDS Adapter<br/>Amadeus, Sabre] LCC[LCC Adapter<br/>항공사 직연동] NDC[NDC Adapter] Booking[Booking Service<br/>예약 생성/관리<br/>PNR 생성<br/>결제 처리] Pricing[Pricing Service<br/>마진 계산<br/>프로모션 적용] Console[Console Service<br/>운임 규칙 관리<br/>실시간 반영] Notification[Notification<br/>Kinesis] Client --> Search Search --> GDS Search --> LCC Search --> NDC Search -->|캐시 키 전달| Booking Booking --> Pricing Console --> Pricing Booking --> Notification
주요 통신 방식
- 동기: REST API | 비동기: Kinesis | DB: 서비스별 독립
Modullith vs MSA 비교
핵심 차이점
| 항목 | MSA | Modullith | 항공 시스템 영향 |
|---|---|---|---|
| 배포 | 5개 파이프라인 | 1개 파이프라인 | 초기 배포 단순화 |
| 개발 속도 | 6-12개월 | 2-3개월 | 빠른 MVP 출시 |
| 모듈 간 호출 | 10-50ms | <1ms | 50배 빠른 응답 |
| 로컬 개발 | 5개 서비스 실행 | 1개만 실행 | 개발 생산성 향상 |
| 트랜잭션 | Saga 패턴 | DB 트랜잭션 | 간단한 에러 처리 |
| 모니터링 | 분산 추적 필요 | 단일 로그 | 장애 추적 용이 |
| 인프라 비용 | 최소 10대 | 2-3대 | 초기 비용 절감 |
| 확장 방식 | 서비스별 개별 | 전체 스케일링 | 추후 선택적 분리 |
성능 실측 (항공권 검색-예약 시나리오)
5회 서비스 간 호출 시:
- MSA: 50-250ms (네트워크 레이턴시)
- Modullith: <5ms (메모리 호출)
항공 발권 시스템 적용 시 장단점
|------|-----|-----------| | 응답 속도 | 네트워크 레이턴시 존재 | 메모리 내 호출 (빠름) | | 처리량 | 서비스 간 통신 오버헤드 | 오버헤드 없음 | | 확장성 | 서비스별 개별 확장 | 전체 스케일링 | | 리소스 | 각 서비스마다 메모리 필요 | 공유 메모리 |
항공 발권 시스템 적용 시 장단점
✅ Modullith의 장점
1. 개발 생산성 향상
현재 MSA:
- 로컬에서 5개 서비스 실행 필요
- 포트 관리, 환경변수 설정 번거로움
- 통합 테스트 시 모든 서비스 구동 필요
Modullith:
- 단일 애플리케이션 실행
- IDE에서 바로 디버깅
- 빠른 피드백 루프
항공 시스템 예시:
- 검색 → 예약 플로우 테스트 시, 5개 서비스 대신 1개만 실행
- 디버거로 Search → Booking → Pricing 전체 추적 가능
2. 단순한 배포
현재 MSA:
- 5개의 배포 파이프라인 관리
- 서비스 간 버전 호환성 체크
- 롤백 시 여러 서비스 조율
Modullith:
- 1개 배포 파이프라인
- 버전 호환성 걱정 없음
- 롤백 단순
3. 네트워크 레이턴시 제거
현재: Search → Booking (REST API, ~10-50ms)
Modullith: Search → Booking (메서드 호출, <1ms)
실제 영향:
- 검색 후 예약까지 3-4번의 서비스 간 호출 → 누적 레이턴시 감소
- 특히 Pricing 계산이 여러 번 호출될 때 성능 개선
4. 트랜잭션 관리 단순화
현재 MSA:
- 예약 생성 실패 → Saga 패턴으로 보상 트랜잭션
- 복잡한 상태 관리
Modullith:
- @Transactional로 DB 트랜잭션 사용 가능
- 실패 시 자동 롤백
항공 시스템 예시:
@Transactional
public Booking createBooking(String cacheKey) {
// 1. 예약 생성
Booking booking = bookingRepo.save(...);
// 2. PNR 생성
pnrService.create(booking);
// 3. 운임 계산
pricing.calculate(booking);
// 실패 시 전체 롤백 (단순!)
}5. 초기 개발 속도
- 팀이 작을 때 유리 (5-15명)
- 인프라 전문가 불필요
- 비즈니스 로직에 집중 가능
6. 비용 절감
현재 MSA:
- 5개 서버/컨테이너 비용
- API Gateway, Service Mesh 등 인프라
- 모니터링 도구 (APM, 분산 추적)
Modullith:
- 1개 서버로 시작 가능
- 기본 모니터링만으로 충분
7. 모듈 경계 검증
// 자동으로 모듈 간 잘못된 참조 검출
@Test
void modulesRespectBoundaries() {
ApplicationModules.of(FlightApp.class)
.verify(); // ❌ Booking에서 Search 직접 참조 시 실패
}❌ Modullith의 단점
1. 확장성 제약
MSA:
- Search 서비스만 10개 인스턴스로 확장 가능
- Booking은 2개만 유지
Modullith:
- 전체 애플리케이션을 확장해야 함
- 불필요한 모듈까지 함께 확장
항공 시스템 영향:
- 검색 트래픽이 예약보다 10배 많은 경우 비효율
- 단, 캐싱으로 검색 부하는 대부분 해결 가능
2. 기술 스택 통일 필요
MSA:
- Search: Node.js (빠른 I/O)
- Booking: Java (안정성)
- Pricing: Python (데이터 분석)
Modullith:
- 모두 Java/Spring으로 통일해야 함
3. 팀 독립성 제약
MSA:
- 각 팀이 독립적으로 배포
- 다른 팀 영향 최소화
Modullith:
- 하나의 코드베이스 공유
- 배포 조율 필요
4. 장애 격리 불가
MSA:
- Booking 장애 시 Search는 정상 동작
Modullith:
- 한 모듈 오류가 전체 영향 가능
- Circuit Breaker 패턴 필요
항공 시스템 리스크:
- Adapter의 외부 API 호출 시 타임아웃으로 전체 시스템 영향 가능
5. 데이터베이스 확장 제약
MSA:
- 각 서비스가 최적의 DB 선택 가능
- Search: Elasticsearch
- Booking: PostgreSQL
- Pricing: MongoDB
Modullith:
- 기술적으로는 가능하나 복잡도 증가
- 보통 하나의 DB로 통일
6. 빌드 시간
MSA:
- 각 서비스 독립 빌드 (병렬)
- 변경된 서비스만 빌드
Modullith:
- 전체 빌드 필요
- 코드 증가 시 빌드 시간 증가
7. 학습 곡선
MSA:
- 많은 레퍼런스와 사례
- 검증된 패턴
Modullith:
- 상대적으로 새로운 접근
- 사례 적음 (2022년 정식 출시)
의사결정 매트릭스
Modullith 적합한 경우
| 조건 | 항공 시스템 해당 여부 |
|---|---|
| 팀 규모 10명 이하 | ✅ 해당 시 유리 |
| 초기 스타트업/신규 프로젝트 | ✅ 신규 시스템 구축 |
| 빠른 기능 개발 필요 | ✅ 초기 MVP 빠르게 |
| 인프라 관리 부담 최소화 | ✅ 개발에 집중 |
| 트래픽 예측 가능 | ⚠️ 계절성 있음 |
| 대부분의 모듈이 비슷한 부하 | ⚠️ 검색 > 예약 |
MSA 필요한 경우
| 조건 | 항공 시스템 해당 여부 |
|---|---|
| 팀 규모 30명 이상 | ❌ 팀 규모에 따라 |
| 모듈별 기술 스택 다양화 | ⚠️ Adapter는 Go 등 고려 가능 |
| 독립적인 배포 필수 | ❌ 초기엔 불필요 |
| 글로벌 분산 배포 | ❌ 초기엔 불필요 |
| 특정 모듈 트래픽 극단적 | ⚠️ 검색은 캐싱으로 해결 |
| 조직 구조가 서비스 단위 | ❌ 초기엔 작은 팀 |
구현 예제
프로젝트 구조
flight-booking-system/
├── pom.xml
└── src/main/java/com/airline/
├── FlightBookingApplication.java
│
├── search/ # 검색 모듈
│ ├── api/
│ ├── domain/
│ ├── cache/
│ └── events/
│
├── booking/ # 예약 모듈
│ ├── api/
│ ├── domain/
│ ├── payment/
│ └── events/
│
├── adapter/ # 공급사 연동 모듈
│ ├── api/
│ ├── gds/
│ ├── lcc/
│ └── ndc/
│
├── pricing/ # 운임 계산 모듈
│ ├── domain/
│ └── internal/
│
├── console/ # 운임 규칙 관리
│ ├── api/
│ └── domain/
│
└── notification/ # 알림 모듈
├── domain/
└── internal/
모듈 간 통신 구조
graph TB subgraph "External" Client[Client] end subgraph "API Layer" SearchAPI[Search API] BookingAPI[Booking API] ConsoleAPI[Console API] end subgraph "Domain Layer" SearchService[Search Service] BookingService[Booking Service] PricingService[Pricing Service] AdapterOrch[Adapter Orchestrator] end subgraph "Event Listeners (Internal)" InvListener[Inventory Listener] PayListener[Payment Listener] NotiListener[Notification Listener] end Client --> SearchAPI Client --> BookingAPI SearchAPI --> SearchService BookingAPI --> BookingService SearchService --> AdapterOrch BookingService --> PricingService BookingService -.->|Event| InvListener BookingService -.->|Event| PayListener BookingService -.->|Event| NotiListener style SearchAPI fill:#e1f5ff style BookingAPI fill:#e1f5ff style ConsoleAPI fill:#e1f5ff style InvListener fill:#fff4e1 style PayListener fill:#fff4e1 style NotiListener fill:#fff4e1
주요 플로우
1. 항공권 검색 플로우
sequenceDiagram actor User participant API as Search Controller participant Service as Search Service participant Adapter as Adapter Orchestrator participant Cache as Redis Cache participant Event as Event Publisher User->>API: POST /api/search/flights API->>Service: searchFlights() Note over Service,Adapter: 병렬 검색 Service->>Adapter: searchParallel() par GDS Adapter->>Adapter: Amadeus API and LCC Adapter->>Adapter: Direct Connect and NDC Adapter->>Adapter: NDC API end Adapter-->>Service: List<FlightOption> Service->>Cache: 결과 캐싱 Cache-->>Service: cacheKey Service->>Event: SearchCompletedEvent Service-->>API: SearchResult API-->>User: Response (cacheKey + flights)
2. 예약 생성 플로우
sequenceDiagram actor User participant API as Booking Controller participant Service as Booking Service participant Cache as Search Cache participant Pricing as Pricing Service participant Event as Event Publisher participant DB as Database User->>API: POST /api/bookings Note over User,API: cacheKey 포함 API->>Service: createBooking() Service->>Cache: get(cacheKey) Cache-->>Service: FlightOptions Service->>Pricing: calculateFare() Pricing-->>Service: FareDetails Service->>Service: generatePNR() Service->>DB: save(Booking) Note over Service,Event: 이벤트 발행 (비동기) Service->>Event: BookingCreatedEvent Service-->>API: Booking API-->>User: Response (PNR, status) Note over Event: 여러 리스너가 처리 Event-->>Event: InventoryListener Event-->>Event: PaymentListener Event-->>Event: NotificationListener
3. 결제 처리 플로우
sequenceDiagram actor User participant API as Booking Controller participant Service as Booking Service participant Payment as Payment Service participant Event as Event Publisher participant DB as Database User->>API: POST /api/bookings/{id}/payment API->>Service: processPayment() Service->>DB: findBooking() DB-->>Service: Booking Service->>Payment: process() Payment-->>Service: PaymentResult alt 결제 성공 Service->>Service: confirmPayment() Service->>Event: BookingConfirmedEvent Service->>DB: update status=CONFIRMED else 결제 실패 Service->>Service: failPayment() Service->>DB: update status=PAYMENT_FAILED end Service-->>API: PaymentResult API-->>User: Response
Modullith 특징적 코드 패턴
1. 모듈 간 이벤트 통신 (핵심!)
// ===== Booking 모듈에서 이벤트 발행 =====
package com.airline.booking.domain
@Service
class BookingService(
private val bookingRepository: BookingRepository,
private val events: ApplicationEventPublisher
) {
fun createBooking(
cacheKey: String,
flightId: String,
passengers: List<PassengerInfo>
): Booking {
// 예약 생성
val booking = Booking.create(...)
bookingRepository.save(booking)
// ✅ Modullith: 다른 모듈에 직접 의존하지 않고 이벤트로 통신
events.publishEvent(
BookingCreatedEvent(
bookingId = booking.id,
pnr = booking.pnr,
totalAmount = booking.totalAmount
)
)
return booking
}
}
// ===== 이벤트 정의 (불변 객체) =====
package com.airline.booking.events
data class BookingCreatedEvent(
val bookingId: Long,
val pnr: String,
val totalAmount: BigDecimal
)
// ===== Notification 모듈에서 이벤트 수신 =====
package com.airline.notification.internal
@Service
internal class BookingEventListener(
private val notificationService: NotificationService
) {
// ✅ @ApplicationModuleListener - Modullith 전용 애노테이션
// 트랜잭션 보장, 비동기 처리 지원
@ApplicationModuleListener
@Async
fun onBookingCreated(event: BookingCreatedEvent) {
// 알림 발송 처리
notificationService.send(event.pnr)
}
}핵심 포인트:
@ApplicationModuleListener: Spring의@EventListener+ 모듈 경계 체크- 이벤트는 모듈 간 유일한 명령/쓰기 통신 수단
- 직접 의존성(import) 없음
- Kotlin의
data class로 불변 이벤트 정의
2. 모듈 경계 정의
// search 패키지에 package-info.java 또는 Kotlin 애노테이션
// Kotlin에서는 별도 파일로 작성
// search/ModuleConfiguration.kt
@org.springframework.modulith.ApplicationModule(
displayName = "Search Module",
allowedDependencies = ["adapter", "console"] // ✅ 명시적 의존성 선언
)
package com.airline.search
// booking 패키지
@org.springframework.modulith.ApplicationModule(
displayName = "Booking Module",
allowedDependencies = ["search", "pricing"]
)
package com.airline.booking핵심 포인트:
- 패키지 레벨에서 모듈 경계 명시
- 허용된 의존성만 사용 가능
- 위반 시 테스트 실패
- Kotlin에서는 별도 파일 또는
package-info.java사용
3. 모듈 검증 테스트
package com.airline
@SpringBootTest
class ModuleStructureTest {
private val modules = ApplicationModules.of(FlightBookingApplication::class.java)
@Test
fun `모듈 구조 검증`() {
// ✅ 모듈 구조 자동 검증
modules.verify()
}
@Test
fun `모듈 다이어그램 생성`() {
// ✅ 모듈 다이어그램 자동 생성
Documenter(modules)
.writeDocumentation()
.writeModulesAsPlantUml()
}
@Test
fun `Search 모듈은 Booking에 의존하지 않음`() {
assertThat(modules.getModuleByName("search"))
.doesNotDependOn("booking")
}
}핵심 포인트:
- 컴파일 타임이 아닌 테스트 타임에 검증
- 잘못된 모듈 참조 자동 탐지
- 문서화 자동 생성
- Kotlin 백틱 문법으로 테스트명 가독성 향상
4. 내부(internal) 패키지 숨김
// ❌ 외부에서 접근 불가
package com.airline.pricing.internal
@Service
internal class FareCalculator {
// Pricing 모듈 내부 로직
// 다른 모듈에서 직접 호출 불가
internal fun calculateMarkup(baseFare: BigDecimal, rule: FareRule): BigDecimal {
return baseFare * rule.markupPercent / BigDecimal(100)
}
}
// ✅ 외부에 노출되는 API
package com.airline.pricing.domain
@Service
class PricingService(
private val calculator: FareCalculator // 내부에서만 사용
) {
// 공개 API
fun calculateFare(flight: FlightOption, passengerCount: Int): FareDetails {
val markup = calculator.calculateMarkup(flight.price, rule)
// ...
return FareDetails(baseFare, markup, taxes, total)
}
}핵심 포인트:
internal패키지는 모듈 외부에서 접근 불가- public API만
domain또는api패키지에 노출 - Kotlin의
internal키워드로 모듈 수준 캡슐화 강제 - 캡슐화 강제
5. 트랜잭션 경계
package com.airline.booking.domain
@Service
class BookingService(
private val bookingRepository: BookingRepository,
private val pnrService: PNRService,
private val pricingService: PricingService
) {
// ✅ Modullith: 단일 트랜잭션으로 처리 가능
@Transactional
fun createBooking(
cacheKey: String,
flightId: String,
passengers: List<PassengerInfo>
): Booking {
// 1. 예약 생성
val booking = bookingRepository.save(
Booking.create(flightId, passengers)
)
// 2. PNR 생성
pnrService.create(booking)
// 3. 운임 계산
pricingService.calculate(booking)
// 실패 시 전체 롤백 (MSA에서는 Saga 패턴 필요)
return booking
}
}핵심 포인트:
- 여러 모듈 작업을 단일 트랜잭션으로 처리
- 실패 시 자동 롤백 (복잡한 보상 트랜잭션 불필요)
- MSA 대비 간단한 에러 처리
- Kotlin의 간결한 문법으로 가독성 향상
6. 모듈 간 조회(Query) 패턴
문제 상황: 검색 시 Console에서 설정한 “비활성 공급사”를 제외해야 하는 경우, 이벤트로는 해결할 수 없습니다. 동기적 조회가 필요합니다.
해결: 공개 API 제공 (권장)
// ===== Console 모듈 =====
package com.airline.console.domain
@Service
class SupplierConfigService(
private val supplierRepository: SupplierRepository
) {
// ✅ 공개 API - 다른 모듈에서 호출 가능
fun getActiveSuppliers(): List<String> {
return supplierRepository.findByActive(true)
.map { it.code }
}
fun isSupplierActive(code: String): Boolean {
return supplierRepository.existsByCodeAndActive(code, true)
}
// 쓰기 작업은 internal로 캡슐화
@Transactional
internal fun updateSupplier(code: String, active: Boolean) {
// Console 내부에서만 사용
}
}
// ===== Search 모듈 =====
// 모듈 의존성 명시
@ApplicationModule(
displayName = "Search Module",
allowedDependencies = ["console", "adapter"] // ✅ console 의존 허용
)
package com.airline.search
package com.airline.search.domain
@Service
class SearchService(
private val supplierConfig: SupplierConfigService, // ✅ 직접 주입
private val adapterOrchestrator: AdapterOrchestrator
) {
fun searchFlights(
origin: String,
destination: String,
departureDate: LocalDate
): SearchResult {
// 1. 활성 공급사 목록 조회 (동기)
val activeSuppliers = supplierConfig.getActiveSuppliers()
// 2. 활성 공급사만 검색
val results = adapterOrchestrator.searchParallel(
activeSuppliers = activeSuppliers,
origin = origin,
destination = destination,
departureDate = departureDate
)
return SearchResult(results)
}
}
// ===== Adapter 모듈 =====
package com.airline.adapter.api
@Service
class AdapterOrchestrator(
private val adapters: Map<String, FlightAdapter> // Bean 이름으로 주입
) {
fun searchParallel(
activeSuppliers: List<String>, // ✅ 필터링된 목록 받음
origin: String,
destination: String,
departureDate: LocalDate
): List<FlightOption> {
return adapters
.filterKeys { it in activeSuppliers } // 활성 공급사만
.map { (_, adapter) ->
CompletableFuture.supplyAsync {
adapter.search(origin, destination, departureDate)
}
}
.map { it.join() }
.flatten()
.sortedBy { it.price }
}
}핵심 원칙:
| 상황 | 방법 | 예시 |
|---|---|---|
| 명령/쓰기 | 이벤트 사용 | 예약 생성 → BookingCreatedEvent |
| 조회/읽기 | 직접 호출 가능 | 공급사 목록 조회 → getActiveSuppliers() |
| 의존 방향 | 명시적 선언 | @ApplicationModule(allowedDependencies) |
모듈 공개 범위:
public class/fun: 다른 모듈에서 사용 가능internal class/fun: 모듈 내부에서만 사용- Kotlin의
internal키워드가 모듈 캡슐화에 유용
대안: Shared Kernel 패턴 (필요시)
// shared 모듈
package com.airline.shared.supplier
interface SupplierRegistry {
fun getActiveSuppliers(): List<String>
fun isActive(code: String): Boolean
}
// console이 구현 제공
package com.airline.console.domain
@Service
class SupplierRegistryImpl(
private val supplierRepository: SupplierRepository
) : SupplierRegistry {
override fun getActiveSuppliers(): List<String> {
return supplierRepository.findByActive(true).map { it.code }
}
override fun isActive(code: String): Boolean {
return supplierRepository.existsByCodeAndActive(code, true)
}
}
// search가 인터페이스만 의존
package com.airline.search.domain
@Service
class SearchService(
private val supplierRegistry: SupplierRegistry // ✅ 인터페이스 의존
) {
fun searchFlights(...): SearchResult {
val activeSuppliers = supplierRegistry.getActiveSuppliers()
// ...
}
}모듈별 책임
graph LR subgraph Search Module S1[항공권 검색] S2[결과 캐싱] S3[캐시 키 생성] end subgraph Booking Module B1[예약 생성/관리] B2[PNR 생성] B3[결제 처리] B4[상태 관리] end subgraph Adapter Module A1[GDS 연동] A2[LCC 직연동] A3[NDC 연동] A4[병렬 호출] end subgraph Pricing Module P1[운임 계산] P2[마진 적용] P3[프로모션 계산] end subgraph Console Module C1[운임 규칙 관리] C2[실시간 반영] end
설정 파일
application.yml (핵심 설정만)
spring:
application:
name: flight-booking-system
# ✅ Modullith 설정
modulith:
# 이벤트 외부화 (Phase 3에서 활성화)
events:
externalization:
enabled: false # Phase 1,2: 내부 이벤트
# 모듈 문서 자동 생성
docs:
enabled: true
# 모듈 검증
validation:
enabled: truePhase별 설정 변경:
# Phase 1,2: 모놀리식
spring.modulith.events.externalization.enabled: false
# Phase 3: 일부 모듈 분리 시
spring.modulith.events.externalization:
enabled: true
broker: rabbitmq
# 특정 이벤트만 외부 브로커로
include:
- BookingCreatedEvent
- SearchCompletedEventMSA와의 차이점 요약
| 측면 | MSA | Modullith |
|---|---|---|
| 모듈 통신 | REST API, 메시지 큐 | 메서드 호출, 내부 이벤트 |
| 트랜잭션 | 분산 트랜잭션(Saga) | 단일 DB 트랜잭션 |
| 배포 | 각 서비스 독립 배포 | 단일 애플리케이션 배포 |
| 모듈 분리 | 물리적 분리 (강제) | 논리적 분리 (검증) |
| 에러 처리 | 복잡한 보상 처리 | 단순 롤백 |
| 개발 환경 | 여러 서비스 실행 | 단일 애플리케이션 실행 |
실전 팁
1. 모듈 설계 원칙
- 비즈니스 도메인 단위로 모듈 분리
- 모듈 간 순환 참조 금지
internal패키지로 구현 세부사항 숨김
2. 이벤트 설계
- 이벤트는 불변(immutable) 객체로 (record 사용)
- 과거형으로 네이밍 (
OrderPlaced,PaymentCompleted) - 필요한 데이터만 포함 (전체 엔티티 X)
3. 테스트 전략
- 각 모듈별 독립 테스트 가능
@Scenario로 이벤트 기반 통합 테스트- CI/CD에 모듈 검증 테스트 포함
4. 점진적 전환
- Phase 1: 내부 이벤트로 시작
- Phase 2: 성능 데이터 수집
- Phase 3: 병목 모듈만 외부화
API 레이어 분리 검토
제안: API를 별도 모듈로 분리?
현재 구조: 제안 구조:
search/ api/
├── api/ ├── search/
├── domain/ → ├── booking/
└── events/ └── pricing/
booking/ search/
├── api/ ├── domain/
├── domain/ └── events/
└── events/
분석 결과
API 모듈 분리가 유리한 경우:
- 멀티 프로토콜 (REST, GraphQL, gRPC)
- 멀티 채널 (Web, Mobile, Partner API)
- API 전담 팀 존재
- 빈번한 API 버저닝
현재 구조 유지가 나은 경우 (항공 시스템):
- 단일 REST API 채널
- 내부 시스템용
- 팀 규모 10명 이하
- 빠른 개발 우선
권장 접근
Phase 1: 각 모듈이 자신의 API 소유
search/api/ - Search 도메인 API
booking/api/ - Booking 도메인 API
pricing/api/ - Pricing 도메인 API
- 도메인 중심 모듈화 (Vertical Slice)
- 빠른 개발 가능
- Modullith 철학과 일치
Phase 2: 공통 관심사만 분리 (선택적)
security/ - 인증/인가
validation/ - 공통 검증
Phase 3: API Gateway 분리 (필요시)
- 독립 서비스로 분리
- 라우팅, Rate Limiting 등 담당
- 도메인 서비스와 분리
결론: 초기에는 각 모듈이 API를 소유하고, 필요시 점진적으로 분리하는 것을 권장합니다.
점진적 전환 전략
timeline title 항공 시스템 아키텍처 진화 로드맵 section Phase 1 (0-6개월) MVP 출시 : Modullith 단일 애플리케이션 : 모듈 경계 엄격 유지 : 이벤트 기반 통신 : 빠른 기능 개발 section Phase 2 (6-12개월) 성능 최적화 : 수평 확장 (여러 인스턴스) : 캐싱 전략 고도화 : DB 쿼리 최적화 : 여전히 Modullith 유지 section Phase 3 (12개월+) 선택적 분리 : Adapter 모듈 독립화 : Search 모듈 분리 검토 : Booking/Pricing 유지 : 80%는 Modullith
Phase 1: Modullith로 시작 (0-6개월)
목표: MVP 빠르게 출시
- 단일 애플리케이션으로 개발
- 모듈 경계는 엄격하게 유지
- 이벤트 기반 통신 패턴 확립
Phase 2: 성능 최적화 (6-12개월)
목표: 트래픽 증가에 대응
- 수평 확장 (여러 인스턴스)
- 캐싱 전략 고도화
- DB 쿼리 최적화
- 여전히 Modullith 유지
Phase 3: 선택적 분리 (12개월+)
목표: 필요한 모듈만 독립화
- Adapter 모듈을 먼저 분리 (Go 언어로 재작성 고려) → 외부 API 호출 최적화
- Search 모듈 분리 고려 (트래픽이 10배 이상 차이나면)
- Booking/Pricing/Console은 Modullith로 유지
외부화 설정 예시
# Phase 3: 일부 모듈 분리 시
spring:
modulith:
events:
externalization:
enabled: true
broker: rabbitmq
# 특정 이벤트만 외부 브로커로
externalize:
- BookingCreatedEvent
- SearchCompletedEventMSA 전환 시 고려사항
핵심 질문: 모듈 간 동기 조회는 어떻게 되나?
Modullith에서 MSA로 전환 시, 동기적 데이터 조회 로직은 REST API로 재개발해야 합니다.
현재 Modullith 구조
// Console 모듈 - 직접 호출
@Service
class SupplierConfigService(
private val supplierRepository: SupplierRepository
) {
fun getActiveSuppliers(): List<String> {
return supplierRepository.findByActive(true).map { it.code }
}
}
// Search 모듈 - 메모리 호출 (<1ms)
@Service
class SearchService(
private val supplierConfig: SupplierConfigService // 직접 주입
) {
fun searchFlights(...): SearchResult {
val activeSuppliers = supplierConfig.getActiveSuppliers() // 메모리 호출
return adapterOrchestrator.searchParallel(activeSuppliers, ...)
}
}MSA 전환 후 구조
// ===== Console Service =====
// ✅ REST API 새로 개발 필요
@RestController
@RequestMapping("/api/suppliers")
class SupplierApi(
private val supplierConfigService: SupplierConfigService
) {
@GetMapping("/active")
fun getActiveSuppliers(): ActiveSuppliersResponse {
val suppliers = supplierConfigService.getActiveSuppliers()
return ActiveSuppliersResponse(
suppliers = suppliers,
cachedAt = Instant.now()
)
}
}
data class ActiveSuppliersResponse(
val suppliers: List<String>,
val cachedAt: Instant
)
// ===== Search Service =====
// ✅ HTTP 클라이언트 새로 개발 필요
@Service
class SearchService(
private val consoleClient: ConsoleClient // REST 클라이언트
) {
fun searchFlights(...): SearchResult {
val activeSuppliers = consoleClient.getActiveSuppliers() // HTTP 호출 (10-50ms)
return adapterOrchestrator.searchParallel(activeSuppliers, ...)
}
}
// Feign Client 구현
@FeignClient(name = "console-service", url = "\${console.service.url}")
interface ConsoleClient {
@GetMapping("/api/suppliers/active")
fun getActiveSuppliers(): List<String>
}전환 작업 항목
| 작업 | 설명 | 예상 공수 |
|---|---|---|
| REST API 개발 | 엔드포인트, DTO, 문서화 | 1-2일 |
| 클라이언트 개발 | Feign/WebClient, 재시도, 타임아웃 | 1-2일 |
| 에러 핸들링 | Circuit Breaker, Fallback | 1일 |
| 캐싱 추가 | 네트워크 호출 최소화 | 1일 |
| 테스트 및 배포 | 통합 테스트, 롤백 계획 | 2일 |
| 총 공수 | 6-8일 |
전환 부담을 줄이는 전략
✅ 전략 1: 인터페이스 추상화 (강력 권장)
처음부터 인터페이스 기반으로 설계하면 전환 시 구현체만 교체하면 됩니다.
// ===== Shared 모듈 (공통 인터페이스) =====
package com.airline.shared.supplier
interface SupplierRegistry {
fun getActiveSuppliers(): List<String>
fun isActive(code: String): Boolean
}
// ===== Phase 1: Modullith 버전 =====
// Console 모듈 - 로컬 구현
@Service
@Primary // Modullith 단계에서 사용
class LocalSupplierRegistry(
private val repository: SupplierRepository
) : SupplierRegistry {
override fun getActiveSuppliers(): List<String> {
return repository.findByActive(true).map { it.code }
}
override fun isActive(code: String): Boolean {
return repository.existsByCodeAndActive(code, true)
}
}
// Search 모듈 - 인터페이스에만 의존
@Service
class SearchService(
private val supplierRegistry: SupplierRegistry // ✅ 인터페이스 의존
) {
fun searchFlights(...): SearchResult {
val activeSuppliers = supplierRegistry.getActiveSuppliers()
return adapterOrchestrator.searchParallel(activeSuppliers, ...)
}
}
// ===== Phase 3: MSA 전환 후 =====
// Search Service에 원격 구현 추가
@Service
@Primary // MSA 단계에서 사용
class RemoteSupplierRegistry(
private val consoleClient: ConsoleClient,
private val cacheManager: CacheManager
) : SupplierRegistry {
@Cacheable("activeSuppliers")
override fun getActiveSuppliers(): List<String> {
return consoleClient.getActiveSuppliers() // HTTP 호출
}
@Cacheable("supplierStatus")
override fun isActive(code: String): Boolean {
return consoleClient.isSupplierActive(code)
}
}
// ✅ SearchService 코드는 전혀 변경 없음!장점:
- SearchService는 수정 불필요
- 단위 테스트 용이 (Mock 가능)
- 점진적 전환 가능 (Feature Flag로 구현 전환)
✅ 전략 2: DTO 미리 정의
처음부터 DTO를 사용하면 나중에 JSON 직렬화가 쉽습니다.
// Modullith 단계부터 DTO 사용
package com.airline.console.api
data class ActiveSuppliersResponse(
val suppliers: List<String>,
val timestamp: Instant = Instant.now()
)
@Service
class SupplierConfigService {
// DTO 반환 (나중에 REST API 응답으로 그대로 사용)
fun getActiveSuppliers(): ActiveSuppliersResponse {
val suppliers = repository.findByActive(true).map { it.code }
return ActiveSuppliersResponse(suppliers = suppliers)
}
}✅ 전략 3: 이벤트 + 캐시 패턴
읽기 빈도가 높고 변경이 적은 데이터는 이벤트로 동기화하는 방식이 효과적입니다.
// ===== Modullith 버전 =====
@Service
class SupplierCache {
private var activeSuppliers: Set<String> = emptySet()
@ApplicationModuleListener
fun onSupplierConfigChanged(event: SupplierConfigChangedEvent) {
this.activeSuppliers = event.activeSuppliers.toSet()
}
fun getActiveSuppliers(): List<String> = activeSuppliers.toList()
}
@Service
class SearchService(
private val supplierCache: SupplierCache // 캐시 조회
) {
fun searchFlights(...) {
val activeSuppliers = supplierCache.getActiveSuppliers() // 즉시 반환
}
}
// ===== MSA 전환 후 =====
@Service
class SupplierCache(
private val consoleClient: ConsoleClient
) {
private var activeSuppliers: Set<String> = emptySet()
// Kafka 리스너로 변경
@KafkaListener(topics = ["supplier-config-changed"])
fun onSupplierConfigChanged(event: SupplierConfigChangedEvent) {
this.activeSuppliers = event.activeSuppliers.toSet()
}
// 또는 주기적 폴링
@Scheduled(fixedRate = 60000) // 1분마다
fun refreshCache() {
this.activeSuppliers = consoleClient.getActiveSuppliers().toSet()
}
fun getActiveSuppliers(): List<String> = activeSuppliers.toList()
}
// ✅ SearchService는 변경 없음!장점:
- 네트워크 호출 최소화
- 응답 속도 유지 (<1ms)
- MSA 전환 후에도 동일한 패턴
단점:
- 실시간성 약간 감소 (캐시 TTL만큼)
- 초기 로딩 처리 필요
전환 공수 비교
✅ 인터페이스 기반 설계 시:
┌─────────────────────────┬────────┐
│ Console: REST API 개발 │ 1-2일 │
│ Search: 구현체 교체 │ 1-2일 │
│ 테스트 & 배포 │ 1-2일 │
├─────────────────────────┼────────┤
│ 총합 │ 3-6일 │
└─────────────────────────┴────────┘
❌ 직접 의존 시:
┌─────────────────────────┬────────┐
│ Console: REST API 개발 │ 1-2일 │
│ Search: 전체 리팩토링 │ 3-5일 │
│ 테스트 & 버그 수정 │ 2-3일 │
├─────────────────────────┼────────┤
│ 총합 │ 6-10일 │
└─────────────────────────┴────────┘
권장 사항
처음부터 준비할 것:
-
✅ 인터페이스 기반 설계
- Shared 모듈에 인터페이스 정의
- 구현체만 교체 가능하도록
-
✅ DTO 사용
- 도메인 객체 직접 노출 금지
- API 응답 전용 DTO 사용
-
✅ 캐싱 전략
- 조회 빈도 높은 데이터는 캐싱
- Redis 등 외부 캐시 고려
-
✅ 모듈 경계 명확화
@ApplicationModule로 의존성 명시- 순환 참조 방지
MSA 전환 시 추가 작업:
- 🔧 API Gateway 설정
- 🔧 서비스 디스커버리 (Consul/Eureka)
- 🔧 분산 추적 (Zipkin/Jaeger)
- 🔧 Circuit Breaker (Resilience4j)
- 🔧 API 버전 관리
의사결정 가이드
아키텍처 선택 플로우차트
graph TD Start([신규 항공 시스템 구축]) Q1{팀 규모 30명 이상?} Q2{DevOps/SRE 팀 있음?} Q3{모듈별 독립 배포 필수?} Q4{특정 모듈 트래픽<br/>10배 이상 차이?} Q5{글로벌 분산 배포<br/>필요?} Modullith[✅ Spring Modullith<br/>추천] MSA[⚠️ MSA 고려<br/>단, 복잡도 주의] Hybrid[🔄 Hybrid 접근<br/>Modullith + 일부 분리] Start --> Q1 Q1 -->|No| Modullith Q1 -->|Yes| Q2 Q2 -->|No| Modullith Q2 -->|Yes| Q3 Q3 -->|No| Q4 Q3 -->|Yes| MSA Q4 -->|No| Modullith Q4 -->|Yes| Hybrid Q5 -->|Yes| MSA Q5 -->|No| Modullith style Modullith fill:#90EE90 style MSA fill:#FFB6C1 style Hybrid fill:#FFD700
✅ Modullith 추천 상황
-
팀 구성
- 개발팀 10명 이하
- 인프라 전문가 없음
- 초기 스타트업 단계
-
프로젝트 특성
- 신규 프로젝트
- 빠른 시장 출시 필요
- 비즈니스 요구사항 변화 많음
-
기술 요구사항
- 트래픽 예측 가능 (초당 100-1000 요청)
- 대부분 모듈의 부하가 비슷함
- 단일 기술 스택 선호 (Java/Spring)
⚠️ MSA 고려 상황
-
팀 구성
- 개발팀 30명 이상
- DevOps/SRE 팀 보유
- 조직이 서비스 단위로 분리됨
-
기술 요구사항
- 특정 모듈 트래픽이 10배 이상 차이
- 모듈별 다른 기술 스택 필요
- 글로벌 분산 배포 필요
-
비즈니스 요구사항
- 모듈별 독립 배포 필수
- 장애 격리 중요
- 팀 간 독립성 극대화 필요
권장 접근
graph LR A[0-1년<br/>Modullith로 시작] -->|모니터링| B[1-2년<br/>병목 파악] B -->|데이터 기반| C[2년+<br/>선택적 분리] style A fill:#90EE90 style B fill:#87CEEB style C fill:#FFD700
핵심 원칙:
“마이크로서비스는 목적지가 아니라 선택지다”
처음부터 완벽한 분산 시스템을 만들려 하지 말고,
실제 데이터로 검증된 병목만 분리하라.
참고 자료
버전 요구사항
최소 버전 및 호환성
| 항목 | 최소 버전 | 권장 버전 | 최신 안정 버전 (2025년 10월) |
|---|---|---|---|
| JDK | Java 17 (LTS) | Java 21 (LTS) ⭐ | Java 23 ✅ |
| Spring Boot | 3.0+ | 3.3+ | 3.5.6 ✅ |
| Spring Framework | 6.0+ | 6.1+ | 6.2.11 ✅ |
| Spring Modulith | 1.0+ | 1.3+ | 1.3.1 ✅ |
| Kotlin | 1.8+ | 2.0+ | 2.1.0 ✅ |
⭐ 항공 발권 시스템 권장: Java 21 (LTS)
- Virtual Thread 지원 (Java 21에서 정식 기능)
- 외부 API 호출이 많은 항공 시스템에 최적
- GDS, LCC, NDC 등 다중 공급사 병렬 호출 시 성능 향상
- LTS 버전으로 장기 지원 보장
Virtual Thread가 필요한 이유:
// Adapter에서 여러 공급사 동시 호출
// Virtual Thread 사용 시 수천 개의 동시 요청 처리 가능
@Service
class AdapterOrchestrator {
@Async
fun searchParallel(...) {
// GDS, LCC, NDC 동시 호출
// Virtual Thread로 경량 스레드 생성
}
}Java LTS 버전:
- Java 17 (LTS, 2021년 9월) - Virtual Thread 미지원
- Java 21 (LTS, 2023년 9월) - Virtual Thread 정식 지원 ⭐
- Java 25 (LTS 예정, 2025년 9월)
호환성:
- ✅ Spring Boot 3.x는 Java 17부터 Java 25까지 공식 지원
- ✅ 최신 JDK (Java 23, 24) 사용 가능
- ✅ 최신 Kotlin 안정 버전 (2.1.0) 사용 가능
- ✅ Spring Boot 3.2+에서 Virtual Thread 자동 지원
중요:
- Spring Boot 3.x는 최소 Java 17이 필요
- Spring Modulith는 Spring Boot 3를 기반으로 하며, 2022년 11월 실험 프로젝트로 시작
- Spring Boot 2.x에서는 Spring Modulith를 사용할 수 없음
- 항공 시스템처럼 외부 API 호출이 많다면 Java 21 (LTS) 필수 권장
Spring Modulith 버전별 호환성
Spring Modulith는 1.1.12 버전부터 공식적인 Spring Boot 호환성 매트릭스를 제공하기 시작했습니다
주요 버전:
- Spring Modulith 1.0.x → Spring Boot 3.0.x
- Spring Modulith 1.1.x → Spring Boot 3.1.x
- Spring Modulith 1.2.x → Spring Boot 3.2.x, 3.3.x
- Spring Modulith 1.3.x → Spring Boot 3.3.x, 3.4.x, 3.5.x
최신 안정 버전 (2025년 10월 기준):
- Spring Modulith: 1.3.1
- Spring Boot: 3.5.6
- Spring Framework: 6.2.11
의존성 설정 (Gradle Kotlin DSL)
build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0"
kotlin("plugin.jpa") version "2.1.0" // JPA 사용 시
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
}
group = "com.airline"
version = "1.0.0"
java {
// Virtual Thread 사용을 위해 Java 21 (LTS) 필수
sourceCompatibility = JavaVersion.VERSION_21
}
repositories {
mavenCentral()
}
dependencies {
// Spring Modulith BOM
implementation(platform("org.springframework.modulith:spring-modulith-bom:1.3.1"))
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Spring Modulith
implementation("org.springframework.modulith:spring-modulith-starter-core")
// Spring Modulith - Test
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}settings.gradle.kts
rootProject.name = "flight-booking-system"Virtual Thread 활성화 (Spring Boot 3.2+)
application.yml
spring:
application:
name: flight-booking-system
threads:
virtual:
enabled: true # Virtual Thread 활성화
# 데이터베이스 설정
datasource:
url: jdbc:postgresql://localhost:5432/flight_db
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
show-sql: true
# 로깅
logging:
level:
org.springframework.modulith: DEBUG또는 프로그래밍 방식:
@Configuration
class AsyncConfig {
@Bean
fun applicationTaskExecutor(): AsyncTaskExecutor {
return SimpleAsyncTaskExecutor("vt-").apply {
setVirtualThreads(true) // Virtual Thread 사용
}
}
}프로젝트 구조 예시
flight-booking-system/
├── build.gradle.kts
├── settings.gradle.kts
├── src/
│ ├── main/
│ │ ├── kotlin/
│ │ │ └── com/airline/
│ │ │ ├── FlightBookingApplication.kt
│ │ │ ├── search/ # Search 모듈
│ │ │ │ ├── domain/
│ │ │ │ │ └── SearchService.kt
│ │ │ │ └── internal/
│ │ │ │ └── SearchRepository.kt
│ │ │ ├── booking/ # Booking 모듈
│ │ │ │ ├── domain/
│ │ │ │ │ └── BookingService.kt
│ │ │ │ └── events/
│ │ │ │ └── BookingCreatedEvent.kt
│ │ │ ├── adapter/ # Adapter 모듈
│ │ │ │ └── api/
│ │ │ │ └── AdapterOrchestrator.kt
│ │ │ └── pricing/ # Pricing 모듈
│ │ │ └── domain/
│ │ │ └── PricingService.kt
│ │ └── resources/
│ │ └── application.yml
│ └── test/
│ └── kotlin/
│ └── com/airline/
│ └── ModuleStructureTest.kt
공식 문서
주요 아티클
블로그 및 사례
- Monolith to Microservices - Martin Fowler
- Should You Use Microservices?
- Spring Modullith 실전 적용기 - Baeldung
도메인 주도 설계
아키텍처 패턴
항공 시스템 관련
커뮤니티
결론
항공권 발권 시스템에 Modullith 적용 권장 이유
- 빠른 시장 진입: 복잡한 인프라 없이 비즈니스 로직에 집중
- 개발 생산성: 단일 프로젝트로 빠른 개발 및 디버깅
- 운영 단순성: 배포, 모니터링, 장애 대응 간소화
- 비용 효율: 초기 인프라 비용 최소화
- 확장 가능: 필요 시 점진적으로 MSA 전환 가능
핵심 메시지
“처음부터 완벽한 마이크로서비스를 목표로 하지 말고,
깔끔한 모듈 구조를 먼저 만들어라.
분산은 필요할 때 자연스럽게 이루어진다.”
문서 버전: 1.0
최종 수정일: 2025년 10월 21일
작성자: 개발팀
검토자: 아키텍처팀