Search
⚙️

[Tech] 마이크로서비스 분리하기 - 코드 레벨 view

태그
Server
Product
날짜
2024/02/19
작성자

Index

개요

안녕하세요, 라포랩스 서버 플랫폼 팀의 지수환입니다.
최근 2년간 라포랩스 백엔드 팀은 백엔드 아키텍처를 모노리식 아키텍처에서 MSA로 전환하는 작업을 진행하고 있습니다. MSA 전환 작업을 진행하면서 다양한 어려움을 겪었는데, 그 중에서는 모노리스 서버에서 코드를 분리하고, 새 서버를 띄우고, 트래픽을 옮기는 등의 세부 마이그레이션 과정에서 겪은 어려움도 많았습니다. 하지만 MSA 전환과 관련된 대부분의 아티클은 세부 마이그레이션 과정보다는 도메인 경계 식별 방식이나 기술 선택 등 거시적인 전략에 초점을 맞추고 있어서, 분리 과정에서 참고할만한 자료를 찾기가 어려웠습니다.
그래서 이번 글에서는 라포랩스에서 모노리스 서버를 마이크로서비스로 분리할 때 어떤 문제를 겪었는지, 그리고 이를 해결하기 위해 어떤 전략을 세웠는지 상세히 공유하려고 합니다.

요구사항

모노리스 서비스에서 마이크로서비스를 분리하는 대규모 시스템 변경이 필요한, 난이도가 높은 작업입니다. 저희 팀은 마이크로서비스 분리 과정에서 해결해야 하는 어려움을 검토하여 총 3가지 요구사항으로 정리했습니다.

컨플릭을 방지하기

MSA로의 전환은 1~2주가 아니라 1~2년을 바라보고 진행하는 작업입니다. 이미 서비스가 프로덕션에서 운영되고 있는 상황에서 이런 긴 기간 동안 서비스 개발을 멈추고 MSA 전환 작업을 진행하는 것은 비즈니스적인 측면을 고려할 때 내리기 어려운 결정입니다. 따라서 마이크로서비스를 분리 작업과 서비스 개발이 병렬적으로 진행될 수 있어야 합니다.
마이크로서비스 분리 작업과 서비스 개발을 동시에 진행하려고 할 때 가장 큰 걸림돌이 되는 요소는 컨플릭입니다. 마이크로서비스 분리 작업은 코드의 구조적인 측면을 변경해야 하므로 코드베이스의 많은 영역을 건드리게 됩니다. 따라서 많은 경우 서비스 개발을 위한 코드 변경과 컨플릭을 일으킵니다. 컨플릭이 발생할 경우 이를 해소하는 데 추가적인 비용이 소모되며, 해소하는 과정에서 새로운 버그가 발생할 수도 있습니다.
따라서 서비스 개발과 마이크로서비스 분리 전략이 동시에 진행되기 위한 가장 중요한 요구사항으로 컨플릭을 최소화하는 것을 설정했습니다.

QA로 인한 배포 지연을 유발하지 않기

대규모 변경이 발생할 때 서비스 개발을 지연시킬 수 있는 또다른 요소는 오랜 QA로 인한 피처 프리즈입니다. Git 레포지토리를 사용하면 1개의 branch를 trunk로 두게 되는데, 이 trunk에는 선형적으로 commit이 쌓입니다. 이러한 구조로 인해 아직 QA팀에서 사인오프하지 않은 기능이 포함된 commit 이후로는 배포가 불가능합니다. 따라서 특정 기능의 QA가 오래 걸리면 hotfix를 하지 않는 이상 새 기능을 배포하기 어려워집니다.
마이크로서비스 분리 작업은 대규모 변경이 발생하는 만큼 꼼꼼하고 오랜 QA가 필요합니다. 따라서 QA 전략을 신중하게 세워서 QA로 인한 배포 지연이 발생하지 않도록 하는 것을 또 하나의 요구사항으로 설정했습니다.

분리 전후의 동작이 동일함을 보장하기

마이크로서비스 분리는 백엔드 팀이 일을 더 잘하기 위한 일종의 리팩토링입니다. 즉, 클라이언트에게 노출되는 API의 인터페이스와 동작은 유지한 채로 내부의 구조만 변경되어야 합니다. 하지만 마이크로서비스 분리는 대규모의 코드 구조 변경을 필요로 하기에, 마이크로서비스 분리 전후의 동작이 동일함을 보장하려면 많은 고민이 필요합니다.
따라서 분리 전후로 동작이 동일함을 보장할 수 있는 전략 세우기를 마지막 요구사항으로 설정했습니다.

