본문으로 건너뛰기

서버

모놀리스, 모듈러 모놀리스, 마이크로서비스: 데이터 소유권과 사가, 분리 시점, 스트랭글러 피그 이전

한 덩어리로 배포하는 모놀리스와 모듈 경계를 세운 모듈러 모놀리스, 서비스마다 DB 를 갖는 마이크로서비스의 구조 차이와 비용을 비교합니다. 서비스별 DB, 사가의 코레오그래피와 오케스트레이션, 네트워크 실패와 관측, 콘웨이 법칙, 분리 시점, 스트랭글러 피그 이전까지 다룹니다.

페이지 수정

서버 애플리케이션을 하나의 배포 단위로 묶을지, 여러 개의 독립된 서비스로 나눌지는 코드가 아직 한 줄도 없는 단계에서부터 나오는 질문입니다. 선택지는 크게 셋입니다. 모든 기능을 한 프로세스에 담는 모놀리스, 같은 한 프로세스 안에서 모듈 경계를 엄격하게 세운 모듈러 모놀리스, 기능별로 프로세스와 데이터 저장소를 분리한 마이크로서비스입니다.

모놀리스, 모듈러 모놀리스, 마이크로서비스의 구조 비교. 모놀리스는 하나의 프로세스와 하나의 DB 를 쓰고, 모듈러 모놀리스는 한 프로세스 안에서 모듈 경계와 스키마를 나누며, 마이크로서비스는 서비스마다 DB 를 갖고 네트워크로 호출한다

모놀리스는 배포 단위가 하나입니다

모놀리스는 모든 하위 도메인이 하나의 배포 가능한 컴포넌트로 묶이고 하나의 데이터베이스를 쓰는 구조입니다. Java 의 WAR 파일 하나, Rails 애플리케이션 디렉터리 하나가 전형적인 예입니다. 호출이 전부 프로세스 안에서 일어나므로 상호작용이 단순하고 효율적이며 한 요청에서 여러 테이블을 바꿔도 ACID 트랜잭션 하나로 끝납니다.

비용은 규모가 커질 때 나타납니다. Chris Richardson 의 microservices.io 는 애플리케이션이 커지고 팀이 늘어날수록 단점이 점점 뚜렷해진다고 설명합니다. 거대한 코드베이스는 특히 새로 합류한 개발자를 위축시키고 이해하고 고치기 어려워 개발이 느려지며, 강한 모듈 경계가 없어서 시간이 지날수록 모듈성이 무너집니다. IDE 와 웹 컨테이너가 무거워져 생산성이 떨어지고, 컴포넌트 하나를 바꿔도 애플리케이션 전체를 다시 배포해야 해서 잦은 배포가 어려워집니다. 트래픽은 인스턴스를 복제해 감당할 수 있지만 데이터량이 늘거나 컴포넌트마다 자원 요구가 다를 때는 따로 확장할 수 없고, 처음 고른 기술 스택에 오래 묶이는 것도 단점으로 듭니다.

모듈러 모놀리스는 경계를 코드로 강제합니다

모듈러 모놀리스도 배포 단위는 하나입니다. 달라지는 쪽은 내부이며 모듈이 서로의 구현 클래스를 직접 참조하지 못하고 공개된 인터페이스만 호출하도록 경계를 세웁니다. 모듈마다 자기 테이블 또는 스키마만 건드리도록 정하면 데이터 경계까지 같이 잡힙니다.

// :orders 모듈 — 다른 모듈에 공개하는 API
interface OrderFacade {
    fun place(command: PlaceOrder): OrderId
}

data class PlaceOrder(val customerId: CustomerId, val lines: List<OrderLineRequest>)

// 구현은 internal 이라 :orders 밖에서는 보이지 않는다
internal class DefaultOrderFacade(
    private val orders: OrderRepository,
    private val payments: PaymentFacade, // :payments 모듈의 공개 API 만 의존
) : OrderFacade {
    override fun place(command: PlaceOrder): OrderId = TODO()
}

Kotlin 의 internal 은 같은 Gradle 모듈 안에서만 보이는 가시성이라 이런 경계를 컴파일러가 지켜 줍니다. 경계가 잘 서 있으면 나중에 특정 모듈을 서비스로 떼어 낼 때 호출 지점이 이미 인터페이스로 모여 있습니다.

Martin Fowler 는 2015년 「MonolithFirst」에서 공들여 설계한 모듈러 모놀리스가 이후 분리를 쉽게 해 준다는 접근을 소개하면서도, 그렇게 잘 풀린 사례를 충분히 많이 듣지는 못해 확신하기 어렵다고 적었습니다. 모듈 경계가 코드 수준의 규율에만 기대면 시간이 지나며 무너지기 쉬워서, 빌드 도구의 모듈 분리나 아키텍처 테스트 같은 기계적 장치를 함께 두는 편이 안전합니다.

