예측 가능한 대규모 서비스 개발하기

Changhoi Kim
당근 테크 블로그
14 min readFeb 7, 2022

안녕하세요. 지리 정보 플랫폼 팀에서 근무 중인 인턴 Jayce에요. 저는 이번에 사용자가 어느 국가에 위치하는지 파악하기 위한 서비스를 새롭게 개발하게 되었어요. 이 작업에서 애플리케이션이 어떻게 동작할지 예측 가능한 개발을 진행했고, 이 과정을 공유해보려고 해요.

GeoLocation

당근마켓은 한국 외에도 일본, 미국, 캐나다, 영국을 대상으로 서비스를 개발하고 있어요. 글로벌 서비스에서는 국가별로 다른 코드를 사용하거나, 사용자 위치에 따라 다른 콘텐츠를 제공하는 등의 이유로 유저의 지리적 위치 정보를 파악하는 것은 정말 중요한 일이에요. 당근마켓의 경우 아직 서비스를 제공하지 않는 국가에서 들어오는 비정상적인 요청을 IP를 통해 차단하고자 하는 요구와 이후 점점 확장되는 국가에서 유저들의 위치를 기반으로 해당 국가에 알맞은 서비스를 제공할 수 있도록 해야 하는 요구가 있었어요. 그래서 지리 정보 플랫폼 팀에서는 유저의 기타 정보를 가지고 국가 정보를 반환해주는 GeoLocation 서비스를 개발했어요.

GeoLocation API라고 하면 일반적으로 지리적 위치 정보를 검색하기 위한 API를 의미해요. 예를 들어 IP 주소, WIFI 엑세스 포인트, 휴대폰 국가 번호, 위·경도 좌표, 등을 기반으로 유저의 위치 정보를 알려주는 기능이에요. 구체적으로 제가 개발했던 서비스는 핵심적으로 두 가지 API를 제공하고 있어요. 위·경도 좌표 정보 기반으로 국가 정보를 찾아주는 Coordinates To Country (이하 CTC) API와 IP 정보 기반으로 국가 정보를 찾아주는 IP to Country (이하 ITC)에요.

CTC를 개발하려면, 국가별로 Polygon 데이터가 필요해요. 이 데이터는 국가의 영해 기준 GeoJSON 파일을 활용했어요. 서비스 초기화 과정에서 GeoJSON 파일을 읽고 2차원 공간을 검색하는 트리에 삽입해 두면, 유저의 위치 정보를 검색하는 과정에서 트리를 활용하여 API를 구현할 수 있어요.

ITC를 개발하기 위해서는 IP와 국가를 매핑한 데이터가 필요해요. 이 부분은 널리 사용되는 MaxMind 사의 GeoIP2 Database를 사용하기로 했어요. GeoIP2 Database를 로컬 데이터베이스(이하 IPDB)로 이용하면 유저의 IP 주소를 통해 국가 정보를 획득할 수 있어요.

아키텍처

단순히 생각해보면 GeoLocation을 구성하기 위해서는 서비스 로직을 수행하는 gRPC 서버, IPDB를 담는 S3, IPDB를 주기적으로 업데이트하는 CronJob이 필요해요.

그런데 위 아키텍처에서는 IPDB를 업데이트한 다음 어떻게 서비스에 반영할지를 고민해봐야 해요. 무중단으로 서비스를 제공하기 위해서는 주기적으로 데이터베이스가 업데이트될 때마다 새로운 도커 이미지를 빌드하고 블루 그린 배포와 같은 무중단 배포 전략을 수행해야 해요. 이런 방법은 작은 파일을 하나 교체하는 작업치고는 너무 무겁고, 복잡한 방법이에요.

이럴 때 사용할 수 있는 방법 중 하나는 중앙 관리 되는 Config Store에서 IPDB 버전을 관리해주고 서버들이 Config Store를 바라보고 있도록 해주는 방법이에요. Config Store의 버전 정보가 변경되면 서버가 이를 감지하고 새로운 데이터베이스를 업데이트 함으로써 무중단 배포 과정을 간소화 할 수 있어요.

그래서 최종적으로 GeoLocation의 아키텍처는 Config Store 역할을 하는 CentralDogma가 추가된 다음과 같은 모습이에요.