라포랩스 백엔드 시스템 구조

계획에 대해 이야기하기 앞서, 먼저 라포랩스 백엔드 시스템의 구조에 대해 간략하게 설명드리겠습니다.

코드 아키텍처

현재 라포랩스 백엔드 모노리스 서버는 Spring Boot + Kotlin으로 작성되어 있습니다. 또한, 헥사고날 아키텍처를 기반으로 하는 gradle multi-project build 구조로 모듈화되어 있습니다.
각 gradle 모듈은 총 4가지 카테고리로 분류됩니다.
api - OpenAPI Specification으로 정의된 API 명세가 위치하는 모듈입니다.
api-server - Spring MVC controller가 위치하는 모듈입니다. 헥사고날 아키텍처 관점에서는 inbound port에 대한 adapter로 작용합니다.
domain - 애플리케이션의 핵심 도메인 로직이 위치하는 모듈입니다.
outbound-adapter - domain에서 외부로 나가는 outbound port에 대한 구현체가 위치하는 모듈입니다. e.g. 서드 파티 연동 등.

개발 환경

라포랩스 백엔드 챕터에서는 현재 DEV, STG, PROD까지 총 3개의 개발 환경을 운영하고 있습니다.
DEV - 개발자가 비교적 자유롭게 사용할 수 있는 개발자 테스트 환경입니다.
STG - 릴리즈 QA를 진행하는 QA 환경입니다. PROD와 최대한 비슷한 상태로 유지합니다.
PROD - 실제 서비스가 운영되고 있는 운영 환경입니다.

전략

앞서 설정한 3가지 요구사항을 달성하기 위해, 크게 2가지 전략을 구상했습니다. 편의상 모노리스 서버를 monolith, 새로 분리할 서버를 ms로 부르겠습니다.
1.
monolith 안에서 ms로 분리할 모듈 구조를 미리 잡아두기
2.
monolith 에서 원격 ms를 사용하는 시점을 피처 플래그로 조절하기

monolith 안에서 ms로 분리할 모듈 구조를 미리 잡아두기

첫 번째 전략은 분리 작업의 영향과 소요 시간을 줄이기 위해 ms로 옮겨가야 하는 코드를 monolith 레포지토리 안에서 미리 모듈 구조대로 리팩토링하는 것입니다. 구체적으로, 아래 그림과 같은 모듈 구조를 monolith 레포지토리 안에서 만드는 것을 목표로 합니다.
다음은 monolith에 남을 모듈입니다.
domain : monolith에서 바라보는 ms의 모델 및 서비스에 대한 인터페이스를 제공합니다.
outbound-adapter : domain 모듈의 서비스 인터페이스에 대한 구현체를 제공합니다.
다음은 ms로 옮겨갈 모듈입니다.
ms-api : ms가 노출할 API spec을 정의합니다.
각 모듈에 들어갈 코드의 예시는 아래의 실행 섹션에서 소개합니다.
리팩토링은 아래와 같은 순서로 진행됩니다.
1.
monolith에서 ms로 분리해낼 gradle 모듈을 식별한다.
2.
1에서 식별한 모듈을 현재 monolith 레포지토리 안에서 api - api-server - domain - outbound-adapter 모듈 구조로 리팩토링한다(각각 ms-api - ms-api-server - ms-domain - ms-outbound-adapter).
3.
2의 ms-api - ms-api-server - ms-domain - ms-outbound-adapter 모듈을 통째로 복사하여 ms 서버를 별도로 띄운다.

이점