마이크로서비스는 독립 배포를 얻고 분산 시스템을 떠안습니다

Fowler 와 James Lewis 가 2014년 3월에 쓴 「Microservices」는 이 스타일을 각자 자기 프로세스에서 돌아가는 작은 서비스들의 모음으로 하나의 애플리케이션을 만드는 접근으로 설명합니다. 서비스는 비즈니스 역량을 중심으로 나뉘고 독립적으로 배포됩니다. 서비스 사이의 통신은 가벼운 메커니즘을 쓰며 같은 글은 로직을 서비스 안에 두고 메시징 인프라는 라우팅만 맡기는 방식을 “똑똑한 엔드포인트와 단순한 파이프”라고 부르며 엔터프라이즈 서비스 버스와 대비시킵니다.

얻는 것은 배포 독립성입니다. 주문 서비스만 고쳤다면 주문 서비스만 빌드하고 내보내면 됩니다. 서비스마다 기술 스택과 확장 단위를 다르게 잡을 수도 있습니다. 대신 함수 호출이던 것이 원격 호출이 되고 그에 따른 비용이 한꺼번에 따라옵니다. Fowler 와 Lewis 도 같은 지점을 짚습니다. 원격 호출은 프로세스 내 호출보다 비싸서 API 를 더 굵은 단위로 설계해야 하고 프로세스 경계를 넘어 동작을 옮기기도 어렵습니다. 같은 글의 각주는 수많은 서비스를 복잡한 토폴로지로 배포하는 일이 모놀리스 하나를 배포하는 일보다 어렵다고 말합니다.

서비스마다 데이터를 소유합니다

마이크로서비스에서 가장 큰 구조적 결정은 데이터입니다. Fowler 와 Lewis 의 글은 마이크로서비스가 서비스마다 자기 데이터베이스를 관리하게 두는 쪽을 택한다고 설명하고 서비스별로 다른 저장 기술을 쓰는 폴리글랏 퍼시스턴스를 그 결과로 소개합니다.

microservices.io 의 Database per Service 패턴은 이를 더 구체적으로 풉니다. 각 서비스는 영속 데이터를 비공개로 두고 자기 API 를 통해서만 노출하며 서비스의 트랜잭션은 자기 데이터베이스만 건드립니다. 관계형 데이터베이스라면 서비스별 비공개 테이블, 서비스별 스키마, 서비스별 데이터베이스 서버 중에서 고를 수 있습니다. 앞의 두 방식은 운영 부담이 가장 낮고 경계를 지키려면 서비스마다 별도 DB 사용자를 두고 grant 같은 접근 제어로 권한을 제한하는 식의 장벽을 두는 것이 좋다고 합니다. 그렇지 않으면 개발자가 API 를 우회하고 싶은 유혹을 받기 때문입니다. 느슨한 결합이 이 패턴의 이점이지만 여러 서비스에 걸친 트랜잭션과 서로 다른 데이터베이스를 조인하는 쿼리가 어려워진다는 대가가 있습니다.

분산 트랜잭션 대신 사가

주문을 확정하려면 결제 서비스에서 결제하고 재고 서비스에서 재고를 잡아야 한다고 해 보겠습니다. 모놀리스에서는 트랜잭션 하나였던 이 작업이 서로 다른 데이터베이스 세 개에 걸칩니다. Fowler 와 Lewis 는 분산 트랜잭션이 구현하기 지독히 어렵다고 쓰며 마이크로서비스 진영이 트랜잭션 없는 조율, 곧 최종적 일관성을 받아들이고 문제가 생기면 보상 작업으로 바로잡는 쪽을 택한다고 설명합니다.

이 방식의 정형화가 사가(Saga)입니다. microservices.io 는 사가를 로컬 트랜잭션의 연속으로 정의합니다. 각 로컬 트랜잭션은 자기 데이터베이스를 갱신하고 다음 로컬 트랜잭션을 촉발하는 메시지나 이벤트를 발행하며, 한 단계가 비즈니스 규칙을 어겨 실패하면 앞서 실행된 변경을 되돌리는 보상 트랜잭션들을 실행합니다. 조율 방식은 둘로 나뉩니다.

사가의 두 가지 조정 방식. 코레오그래피는 주문, 결제, 재고 서비스가 이벤트를 주고받으며 실패 이벤트로 보상 트랜잭션을 연쇄 실행한다. 오케스트레이션은 중앙 오케스트레이터가 각 서비스에 명령을 보내고 응답에 따라 보상을 지시한다

