soulee.dev

MSA를 시작하다

· 24 min read

시작하며

회사에 처음 들어오며, 새로운 플랫폼 서비스를 만들게 되었다. 이 플랫폼은 기존 레거시 시스템과 연동되어야 했고, 기능들은 모듈 단위로 분리되어야 했다. 그리고 나는 이 플랫폼을 MSA(Microservice Architecture) 로 만들자고 했다.

지금 돌아보면 새로 들어온 주니어 개발자의 패기였던 것 같다. 회사는 MSA를 해 본 적이 없었고, 조직 안에 경험이 있는 사람도 아무도 없었다. 코드를 작성하고 구조를 짜는 일을 넘어, 이를 뒷받침할 인프라를 설계하는 일에 대해서는 더더욱 그랬다.

프로젝트를 진행하면서 정말 많이 고민했다. 다른 회사는 어떻게 하고 있을까? 다른 회사의 내부 사정을 볼 수는 없겠지만 그 일부라도 알아보기 위해 국내외 기업들의 컨퍼런스 발표와 기술 블로그를 찾아보았고, 현업에 있는 지인이 있으면 조금이라도 물어보곤 했다.

이 글은 그렇게 MSA를 시작했던 경험과, 뒤늦게 스터디를 하면서 그때의 선택을 회고하는 글이다.

왜 MSA가 필요했을까?

일반적으로는 모놀리식(Monolithic)으로 먼저 시작하는 것이 정석이다. 마틴 파울러도 MonolithFirst라는 글에서, 처음부터 마이크로서비스로 시작하기보다는 모놀리스로 시작해서 필요할 때 나누라고 권한다. 그런데도 우리는 왜 처음부터 MSA를 택했을까?

가장 큰 이유는 회사의 시스템들이 모두 거대한 DB 하나만 바라보고 있었다는 점이다. 시스템끼리 연계해야 할 때 기댈 수 있는 것은 API가 아니라 그 DB 하나였다. 새로운 서비스가 기존 시스템과 자연스럽게 연계할 방법이 없었던 것이다.

DB를 공유하면 문제가 생기기 쉽다.

  • 다른 팀이 스키마(Schema)를 변경하면, 우리 서비스도 영향을 받는다.
  • 반대로 우리가 스키마를 바꾸려면, 그 테이블을 쓰는 모든 팀과 협의해야 한다.
  • 결국 다른 팀의 배포 일정에 우리 배포 일정이 묶이게 된다.

비즈니스는 커지는데 개발 조직이 그 속도를 따라가지 못하는 경우가 많다. 특히 여러 팀이 하나의 시스템에 기여하기 시작하면 이런 문제는 더 자주 생긴다.

재미있는 점은, 이 문제가 우리 회사만의 문제가 아니었다는 것이다.

2002년, 제프 베조스는 아마존의 모든 팀에 “팀 간에는 서비스 인터페이스(API)로만 통신하라” 고 지시했다. API를 거치지 않는 통신은 모두 금지되었다. (심지어 다른 팀의 DB를 직접 조회하는 것도 예외가 아니었다) 그리고 모든 인터페이스는 언젠가 외부에 공개할 수 있도록 설계해야 했다.

흔히 API Mandate 라고 부르는 이 지시는, 공유 DB 때문에 조직의 확장이 막히는 문제에서 출발했다. 우리가 겪던 문제와 정확히 같은 문제였다.

여기에 신규 플랫폼이라는 조건, 레거시와의 연동, 기능 단위의 모듈화라는 요구 사항이 더해졌다. 그래서 우리는 모놀리스를 거치지 않고 처음부터 MSA를 택했다.

물론 서비스를 쪼개는 데에는 러닝커브를 비롯한 큰 부담이 따른다. 그 부담이 무엇이었는지는 아래에서 이야기하려고 한다.

MSA란 무엇인가

크리스 리처드슨의 『마이크로서비스 패턴』은 MSA를 이렇게 설명한다. 모놀리식이 애플리케이션을 하나의 컴포넌트로 구성하는 아키텍처 스타일이라면, MSA는 애플리케이션을 느슨하게 결합된, 독립적으로 배포할 수 있는 서비스들로 구성하는 아키텍처 스타일이다.

이 정의를 한 줄로 줄이면 결국

응집도는 높이고, 결합도는 낮춘다.

라고 느꼈다.