위의 계획은 3가지 요구사항 중 컨플릭을 방지하기분리 전후의 동작이 동일함을 보장하기를 달성하는 데 도움을 줍니다.
컨플릭을 방지한다.
위 모듈 구조를 미리 잡아놓으면, 마이크로서비스 분리와 서비스 개발이 주로 수정하는 모듈이 서로 겹치지 않습니다.
서비스 개발 - ms-domain, ms-outbound-adapter
마이크로서비스 분리 - outbound-adapter, ms-api, ms-api-server
따라서 두 작업이 컨플릭을 일으킬 확률이 매우 낮아집니다.
분리 전후의 동작이 동일함을 보장한다.
ms를 분리할 때 이미 monolith 내부에서 호출되던 코드를 그대로 ms로 복사해서 분리하기 때문에, ms 분리 전후로 비즈니스 로직을 위한 코드(ms-domain, ms-outbound-adapter, ms-api-server, ms-api 모듈의 코드)가 달라지지 않습니다. 이는 ms 분리 전후의 동작이 동일함을 보장하는 데 큰 도움이 됩니다.
이 외에도 아래와 같은 추가적인 이점을 가집니다.
API 명세 변경에 대한 피드백 루프가 빨라진다.
ms-api 모듈을 다른 레포지토리에 두고 작업하는 경우, ms-api 모듈에 정의된 API 명세가 수정되면 아래의 과정을 거쳐야 monolith 레포지토리의 outbound-adapter 모듈에서 사용할 수 있습니다.
1.
ms 레포지토리에서 kotlin code를 생성한다.
2.
1에서 생성한 kotlin code를 maven repository에 publish 한다.
3.
2에서 publish 한 package를 monolith 레포지토리에서 gradle sync를 통해 받아온다.
반면 ms-api 모듈이 monolith 레포지토리에 있는 경우, 한 단계만 거치면 됩니다.
1.
monolith 레포지토리에서 kotlin code를 생성한다.
네트워크 통신이나 gradle sync와 같은 과정이 없으므로, API 명세의 변경에 대한 피드백 루프가 매우 빨라집니다. ms 분리 작업의 상당수가 메소드 호출을 API 호출로 바꾸기 위해 API 명세를 재작성하는 것이므로, 이 이점은 분리 작업의 속도를 상당히 향상시켜줍니다.

monolith에서 원격 ms를 사용하는 시점을 피처 플래그로 조절하기

남은 요구사항인 QA로 인한 배포 지연을 유발하지 않기를 달성하기 위해, monolith에서 원격 ms를 호출하게 되는 시점을 피처 플래그로 조절하기로 했습니다.
1.
Branch by abstraction을 위해, monolith에 남을 domain 모듈을 전부 인터페이스로 변경한다.
2.
domain 모듈의 인터페이스에 대해 두 가지 구현체를 구현한다.
in-process impl : 같은 레포지토리 안에 있는 ms-api-server 모듈의 controller 메소드를 직접 호출하는 구현체.
remote impl : HTTP client를 활용하여 원격 ms의 API를 호출하는 구현체.
두 구현체 중 어떤 것을 사용할지는 피처 플래그로 제어한다.
3.
원격 ms를 사용할 시점이 되면 피처 플래그를 켜서 remote impl을 사용한다.

이점

피처 플래그를 사용하면 QA로 인한 배포 지연을 유발하지 않기 요구사항을 쉽게 달성할 수 있습니다.
QA로 인한 배포 지연을 유발하지 않는다.
피처 플래그를 도입하면 STG는 remote impl을 사용해서 마이크로서비스 분리 작업에 대한 QA를 진행하고, PROD는 in-process impl을 사용하여 마이크로서비스 분리 작업의 영향이 없는 상태로 배포할 수 있습니다. 즉, 마이크로서비스 분리 작업에 대한 긴 QA가 배포 지연을 발생시키지 않습니다.
위와 같이 피처 플래그를 활용하여 QA를 진행하는 경우, STG 환경과 PROD 환경의 구조가 달라지므로 STG QA가 큰 의미가 없는 것이 아니냐는 의문을 제기할 수 있습니다. 하지만 저희 팀은 이러한 구조에서도 STG QA가 충분히 의미가 있을 것이라고 판단했습니다. ms 분리 전후로 비즈니스 로직에 대한 코드는 달라지지 않으므로, in-process impl과 remote impl의 동작 차이는 비즈니스 로직 외의 인프라 관련 코드에서 발생합니다. 이러한 구조로 인해, STG에서 remote impl을 사용하는 상태로 QA를 진행하면 비즈니스 로직과 무관한 문제가 발견될 수는 있지만(false positive), 비즈니스 로직에 문제가 있는데 놓치는 경우(false negative)는 없을 것입니다.
이 외에도 아래와 같은 추가적인 이점을 가집니다.
문제가 발생할 경우 롤백이 간단하다.
원격 ms로 트래픽을 보내는 것을 in-process impl과 remote impl 중 어떤 것을 사용할지에 대한 피처 플래그로 제어합니다. 따라서 PROD에서 remote impl을 사용하게 했을 때 문제가 발생하면 피처 플래그를 변경하여 in-process impl을 사용하도록 변경하는 방식으로 즉시 롤백이 가능합니다.

요약

최종적으로 아래와 같은 모듈 구조를 목표로 리팩토링을 진행합니다.

실행

전략을 세웠으니 이제 점진적으로 분리 작업을 수행할 차례입니다.

monolith 레포지토리 내부에서 모듈 구조 리팩토링하기

