당신은 마이크로서비스가 필요하지 않다

“마이크로서비스로 갈 거야, 모놀리스로 갈 거야?”

새 프로젝트를 시작한다고 하면, 어김없이 이런 질문이 따라오곤 했다. 마치 모놀리스는 레거시이고, 마이크로서비스는 모던한 것처럼.

하지만 마이크로서비스는 아키텍처 스타일일 뿐, 진화의 방향이 아니다.

마이크로서비스의 숨겨진 비용

마이크로서비스의 장점은 이제는 충분히 잘 알려져 있다. 독립 배포, 기술 스택 자유, 팀 자율성. 하지만 이 장점이 실현되려면 전제 조건이 있다. 서비스가 충분히 많고, 팀이 충분히 크고, 트래픽이 충분히 다양해야 한다.1

그 전제가 없는 상태에서 마이크로서비스를 도입하면, 비용이 장점보다 커지게 된다.

문제모놀리스마이크로서비스
함수 호출나노초네트워크 왕복 (밀리초~)
트랜잭션DB 트랜잭션 한 번Saga 패턴, 보상 트랜잭션, 이벤트 큐
디버깅스택 트레이스분산 트레이싱 (Jaeger, Zipkin)
배포바이너리 하나서비스 N개의 버전 호환성 관리
로컬 개발 환경단일 커맨드Docker Compose + 서비스 N개 띄우기
데이터 정합성JOIN 한 번API 호출 체이닝, 이벤트 소싱

서비스 3개를 운영하는 팀이 Saga 패턴을 구현하고, 분산 트레이싱을 붙이고, 서비스 간 API 버전을 관리하는 건 문제를 해결하는 게 아니라 문제를 만드는 것이다.

모듈러 모놀리스

모듈러 모놀리스는 코드의 경계는 마이크로서비스처럼 나누되, 배포는 하나로 유지하는 구조다.

핵심은 간단하다.

  • 모듈 간 직접 import 금지
  • 모듈 간 통신은 명시적 인터페이스를 통해서만
  • 데이터베이스 스키마도 모듈별로 분리

배포 단위만 하나일 뿐, 내부는 이미 분리되어 있다. 나중에 특정 모듈을 떼어내야 할 때, 네트워크 호출로 바꾸기만 하면 된다.

프로젝트 구조

├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── user/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── model.go
│   │   └── event.go
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── model.go
│   │   └── event.go
│   ├── payment/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── model.go
│   │   └── event.go
│   └── platform/
│       ├── eventbus/
│       ├── database/
│       └── config/
├── go.mod
└── go.sum

각 모듈은 자신만의 handler, service, repository, model을 가진다. internal/orderinternal/user를 직접 import하는 일은 없다.

모듈 간 통신

모듈러 모놀리스의 가장 큰 이점은 같은 프로세스, 같은 데이터베이스라는 것이다. 마이크로서비스에서는 불가능한 크로스 모듈 트랜잭션 때문에 머리를 싸맬 필요가 없다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// internal/order/service.go
func (s *Service) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	order := Order{
		ID:     uuid.New(),
		UserID: req.UserID,
		Items:  req.Items,
		Status: StatusPending,
	}

	if err := s.repo.Save(ctx, tx, order); err != nil {
		return err
	}

	if err := s.payment.CreatePayment(ctx, tx, order.ID, order.Total()); err != nil {
		return err
	}

	return tx.Commit()
}

주문 생성과 결제 생성이 하나의 트랜잭션으로 묶인다. 마이크로서비스였다면 Saga 패턴, 보상 트랜잭션, 이벤트 큐가 필요했을 것이다. 여기서는 tx.Commit() 한 번이면 된다.

모듈 간 호출은 인터페이스를 통해 이루어진다.

1
2
3
4
// internal/order/dependency.go
type PaymentService interface {
	CreatePayment(ctx context.Context, tx *sql.Tx, orderID uuid.UUID, amount int64) error
}

order 모듈은 payment 패키지를 직접 import하지 않는다. 인터페이스만 알고, 구현체는 main.go에서 주입한다.

정합성이 필요 없는 후속 작업(알림 발송, 로그 기록 등)은 이벤트로 처리한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// internal/platform/eventbus/bus.go
type Event struct {
	Type    string
	Payload any
}

type Handler func(Event)

type Bus struct {
	mu       sync.RWMutex
	handlers map[string][]Handler
}

func New() *Bus {
	return &Bus{handlers: make(map[string][]Handler)}
}

func (b *Bus) Subscribe(eventType string, h Handler) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.handlers[eventType] = append(b.handlers[eventType], h)
}

func (b *Bus) Publish(e Event) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, h := range b.handlers[e.Type] {
		h(e)
	}
}
1
2
3
4
5
6
7
8
9
// 트랜잭션 커밋 이후, 알림은 이벤트로
if err := tx.Commit(); err != nil {
	return err
}

s.bus.Publish(eventbus.Event{
	Type:    "order.created",
	Payload: order,
})

트랜잭션이 필요한 곳은 트랜잭션으로, 느슨한 결합이 필요한 곳은 이벤트로. 이 구분이 모듈러 모놀리스의 핵심이다.

마이크로서비스가 정말 필요한 순간

모듈러 모놀리스에도 한계는 있다.

  • 독립적 스케일링 — 특정 모듈만 10배 트래픽을 받는다면, 전체를 스케일링하는 건 낭비다.
  • 기술 스택 다양성 — 모듈마다 적합한 언어가 다른 경우.
  • 팀 독립성 — 10개 이상의 팀이 하나의 저장소에서 배포 일정을 조율하는 건 병목이다.
  • 장애 격리 — 결제 모듈의 메모리 누수가 전체 서비스를 죽이면 안 되는 경우.

하지만 이 요구사항들이 실제로 발생하는 시점은, 역시 생각보다 훨씬 늦다.

MSA가 필요한 조건모듈러 모놀리스로 충분한 경우
팀이 10개 이상팀이 1~3개
모듈별 트래픽 편차가 극심전체 트래픽이 균일
서로 다른 언어/런타임 필요단일 기술 스택
서비스별 독립 SLA전체 SLA가 동일

결론

경계는 코드로 먼저 나누고, 인프라는 나중에 나눠도 늦지 않다. 모듈러 모놀리스에서 마이크로서비스로의 전환은 설계가 아니라 운영의 문제다.