마틴 파울러와 제임스 루이스는 Microservices라는 글에서 마이크로서비스의 특징을 아홉 가지로 정리했다. 서비스를 통한 컴포넌트화, 비즈니스 역량 중심의 조직, 프로젝트가 아닌 제품, 똑똑한 엔드포인트와 멍청한 파이프, 분산된 거버넌스, 분산된 데이터 관리, 인프라 자동화, 장애를 전제한 설계, 진화적 설계.

이 중에서 직접 MSA를 해 보고 나서 가장 크게 와닿은 것은 세 가지다.

  • 분산된 데이터 관리: 서비스마다 자신의 DB를 갖는다. 그래서 다른 팀과 일일이 협의하지 않고도 스키마를 바꿀 수 있다. 우리가 MSA를 택한 이유 그 자체다.
  • 인프라 자동화: 서비스가 여러 개로 늘어나면 빌드, 테스트, 배포도 서비스 수만큼 늘어난다. 이 과정이 자동화되어 있지 않으면 MSA의 장점이 금방 비용으로 바뀐다.
  • 장애를 전제한 설계: 서비스들이 네트워크로 통신하는 이상, 어딘가는 반드시 실패한다. 넷플릭스가 일부러 운영 환경의 서버를 죽이는 Chaos Monkey를 만든 이유이기도 하다.

한 가지 조심해야 할 것이 있다. 서비스를 여러 개로 나누었는데도 반드시 함께 배포해야 한다면, 그것은 MSA가 아니라 분산 모놀리스다. 모놀리스의 단점과 분산 시스템의 단점을 모두 갖게 되는 최악의 조합이다. 공유 라이브러리도 마찬가지다. 편리하지만, 잘못 쓰면 서비스 사이에 보이지 않는 결합을 만든다.

서비스를 어떻게 나눴나

우리는 신규 플랫폼이었기 때문에, 기존 시스템을 쪼개는 것이 아니라 처음부터 경계를 정하고 시작해야 했다. 그리고 우리의 기준은 도메인이었다.

플랫폼의 중심에는 계약을 다루는 서비스와, 그 계약을 심사하는 서비스가 있었다. 그리고 레거시 연동, 사용자 관리, 결제처럼 이 둘을 지원하는 서비스들이 주변에 있었다.

왜 도메인이 기준이 되어야 할까? MSA를 시작하고, 이해도를 높이기 위해 시작한 스터디에서 DDD(Domain-Driven Design)를 공부하면서 그 이유를 조금 더 명확하게 이해할 수 있었다.

크리스 리처드슨의 『마이크로서비스 패턴』에서는 “전사 통합 모델은 실패한다” 고 말한다. 배달팀이 말하는 주문, 정산팀이 말하는 주문, CS팀이 말하는 주문은 모두 다르다. 이것을 하나의 완벽한 모델로 합의하려고 하면 회의는 끝나지 않고, 합의할수록 모든 부서의 요구 사항이 덕지덕지 붙는다.

우리 상황으로 바꿔 보면 “계약” 이 그렇다. 계약 서비스에게 계약은 작성하고 체결하는 대상이지만, 심사 서비스에게 계약은 검토하고 승인하거나 반려하는 대상이다. (같은 단어지만 관심사가 다르다)

그래서 DDD는 경계를 긋고, 그 경계 안에서만 용어를 통일하자고 제안한다. 이 경계를 경계 컨텍스트(Bounded Context) 라고 하고, 경계 안에서 기획자와 개발자가 같은 뜻으로 쓰는 말을 유비쿼터스 언어(Ubiquitous Language) 라고 한다.

우리도 실제로 이런 일을 겪었다. 같은 단어를 두고 기획자와 개발자가, 그리고 다른 개발팀이 저마다 다른 의미로 쓰고 있었다. 모두 같은 단어를 쓰고 있었으니 같은 이야기를 하고 있다고 생각했지만, 실제로는 서로 다른 것을 의미하고 있었던 경우가 있었다. 유비쿼터스 언어가 왜 필요한지는 이런 일을 한 번 겪고 나서야 와닿았다.

경계를 어디에 그을지 판단할 때는 두 가지 원칙이 도움이 된다.

  • 단일 책임 원칙(SRP): 심사 규칙이 바뀌면 심사 서비스만 바뀌어야 한다. 다른 서비스까지 고쳐야 한다면 결합도가 높은 것이다.
  • 공동 폐쇄 원칙(CCP): 함께 바뀌는 것은 함께 두어야 한다. 요구 사항 하나에 여러 서비스를 동시에 고쳐야 한다면, 합쳐야 할 것을 쪼갠 것이다.

SRP가 결합도를 낮추는 원칙이라면, CCP는 응집도를 높이는 원칙이다. 결국 여기서도 응집도는 높이고, 결합도는 낮춘다로 귀결된다.