분리 작업 중 가장 까다로우면서도 많은 부분을 차지하는 과정은 monolith 레포지토리 내에서 모듈 구조를 리팩토링하는 것입니다. 변경은 작을수록 더욱 빠르고 안전하게 도입할 수 있으므로, 우리는 이 big bang을 작은 변경들의 합으로 분해할 필요가 있습니다.
아래는 상세한 모듈 구조 리팩토링 과정을 나타냅니다.
1.
ms로 분리해낼 domain, outbound-adapter 모듈의 이름에 ms- prefix를 붙입니다.
ms- prefix가 붙은 모듈은 나중에 ms로 떨어져 나갈 모듈입니다.
2.
monolith에 남을 domain 모듈을 만듭니다.
domain 모듈은 monolith에서 사용할 ms에 대한 anti-corruption layer가 될 것입니다.
이때, domain 모듈에는 크게 두 가지 종류의 클래스가 존재하게 됩니다.
a.
엔티티, 값 객체
b.
서비스 (도메인 서비스, 애플리케이션 서비스)
이 모듈의 코드를 최대한 빠르게, 또한 최소한의 변경으로 만들기 위해, 임시로 ms-domain 모듈의 클래스에 대해 Kotlin의 typealias를 걸어둡니다.
이 과정 이후, monolith의 다른 도메인이 이 도메인을 사용하고 싶을 때는 ms- prefix가 붙은 모듈에는 의존하지 않고 domain 모듈에만 의존합니다.
3.
2의 typealias 중 서비스를 먼저 인터페이스화 합니다.
서비스를 인터페이스로 정의했으므로 이에 대한 구현체가 필요한데, 이는 outbound-adapter 모듈을 새롭게 만들어서 구현합니다. 이때 구현체는 ms-domain의 메소드를 호출하는 proxy처럼 구현합니다.
4.
이제 2의 typealias 중 엔티티 & 값 객체를 클래스로 바꿀 차례입니다.
이를 최대 효율로 변경하려면 API 명세와 DTO 변환 로직을 한 번에 구현하는 것이 가장 좋습니다. 따라서 아래의 작업을 한 번에 진행합니다.
a.
domain 모듈에 남을 엔티티 & 값 객체에 대한 클래스를 작성합니다.
b.
1에 맞춰서 ms-api 모듈에 API 명세를 작성합니다.
c.
2에 맞춰서 ms-api-server 모듈에 controller를 작성합니다.
이 과정에서 ms-api 모듈의 DTO ms-domain 모듈의 엔티티 & 값 객체 변환 로직을 작성하게 됩니다.
d.
outbound-adapter 모듈의 in-process impl이 controller method를 호출하도록 수정합니다.
이 과정에서 domain 모듈의 엔티티 & 값 객체 ms-api 모듈의 DTO 변환 로직을 작성하게 됩니다.
이 과정은 domain 모듈의 모든 엔티티 & 값 객체에 대해 한 번에 진행할 필요가 없으며, 일부분씩 점진적으로 변환해나가면 됩니다. 예시로, 주문 마이크로서비스 분리 시 이 과정을 8개의 PR로 끊어서 작업했습니다.
5.
outbound-adapter 모듈에 remote-impl을 구현합니다. 이는 ms 분리 이후 원격으로 호출을 하기 위함입니다.
이때 피처 플래그로 in-process impl과 remote impl 중 어떤 것을 사용할지를 결정할 수 있도록 구현합니다. 피처 플래그는 Spring Boot가 제공하는 @ConditionalOnProperty 어노테이션을 통해 쉽게 구현할 수 있습니다.
피처 플래그가 잘 동작하는 것은 간단한 integration test를 작성하여 쉽게 확인할 수 있습니다. 아래는 Spring TestContext Framework가 제공하는 @TestPropertySource 어노테이션을 통해 작성한 integration test입니다.

QA 및 배포하기

이제 코드 상에서 ms를 분리할 준비를 마쳤습니다. 남은 단계는 총 3가지 입니다.
1.
새로운 레포지토리에 코드를 복사하여 서버를 띄우기
2.
STG 환경에서 QA 진행하기
3.
PROD 환경에 배포하기

새로운 레포지토리에 코드를 복사하여 서버를 띄우기