당근마켓 플랫폼 개발실은 LINE에서 개발한 CentralDogma 라는 Config Store를 사용해요. 애플리케이션 서버들은 CentralDogma 서버의 파일 정보를 받고, 변경이 발생하면 변경된 파일 정보를 받아서 필요에 맞게 사용할 수 있어요.

CentralDogma에 대해 더 자세히 알고 싶으시다면, 공식문서를 확인해보세요!

GeoLocation에서는 IPDB의 버전과 Hash, 업데이트 시각을 하나의 설정 모델로 정의하여 CentralDogma에 기록해 두었어요. 이 설정 모델은 매일 한 번씩 최신 버전의 데이터베이스가 있는지 확인해 업데이트해주고 있어요. 만약 설정 모델이 업데이트되면, 각 서버는 설정의 변경을 감지해서 새로운 IPDB를 다운로드하여 서비스에 반영해요.

프로토타입

본 서비스를 개발하기에 앞서, 프로토타입을 파이썬으로 먼저 개발했어요. 프로토타입이 꼭 필요했던 것은 아니지만, 관련된 기능들을 빠르게 개발해보고 배포와 성능 테스트까지 사이클을 한 번 돌려보기 위해 진행됐어요. 파이썬은 저와 팀이 익숙하게 사용할 수 있는 언어 중 프로젝트를 구성하기 위해 필요한 많은 패키지가 제공되고 있던 언어이기 때문에 프로토타입에 사용되었어요.

ITCCTC의 핵심적인 내부 코드를 서비스 레이어로 구성하고 gRPC 서버의 의존성으로 넣어주었어요. CentralDogma 와 상호작용하는 서비스 클래스에 CentralDogma 서버의 설정 파일을 바라보다가 변경점이 생기면 알려주는 Watcher를 만들고 ITC 서비스가 이 Watcher를 사용해 S3에서 새 버전의 IPDB를 다운로드할 수 있도록 했어요. 결론적으로는 파이썬 프로젝트로 구성했던 아키텍처는 대략 다음 그림처럼 생겼어요.

애플리케이션이 시작할 때, CentralDogmaSvc를 초기화하면서 최신 버전의 IPDB 정보를 한 번 가져온 다음, S3에서 해당 파일을 다운을 받고 IPToCountrySvc를 초기화하는 구조였어요. 초기화 과정이 여러 외부 서비스와 의존적인 상태에요.

이렇게 구성된 프로토타입을 당근마켓의 배포 시스템인 Kontrol을 사용해 배포한 다음 성능 테스트를 진행하였고, 그 결과는 다음과 같았어요.

ITC의 경우 1.3K RPS 정도의 요청을 받았어요. CTC의 경우는 240 RPS 정도를 받았는데, 실패 케이스가 25% 정도 섞여 있으면 140 RPS를 받았어요. 두 요청 모두 CPU 부하가 병목의 원인이었어요.

특히 문제가 됐던 CTC의 흐름은 다음과 같아요.

  1. 애플리케이션 초기화 시점에 STRTree에 Geometry 데이터를 삽입
  2. 좌표 검색을 하면, STRTree.nearest_item으로 트리에서 가장 가까운 Geometry 검색
  3. 해당 Geometry가 실제로 포인트를 포함하고 있는지 Geometry.contains 연산

STRTree는 R Tree의 일종으로, 2차원 공간 정보를 검색할 수 있는 트리 구조에요. R Tree는 최소 경계 사각형(MBR, Minimum Bounding Rectangle)을 트리 구조로 저장하고 리프 노드에 실제 데이터를 저장하는 방법으로 공간 정보를 관리해요.

STRTree는 GEOS에서 제공하고 있는 질의 검색 전용 트리로, 한 번 트리가 구성되고 나면 이후 삽입과 삭제가 불가능하고 검색만 할 수 있도록 구성된 트리에요.

프로토타입에서는 STRTree의 검색 흐름과 Geometry의 Contains연산이 CPU Bound가 심하구나 정도만 확인하고 프로젝트를 마무리 지었어요.

Go 프로젝트

파이썬 프로토타입을 만들면서, 그리고 성능 테스트를 진행하면서, 본 프로젝트에서는 더 신경 써서 만들어야겠다고 생각했던 몇 가지가 있는데, 가장 중요한 것은 테스트 결과에서 드러나듯 CTC의 CPU Bound 문제를 해결하는 것이었어요. 파이썬에 비해 빠르고 가벼운 Go로 프로젝트를 포팅하고, CTC를 구성하는 서비스 함수의 CPU Bound를 신경 쓰며 구성함으로써 이 문제를 해결해보고자 했어요.

