본문 바로가기
Coding/설계 | 경험

Monolithic에서 MSA로의 전환기

by Hide­ 2019. 12. 16.
반응형

1. 개요

이전에 다니던 회사의 시스템은 하나의 모놀리틱 서버와 핵심 알고리즘을 수행하는 마이크로 서비스하나로 구성되어 있었다. 또한 PHP로 작성되어 있었는데, 입사 시점과 맞물려 Python으로의 스택 이전과 동시에 특정 도메인을 Microservice Architecture로 분리하는 계획이 잡혀있었다. 돌이켜보면 정말 즐거운 일이었고 챌린징한 업무였기에 많은 성장을 이뤄 냈던 작업이라고 생각한다. 요즘 MSA가 많은 주목을 받으며 인터넷에서도 쉽게 관련 정보를 찾아볼 수 있는데, 실제 실무를 진행하며 느꼈던 점과 과정에 대해 남겨본다. 또한 본 포스팅은 전환했던 과정을 실무 중점적으로 설명하는 포스팅이므로  Monolithic Architecture, Microservice Architecture에 대한 설명을 포함하지 않는다.

2. 기존 시스템

매칭을 맡고있는 서버를 제외하면 기존 시스템은 모놀리틱 시스템이므로 PHP서버 - MySQL 형태로 통신하고 있었다. (기타 캐시 레이어 등 핵심적인 요소가 아닌 부분은 설명에서 제외한다) 데이터베이스는 MySQL이 아닌 MongoDB를 사용하기로 입사 당시부터 결정이 나있는 상태였으므로 파이썬 - MongoDB 형태로 분리할 예정이다.

3. 설계 및 트러블 슈팅(CUD까지의 작업)

  1. 데이터베이스의 변화로 인해 스키마의 구조도 많이 달라졌다. 기존에 정규화를 통해 세분화되어있던 테이블들을 NoSQL의 특성에 맞춰 역정규화 하여 몇몇 필드들을 EmbeddedDocument 형태로 설계했기에 기존 데이터를 마이그레이션 하기 위해서는 직접 Converting 툴을 제작해야 했다.
  2. 무중단 배포를 진행해야 한다. 사실 많은 건수가 아니라면 크게 신경쓰지 않아도 된다고 생각할 수 있지만, 데이터는 그 자체로 회사의 소중한 자산이므로 하나라도 놓치지 않기 위해 최선을 다해야 한다.
  3. 두번째 케이스의 연장선으로 데이터의 유실 및 중복이 없어야 한다.

위 상황들을 염두에 두고 시스템을 설계해나가기 시작했다. 먼저 우리 시스템의 경우 CRUD 중 Read부분이 사용자에게 직접 Display되는 부분이므로 오류 발생 시 가장 크리티컬한 섹션이라고 생각했다. 따라서 CUD 즉, 생성/수정/삭제 먼저 적용하고 읽기는 가장 마지막에 적용하도록 했다.

종합하여 마이그레이션 순서를 대략적으로 정리하자면 다음과 같다.

  1. 파이썬 서버에 데이터를 저장하는 엔드포인트를 구현한다.
  2. PHP서버에 데이터를 저장한 이후 해당 데이터의 값을 파이썬 서버에도 저장하는 코드를 추가한다.
    1. 여기까지 진행하여 배포했다면, 배포 이후 최신 데이터들은 MySQL, MongoDB에 모두 동일하게 쌓여가고 있을 것이다.
  3. MongoDB의 스키마 구조를 NoSQL에 맞게 새롭게 설계하였으므로 직접 컨버팅/마이그레이션하는 툴을 제작한다.
  4. MySQL에 저장되어있는 이전 데이터를 MongoDB로 마이그레이션한다. (Bulk Insert로 1만건씩 수행하였다. 조인해야하는 테이블이 많기 때문에 1만건 이상으로 수행 시 쿼리가 깨지는 경우가 발생했기 때문이다)
  5. 마이그레이션이 종료되면 전체적으로 어긋난 데이터가 없는지 검사하고 혹시라도 존재한다면 One-by-One으로 데이터의 상태를 업데이트한다.

위 2, 3번을 보고 왜 저렇게 하는지 의문점이 생길수 있기에 부가적인 설명을 덧붙인다. 만약 MySQL로의 데이터 적재를 중단하고 바로 파이썬 서버와 통신하여 MongoDB에만 데이터를 쌓아간다면, 파이썬 서버에 문제가 생기거나 MongoDB 데이터의 정합성에 문제가 생겼을 때 대처하기 까다롭다. 파이썬 서버에 문제가 발생하여도 MySQL의 데이터는 동시에 쌓여가고 있기에 해당 데이터를 보여준다면 사용자 입장에서는 문제를 느낄 수 없기 때문에 UX에 아무런 영향을 끼치지 않는다.