코레오그래피에서는 각 로컬 트랜잭션이 도메인 이벤트를 발행하고 그 이벤트가 다른 서비스의 다음 로컬 트랜잭션을 촉발합니다. 중앙 조정자가 없는 대신 전체 흐름이 여러 서비스의 이벤트 핸들러에 흩어집니다. 오케스트레이션에서는 중앙 오케스트레이터가 참여자에게 다음에 실행할 로컬 트랜잭션을 지시하므로 흐름이 한곳에 모이는 대신 오케스트레이터가 새로운 책임 지점이 됩니다.

class PlaceOrderSaga(
    private val payments: PaymentClient,
    private val stock: StockClient,
    private val orders: OrderRepository,
) {
    fun run(order: Order) {
        val paymentId = payments.charge(order.id, order.total)
        try {
            stock.reserve(order.id, order.lines)
            orders.markConfirmed(order.id)
        } catch (e: StockUnavailable) {
            payments.refund(paymentId)
            orders.markCancelled(order.id)
        }
    }
}

refund 가 결제의 보상 트랜잭션입니다. microservices.io 가 지적하듯 사가에는 ACID 의 자동 롤백이 없어서 개발자가 단계마다 되돌리는 동작을 직접 설계해야 합니다. 격리성이 없다는 약점도 있습니다. Richardson 은 『Microservices Patterns』(Manning, 2018)에서 동시에 실행되는 사가나 일반 트랜잭션이 중간 상태를 읽거나 덮어쓰는 이상 현상과, 이를 줄이는 대응책(countermeasure)을 따로 다룹니다. 위 코드는 개념을 보이려는 동기 호출 버전이며 실제로는 오케스트레이터가 중간에 죽어도 이어갈 수 있도록 진행 상태를 저장하고 각 호출을 재시도해도 안전하게 만들어야 합니다.

네트워크는 실패하고, 실패는 보이지 않으면 고치기 어렵습니다

서비스 사이의 호출은 느려지거나 응답이 없거나 중간에 끊길 수 있습니다. Fowler 와 Lewis 는 마이크로서비스 애플리케이션이 서비스 하나의 실패를 견디도록 설계되어야 한다고 쓰고 서킷 브레이커 같은 장치와 실시간 모니터링을 그 수단으로 소개합니다. 서비스 호출이 한 요청 안에서 여러 단계로 이어지면 어느 구간이 느렸는지, 어느 서비스가 오류를 냈는지 찾는 일이 단일 프로세스의 스택 트레이스보다 훨씬 번거로워집니다. 요청 하나를 서비스들에 걸쳐 따라갈 수 있는 상관 ID, 서비스별 지표, 로그 수집 같은 관측 수단은 선택 사항이라기보다 분리의 전제 조건에 가깝습니다. 같은 글은 이를 위해 실시간 모니터링과 시맨틱 모니터링을 갖추라고 권합니다.

아키텍처는 조직의 모양을 따라갑니다

Fowler 와 Lewis 는 Melvin Conway 가 1968년에 쓴 문장을 인용합니다. 시스템을 설계하는 조직은 그 조직의 커뮤니케이션 구조를 닮은 설계를 낳는다는 내용입니다. 프런트엔드 팀, 서버 팀, DB 팀처럼 기술 계층별로 팀이 나뉘면 작은 기능 하나도 팀 세 곳의 조율을 거쳐야 하고 마이크로서비스는 이를 비즈니스 역량 단위의 교차 기능 팀으로 바꾸는 방향을 제시합니다. 그래서 서비스 경계를 잡을 때는 팀 경계도 함께 따져 봐야 합니다. 팀 하나가 서비스 열 개를 들고 있거나, 서비스 하나를 팀 네 곳이 같이 고치는 구조라면 독립 배포라는 이점이 조직에서 사라집니다.

언제 나눌지

세 구조를 몇 가지 축으로 놓고 보면 다음과 같습니다.

축모놀리스모듈러 모놀리스마이크로서비스
배포 단위1개1개서비스 수만큼
데이터 일관성로컬 ACID 트랜잭션로컬 ACID 트랜잭션서비스 간 최종적 일관성, 사가
호출 비용프로세스 내프로세스 내네트워크
경계 강제규율에 의존모듈 시스템과 테스트로 강제프로세스와 DB 로 강제
운영 복잡도낮음낮음높음, 관측과 배포 자동화 필수

