[번역] 잘가요 마이크로서비스: 100개의 문제점 투성이를 1개의 슈퍼스타로
이 글은 Segment의 Alexandra Noonan이 작성한 Goodbye Microservices: From 100s of problem children to 1 superstar의 번역입니다.
음지에 숨어서 살아오지 않았더라면, 당신은 아마도 마이크로서비스가 오늘날의 아키텍처란 것을 알고 있을겁니다. 트렌드와 함께 성장하며 Segment는 마이크로서비스를 최고의 선택으로 초기에 빠르게 채택하였습니다. 그 결과 몇몇 케이스에서는 마이크로서비스가 성공적일 수 있었고, 곧 알게 되겠지만 그 외의 경우에서는 그렇지 않다는 걸 알았죠.
잠시 설명하자면, 마이크로서비스는 서비스 중심의 소프트웨어 아키텍처입니다. 서버사이드 애플리케이션이 단일 목적의, 그리고 낮은 네트워크 오버헤드를 갖는 여러 서비스로 구성되어 있죠. 가장 큰 장점은 향상된 모듈화, 테스트 부담을 덜 수 있다는 점, 더 나은 함수 구성, 환경 격리, 개발 팀 자율성입니다. 이 반대는 모놀리식 아키텍쳐입니다. 많은 기능이 하나의 서비스에 뭉쳐 돌아가는데 테스트, 배포, 스케일링 또한 하나의 유닛을 기반으로 하죠.
2017년 초 우리는 Segment의 제품의 코어 파트들과 함께 정점에 도달했습니다. 그것은 마치 만약 우리가 마이크로서비스 트리를 떨어지게 한 것 같았고, 모든 가지가 시들기 시작했습니다. 우리가 빠르게 움직일 수 있게 해주는 대신, 작은 팀들은 그들이 엄청난 복잡도의 수렁에 빠지게 된 것을 알게 되었습니다. 이 아키텍처의 본질적인 이득은 짐이 되었습니다. 우리의 개발 속도가 곤두박질침에 따라, 결함율은 폭발하고 말았습니다.
결국, 팀은 그들이 더이상 진행할 수 없다는 것을 알게 되었습니다. 3명의 풀타임 엔지니어가 대부분 그들의 시간을 그저 시스템이 살아있게 하면서 말이죠. 무언가가 변화했습니다. 이 포스트는 우리가 어떻게 돌아갔는지와 우리 제품 요구와 팀의 요구에 맞게 변화하는 목표를 실행했는지에 관한 내용입니다.
Why Microservices work worked
Segment의 고객 데이터 인프라는 초당 수많은 이벤트를 처리하고 이 이벤트들을 파트너 API로 재전송합니다. 우리가 서버사이드 목적지라고 부르는 곳들이죠. 저들 중에는 Google Analytics, Optimizely, 또는 커스텀 웹훅같은 것들로 이루어진 수백가지 이상의 목적지가 존재합니다.
우리가 프로젝트를 처음 런칭했던 몇년 전으로 돌아가보면 그당시 아키텍처는 심플했습니다. 이벤트를 받는 API가 있었고 그 이벤트를 메시지 큐로 재전송해주기만 했죠. 이 경우 웹 또는 모바일 앱에서 사용자나 사용자의 액션이 포함된 정보가 JSON 형태로 넘어옵니다. 간단히 페이로드를 살펴보면 이렇게 생겼습니다:
{
"type": "identify",
"traits": {
"name": "Alex Noonan",
"email": "[email protected]",
"company": "Segment",
"title": "Software Engineer"
},
"userId": "97980cfea0067"
}
이벤트가 큐에서부터 consume될 때, consumer에 의해 관리되는 설정은 어느 목적지에서 이벤트를 받을지를 결정하기 위해 사용되었습니다, 잇따라 이는 굉장히 유용했는데 왜냐하면 개발자들은 수많은 integration을 직접 구성하는 대신, 오직 이 이벤트를 하나의 엔드포인트로 전송하면 됐기 때문입니다. 그게 바로 Segment의 API죠.
만약 목적지로 보내는 요청 중 하나가 실패한다면, 가끔씩 우리는 나중에 해당 이벤트를 다시 전송하기도 했습니다. 몇몇 실패 이벤트는 재시도해도 괜찮았던 반면 일부는 그렇지 않았죠. 재시도 가능한 에러는 아무런 변동 없이 목적지에서 잘 받아줄 수 있었습니다. 예를 들어 HTTP 500 에러, 요청 수 제한, 그리고 타임아웃 같은 것들이 이에 해당됩니다. 재시도 불가능한 에러는 목적지에서 절대로 받아주지 않는단 것을 확신할 수 있는 요청들이었습니다. 예를 들어, 잘못된 인증 정보를 갖고 있거나 빠진 필드가 있는 요청이 이에 해당됩니다.
이때, 재시도 이벤트와 새 이벤트 모두를 담고 있던 단일 큐는 몇번의 재시도를 시행했고 그 결과 head-of-line blocking 현상이 나타났습니다. 이런 특별한 상황에서 이게 의미하는 것은, 만약 한 목적지가 느려지거나 죽게 되면 재시도는 큐를 가득 채울 것이고, 모든 목적지에 대해 딜레이가 발생하게 됩니다.
목적지 X가 임시적 이슈가 발생했고 모든 요청이 타임아웃과 함께 에러가 발생한다고 생각해봅시다. 이제, 이 요청은 목적지 X에 도달하지 못한 엄청난 수의 요청을 만들어낼 뿐 아니라 모든 실패한 요청이 다시 큐에 재시도로 쌓이게 됩니다. 우리의 시스템이 로드에 따라 자동으로 스케일되었기 때문에 이렇게 급작스럽게 증가하는 큐는 서비스 스택을 스케일업하는 우리의 능력을 뛰어 넘게 되어버렸고 결과 새 이벤트에 대해 딜레이가 발생하였습니다. 목적지 X가 잠시 죽었기 때문에 모든 목적지에 전달하는 시간이 증가하는 겁니다. 고객들은 제때제때 이 전달이 보장되는 것에 의존한 서비스를 만들었고 따라서 우리는 계속해서 이 파이프라인에서 어느 곳이든 어디론가 향하는 요청의 대기 시간을 늘릴 수는 없었습니다.
head-of-line blocking 문제를 해결하기 위해, 팀은 분리된 서비스를 만들어 각 목적지에 따라 큐를 했습니다. 이 새 아키텍처는 이벤트를 받아 처리하고 복사한 이벤트를 각 선택된 목적지로 분배하여 배포하는 추가적인 라우터 프로세스로 구성되어 있었습니다. 이제 만약 한 목적지에 문제가 생기면 해당 큐만 문제를 겪고 나머지 목적지에는 영향을 끼치지 않았습니다. 마이크로서비스 스타일 아키텍처는 목적지 각각을 격리시켰고, 이는 한 목적지가 자주 그렇듯 문제를 발생시킬 때 매우 유용했었습니다.
레포 분리 케이스
각 목적지의 API는 서로 다른 요청 포맷을 사용합니다. 이벤트를 특정 포맷에 매치시키기 위해 변환하는 커스텀 코드를 필요로 했죠. 간단한 한 예는 우리의 API는 생일을 traits.birthday
로 전송하는데 반해 목적지 X가 페이로드에 생일을 traits.dob
로 전송해야 했던 예입니다. 목적지 X의 이 변환 코드는 대충 이렇게 생겼습니다:
const traits = {}
traits.dob = segmentEvent.birthday
최근 많은 목적지 엔드포인트들은 이런 변환을 상대적으로 쉽게 하는 Segment의 요청 포맷을 채택했습니다. 하지만 이런 변환은 목적지의 API 구조에 의존하여 매우 복잡해질 수도 있습니다. 예로, 몇몇 오래되고 제멋대로 생긴 목적지가 있는데 이때 우리는 이런 값들을 손수 직접 만든 XML 페이로드에 담아넣기도 했습니다.
초기 목적지가 여러 서비스로 나뉘어졌을 때 모든 코드는 한 레포안에 있었습니다. 좌절감을 주었던 가장 큰 부분은 바로 한 개의 깨진 테스트가 모든 목적지 테스트를 실패로 이끌었다는 점이었습니다. 우리가 어떤 변화를 배포하길 원했을 때, 우리는 해당 테스트가 초기의 변경과 아무런 상관이 없었음에도 깨진 테스트를 고치는데 시간을 써야만 헀습니다. 이 문제에 대응하기 위해 각각의 목적지로 향하는 코드는 각각의 레포를 갖게끔 되었습니다. 모든 목적지의 코드는 이미 각각의 서비스로 나뉘어져 있었고 따라서 과정은 순조로웠습니다.
이렇게 레포를 분리하여 코드를 나눈 결과 우리는 손쉽게 테스트를 격리할 수 있게 되었습니다. 이 격리조치는 개발팀이 목적지에 대한 코드를 건드려야 할 때 빠르게 움직일 수 있게끔 해주었습니다.
마이크로서비스의 스케일링과 레포
시간이 지남에 따라 우리는 50개가 넘는 목적지를 추가했고 이가 의미하는 바는 50개의 레포가 생겼다는 것입니다. 이러한 코드베이스를 손쉽게 관리하고 개발함에 있어 짐을 덜기 위해 우리는 일반적인 코드와 기능을 공유 라이브러리로 나누어 관리했습니다, 각각의 목적지로 손쉽게 가게끔 HTTP 요청 핸들링 코드 같은 것들이 이에 포함됩니다.
예로 만약에 우리가 한 이벤트로부터 사용자의 이름을 가져오길 원한다면 event.name()
을 실행하여 어드 목적지의 코드에서든 이를 가져올 수 있습니다. 이 공유 라이브러리는 이벤트에 name
이나 Name
이란 키의 속성이 있는지 확인합니다. 만약 이것들이 존재하지 않다면 firstName
, first_name
, FirstName
같은 것들을 찾아보았습니다. last name에도 동일하게 말이죠, 이렇게 찾아서 두 이름을 하나의 풀네임으로 바꾸어줬습니다.
Identify.prototype.name = function() {
var name = this.proxy('traits.name');
if (typeof name === 'string') {
return trim(name)
}
var firstName = this.firstName();
var lastName = this.lastName();
if (firstName && lastName) {
return trim(firstName + ' ' + lastName)
}
}
이 공유 라이브러리는 새 목적지 코드를 손쉽게 만들 수 있게 해줬습니다. 친숙함은 머리를 싸매지 않고도 손쉽게 공유된 기능을 사용할 수 있게끔 해주었습니다.
하지만 새로운 문제가 드러나기 시작했습니다. 이 공유 라이브러리에 변화를 주게 되면 모든 목적지에 영향을 끼친다는 것이었습니다. 이로 인해 이 라이브러리를 유지보수할 때 꽤 많은 시간과 신경을 써주어야 했습니다. 수많은 서비스를 테스트하고 배포해야한다는 것을 알고있는 동안 우리가 만든 이 라이브러리에 변화를 주는 것은 매우 위험한 일이었습니다. 시간이 지나, 엔지니어들은 업데이트된 버전의 라이브러리를 하나의 목적지 코드베이스에만 적용하게 되었습니다.
결국 시간이 지난 후, 각기 다른 목적지 코드베이스별로 라이브러리 버전이 갈라지기 시작했습니다. 우리가 각각의 목적지 코드베이스 사이에 커스텀을 줄이는 이점이 뒤집히기 시작했습니다. 결국 모든 목적지 코드는 이 공유 라이브러리의 서로 다른 버전을 사용하고 있었습니다. 우리는 변경사항을 바로바로 적용할 수 있는 자동화 도구를 만들 수 있었지만, 이 시점에서 개발자의 생산성 저하를 겪었을 뿐 아니라 우리는 마이크로서비스 아키텍처로 인한 다른 이슈를 겪기 시작했습니다.
추가적인 다른 문제는 각각의 서비스는 고유의 로드 패턴을 갖고 있었다는 점입니다. 일부 서비스는 하루에 수많은 이벤트를 처리하는 반면 일부 서비스는 초당 수천개의 이벤트를 처리합니다. 적은 수의 이벤트를 처리하는 목적지의 경우, 예상치 못하게 로드가 갑자기 튈 때마다 오퍼레이터가 수동으로 요구하는 로드에 맞게끔 서비스를 스케일업 해줘야 합니다.
우리가 오토 스케일링 구현을 갖고 있었지만, 각각의 서비스는 요구하는 CPU와 메모리 리소스에 맞게끔 구성되어 있었습니다. 그리고 이는 오토 스케일링 구현을 예쁘게만 만들어줬습니다. (more art than science)
목적지의 수가 매우 빠르게 증가하며 팀은 평균적으로 한달에 3개의 목적지를 추가했으며, 이것은 더 많은 레포, 더 많은 큐, 더 많은 서비스를 의미했습니다. 우리의 마이크로 서비스 아키텍쳐와 함께 우리의 운영 오버헤드는 각 목적지 추가에 따라 선형적으로 증가했습니다. 따라서, 우리는 다시 원래대로 돌아가기로 결정했고 모든 파이프라인을 재검토했습니다.
마이크로서비스와 큐 버리기
가장 먼저 해야했던 일은 140개가 넘는 마이크로서비스를 1개의 마이크로서비스로 통합하는 일이었습니다. 이 모든 마이크로서비스를 관리하는 오버헤드는 우리 팀에겐 너무나도 컸습니다. 우리는 로드가 튀는 것을 지켜보며 항시 대기해야 하는게 당연했기 때문에 말 그대로 잠도 제대로 자지 못했습니다.
하지만, 그 당시에 아키텍처를 하나의 서비스로 옮기는 작업은 도전에 가까웠습니다. 목적지마자 분리된 큐를 사용함에 따라 각각의 워커는 모든 큐에 작업이 들어왔는지 체크해야 했고 결국 복잡하지 않은 목적지 서비스에도 복잡성을 추가했습니다. 이는 Centrifuge의 가장 큰 영감이 되었습니다. Centrifuge는 모든 각각의 큐를 교체하고 이벤트를 하나의 모놀리식 서비스에 전송하는 것을 담당합니다.
하나의 모노레포로 옮기기
오직 하나의 서비스가 있다는 것을 감안하면, 목적지 서비스 코드를 하나의 레포로 옮기는 것은 합리적인 일입니다, 이것은 모든 다른 의존성과 테스트를 하나의 레포로 묶는 것을 의미하기도 하죠. 우리는 이게 엄청나게 지저분하게 진행될 것이란 것을 알고 있었습니다.
120개의 각기 다른 의존성에 대해 우리는 모든 목적지에서 오직 하나의 버전을 사용하기로 결정했습니다. 우리가 목적지를 옮김에 따라 우리는 해당 목적지가 사용하고 있었던 의존성의 버전을 체크하고 최신 버전으로 올려야 했습니다. 우리는 새로운 버전의 의존성에서 깨지는 모든 문제를 해결했습니다.
이러한 변화와 함께 우리는 더이상 의존성 버전간의 차이를 추적할 필요가 없어졌습니다. 모든 우리의 목적지 코드는 같은 버전을 사용했고, 이는 코드베이스간의 복잡도를 엄청나게 줄여주었습니다. 목적지 코드를 관리하는 일은 이제 더 적은 시간을 소모했고 더 적은 리스크를 요구했습니다.
우리는 또한 모든 목적지에 대한 테스트가 빠르고 쉽게 돌아가게끔 되기를 원했습니다. 모든 테스트를 돌리는 일은 앞서 설명한 내용대로 공유 라이브러리를 배포할 때 가장 걸리적거리는 일중 하나였습니다.
운좋게도, 모든 목적지 테스트 코드는 비슷한 구조를 가졌습니다. 그것들은 커스텀 변환 로직이 맞는지, 또한 HTTP 요청을 파트너의 엔드포인트로 보내 이벤트가 목적지에 예상했던 대로 나타나는지 검증하기 위한 간단한 유닛 테스트를 가졌습니다.
회상해보면 각각의 목적지 코드베이스를 각각의 레포로 나누는 원래의 목적은 테스트 실패 케이스를 나누는 것이었습니다. 하지만 이는 잘못된 점인 것으로 밝혀졌습니다. HTTP 요청을 만드는 테스트는 여전히 적은 빈도로 실패합니다. 각각의 레포로 나뉜 목적지 코드로 인해 실패하는 테스트를 고쳐야겠다는 동기는 매우 적었습니다. 이러한 열약한 환경은 기술 채무를 끊임없이 만들어냈습니다. 종종 1~2시간밖에 걸리지 않는 작은 변화가 모습을 드러내려면 며칠에서 일주일 정도까지 소요될 겁니다.
회복력있는 테스트 만들기
테스트 도중 목적지 엔드포인트로 나가는 HTTP 요청은 테스트 실패의 주 원인이었습니다. 만료된 인증 토큰 등의 관련없는 이슈는 테스트를 실패하게 만들어선 안됩니다. 우리는 또한 몇몇의 목적지 엔드포인트는 다른 것들에 비해 훨씬 느리다는 것을 경험으로부터 알게 되었습니다. 몇몇 목적지는 테스트를 돌리기 위해 5분 넘게 소요되기도 했습니다. 140개가 넘는 목적지로 인해 테스트는 한시간까지 소요되기도 했습니다.
이런 문제를 모두 해결하기 위해 우리는 Traffic Recorder를 만들었습니다. Traffic Recorder는 yakbak을 이용해서 만들었고 목적지 테스트의 트래픽을 기록하고 저장하는 일을 했습니다. 테스트가 처음 실행될 때마다 모든 요청과 그에 맞는 응답은 한 파일에 기록됐습니다. 이어서 테스트가 실행될 때 요청을 목적지 엔드포인트에 다시 전송하는 대신 파일에 있는 요청과 응답이 다시 실행됐습니다. 이 파일들은 레포에 포함되어 테스트가 지속적으로 모든 변경에 일관적으로 유지되게 했습니다. 이제 테스트가 인터넷을 통한 HTTP 요청에 더이상 의존하지 않게됨에 따라 테스트가 훨씬 더 탄력적으로 실행될 수 있게 되었고, 싱글 레포로 옮길 수 있게끔 되었습니다.
저는 처음으로 모든 목적지에 대해 테스트를 돌릴 때와 Traffic Recorder를 도입한 후를 기억하고 있습니다. Traffic Recorder의 도입으로 140개가 넘는 모든 목적지에 대해 테스트를 돌리는데 겨우 몇 밀리초밖에 소요되지 않습니다. 과거에는 오직 하나의 목적지가 몇 분 이상 잡아먹기도 했습니다. 이건 마치 마법처럼 느껴졌습니다.
Why a Monolith works
모든 목적지 코드베이스가 한 레포에 들어가게 됨에 따라 이들 모두는 한 서비스로 합쳐졌습니다. 모든 목적지가 한 서비스에 모이면서 개발자들의 생산성은 상당히 많이 향상됐습니다. 우리는 더이상 공유 라이브러리 중 하나를 바꿈에 따라 140개 이상의 서비스를 재배포할 필요가 없어졌습니다. 한 엔지니어가 서비스를 몇 분 안에 배포할 수 있었습니다.
증거는 향상된 속도였습니다. 2016년, 우리가 구성한 마이크로서비 아키텍쳐가 여전히 있었을 당시 우리는 공유 라이브러리에 32회의 개선작업을 진행했습니다. 올해 우리는 46개의 개선작업을 진행했습니다. 우리는 지난 6개월간 2016년 전체에 한 것보다 더 많은 개선을 진행했습니다.
이러한 변화는 우리의 운영 스토리에도 이득이 되었습니다. 모든 목적지가 한 서비스에서 돌아가기 때문에 우리는 CPU와 메모리를 많이 요구하는 목적지를 잘 조합하여 서비스 요구사항에 맞게 엄청나게 쉽게 서비스를 스케일링 할 수 있었습니다. 매우 큰 워커 풀은 로드가 엄청나게 튈 때 대응할 수 있게 되어 우리는 더이상 작은 양의 로드를 처리하는 목적지에 대해 모니터링할 필요가 없어졌습니다.
트레이드오프
우리의 마이크로서비스 아키텍처를 하나의 모놀리식으로 옮겨가는 모든 전반적인 일은 매우 큰 향상입니다, 하지만 몇몇 트레이트오프가 존재합니다:
- 장애 격리는 어렵습니다. 모든 것이 모놀리식으로 돌아가기 때문에 만약에 한 목적지에서 어떤 버그가 발생했을 때 전체 서비스를 죽게끔 합니다. 우리는 포괄적인 자동화된 테스트를 진행하고 있지만 테스트는 그 당시까지만 커버합니다. 우리는 현재 모놀리식을 유지하며 한 목적지가 전체 서비스를 죽이지 않도록 하는 훨씬 더 강력한 방법을 연구하고 있습니다.
- In-memory 캐싱은 덜 효율적입니다. 이전에는 한 서비스가 하나의 목적지를 담당했기 때문에, 적은 트래픽의 목적지는 적은 프로세스를 요구했습니다. 이는 제어부의 메모리 캐시가 Hot 상태를 유지한다는 것을 의미했습니다. 이제 캐시가 3000개가 넘는 프로세스에 걸쳐 분산됨에 따라 이 캐시를 히트할 확률이 더 낮아졌습니다. 우리는 이 문제를 해결하기 위해 Redis같은 것들을 사용할 수 있겠지만 그렇게 하면 우리가 고려해야 하는 또다른 스케일링 지점이 생기게 됩니다. 결국 우리는 운영상의 이점을 고려하여 캐싱 효율성 저하를 받아들였습니다.
- 의존성 버전의 업데이트는 여러 목적지를 작동 불능하게 만들 수 있습니다. 모든것을 한 레포로 옮겨 이전에 있던 의존성 문제를 해결한 반면, 이것이 의미하는 또다른 바는 만약 우리가 라이브러리의 새로운 버전을 사용하길 원한다면 우리는 이 새 버전을 모든 목적지 코드에 적용해야 할 필요가 생깁니다. 하지만 우리 생각으로 이정도는 충분히 트레이드오프할만한 부분이라고 봅니다. 또한 포괄적인 자동화된 테스트로 인해 새로운 의존성으로 인해 어떤 것들이 깨지는지 빠르게 확인할 수 있습니다.
결론
우리의 초기 마이크로서비스 아키텍쳐는 잠깐동안 제대로 동작했습니다, 그 당시 있었던 성능 이슈를 각각의 목적지를 나눔으로 해결했죠. 하지만 우리는 스케일을 고려하지 못했습니다. 우리는 벌크 업데이트가 필요할 때 테스팅과 배포를 어떻게 해야할지 적절한 방식을 찾지 못했습니다. 그 결과 개발자의 생산성이 매우 빠르게 저하됐습니다.
모놀리식으로 옮기는 작업은 우리에게 운영상의 문제를 파이프라인에서 제거하고 개발자 생산성을 매우 크게 높일 수 있게 해줬습니다. 우리는 이 작업을 가볍게 하지는 않았지만 이게 제대로 될것인지 고려해봐야 한다는 것을 알고 있었습니다.
- 우리는 모든 것을 하나의 레포에 넣기 위해 견고한 테스트가 필요했습니다. 이게 없다면 우리는 처음에 우리가 이 서비스를 나누려 했을 떄와 같은 상황에 닥쳤을 겁니다. 끊임없이 실패하는 테스트는 과거의 생산성을 저하시켰고, 우리는 이런 일이 다시는 발생하지 않기를 원합니다.
- 우리는 모놀리식 아키텍쳐로 전환했을 때 생길 트레이드오프를 받아들였고 각각에 대해 좋은 스토리를 가지는 것을 확실하게 했습니다. 우리는 이 변화와 함께 온 여러 희생에 대해 받아들여야만 했습니다.
마이크로서비스와 모놀리식 사이에서 결정을 할 때 고려해야 할 부분이 여럿 있습니다. 우리의 인프라 중 일부 부분에서 마이크로서비스는 잘 작동하지만 우리의 서버 사이드 목적지에서는 어떻게 이런 인기있는 트렌드가 생산성과 성능을 저하시킬 수 있는지를 보여주는 좋은 예였습니다. 알고 보니, 우리를 위한 솔루션은 모놀리식이었습니다.
The transition to a monolith was made possible by Stephen Mathieson, Rick Branson, Achille Roussel, Tom Holmes, and many more.
Special thanks to Rick Branson for helping review and edit this post at every stage.
번역 후 정리 및 개인 견해
이 글에서 저자는
- Segment가 제공하는 서비스는 어떤 서비스인지
- 어떤 이슈가 있었는지
- 왜 마이크로서비스를 도입하게 됐는지
- 도입 과정은 어떻게 됐는지
- 도입 후에 어떤 문제가 생겼는지
- 문제를 어떻게 해결하려 시도했는지
- 결국 왜 모놀리식을 선택했는지, 모놀리식으로 돌아온 이후 포기해야 했던 것은 무엇이었는지
를 설명하고 있습니다. 요약 겸 차근차근 짚어보죠.
Segment가 제공하는 서비스
글로 이해하기로는 Segment는 개발자가 원하는 이벤트(데이터)를 Segment 서비스로 모아 특정 웹훅(글에서 설명하는 '목적지')으로 보내주는 역할을 합니다.
어떤 이슈가 있었나
Segment에서 만든 이 서비스는 각 이벤트가 들어올 때마다 큐로 보내고(publish) 다른 쪽에서는 큐에서 이벤트를 꺼내(consume) 특정 엔드포인트('목적지')로 재전송하는 구조로 되어 있었습니다. 이때 문제가 발생했던 부분은
- 목적지로 가는 요청은 항상 성공하지 않는다
- 실패한 요청을 재시도하는 루틴이 있는데, 계속해서 실패하는 요청의 경우 다시 시도해도 또 실패할 확률이 매우 높기 때문에 이런 경우 head-of-line blocking 문제가 생겨 큐가 잠겨버리고 다른 요청을 처리할 수 없다
- 모든 목적지에 대한 테스트를 만들었는데 한 목적지 테스트가 실패하는 경우 전체 테스트 실패로 이어졌다
는 것이었습니다.
왜 마이크로서비스를 도입하게 되었는지
글 초반부에는 '시대를 따라가며' 마이크로서비스를 도입했다고 간략하게 이야기하지만 실제 마이크로서비스 도입 계기에는 다음과 같은 것들이 있었습니다.
- 테스트 실패의 분리
- 실패하는 요청으로 인해 발생하는 head-of-line blocking 문제 격리
사실 글에서 마이크로서비스를 이래서 도입했다 할만한 마땅한 해답이 있지는 않습니다. 정말로 초반에 말했던대로 트렌드를 쫒다가 마이크로서비스를 도입하게 된 것으로 보입니다.
도입 과정은 어떻게 됐는지
일단은 실패하는 테스트를 분리하여 격리시키기 위해 레포를 분리했습니다. 이후에 분리된 레포에서 공용으로 사용되는 코드를 다루기 위해 공유 라이브러리를 여럿 만들었고 이를 각각의 서비스 레포에 외부 패키지 형태로 포함시켰습니다.
마이크로서비스 도입이 그렇게 꼭 필요한 상황은 아니었다고 보기 때문에 도입 과정 또한 간단하게만 느껴집니다.
도입 후에 어떤 문제가 생겼는지
- 각각의 서비스별로 요구하는 스펙이 달랐기 때문에 100개가 넘어가는 각각의 서비스를 스케일링하는 것은 매우 어려웠다고 이야기합니다.
- 공유 라이브러리의 관리가 어려워졌습니다. 모든 곳에서 필요한 하나의 공유 라이브러리를 업데이트하면 100개가 넘는 모든 서비스의 코드를 업데이트해야만 했습니다.
- 목적지가 추가됨에 따라 서비스, 레포, 큐가 하나씩 하나씩 선형적으로 늘어나게 되었습니다. 이는 운영 비용이 늘어나는 결과를 초래했고 결국 운영 팀은 이렇게 된 상황을 관리하는데 모든 시간을 써야만 했다고 이야기합니다.
문제를 어떻게 해결하려 시도했는지
너무나도 간단하게 '그냥 마이크로서비스를 걷어버리자'는 의견으로 모였습니다. 복잡한 큐를 없애고, 분리되어 있었던 모든 코드를 한 레포로 다시 합쳤습니다.
결국 왜 모놀리식을 선택했는지, 포기해야 했던 것은 무엇이었는지
트레이드오프, 결론 파트를 확인해보시면 됩니다.
글에 몇가지를 짚고 싶습니다.
- 일단 기존 구조적인 문제입니다. 초기 구조상으로도 충분히 blocking되지 않도록 서비스를 만들 수 있었다고 보는 입장에서, 하나의 테스트가 기능이 전체 서비스에 영향을 끼치는 구조를 놓고 이러한 문제를 해결하기 위해 마이크로서비스를 도입했다는 것은 이해가 되지 않는 부분입니다.
- 많은 회사들이 마이크로서비스를 하고 있습니다. 마이크로서비스를 도입할 때 공유 라이브러리를 어떻게 관리해야 하는지, 마이크로서비스의 스케일링 및 orchestration은 어떻게 해야하는지에 대한 best practice는 이미 많이 나와 있습니다. 많은 사람들이 사용하는 netflix 또한 마이크로서비스를 주도하는 선두주자이고 관련된 많은 정보를 공유하고 있습니다. 이렇게 폭넓고 깊은 사례, 사용 예가 나와있는 아키텍쳐를 이 글에서 설명하는 만큼까지만 활용할 수 없었나 하는 생각이 듭니다.
(작성중)