포스트

모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기

“그냥 MSA로 가면 안 되나요?”

새 프로젝트를 시작하면 자연스럽게 드는 질문이 있다. “마이크로서비스로 만들어야 하지 않을까?” 특히 주식 플랫폼처럼 여러 도메인(시세, 뉴스, 알람, 포트폴리오, 체결)이 얽혀 있으면 더욱 그런 생각이 든다.

monticker는 모듈식 모놀리스(Modular Monolith) 를 선택했다. 이 결정의 배경을 정리한다.


MSA가 실제로 주는 것과 빼앗아 가는 것

MSA의 장점은 분명하다. 서비스별 독립 배포, 언어·프레임워크 자유, 팀별 소유권. 그런데 이 장점들은 규모가 될 때 의미가 있다.

초기 단계에서 MSA를 선택하면 대신 얻게 되는 것들이 있다.

1
2
3
4
5
6
7
8
9
10
11
모놀리스에서의 함수 호출:
  userService.getUser(userId)   // 1 nanosecond

MSA에서의 RPC/HTTP 호출:
  GET /api/users/{userId}       // 1~50 milliseconds
  + 네트워크 불안정성
  + 직렬화/역직렬화 비용
  + 서비스 디스커버리 설정
  + 인증 전파 (JWT 또는 서비스 토큰)
  + 분산 트랜잭션 처리
  + 로컬 개발 환경 복잡도 (docker-compose 20개 서비스)

monticker의 초기 팀 규모와 트래픽을 감안하면, MSA는 해결보다 문제를 더 많이 만들어낸다.

“MSA는 확장성을 위한 선택이 아니라, 조직 경계를 코드로 표현하기 위한 선택이다.” — Sam Newman, Building Microservices


모듈식 모놀리스의 구조

모놀리스라고 해서 스파게티 코드를 말하는 게 아니다. 패키지 경계를 명확히 나누고, 모듈 간 의존 방향을 강제하는 구조다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
backend/api/src/main/kotlin/com/monticker/api/
├── auth/          # 인증·인가
├── stock/         # 종목 기본 정보
├── marketdata/    # 시세·캔들·호가창
├── event/         # 이벤트 탐지·타임라인
├── alert/         # 알람 규칙·이력
├── paper/         # 모의투자 주문
├── matching/      # CLOB 체결 엔진
├── risk/          # 리스크 한도 시스템
├── wallet/        # 투자 원장·감정 태그
├── quant/         # Quant Lab 룰·백테스트
├── analytics/     # 포트폴리오 최적화·패턴
├── news/          # 뉴스·공시
└── screener/      # 종목 스크리너

각 모듈은 독립적인 패키지 안에 Controller, Service, Repository를 가진다. 다른 모듈의 Repository에 직접 접근하지 않고, 항상 해당 모듈의 Service를 통한다.


Kotlin + Spring Boot 3.5 선택

Kotlin을 선택한 이유

Java 대신 Kotlin을 선택한 결정적 이유는 표현력이다.

1
2
3
4
5
6
7
8
// Java: 뻔하고 장황하다
public Optional<User> findUserByEmail(String email) {
    return userRepository.findByEmail(email);
}

// Kotlin: 간결하면서도 null 안전
fun findUserByEmail(email: String): User? =
    userRepository.findByEmail(email)

금융 도메인에서 Kotlin이 더 빛나는 경우는 data class와 sealed class다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 주문 상태 머신을 sealed class로 표현
sealed class OrderStatus {
    object Pending : OrderStatus()
    object Reserved : OrderStatus()
    data class PartiallyFilled(val filledQty: Int) : OrderStatus()
    object Filled : OrderStatus()
    object Cancelled : OrderStatus()
}

// when 표현식으로 모든 케이스를 컴파일 타임에 검증
fun processStatus(status: OrderStatus): String = when (status) {
    is OrderStatus.Pending    -> "주문 접수됨"
    is OrderStatus.Reserved   -> "예약금 차감됨"
    is OrderStatus.PartiallyFilled -> "부분 체결: ${status.filledQty}주"
    is OrderStatus.Filled     -> "전량 체결 완료"
    is OrderStatus.Cancelled  -> "주문 취소됨"
    // else 없이도 컴파일 통과 — 모든 케이스 처리됨
}

Spring Boot 3.5

Spring Boot 3.x는 GraalVM Native Image를 공식 지원한다. 모놀리스에서 시작하더라도 나중에 특정 서비스를 native로 컴파일해 메모리를 줄이는 출구 전략이 생긴다.

1
2
3
4
5
6
7
# application.yml
spring:
  datasource:
    url: ${DB_URL}
  flyway:
    enabled: true
    locations: classpath:db/migration

Flyway로 DB 마이그레이션을 코드로 관리하고, 환경변수로 설정을 주입하는 12 Factor App 원칙을 따른다.


모듈 간 경계 강제 방법

모듈식 모놀리스에서 가장 중요한 것은 경계를 지키는 규율이다. 코드 리뷰에서 잡지 않으면 금방 무너진다.

monticker에서는 세 가지 방법으로 경계를 강제한다.

1. 패키지 private 활용

각 모듈의 내부 구현체(*Repository, 내부 DTO)는 internal 키워드로 모듈 밖에서 접근 불가.

1
2
3
4
5
6
// 이건 모듈 외부에서 주입받아 사용 가능
@Service
class MatchingOrderBookService(...)

// 이건 모듈 내부에서만 사용
internal class OrderBookRepository(...)

2. 서비스 레이어를 통한 접근

다른 모듈의 데이터가 필요하면 Repository가 아닌 Service를 통한다.

1
2
3
4
5
6
7
8
9
// BAD: matching 모듈이 wallet 모듈의 Repository를 직접 접근
class MatchingService(
    private val ledgerRepository: LedgerRepository  // 금지
)

// GOOD: 항상 해당 모듈의 Service를 통한다
class MatchingService(
    private val ledgerService: LedgerService         // 허용
)

3. 이벤트 기반 느슨한 결합

동기 호출이 불필요한 경우 Spring의 ApplicationEvent로 느슨하게 연결한다.

1
2
3
4
5
6
7
// 체결 완료 → 원장 기록 (동기 결합 불필요)
eventPublisher.publishEvent(FillCompletedEvent(fill))

@EventListener
fun onFillCompleted(event: FillCompletedEvent) {
    ledgerService.recordFill(event.fill)
}

언제 MSA로 전환할 것인가

모놀리스를 선택했다고 MSA를 포기한 게 아니다. 명확한 신호가 보이면 전환한다.

신호전환 후보
Quant 백테스트가 API 응답에 영향을 줌Rule Engine 서비스 분리
시세 처리량이 JVM GC에 걸림Go 수집기 → 이미 분리됨
전략 마켓 팀이 별도로 생김Strategy Market 서비스 분리
WebSocket 연결이 수만 개를 넘어섬Netty → 이미 분리됨

현재 Go Market Gateway와 Netty Broadcast Gateway가 이미 분리된 독립 프로세스로 동작한다. “필요할 때 분리”하는 원칙이 실제로 적용된 예다.


정리

  • 모놀리스 ≠ 스파게티 코드. 명확한 패키지 경계와 의존 방향 규칙이 있으면 충분히 유지보수 가능하다.
  • MSA의 복잡도는 팀 규모와 트래픽이 그것을 정당화할 때 도입한다.
  • Kotlin의 sealed class·data class·when 표현식은 금융 도메인의 상태 머신을 안전하게 표현한다.

다음 편에서는 왜 일반 PostgreSQL이 아닌 TimescaleDB를 시계열 저장소로 선택했는지 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다