백엔드 아키텍처를 공부하다 보면 거의 항상 등장하는 비교가 있다. 모놀리식(Monolithic)과 MSA(Microservice Architecture)다.
모놀리식 아키텍처는 하나의 애플리케이션 안에 모든 기능(회원, 주문, 결제, 상품 등)이 함께 들어있는 구조다. 하나의 코드베이스, 하나의 빌드 산출물(JAR), 하나의 배포 단위로 동작한다. 우리가 Spring Boot 프로젝트를 처음 시작할 때 만드는 구조가 보통 이 형태다.
MSA는 각 기능을 독립적인 서비스로 쪼개서, 서비스마다 별도로 개발, 배포, 확장하는 구조다. 회원 서비스, 주문 서비스, 결제 서비스가 각각 독립된 애플리케이션으로 존재하고, 서비스 간에는 네트워크(API 호출, 메시징)로 통신한다.
[모놀리식]
┌─────────────────────────────┐
│ 하나의 애플리케이션 │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 회원 │ │ 주문 │ │ 결제 │ │
│ └──────┘ └──────┘ └──────┘ │
│ 하나의 DB │
└─────────────────────────────┘
[MSA]
┌────────┐ ┌────────┐ ┌────────┐
│ 회원서비스 │ │ 주문서비스 │ │ 결제서비스 │
│ 자체 DB │ │ 자체 DB │ │ 자체 DB │
└────┬───┘ └────┬───┘ └────┬───┘
└──── 네트워크 통신 ────┘
처음부터 MSA로 시작하는 게 정답은 아니다. 각각의 구조가 해결하는 문제가 다르다.
MSA는 공짜로 얻어지는 게 아니다. 서비스 간 통신, 데이터 일관성, 분산 트랜잭션, 운영 복잡도라는 새로운 문제가 따라온다.
"주문하기" 기능이 두 구조에서 어떻게 처리되는지 비교해보자.
[Client]
|
| POST /api/orders
↓
[단일 Spring Boot 애플리케이션]
|
| OrderController
| → OrderService.createOrder()
| → UserRepository (같은 JVM, 같은 DB)
| → ProductRepository (같은 JVM, 같은 DB)
| → PaymentService (같은 JVM, 같은 DB)
| → 하나의 @Transactional 안에서 전부 처리
↓
[Client]
Response (단일 트랜잭션으로 일관성 보장)
[Client]
|
| POST /api/orders
↓
[API Gateway]
|
| 요청을 Order Service로 라우팅
↓
[Order Service]
|
| ① User Service에 사용자 정보 조회 요청 (HTTP/gRPC)
| GET http://user-service/users/{id}
↓
[User Service] → 응답
|
| ② Product Service에 재고 확인 요청
| GET http://product-service/products/{id}/stock
↓
[Product Service] → 응답
|
| ③ Order 데이터 자체 DB에 저장
| ④ Payment Service에 결제 요청 이벤트 발행 (메시지 큐)
↓
[Message Queue] → [Payment Service]
|
| 비동기로 결제 처리, 결과를 이벤트로 다시 발행
↓
[Order Service]
| 결제 완료 이벤트 수신 → 주문 상태 업데이트
↓
[Client]
Response (즉시 응답, 결제는 비동기로 진행될 수 있음)
모놀리식은 하나의 트랜잭션으로 묶이지만, MSA는 여러 서비스에 걸친 흐름이라 "전부 성공 또는 전부 실패"를 보장하기가 훨씬 복잡해진다. 이 문제를 해결하기 위해 Saga 패턴, 이벤트 기반 아키텍처 같은 별도 기법이 필요해진다.
MSA에서 클라이언트의 모든 요청을 받아서 적절한 서비스로 라우팅하는 진입점이다. 인증, 로깅, 속도 제한 같은 공통 기능도 여기서 처리하는 경우가 많다. 클라이언트는 개별 서비스의 위치를 알 필요 없이 Gateway 하나만 알면 된다.
서비스 인스턴스가 동적으로 늘어나거나 줄어들 때, 각 서비스가 서로의 현재 위치(IP, 포트)를 찾을 수 있게 해주는 메커니즘이다. Eureka, Consul 같은 도구가 이 역할을 한다.
하나의 비즈니스 흐름이 여러 서비스에 걸쳐 있을 때, 각 서비스의 로컬 트랜잭션을 순차적으로 실행하고, 실패 시 보상 트랜잭션(Compensating Transaction)으로 되돌리는 패턴이다. 전통적인 DB 트랜잭션처럼 완벽한 ACID를 보장하진 않지만, 분산 환경에서 일관성을 다루는 현실적인 방법이다.
한 서비스가 응답하지 않거나 느려질 때, 호출하는 쪽이 무한정 기다리지 않고 빠르게 실패 처리하거나 대체 응답을 주도록 하는 패턴이다. 한 서비스의 장애가 연쇄적으로 전체 시스템에 퍼지는 것을 막아준다. Resilience4j 같은 라이브러리로 구현한다.
모놀리식에서 MSA로 전환할 때 Spring 생태계가 제공하는 도구들이다.
[모놀리식 - 기본 Spring Boot]
spring-boot-starter-web
spring-boot-starter-data-jpa
→ 하나의 애플리케이션, 하나의 배포 단위
[MSA - Spring Cloud 생태계]
spring-cloud-starter-gateway → API Gateway
spring-cloud-starter-netflix-eureka-client → 서비스 디스커버리
spring-cloud-starter-openfeign → 서비스 간 HTTP 통신을 선언적으로
spring-cloud-starter-circuitbreaker-resilience4j → 장애 격리
spring-kafka → 비동기 이벤트 통신
각 서비스는 독립적인 Spring Boot 애플리케이션이고,
각자 자신의 build.gradle, 자신의 DB, 자신의 배포 파이프라인을 갖는다.
서비스 간 연결은 코드 레벨이 아니라 네트워크 레벨에서 이루어진다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final PaymentService paymentService;
private final OrderRepository orderRepository;
@Transactional // 전부 성공 또는 전부 롤백
public Order createOrder(OrderRequest request) {
User user = userRepository.findById(request.userId()).orElseThrow();
Product product = productRepository.findById(request.productId()).orElseThrow();
product.decreaseStock(request.quantity());
paymentService.charge(user, product.getPrice());
Order order = Order.create(user, product, request.quantity());
return orderRepository.save(order);
// 중간에 예외 발생 시 전부 롤백됨 — 같은 DB, 같은 트랜잭션
}
}
// Order Service에서 User Service를 호출하는 인터페이스
@FeignClient(name = "user-service") // Eureka에 등록된 서비스 이름으로 찾음
public interface UserServiceClient {
@GetMapping("/users/{id}")
UserResponse getUser(@PathVariable Long id);
}
// Order Service에서 Product Service 호출
@FeignClient(name = "product-service")
public interface ProductServiceClient {
@GetMapping("/products/{id}")
ProductResponse getProduct(@PathVariable Long id);
@PostMapping("/products/{id}/decrease-stock")
void decreaseStock(@PathVariable Long id, @RequestBody DecreaseStockRequest request);
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserServiceClient userServiceClient;
private final ProductServiceClient productServiceClient;
private final OrderRepository orderRepository; // Order Service 자체 DB
public Order createOrder(OrderRequest request) {
// 다른 서비스를 네트워크로 호출 — 트랜잭션이 통합되지 않음!
UserResponse user = userServiceClient.getUser(request.userId());
ProductResponse product = productServiceClient.getProduct(request.productId());
productServiceClient.decreaseStock(request.productId(),
new DecreaseStockRequest(request.quantity()));
Order order = Order.create(user.id(), product.id(), request.quantity());
return orderRepository.save(order);
// 만약 여기서 실패하면? 이미 호출한 decreaseStock은 어떻게 되돌릴까?
// → 이게 MSA에서 분산 트랜잭션 문제가 발생하는 지점
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductServiceClient productServiceClient;
@CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
public ProductResponse getProduct(Long productId) {
return productServiceClient.getProduct(productId);
}
// Product Service가 응답하지 않을 때 대체 처리
public ProductResponse getProductFallback(Long productId, Throwable t) {
return ProductResponse.unavailable(productId);
// 전체 시스템이 멈추는 대신 기본값으로 응답
}
}
// Order Service — 주문 생성 후 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(Order.create(request));
// 결제 서비스에게 "주문이 생성됐다"는 이벤트만 발행
// 결제 처리 결과를 기다리지 않음
kafkaTemplate.send("order-created", new OrderCreatedEvent(order.getId(), order.getTotalPrice()));
return order;
}
}
// Payment Service — 이벤트를 구독해서 결제 처리
@Component
@RequiredArgsConstructor
public class OrderEventConsumer {
private final PaymentService paymentService;
@KafkaListener(topics = "order-created")
public void handleOrderCreated(OrderCreatedEvent event) {
paymentService.processPayment(event.orderId(), event.totalPrice());
// 결제 완료 후 "payment-completed" 이벤트를 다시 발행해서
// Order Service가 주문 상태를 업데이트하도록 함
}
}
"대기업은 다 MSA를 쓰니까 우리도 써야 한다"는 생각은 위험하다. 팀 규모가 작고 서비스가 단순하다면, MSA의 운영 복잡도가 오히려 손해가 된다. 서비스 디스커버리, 분산 트랜잭션, 모니터링 인프라까지 신경 써야 할 게 너무 많아진다. 초기 스타트업이나 소규모 팀에서는 모놀리식으로 시작해서 필요한 부분만 점진적으로 분리하는 게 현실적인 경우가 많다.
"각 서비스를 분리했지만 DB는 하나를 공유한다"는 흔한 실수다. 이러면 진짜 MSA가 아니라 "분산된 모놀리식"이 된다. 한 서비스가 DB 스키마를 바꾸면 다른 서비스에 영향을 줄 수 있고, 서비스 간 독립성이 깨진다. MSA의 핵심 원칙은 "각 서비스는 자신의 데이터를 자신만 소유한다"는 것이다.
모놀리식의 @Transactional은 DB 레벨에서 원자성을 보장하지만,
MSA에서 여러 서비스에 걸친 작업은 네트워크 호출의 연속이라 한순간에 롤백시킬 방법이 없다.
Saga 패턴으로 "이미 한 작업을 거꾸로 취소하는 보상 트랜잭션"을 직접 설계해야 한다.
이건 자동으로 되는 게 아니라 개발자가 명시적으로 구현해야 하는 부분이다.
처음부터 완벽한 MSA로 설계하려고 하면 오버엔지니어링이 되기 쉽다. 모놀리식으로 시작해서 도메인 경계를 명확히 만들어두고, 실제로 독립 배포/확장이 필요한 지점이 생기면 그 부분만 분리하는 전략이 많이 쓰인다. 이걸 "모놀리식 우선(Monolith First)" 전략이라고 부른다.
모놀리식이라도 패키지 구조를 도메인 단위로 명확히 나누고
(order, user, payment 패키지로 분리하고 서로 직접 참조하지 않도록 설계),
DB 테이블도 도메인 경계를 의식해서 설계해두면
나중에 MSA로 분리할 때 훨씬 수월하다.
요청이 여러 서비스를 거치면 "어디서 느려졌는지", "어디서 에러가 났는지" 파악이 어려워진다. Zipkin, Jaeger 같은 분산 트레이싱 도구로 요청 하나의 전체 흐름을 추적할 수 있게 해야 한다. 이게 없으면 MSA의 디버깅은 거의 불가능에 가깝다.
서비스가 독립적으로 배포되기 때문에,
한 서비스의 API가 바뀌면 그 API를 쓰는 다른 서비스가 깨질 수 있다.
API 버전을 명시하고(/v1/users, /v2/users), 하위 호환성을 유지하면서 점진적으로 변경하는 습관이 필요하다.
처음엔 MSA가 "더 좋은 최신 기술"이라고만 생각했는데, 실제로 동작 흐름을 비교해보니 모놀리식의 단순함이 가진 장점이 명확하게 보였다. 하나의 트랜잭션으로 처리되던 게 MSA에서는 여러 네트워크 호출로 쪼개지고, 실패했을 때 되돌리는 것조차 직접 설계해야 한다는 게 생각보다 큰 부담이었다.
MSA는 "기술적으로 우월한 구조"가 아니라 "특정 문제를 해결하기 위한 트레이드오프"라는 걸 느꼈다. 독립 배포와 확장성을 얻는 대신 복잡도와 운영 비용을 지불하는 구조다. 지금 만들고 있는 서비스 규모에서 이 트레이드오프가 정말 필요한지 먼저 따져보는 게 아키텍처를 고르는 시작점이라는 걸 깨달았다.