0. 들어가며
소프트웨어 아키텍처는 마치 도시 계획과 같다. 작은 마을을 개발할 때와 대도시를 개발할 때의 설계 방식이 다르듯, 소프트웨어도 규모와 요구사항에 따라 적절한 아키텍처를 선택해야 한다.
과거에는 대부분의 애플리케이션이 모놀리식(Monolithic) 방식으로 개발되었다. 하지만 클라우드 환경이 보편화되고 시스템이 점점 복잡해지면서, 마이크로서비스 아키텍처(MSA)가 새로운 대안으로 떠올랐다. 그렇다면 모놀리스에서 MSA로의 전환은 왜 필요하며, 그 과정에서 어떤 중간 단계들이 존재할까?
이 글에서는 모놀리식 아키텍처의 개념부터 시작하여, 모듈형 모놀리스, 클린 아키텍처, 헥사고날 아키텍처를 거쳐 MSA로 이어지는 아키텍처의 진화 과정을 살펴본다. 이는 MSA를 본격적으로 학습하기 전에 반드시 이해해야 할 기초 지식이다.
1. Monolithic Architecture란?
1.1. 모놀리식 아키텍처의 정의
모놀리식 아키텍처는 애플리케이션의 모든 기능이 하나의 코드베이스로 통합되어 하나의 실행 파일이나 패키지로 배포되는 전통적인 구조다.
전형적인 계층 구조:
[Presentation Layer] → [Business Logic Layer] → [Data Access Layer] → [Database]
- 프레젠테이션 계층: 사용자 인터페이스, API 엔드포인트
- 비즈니스 로직 계층: 핵심 비즈니스 규칙과 처리
- 데이터 접근 계층: 데이터베이스 연동
모든 것이 하나의 애플리케이션에 포함되어 있어 개발 초기에는 단순하고 빠르게 시작할 수 있다.
1.2. 모놀리식 아키텍처의 장점
개발 초기 단순함
- 하나의 프로젝트에서 모든 코드를 관리하므로 초기 개발이 단순하고 빠르다.
- IDE가 하나의 프로젝트만 로드하면 되므로 개발 환경 구성이 쉽다.
- 새로운 개발자가 온보딩하기에도 상대적으로 간단하다.
통합 테스트 용이성
- 모든 기능이 하나의 애플리케이션에 통합되어 있어 통합 테스트가 비교적 간단하다.
- Mocking할 대상이 적다.
- End-to-End 테스트를 한 번에 실행할 수 있다.
배포 단순함
- 하나의 패키지(WAR, JAR)를 빌드하여 배포하면 되므로 초기 배포와 운영이 단순하다.
- 배포 파이프라인이 복잡하지 않다.
- 롤백도 단순하다 (이전 버전으로 교체).
성능
- 서비스 간 통신이 프로세스 내에서 이루어지므로 분산 시스템에 비해 성능상 이점이 있다.
- 네트워크 지연이 없다.
- 메서드 호출 수준의 통신이 가능하다.
트랜잭션 관리
- ACID 트랜잭션을 쉽게 보장할 수 있다.
- 하나의 데이터베이스를 사용하므로 분산 트랜잭션에 대한 고민이 필요 없다.
- 데이터 일관성을 강하게 유지할 수 있다.
1.3. 모놀리식 아키텍처의 한계
복잡성 증가
- 애플리케이션이 커질수록 코드베이스가 방대해지고 복잡해져 이해하고 수정하기 어려워진다.
- 개발자가 전체 코드를 이해하기 어렵다.
- 의존성 지옥(Dependency Hell)에 빠지기 쉽다.
- 작은 변경이 예상치 못한 사이드 이펙트를 발생시킬 수 있다.
배포 리스크
- 작은 변경에도 전체 애플리케이션을 다시 빌드하고 배포해야 한다.
- 변경의 영향 범위가 넓어 리스크가 크다.
- 하루에 여러 번 배포하기 어렵다.
- 배포 시간이 오래 걸린다.
확장성 제한
- 특정 기능만 확장해야 할 경우에도 전체 애플리케이션을 확장해야 하므로 비효율적이다.
- 예를 들어, 결제 기능만 트래픽이 많아져도 전체 시스템을 확장해야 한다.
- 리소스 활용이 비효율적이다.
기술 스택 고정
- 전체 애플리케이션이 동일한 기술 스택을 사용해야 한다.
- 새로운 기술 도입이 어렵다.
- 부분적으로만 최신 기술을 적용할 수 없다.
- 레거시 기술에 계속 의존해야 할 수 있다.
장애 격리 어려움
- 일부 모듈의 장애가 전체 시스템의 장애로 이어질 수 있다.
- 메모리 누수가 하나의 모듈에서 발생해도 전체 애플리케이션이 중단된다.
- 부분적 장애 처리가 어렵다.
개발 조직 확장의 어려움
- 여러 팀이 하나의 코드베이스에서 작업하면 충돌이 잦아진다.
- 코드 소유권이 모호해진다.
- 독립적인 릴리즈 사이클을 가져가기 어렵다.
- Conway의 법칙에 따라 조직 구조가 아키텍처에 제약을 받는다.
2. Modular Monolithic Architecture
2.1. 모듈형 모놀리스의 등장 배경
모놀리스의 단점을 보완하면서도 분산 시스템의 복잡성을 피하기 위한 중간 지점의 아키텍처가 필요했다. 이것이 바로 모듈형 모놀리스(Modular Monolith)다.핵심 아이디어는 "잘 설계된 모듈 경계"와 "엄격한 의존성 규칙"을 통해 모놀리스의 단점의 완화이다.
2.2. 모듈형 모놀리스의 특징
명확한 모듈 경계
- 각 모듈은 특정 비즈니스 기능을 담당한다.
- 다른 모듈과의 의존성이 명확히 정의된다.
- 자바의 경우 패키지 수준에서 모듈을 분리하고, public API만 외부에 노출한다.
독립적인 개발
- 팀별로 모듈을 할당하여 독립적으로 개발할 수 있다.
- 인터페이스만 협의되면 각 팀은 자신의 모듈을 자유롭게 개발한다.
- 모듈 내부 구현은 자유롭게 변경할 수 있다.
단일 배포 단위
- 모든 모듈이 하나의 애플리케이션으로 패키징되어 배포된다.
- 운영 복잡도는 모놀리스 수준으로 유지된다.
- 분산 시스템의 복잡성을 피할 수 있다.
점진적 전환 가능성
- 나중에 특정 모듈만 독립적인 마이크로서비스로 분리하기 용이한 구조다.
- Strangler 패턴을 적용하여 점진적으로 전환할 수 있다.
2.3. 모듈형 모놀리스의 구현 예시
프로젝트 구조
com.eshop/
├── application/ # 애플리케이션 진입점
├── common/ # 공통 유틸리티 (의존성 없음)
├── modules/
│ ├── user/ # 사용자 모듈
│ │ ├── api/ # 외부 노출 API
│ │ ├── domain/ # 비즈니스 로직
│ │ ├── infrastructure/# DB, 외부 연동
│ │ └── module-info.java # 모듈 의존성 정의
│ ├── product/ # 상품 모듈
│ ├── order/ # 주문 모듈
│ └── payment/ # 결제 모듈
└── bootstrap/ # 애플리케이션 설정
모듈 간 의존성 정의 (module-info.java)
module com.eshop.order {
requires com.eshop.product; // 상품 모듈에 의존
requires com.eshop.user; // 사용자 모듈에 의존
requires com.eshop.common;
exports com.eshop.order.api;
}
2.4. 모듈형 모놀리스 vs 순수 모놀리스
| 특성 | 순수 모놀리스 | 모듈형 모놀리스 |
| 코드 구조 | 계층형(Layer) | 기능형(Feature) |
| 모듈 경계 | 모호함 | 명확함 |
| 의존성 | 어떤 클래스든 접근 가능 | 인터페이스로만 통신 |
| 테스트 | 전체 애플리케이션 컨텍스트 필요 | 모듈 단위 테스트 가능 |
| 기술 부채 | 누적되기 쉬움 | 관리 가능 |
| 마이크로서비스 전환 | 대규모 재작성 필요 | 점진적 분리 가능 |
3. Clean Architecture
3.1. Clean Architecture 개념
Robert C. Martin(Uncle Bob)이 제안한 Clean Architecture는 관심사의 분리(Separation of Concerns)를 최우선으로 하는 아키텍처 철학이다. 핵심은 의존성 규칙(Dependency Rule)으로, 안쪽으로 갈수록 추상화 수준이 높아지고 바깥쪽에서 안쪽으로만 의존해야 한다.
계층 구조:
[Entities] ← [Use Cases] ← [Interface Adapters] ← [Frameworks & Drivers]
(핵심) (애플리케이션) (컨트롤러, 프레젠터) (DB, UI, 외부)
3.2. Clean Architecture의 4개 계층
1. 엔티티(Entities)
- 기업의 핵심 비즈니스 규칙을 캡슐화한 객체
- 데이터베이스 테이블과 매핑되는 JPA 엔티티가 아니라, 순수한 비즈니스 객체
- 어떤 외부 요소에도 의존하지 않음
2. 유스 케이스(Use Cases)
- 애플리케이션 특화 비즈니스 규칙
- 사용자의 요청을 처리하는 흐름을 정의
- 엔티티를 조작하여 특정 작업을 수행
- 엔티티에만 의존
3. 인터페이스 어댑터(Interface Adapters)
- 외부 세계(컨트롤러, 게이트웨이, 프레젠터)와 유스 케이스 간의 데이터 변환을 담당
- 유스 케이스와 엔티티에 의존
4. 프레임워크와 드라이버(Frameworks & Drivers)
- 가장 바깥쪽 계층
- 데이터베이스, 웹 프레임워크, UI 등 구체적인 기술이 위치
- 모든 안쪽 계층에 의존
3.3. 의존성 규칙(Dependency Rule)
핵심 원칙: 소스 코드 의존성은 반드시 안쪽(고수준 정책)을 향해야 한다. 바깥쪽 계층의 변경이 안쪽 계층에 영향을 미치지 않는다.
- 엔티티는 아무것도 의존하지 않는다.
- 유스 케이스는 엔티티에 의존한다.
- 인터페이스 어댑터는 유스 케이스와 엔티티에 의존한다.
- 프레임워크와 드라이버는 모든 안쪽 계층에 의존한다.
이 규칙을 통해 프레임워크 독립성, 테스트 용이성, 도메인 중심 설계를 달성할 수 있다.
4. Hexagonal Architecture (Ports and Adapters)
4.1. 헥사고날 아키텍처 개념
Alistair Cockburn이 제안한 Hexagonal Architecture(또는 Ports and Adapters)는 Clean Architecture와 유사한 철학을 공유한다. 애플리케이션의 코어를 육각형으로 표현하고, 각 면에 포트(Port)를 두어 외부와 통신하는 구조다. 핵심 아이디어는 애플리케이션 코어는 외부 세계(데이터베이스, UI, 외부 API)에 대해 전혀 알지 못하도록 하는 것이다. 모든 외부 연동은 포트와 어댑터를 통해 이루어진다.
[웹 어댑터] → [웹 포트] → [애플리케이션 코어] ← [영속성 포트] ← [JPA 어댑터]
↓
[외부 API 포트] ← [REST 클라이언트 어댑터]
4.2. 포트와 어댑터
포트(Port)
- 애플리케이션 코어가 외부와 통신하기 위한 인터페이스
- 입력 포트: 외부에서 코어로 들어오는 요청을 처리 (유스 케이스 인터페이스)
- 출력 포트: 코어에서 외부로 나가는 요청을 처리 (리포지토리 인터페이스)
어댑터(Adapter)
- 포트를 실제 기술에 연결하는 구현체
- 입력 어댑터: 웹 컨트롤러, 메시지 리스너 등
- 출력 어댑터: JPA 리포지토리 구현체, REST 클라이언트 등
4.3. Clean Architecture vs Hexagonal Architecture
두 아키텍처는 철학과 목표가 매우 유사하다. 주요 차이점은:
- Clean Architecture: 계층 구조에 초점 (의존성 규칙)
- Hexagonal Architecture: 포트와 어댑터 패턴에 초점 (코어와 외부 세계의 격리)
실제로 두 아키텍처는 함께 사용되는 경우가 많다. Clean Architecture의 계층 구조 내에서 Hexagonal Architecture의 포트-어댑터 패턴을 구현할 수 있다.
5. [실습 정리] Monolithic Application (Eshop) 구조
5.1. Eshop 프로젝트 개요
Eshop은 이커머스 도메인의 모놀리식 애플리케이션으로, Clean Architecture 원칙을 적용하여 구현되었다.
핵심 비즈니스 기능:
- 사용자 관리 (회원가입, 로그인, 프로필)
- 상품 관리 (카탈로그, 재고)
- 주문 관리 (주문 생성, 조회, 취소)
- 결제 처리 (결제 승인, 환불)
5.2. 계층별 구조
1. 도메인 계층 (Domain Layer) - 가장 안쪽
// domain/entity/Order.java
package com.eshop.domain.entity;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Order {
private String id;
private String userId;
private List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
private LocalDateTime createdAt;
public Order(String userId) {
this.id = UUID.randomUUID().toString();
this.userId = userId;
this.items = new ArrayList<>();
this.status = OrderStatus.CREATED;
this.createdAt = LocalDateTime.now();
this.totalAmount = Money.ZERO;
}
public void addItem(String productId, String productName, int quantity, Money price) {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("Can only add items to CREATED order");
}
OrderItem item = new OrderItem(productId, productName, quantity, price);
items.add(item);
calculateTotal();
}
public void place() {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
}
// 비즈니스 로직...
}
// domain/entity/Money.java (값 객체)
public class Money {
public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance("USD"));
private BigDecimal amount;
private Currency currency;
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
2. 애플리케이션 계층 (Application Layer) - 유스 케이스
// application/port/input/CreateOrderUseCase.java (입력 포트)
package com.eshop.application.port.input;
public interface CreateOrderUseCase {
String execute(CreateOrderCommand command);
}
// application/usecase/CreateOrderService.java (유스 케이스 구현)
package com.eshop.application.usecase;
import com.eshop.application.port.input.CreateOrderCommand;
import com.eshop.application.port.input.CreateOrderUseCase;
import com.eshop.application.port.output.OrderRepository;
import com.eshop.application.port.output.ProductCatalog;
import com.eshop.domain.entity.Order;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final ProductCatalog productCatalog;
@Override
public String execute(CreateOrderCommand command) {
Order order = new Order(command.getUserId());
command.getItems().forEach(item -> {
var product = productCatalog.findById(item.getProductId());
order.addItem(
product.getId(),
product.getName(),
item.getQuantity(),
product.getPrice()
);
});
orderRepository.save(order);
return order.getId();
}
}
3. 인프라스트럭처 계층 (Infrastructure Layer) - 어댑터
// infrastructure/adapter/persistence/JpaOrderRepository.java (출력 어댑터)
package com.eshop.infrastructure.adapter.persistence;
import com.eshop.application.port.output.OrderRepository;
import com.eshop.domain.entity.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springDataOrderRepository;
private final OrderMapper orderMapper;
@Override
public void save(Order order) {
OrderJpaEntity entity = orderMapper.toJpaEntity(order);
springDataOrderRepository.save(entity);
}
@Override
public Order findById(String id) {
return springDataOrderRepository.findById(id)
.map(orderMapper::toDomainEntity)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// infrastructure/adapter/web/OrderController.java (입력 어댑터)
package com.eshop.infrastructure.adapter.web;
import com.eshop.application.port.input.CreateOrderCommand;
import com.eshop.application.port.input.CreateOrderUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping
public String createOrder(@RequestBody CreateOrderRequest request) {
CreateOrderCommand command = mapToCommand(request);
return createOrderUseCase.execute(command);
}
private CreateOrderCommand mapToCommand(CreateOrderRequest request) {
// 매핑 로직
}
}
4. 의존성 주입 설정
// infrastructure/config/UseCaseConfig.java
package com.eshop.infrastructure.config;
import com.eshop.application.port.output.OrderRepository;
import com.eshop.application.port.output.ProductCatalog;
import com.eshop.application.usecase.CreateOrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UseCaseConfig {
@Bean
public CreateOrderService createOrderService(
OrderRepository orderRepository,
ProductCatalog productCatalog) {
return new CreateOrderService(orderRepository, productCatalog);
}
}
5.3. 아키텍처의 장점
테스트 용이성
- 도메인 로직이 순수 자바 객체로 구현되어 단위 테스트가 쉽다.
- 리포지토리나 외부 API는 Mocking하여 유스 케이스 테스트 가능
유지보수성
- 계층 간 의존성이 명확하여 변경 영향 범위를 예측하기 쉽다.
- 비즈니스 로직이 인프라 코드와 분리되어 있다.
기술 독립성
- JPA에서 다른 ORM으로 변경해도 도메인 로직은 영향 없음
- Spring에서 다른 프레임워크로 변경 가능
6. 정리: 아키텍처 진화의 방향
6.1. 각 아키텍처의 위치
[순수 모놀리스] → [모듈형 모놀리스] → [헥사고날/클린] → [마이크로서비스]
↓ ↓ ↓ ↓
단순함 구조화 유연성 분산/확장
6.2. 진화 과정의 핵심 개념
1. 모듈화(Modularity)
- 코드를 비즈니스 기능별로 그룹화
- 명확한 경계와 인터페이스 정의
2. 관심사 분리(Separation of Concerns)
- 각 계층이 자신의 책임에만 집중
- 의존성 방향 규칙 준수
3. 인터페이스 기반 통신
- 구현체가 아닌 인터페이스에 의존
- 변경의 영향 격리
4. 도메인 중심 설계
- 기술이 아닌 비즈니스에 집중
- 유비쿼터스 언어 사용
6.3. 다음 단계로의 전환
모놀리스에서 MSA로의 전환은 하루아침에 이루어지지 않는다. 다음의 단계를 고려할 수 있다:
- 1단계: 모듈형 모놀리스로 리팩토링 (내부 구조 개선)
- 2단계: Clean/Hexagonal 아키텍처 적용 (의존성 규칙 확립)
- 3단계: 특정 모듈을 독립 서비스로 분리 (Strangler 패턴)
- 4단계: 완전한 마이크로서비스 아키텍처로 전환
이러한 점진적 접근을 통해 위험을 최소화하면서 아키텍처를 진화시킬 수 있다.
'MSA > MSA 아키텍처' 카테고리의 다른 글
| [BASIC #3] MSA 동기 통신 전략과 API Gateway 패턴 (0) | 2026.03.02 |
|---|---|
| [BASIC #2] Microservice Architecture 개요와 서비스 분해 전략 (0) | 2026.03.02 |
| [5] EDA (0) | 2025.10.20 |
| [4] SAGA 패턴 (0) | 2025.10.20 |
| [3] CQRS (0) | 2025.10.20 |