새로운 서버를 띄우기 위해서는 ms-api, ms-api-server, ms-domain, ms-outbound-adapter 모듈을 다른 레포지토리로 복사하고, 이 코드를 배포할 수 있는 아티팩트로 빌드해야 합니다.
이때, 서버를 띄우기 시작하는 시점부터 실제로 PROD 환경에서 ms 분리를 완료할 때까지 꽤 오랜 시간이 걸릴 수 있습니다. 이 기간 동안 monolith 레포지토리에 대해 피처 프리즈를 걸지 않기 위해서는 monolith 레포지토리에 발생한 변경사항을 ms 레포지토리에도 지속적으로 적용해주는 것이 필요합니다.
이를 위해서는 코드 복사를 수동으로 하는 대신 자동화를 해두는 것이 편리합니다. 이미 복사할 코드의 모듈 구조가 ms로 이동한 이후의 코드 구조와 일치하기 때문에, 아래와 같은 간단한 스크립트를 통해서 코드 복사를 자동화할 수 있습니다.
for path in ../damoa-server/microservice-core/order/ms-*; do module=$(echo "$path" | sed 's/..\/damoa-server\/microservice-core\/order\/ms-//') if [[ "$module" == "domain" || "$module" == "outbound-adapter" ]]; then rm -r "$module/order" cp -r "../damoa-server/microservice-core/order/ms-$module" "$module/order" else rm -r "$module" cp -r "../damoa-server/microservice-core/order/ms-$module" "$module" fi; done # 후략
Bash
복사
코드 외에도 환경별 config / secret 값 역시 monolith에서 적절히 복사해와야 합니다.
라포랩스는 secret을 vault로 관리하고 있는데, vault 값 복사 역시 사람의 실수를 줄이기 위해 위와 비슷한 쉘 스크립트를 작성해서 복사했습니다.

STG 환경에서 QA 진행하기

아티팩트를 빌드하고 config / secret을 설정해서 서버를 띄웠으니, 이제 QA를 진행할 차례입니다.
QA를 진행하기 위해서, 이전에 개발한 피처 플래그를 사용하여 STG 환경에서는 remote impl을 사용하도록 설정합니다.
여전히 PROD 환경은 in-process impl을 사용하므로, PROD 환경의 동작은 아무것도 달라지지 않습니다. 위에서 언급한 monolith → ms 코드 복사 자동화가 있으므로, 피처 프리즈를 하지 않고 서비스 개발을 지속하면서도 오랜 기간 QA를 꼼꼼하게 진행할 수 있습니다.

PROD 환경에 배포하기

이제 마지막 단계입니다. STG에서 QA를 진행한대로, PROD에서도 피처 플래그를 활용하여 remote impl을 사용하도록 설정하면 분리가 완료됩니다.
문제가 발생할 경우, 피처 플래그 원복을 통해 빠르게 롤백할 수 있습니다.

monolith에 남아 있는 ms 관련 코드 삭제

가장 신나는 코드 삭제 시간입니다. 더 이상 사용하지 않는 in-process impl과 ms- prefix가 붙은 모듈을 전부 삭제해 줍니다.

마무리

라포랩스 백엔드 팀은 위와 같은 과정을 통해 점진적으로, 또한 안전하게 마이크로서비스 분리를 수행할 수 있었습니다.
분리 과정은 전반적으로 만족스러웠으나, 아쉬운 점도 있었습니다. 가장 아쉬웠던 점은 API 명세 작성이나 in-process impl & remote impl 작성 등 단순 반복 작업이 많았는데, 이를 전부 수기로 작성한 부분이었습니다. 일정한 패턴으로 코드를 추가하는 작업이 많았던 만큼 자동화된 code generation 스크립트를 작성하려고 했으나, 초반에 조금 시도하다가 의도대로 잘 동작하지 않아 금방 포기했었습니다. 지금 되돌아보면 code generation 자동화에 시간을 조금 더 쏟았다면 더욱 빠르고 안전하게 ms 분리 작업을 마칠 수 있지 않았을까 싶습니다.
이 글이 ms 분리를 시작하고자 하는 팀에게 조금이나마 도움이 되기를 바랍니다. 덧붙이자면, 이 글을 통해 전해드린 ms 분리 방식은 다양한 분리 방식 중의 하나일 뿐이며, 모든 팀의 상황에 적합한 방식은 아닐 수 있습니다. 중요한 것은 팀이 처한 상황을 정확히 파악하고, 이를 바탕으로 달성하고자 하는 목적을 명확히 세우는 것이라고 생각합니다. 현재 상황에 대한 정확한 이해와 명확한 방향성만 있다면, 올바른 트레이드오프를 통해 적합한 계획을 세우는 것은 보다 간단한 일이 될 것입니다.
한편, DB 및 kafka consumer의 마이그레이션이나 분리 과정에서 겪은 예상치 못한 troubleshooting 등 이 글에 담지 못한 시행착오가 아직 많이 남아 있습니다. 남은 이야기들은 기회가 되면 다른 글을 통해 공유해보도록 하겠습니다.