CTC 문제를 해결한 얘기를 하기 전에, Go로 포팅하는 과정에서 변경된 프로젝트 구조 얘기를 잠깐 해볼게요. 위에서 언급했듯, 프로토타입에서는 애플리케이션의 초기화 과정이 다른 외부 서비스에 종속적이었어요. 즉, CentralDogma, S3에서 문제가 발생하면 애플리케이션이 초기화되지 않고 서비스가 뜰 수 없는 상태였어요. 사실 많은 서비스가 그렇지만, 이 프로젝트는 로컬 파일들만으로 동작할 수 있기 때문에, 기본 IPDB를 넣어주고 다른 의존적인 서비스들이 동작하지 않더라도 우선 서비스가 뜰 수 있게 만들었어요. 그리고 아예 독립적으로 Goroutine을 돌려서 애플리케이션의 IPDB 업데이트하도록 수정했습니다. 결과적으로는 아래와 같은 형태로 바뀌었어요.

서비스 레이어의 코드가 서로 의존적으로 초기화되지 않고, countryFinder.Server라는 gRPC 레이어에서 서비스 레이어를 주입 받아서 필요한 메소드들을 활용해 독립적으로 watchLoop을 돌려주도록 수정했어요. default.mmdb라는 기본 IPDB 파일을 가지고 애플리케이션을 시작함으로써, 애플리케이션의 초기화가 외부 서비스와 상관없이 동작하도록 수정되었어요.

이제 CTC의 CPU Bound 문제를 해결했던 과정을 얘기해 볼게요. 새로 만들어지는 Go 프로젝트에서는 CTC 로직을 위해, GEOS를 포팅한 gogeos 패키지를 사용했어요. gogeos 패키지에는 STRTree를 사용할 수 없어서, R Tree를 구현한 패키지를 따로 사용했어요. 다음과 같은 흐름으로 CTC를 구현했어요.

  1. 애플리케이션 초기화 시점에 R Tree에 Geometry의 Bounding Box를 삽입
  2. 좌표 검색을 하면, RTree.SearchIntersect으로 좌표를 포함하는 모든 Bounding Box를 검색
  3. 검색된 Bounding Box를 순회하며, Contains 연산으로 정확한 Geometry 확인

Bounding Box는 국가 Polygon을 감싸는 가장 작은 사각형을 의미해요.

ex. 대한민국 Bounding Box

Bounding Box를 가지고 검색을 하는 R Tree는 한 점과 가장 가까운 아이템을 찾는 정확성이 떨어져서, 점과 겹치는 모든 Bounding Box를 찾아주는 SearchIntersect을 사용했어요.

SearchIntersect의 성능은 R Tree의 Branching Factor와 유관해요. Branching Factor는 R Tree 안에서 하나의 노드가 가질 수 있는 엔트리 수를 의미해요. R Tree 패키지에서 하나의 노드 안에 최소, 최대 몇 개의 엔트리가 들어갈 수 있는지를 설정할 수 있었는데, 이에 따라 트리의 높이와 리프 노드에서 탐색해야 하는 엔트리 수가 결정되기 때문에 성능과 직결돼요. 예를 들어서 m개의 엔트리가 노드에 들어갈 수 있고 전체 엔트리 수가 X개라면 트리의 높이는 약 log m (x)가 되고, 찾은 노드에서는 m번의 탐색을 해야 해요. GeoLocation이 사용하는 데이터는 250개의 Bounding Box를 다루고 있어서 다음과 같이 Go의 벤치 테스트를 활용해 최적의 Branching Factor를 설정했어요.

R Tree는 원래 B Tree처럼 Disk based 자료구조이기 때문에, 하나의 노드가 하나의 페이지 사이즈만큼이 되도록 최대 엔트리 수를 설정하는 것이 가장 효율적이라고 합니다. 그러나 사용된 패키지는 메모리에 모두 올라가는 구조였기 때문에, 최소 엔트리 수만 결정하고 그에 따라 최대 엔트리 수를 설정하면 됐어요. 일반적으로 엔트리가 3, 40% 정도 차 있는 상태가 퍼포먼스에 가장 좋다고 해서 최소 엔트리 수의 세 배 정도를 최대 엔트리 수로 설정했어요.