Fowler 는 「MonolithFirst」에서 성공한 마이크로서비스 사례 거의 전부가 너무 커져서 쪼개진 모놀리스에서 출발했다고 관찰합니다. 초기에는 사용자가 이 제품을 원할지부터 불확실하고 마이크로서비스에 따르는 운영 부담(그가 MicroservicePremium 이라 부르는 비용)이 속도를 늦춥니다. 경계를 처음부터 정확히 긋기도 어렵습니다. 익숙한 도메인의 경험 많은 아키텍트도 초기에는 경계를 맞추기 어렵고 서비스 사이에서 경계를 옮기는 리팩터링은 모놀리스 안에서보다 훨씬 비쌉니다. Fowler 는 이 주장이 근거가 많지 않은 잠정적 조언이라고 스스로 밝히며 이미 마이크로서비스 경험이 있는 팀이라면 처음부터 시작하는 것도 합리적일 수 있다고 인정합니다. 모든 모놀리스가 깔끔하게 분해되는 것도 아니어서 의존성이 얽힌 경우에는 쪼개기가 지저분해진다고도 덧붙입니다.

그러면 분리를 검토할 신호는 무엇일까요. 앞서 본 비용 구조에서 거꾸로 읽으면 후보가 나옵니다. 한 모듈만 따로 확장해야 하고 전체 복제가 비싼 경우, 배포 주기가 크게 다른 모듈이 같은 파이프라인에 묶여 서로를 막는 경우, 팀이 커져서 한 저장소의 조율 비용이 개발 속도를 넘어서는 경우입니다. 이런 이유가 없는데 분리하면 분산 시스템의 비용만 남습니다.

스트랭글러 피그로 옮기기

이미 커진 모놀리스를 나누는 방법으로 Fowler 는 2004년 글에서 스트랭글러 애플리케이션을 소개했습니다. 이름은 호주 퀸즐랜드 해안의 열대우림에서 본 교살무화과에서 왔습니다. 이 무화과는 나무 윗가지에서 싹을 틔워 뿌리가 흙에 닿을 때까지 줄기를 타고 내려오며, 오랜 세월에 걸쳐 숙주 나무를 감싸 죽입니다. 처음 글의 제목은 「Strangler Application」이었는데, ‘스트랭글러’라는 말만 떨어져 나가 폭력적인 어감으로 쓰이는 일이 잦아지자 Fowler 는 2019년에 식물 비유를 살리려고 「Strangler Fig Application」으로 이름을 바꾸었습니다.

Fowler 는 핵심 시스템을 한 번에 다시 만드는 재작성이 보기보다 훨씬 복잡하고 위험으로 가득하다고 봤습니다. 전환 날짜가 다가올수록 압박이 커지고, 새 기능은 계속 요구되는데 옛 동작은 그대로 남겨야 하며 때로는 옛 버그까지 옮겨야 합니다. 대안은 옛 시스템의 가장자리에 새 시스템을 조금씩 만들어 몇 년에 걸쳐 키우다가 결국 옛 시스템을 대체하는 것입니다. 그는 이벤트를 가로채 기능을 조금씩 새 시스템으로 옮기는 Event Interception 과 Asset Capture 를 기본 전략으로 꼽았습니다. 이 방식을 고르는 가장 중요한 이유는 위험 감소이며, 꾸준히 가치를 내고 잦은 릴리스로 진행 상황을 지켜볼 수 있다는 점도 듭니다. 더 비싸다고 생각하는 사람이 많지만 그는 그렇게 확신하지 않는다고 적었습니다. 릴리스 주기가 짧으면 일괄 재작성이 흔히 만들어 내는 불필요한 기능을 피할 수 있기 때문입니다. 새 애플리케이션도 나중에 쉽게 대체될 수 있게 설계해야 한다는 말도 덧붙였습니다.

아래 순서는 이 글에서 정리한 한 가지 예로 Fowler 의 글에는 없는 내용이며, 데이터를 먼저 옮길지 코드를 먼저 옮길지는 상황에 따라 달라집니다. 모놀리스에서 마이크로서비스로 갈 때 이 방식을 적용하면 다음 순서가 됩니다. 이음매를 찾아 첫 번째 서비스 후보(변경이 잦고 다른 부분과 결합이 약한 모듈)를 고릅니다. 호출이 프로세스 안에서 그 모듈 인터페이스를 거치게 만든 뒤, 같은 인터페이스를 원격 호출로 구현해 새 서비스에 연결합니다. 그 서비스가 쓰던 테이블을 별도 데이터베이스로 옮겨 데이터 소유권을 넘기고 옛 코드는 삭제합니다. 모듈러 모놀리스가 앞서 만들어 둔 경계가 바로 이 단계의 이음매 역할을 합니다.

참고 자료


페이지 수정