추가적으로 3번 PHP서버에서 파이썬 서버로 데이터를 전송할 때 실패를 대비하여 Redis를 활용하였다. Request의 Response가 200이 아니라면 Redis에 저장해두고 주기적으로 크론잡을 통해 재전송해주는 형태이다. SQS등 많은 선택지가 있었지만 Redis가 가장 익숙했고 빈도가 적을 것으로 예상했기에 Kafka, RabbitMQ등의 메시징 큐를 이용하는것은 오버 엔지니어링이라고 생각했다.

 

4. Read 구현 및 또다시 발생한 문제들

여기까지 진행하면 CUD는 모두 동기화 및 마이그레이션이 완료된 상태이다. 이제 가장 중요한 유저들에게 Display되는 Read부분을 구현해야한다. 여기서 이슈가 발생했는데, 우리 서비스는 데이팅 서비스이므로 내가 좋아요를 수신했거나 발송한 경우, 그리고 차단했거나 차단당한 유저는 모두 제외시켜줘야 했다. 따라서 조인을 해야했는데, MongoDB의 경우 조인이 없기 때문에 어플리케이션 조인을 수행해야했고 그 결과로 8초라는 어마어마한 시간이 돌아왔다. (관계 정보만 5억건 정도였다) 1차적으로 멘붕이 왔지만 힘을 내서 조금 더 찾아보니 aggregate를 사용하면 조인과 비슷한 효과를 내는 쿼리를 수행할 수 있다는 사실을 알 수 있었다. 이 부분에서 정말 많은 삽질을 했는데, 결과적으로 aggregate도 우리의 문제를 해결해주진 못했다.

그렇게 전사 로드맵이 밀려가던 도중 추가적인 요구사항이 생겼다. 기존에는 단순 필터링 형태의 쿼리 결과를 유저들에게 뿌려주는 형태였는데, 위에서 설명한 매칭 서버(ElasticSearch)를 사용하여 매칭 알고리즘을 도입하는 것이었다. 여기서도 한번 더 이슈가 발생했다. ES는 많은 필터 조건이 포함된 알고리즘을 적용한 서버이므로 연산에 많은 비용이 소모되기 때문에 트래픽이 몰리면 터지는 것이었다. 물론 여기서 캐시 레이어를 두는 등 다양한 방법들을 강구하여 시도해봤지만 결과적으로 모두 실패하였다. 따라서 우리는 매칭 알고리즘 또한 데이터베이스에서 진행하는것이 낫다고 판단하고 대안을 찾아보기로 했다. 

5. 안녕 MongoDB

우리는 MongoDB를 보내주기로 결정했고 그 이유는 다음과 같았다.

