항공권 발권 시스템: 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: 검증된 병목만 독립 서비스로 분리


목차

  1. Spring Modullith란?
  2. 현재 MSA vs Modullith 비교
  3. 항공 시스템 적용 시 장단점
  4. 구현 가이드
  5. 의사결정 기준
  6. 버전 및 참고자료

예상 독서 시간: 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 비교

핵심 차이점

항목MSAModullith항공 시스템 영향
배포5개 파이프라인1개 파이프라인초기 배포 단순화
개발 속도6-12개월2-3개월빠른 MVP 출시
모듈 간 호출10-50ms<1ms50배 빠른 응답
로컬 개발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: true

Phase별 설정 변경:

# Phase 1,2: 모놀리식
spring.modulith.events.externalization.enabled: false
 
# Phase 3: 일부 모듈 분리 시
spring.modulith.events.externalization:
  enabled: true
  broker: rabbitmq
  # 특정 이벤트만 외부 브로커로
  include:
    - BookingCreatedEvent
    - SearchCompletedEvent

MSA와의 차이점 요약

측면MSAModullith
모듈 통신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
        - SearchCompletedEvent

MSA 전환 시 고려사항

핵심 질문: 모듈 간 동기 조회는 어떻게 되나?

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, Fallback1일
캐싱 추가네트워크 호출 최소화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일 │
└─────────────────────────┴────────┘

권장 사항

처음부터 준비할 것:

  1. 인터페이스 기반 설계

    • Shared 모듈에 인터페이스 정의
    • 구현체만 교체 가능하도록
  2. DTO 사용

    • 도메인 객체 직접 노출 금지
    • API 응답 전용 DTO 사용
  3. 캐싱 전략

    • 조회 빈도 높은 데이터는 캐싱
    • Redis 등 외부 캐시 고려
  4. 모듈 경계 명확화

    • @ApplicationModule로 의존성 명시
    • 순환 참조 방지

MSA 전환 시 추가 작업:

  1. 🔧 API Gateway 설정
  2. 🔧 서비스 디스커버리 (Consul/Eureka)
  3. 🔧 분산 추적 (Zipkin/Jaeger)
  4. 🔧 Circuit Breaker (Resilience4j)
  5. 🔧 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 추천 상황

  1. 팀 구성

    • 개발팀 10명 이하
    • 인프라 전문가 없음
    • 초기 스타트업 단계
  2. 프로젝트 특성

    • 신규 프로젝트
    • 빠른 시장 출시 필요
    • 비즈니스 요구사항 변화 많음
  3. 기술 요구사항

    • 트래픽 예측 가능 (초당 100-1000 요청)
    • 대부분 모듈의 부하가 비슷함
    • 단일 기술 스택 선호 (Java/Spring)

⚠️ MSA 고려 상황

  1. 팀 구성

    • 개발팀 30명 이상
    • DevOps/SRE 팀 보유
    • 조직이 서비스 단위로 분리됨
  2. 기술 요구사항

    • 특정 모듈 트래픽이 10배 이상 차이
    • 모듈별 다른 기술 스택 필요
    • 글로벌 분산 배포 필요
  3. 비즈니스 요구사항

    • 모듈별 독립 배포 필수
    • 장애 격리 중요
    • 팀 간 독립성 극대화 필요

권장 접근

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월)
JDKJava 17 (LTS)Java 21 (LTS)Java 23 ✅
Spring Boot3.0+3.3+3.5.6 ✅
Spring Framework6.0+6.1+6.2.11 ✅
Spring Modulith1.0+1.3+1.3.1 ✅
Kotlin1.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

공식 문서

주요 아티클

블로그 및 사례

도메인 주도 설계

아키텍처 패턴

항공 시스템 관련

커뮤니티


결론

항공권 발권 시스템에 Modullith 적용 권장 이유

  1. 빠른 시장 진입: 복잡한 인프라 없이 비즈니스 로직에 집중
  2. 개발 생산성: 단일 프로젝트로 빠른 개발 및 디버깅
  3. 운영 단순성: 배포, 모니터링, 장애 대응 간소화
  4. 비용 효율: 초기 인프라 비용 최소화
  5. 확장 가능: 필요 시 점진적으로 MSA 전환 가능

핵심 메시지

“처음부터 완벽한 마이크로서비스를 목표로 하지 말고,
깔끔한 모듈 구조를 먼저 만들어라.
분산은 필요할 때 자연스럽게 이루어진다.”


문서 버전: 1.0
최종 수정일: 2025년 10월 21일
작성자: 개발팀
검토자: 아키텍처팀