물론 현실은 이론처럼 깔끔하지 않다. 스터디 토론에서도 “비즈니스 능력이나 DDD로 나눌 수 있다는 건 알겠는데, 현실에서는 어떻게 나눠야 하느냐”는 질문이 있었다. 그때 나온 답 중 하나가 기억에 남는다.

바뀌지 않는 가치를 중심으로 설계한다.

여기에 기준을 하나 더하자면, 서비스 경계는 트랜잭션이 경계를 넘지 않아도 되는 곳에 그어야 한다. 하나의 작업이 여러 서비스에 걸치는 순간, 뒤에서 이야기할 분산 트랜잭션 문제가 시작되기 때문이다.

MSA의 비용

MSA를 유지하는 데에는 비용이 만만치 않게 든다. 그리고 그 비용의 대부분은 코드 바깥에 있다.

MSA가 제대로 돌아가려면 이를 뒷받침하는 인프라가 있어야 하고, 이 방식을 이해하고 함께 운영할 조직이 있어야 한다. 그래서 MSA는 단순한 개발 방법 하나가 아니라, 조직과 인프라와 데이터베이스가 하나로 묶인 아키텍처라고 생각한다.

MSA가 2010년대에 들어 널리 퍼질 수 있었던 것도 Docker, Kubernetes, CI/CD, 클라우드가 함께 발전했기 때문이다. 바꿔 말하면, MSA는 이런 것들이 이미 갖춰져 있다고 전제하는 아키텍처다.

스터디를 하면서 그 전제들을 정리해 보니 대략 이렇다.

MSA가 전제하는 것하는 일
서비스 간 통신동기 통신(REST, gRPC)과 비동기 통신(메시지 브로커)으로 서비스끼리 대화한다
서비스 디스커버리계속 바뀌는 서비스 인스턴스의 IP와 포트를 찾아 준다
장애 격리타임아웃, 요청 수 제한, 서킷 브레이커로 한 서비스의 장애가 전체로 번지는 것을 막는다
배포 자동화CI/CD와 자동화 테스트로 서비스마다 독립적으로 배포한다

우리는 서비스 간 통신에 REST와 Redis Streams를 사용했다. 동기적으로 결과가 필요한 요청은 REST로, 다른 서비스에 알리기만 하면 되는 이벤트는 Redis Streams로 주고받았다. Kafka나 Istio 같은 선택지도 있었지만, 경험도 이러한 인프라를 운영하기 위한 전문 인력이 부족한 상황에서 운영 부담이 큰 도구를 들이기는 어려웠다.

메시지 브로커를 쓴다는 것 자체도 비용이다. 브로커가 멈추면 전체가 멈추는 단일 장애 지점(SPoF)이 되고, 성능 병목이 되기도 하며, 운영해야 할 대상이 하나 더 늘어난다.

분산 데이터 처리

서비스마다 DB를 나누고 나면 곧바로 이런 질문을 마주하게 된다. 여러 서비스에 걸친 작업은 어떻게 하나의 트랜잭션처럼 처리할까?

java
@Transactional
void placeOrder() {
    orderRepo.save(order);
    mq.publish(orderCreated);   // 메시지 발행
    pointService.deduct(user);  // 에러 발생 → 롤백
}

위 코드에서 포인트 차감이 실패하면 주문 저장은 롤백된다. 하지만 “주문이 생성되었다”는 메시지는 이미 브로커로 나가 버렸다. 다른 서비스들은 존재하지 않는 주문을 기준으로 움직이게 된다. 메시지 발행을 커밋 뒤로 미루더라도, 이번에는 커밋은 됐는데 발행이 실패하는 경우가 생긴다.

DB와 메시지 브로커는 서로 다른 시스템이라서, 하나의 트랜잭션으로 묶을 수 없다. 그래서 나온 방법이 Transactional Outbox 패턴이다. 메시지를 브로커로 바로 보내지 않고, 같은 DB의 Outbox 테이블에 비즈니스 데이터와 함께 저장한다. 이렇게 하면 로컬 트랜잭션 하나로 원자성이 보장된다. 그다음 별도의 릴레이가 Outbox 테이블을 읽어서 브로커로 발행한다.

메시지를 받는 쪽에도 챙겨야 할 것이 있다. Redis Streams의 컨슈머 그룹은 메시지를 처리하고 확인(ACK)하기 전에 컨슈머가 죽으면, 그 메시지를 다시 전달할 수 있다. 즉 같은 메시지를 두 번 이상 받을 수 있다(at-least-once). 그래서 같은 메시지를 여러 번 처리해도 결과가 달라지지 않도록 멱등한 핸들러를 작성하거나, 처리한 메시지의 ID를 저장해 두고 중복을 걸러 내야 한다.