첫 번째로 위치 사업자의 경우 위도/경도를 plaintext로 저장하면 안된다. 그래서 기존 MySQL에서는 Geohash형태로 저장하여 사용하고 있었다. 여기서 문제점은, MongoDB의 경우 위치 기반 쿼리를 수행하려면 Geospatial(https://hides.tistory.com/1039)이란걸 사용해야하는데, 이게 저장될때는 내부적으로 Geohash로 저장되지만 실제 Geohash값을 통해 반경 몇km이내에 존재하는 로우를 뽑아내는 쿼리는 지원하지 않았다. 우리는 위도/경도를 plaintext로 저장하지 않은 형태에서 반경 km를 파라미터로 하여 검색할 수 있는 기능이 필요했는데 MongoDB에서는 해답을 찾지 못했다.

두 번째로 처음보다 많은 조인 조건이 생겼다. 관계가 생겼다는 이야기이다. 관계가 많은 경우 당연히 관계형 데이터베이스를 사용하는것이 맞다고 판단했다.

세 번째로 PostgreSQL의 EARTH타입을 사용하면 Geohash로 검색하는것과 동일한 효과를 낼 수 있다는 점을 알게되었다. 실제 저장되는 형태는 37.123, 127.123이 아닌 3자리 숫자들의 모음이지만, 위치 사업자 관련 담당 부서에 문의를 해보니 plaintext만 아니면 된다는 답변을 들었고 도입을 결정할 수 있었다.

6. 다시 한번 설계 및 구현으로

기존에 MongoDB로 작성된 많은 코드와 데이터를 버리고 PostgreSQL에 맞는 테이블을 다시 설계하였다. 그리고 MongoDB로 마이그레이션을 진행했던 것과 동일한 방법으로 마이그레이션 및 동기화 작업을 수행하였다. 그리고 CRUD에서 남은 부분인 읽기 부분을 구현하고 적용하였다. 물론 여기서도 당~연스럽게 이슈가 발생했다. 쿼리가 느린 것이다. 그냥 너무 느렸다. 쿼리를 튜닝한 이야기는 그것만으로 너무 길기 때문에 간략하게 이야기하자면, 아래와 같이 해결했다.

첫 번째로 관계 데이터 테이블을 파티셔닝 처리하였다.  (https://hides.tistory.com/1040) 5억건에 달하는 데이터를 그냥 조인하니 너무 느렸기 때문이다.

두 번째로 인덱스를 수정하였다. 몇가지만 이야기하자면, 경우에 따라서 인덱스를 태우지 않는 경우가 더 빠를 때도 있었다. 아마 인덱스를 태우지 않았을 때 더 빠르게 데이터를 걸러낼 수 있는 상황이었기 때문이라고 추측된다. 그리고 복합 인덱스의 경우 인덱스 순서가 중요하다. 카디널리티가 높은 즉, 중복도가 적은 컬럼이 앞쪽 순서로 위치하도록 해야 한다. 이는 복합 인덱스 카디날리티 로 검색하면 많은 정보가 나올테니 해당 정보들을 참고하도록 한다. 역시 쿼리는 노가다가 답이다.

위와 같은 과정을 겪은 이후 읽기까지 적용시켰다. 이제 완벽하게 라우팅을 파이썬 서버로 돌리는 작업을 진행해줘야 한다. 기존에는 아래와 같은 흐름을 가졌다.

 

파이썬 서버로부터 전달받은 Response를 그대로 유저에게 전송해주고 있다. 그렇다면 PHP서버는 아무런 일도 안하는데 왜 이러한 흐름으로 구성했을까? 만약 작업을 진행하며 파이썬 서버에 오류가 발생했거나 데이터에 문제가 있을 경우 PHP서버의 MySQL에서 다시 정상적인 데이터를 뽑아서 보내주기 위함이다. 하나의 방어책이라고 생각하면 된다.

7. 최종 분리

현재까지 작성한 엔드포인트들(v2/v3)은 PHP서버를 거쳐서 오는 형태이기에 AOS/IOS앱과 직접적으로 통신하는 규약과 다소 달랐다. (파라미터 등) 따라서 직접 통신하는 엔드포인트(v4)도 새롭게 구현하고 완전한 MSA로의 단계들을 준비하고 있었다.

이 시기와 맞물려 사내 시스템에 API Gateway(Kong)이 도입되었다. 따라서 보다 수월하게 모든 요청을 파이썬 서버로 돌릴 수 있게 되었다. 먼저 기존 요청은 v2/v3으로 들어오고 있었다. AOS/IOS앱은 배포를 해도 즉시 적용되는것이 아니기도 하고 사용자에 따라 어플리케이션을 업데이트 한 사용자와 그렇지 않은 사용자로 나뉘기 때문에(일괄적으로 업데이트를 하지 않기 때문에) 모든 트래픽을 한번에 돌릴수는 없었다. 따라서 아래와 같은 계획을 세웠다.

첫 번째로 앱의 기존 엔드포인트를 모두 v4로 돌린다음 앱스토어/마켓에 배포한다.

두 번째로 v5/v6 엔드포인트를 임시적으로 생성하고 v2/v3와 동일한 파라미터를 받도록 구현한다. 그리고 받은 데이터를 모두 v4의 Usecase(비즈니스 로직)로 전달한다.

세 번째로 API Gateway에서 v2/v3을 v5/v6으로 라우팅하도록 설정한다.

* v2/v3: PHP를 거쳐서 오는 구 엔드포인트

* v4: 파이썬으로 직접 통신하는 신 엔드포인트

* v5/v6: 구 엔드포인트와 동일한 파라미터를 받아서 신 엔드포인트의 비즈니스 로직 레이어로 전송하는 임시 엔드포인트

 

위와 같이 진행한다면 앱을 업데이트한 유저의 데이터도 정상적으로 반영되고 업데이트하지 않은 유저의 데이터도 정상적으로 반영이 되기 때문에 데이터의 유실없이 모든 트래픽을 파이썬으로 전환시킬 수 있었다. 따라서 데이터의 흐름은 아래와 같아진다.

 

 

8. 회고

처음 MongoDB로 프로젝트를 시작했던 점은 정말 큰 실패 요인이었다. 물론 주니어로 입사하여 기존에 틀이 잡혀있던 계획에 대해 검토할 시간적인, 정신적인 여유가 없기 때문이기도 했지만 이는 프로젝트를 주도적으로 진행했던 나의 잘못이 분명했다. 이 사건 이후 특정 기술을 도입할 때 한번 더 살펴보는 습관이 생겼다.

사실 쉽지 않은 프로젝트였다. 많은 시행 착오를 거쳤으며 좌절감 또한 맛보았다. 하지만 이러한 챌린지들을 겪은 이후 무척 단단해진 내 모습을 발견할 수 있었다. 실력뿐만 아니라 정신적으로도 많은 단련이 됐다. 앞으로 나의 개발 인생에 정말 많은 영향을 행사한 사건이 되지 않을까 싶다.

MSA로의 이전하는 과정은 까다롭고 어려운 문제다. 그렇기에 또 나처럼 맨땅에 삽질하는 많은 사람들에게 이 글이 조금이나마 도움이 되었으면 좋겠다. 마지막으로 힘들었던 기간에 주변에서 기술적으로, 그리고 정신적으로 격려를 해준 모든 동료들에게 다시 한번 고맙다는 인사를 전한다.