벤치 테스트를 했을 때, 어떤 수치를 사용했어도 괜찮겠다고 생각 했을 만큼 준수한 성능을 보여주었지만, 테스트해서 결과를 얻었으니, 가장 빠르게 나오는 설정값을 사용했어요.

프로토타입에서 겪었던 문제들을 해결했(다고 생각하)고, 배포하고 얼마나 좋아졌을지 기대하며 성능 테스트를 진행해봤어요. 그러나 여전히 CTC의 CPU Bound 문제가 해결되지 않았고 오히려 더 악화되었어요.

문제의 원인은 CTC의 병목 이유가 Tree의 서치 과정에 있는 것이 아니라, GeometryContains 연산이 무겁기 때문이라는 점이었어요. SearchIntersects의 결과가 평균적으로 4개 정도의 Bounding Box가 나오는데, Contains 연산을 4번 정도 반복하고 있기 때문에 이런 문제가 발생했어요.

아래는 gogeosGeometry.Contains 연산의 벤치 테스트를 진행한 결과에요.

이 문제를 해결하기 위해 Contains 연산을 지원해주는 다른 GIS 패키지를 찾았고, 같은 코드를 동작시키기 위해 필요한 것들을 갖추고 있는 패키지를 추려서 관련된 함수의 성능 테스트를 진행했어요. geoos, orb 패키지로 함수의 성능 테스트를 진행했어요.

패키지별 RPS 그래프

orb 패키지의 Contains 함수가 2000 RPS 정도가 나와서 이 함수로 대체하는 방법으로 코드를 수정해서 서비스 함수의 벤치 테스트를 한 결과는 다음과 같았어요.

10배 정도 더 좋아진 모습을 보고, 서버에서도 1000 RPS 이상 나와주길 바라면서 재배포 후 다시 스트레스 테스트를 진행했어요. 그러나 그 결과는 여전히 만족스럽지 못 했어요.

문제를 해결하기 위한 방법을 리서치하고 테스트하다가, 팀원이 알려준 GEOS의 Prepared Geometry를 사용해보기로 했어요. 더 빠르게 ContainsIntersects 연산을 할 수 있게 해주는 타입이라고 해요. 비교의 기준이 되는 Geometry가 변하지 않는다는 특성을 이용해 Geometry 내부적으로 비교를 위해 계산해야 하는 부분들을 미리 계산해서 캐싱해두는 방식이에요. 특히 ContainsIntersects와 같은 연산을 할 때 연산 과정을 짧게 끝낼 수 있는 몇 가지 케이스에 short-circuit을 만들어 연산의 최적화를 추가로 해줘요. 이러한 방법으로 기존 Geometry를 사용한 연산 대비 약 40배 정도의 성능 향상을 만들 수 있어요.

애플리케이션을 초기화하는 과정에 GeoJSON 데이터로 Geometry를 만드는 것 외 Prepared Geometry를 추가로 더 만들고, Prepared Geometry 타입으로 Contains 연산을 수행했어요.

벤치 테스트를 했을 때 정말 지수적으로 성능 개선을 이뤘어요.

결과

결과적으로 현재 버전의 스트레스 테스트 결과는 다음과 같아요.

프로토타입에서 두 API에서 발생했던 CPU Bound 문제가 해결되었어요. 이 이상의 퍼포먼스를 위해서는 서버를 증설해야 해요. 현재 GeoLocation은 당근마켓에서 일반적으로 사용하고 있는 모니터링 도구들을 붙인 다음 배포되었어요.

결과적으로 CTC의 경우 일련의 과정을 통해 프로토타입부터 최종 버전까지 약 66배 정도의 성능 향상을 만들었어요.

CTC RPS

마치며

일정 규모 이상의 서비스에서는 애플리케이션의 성능이 얼마나 나올 수 있는지 명확히 측정하고, 그에 따라 얼마나 서버를 증설할 것인지 결정하는 과정이 정말 기본적인 프로세스에요. 제 개인적으로는 이 과정들이 머릿속으로만 이해하는 정도였는데, 이번 기회에 직접 이런 과정을 겪어봐서 알차고 즐겁게 개발할 수 있었어요.

Reference

--

--