여러 서비스에 걸친 작업 전체를 다루는 방법으로는 Saga 패턴이 있다. 각 서비스가 자신의 로컬 트랜잭션을 수행하고, 완료되면 메시지를 발행해서 다음 단계를 진행시킨다. 중간에 실패하면 이미 완료된 단계들을 되돌리는 보상 트랜잭션을 실행한다.

여기서 가장 중요한 개념은 최종적 일관성(Eventual Consistency) 이다. 지금 이 순간에는 서비스마다 데이터가 맞지 않을 수 있지만, 모든 단계가 끝나면 결국 일관된 상태가 된다는 뜻이다. 분산 환경에서는 “항상 맞는 데이터”를 포기하고 “결국 맞게 되는 데이터”를 설계해야 한다.

공부하면서 한 가지 해 보고 싶은 것도 생겼다. Saga 오케스트레이터로 흐름을 관리하더라도, 네트워크 오류나 복제 지연처럼 예상하지 못한 이유로 데이터가 어긋날 수 있다. 그래서 Saga에 대사처리(Reconciliation)를 함께 두면 정합성을 한 번 더 확인할 수 있지 않을까 하는 생각이다. 실제로 여러 회사의 발표를 보면, 완벽한 Saga를 구현하기보다 대사처리로 최종 정합성을 맞추는 경우가 많았다.

통신 방식과 Outbox, Saga에 대해서는 다음 글에서 더 자세히 다루려고 한다.

회고

MSA로 얻은 것도 분명히 있었다. 서비스가 도메인별로 나뉘어 있었기 때문에, 한 서비스를 고칠 때 다른 서비스의 일정을 기다릴 필요가 없었다. 그래서 더 짧은 주기로 여러 번 개발 사이클을 돌릴 수 있게 되었다. 애자일하게 일한다는 것이 무엇인지 조금은 체감할 수 있었다.

하지만 가장 아쉬운 점도 여기에 있었다. 배포는 여전히 수동이었다.

앞에서 MSA를 “독립적으로 배포할 수 있는 서비스들”로 정의했다. 그런데 배포가 수동이면 서비스가 늘어날수록 배포해야 할 대상도 함께 늘어나고, 그만큼 사람의 손이 더 많이 간다. 사이클은 짧아졌지만, 결국 그 속도의 상한은 수동 배포가 정하고 있었다.

아마존은 2011년에 이미 평균 11.6초마다 한 번씩 운영 환경에 배포했다고 한다. 그 숫자를 가능하게 한 것은 서비스를 잘게 나눈 구조만이 아니라, 그 구조를 뒷받침하는 자동화였다. CI/CD가 갖춰져 있었다면 MSA와 강한 시너지를 냈을 것이다.

그래서 다시 한다면, 인프라를 먼저 잡아 두고 시작하고 싶다. 아직 우리 팀의 구조는 MSA에 맞는 인프라가 충분히 갖춰지지 않은 상태다. 하나씩 채워 나가려고 한다.

마치며

솔직히 기술적인 구현은 AI를 활용해서 어떻게든 진행할 수 있었다. 하지만 앞에서 이야기한 것처럼, 결정을 내려야 하는 순간마다 어떻게 해야 할지 고민이 많았다. 서비스를 어디서 나눌지, 무엇으로 통신할지, 데이터가 어긋나면 어떻게 할지. AI가 답을 줄 수는 있어도, 그 답을 고르는 것은 결국 나였다.

그러면서 내가 사용하고 있는 기술을 더 깊이 이해하고 싶다는 갈증이 생겼다. 그리고 거기서 더 나아가, MSA를 진행하지 않는 팀원들까지 포함해서 MSA를 전사적으로 전파하고 싶었다. 앞에서 MSA는 조직까지 묶인 아키텍처라고 했는데, 그렇다면 혼자 아는 것만으로는 부족하다고 생각했다.

그래서 내 경험을 조금 더 잘 설명할 수 있도록, MSA 스터디를 시작했다.

이 글에서는 왜, 어떻게 MSA를 시작했는지를 이야기했다. 다음 글부터는 스터디에서 다룬 내용을 우리 경험과 엮어서 하나씩 정리해 보려고 한다.

  • 서비스 간 통신과 Transactional Outbox
  • Saga와 최종적 일관성
  • 애그리거트와 트랜잭션 경계

참고 자료