사용자의 배달 주소를 기반으로 어느 행정동/법정동에 속해 있는지를 판단하기 위해 기존에는 C++로 작성된 웹 서버를 사용하였습니다. 서버 한 대당 피크 시간 기준 2000TPS를 상회하는 많은 요청을 10ms 이하 시간으로 응답할 수 있는 높은 성능을 제공했지만, C++의 특성상 여러가지 단점이 존재했습니다. 이를 Java 및 Spring Boot 기반으로 전환하기까지의 경험을 공유합니다.
배경
배달의민족에서는 배달 주소를 기반으로 어느 행정동/법정동에 속해 있는지를 판단하여 다양한 곳에 활용하고 있습니다. 사용자가 홈 화면에 처음 진입한 순간부터 쿠폰 발급, 주문에 이르기까지 사용자 경험 전 과정에 동 데이터가 활용되고 피크 시간에는 전체 서버 기준 1분에 80만 개 이상의 동 데이터 요청을 처리하고 있습니다.
S2 Cell 형태로 표현된 송파구 방이동
셀러상품플랫폼팀에서는 대한민국 전역의 행정동 및 법정동 데이터를 S2 Geometry 형태로 가공 및 저장하고 있습니다. 이를 활용하여 배달 주소의 위경도 값을 입력으로 받아 여러 계층으로 이루어진 S2 기하 데이터를 검색하여 유관부서에 행정동/법정동 정보를 빠르게 제공할 수 있도록 노력하고 있습니다.
기존에는 C++로 작성된 웹 서버를 사용하였고 서버 한 대당 2000TPS를 상회하는 많은 요청이 들어와도 10ms 이하 시간으로 응답할 수 있는 높은 성능을 제공하였습니다. 수년간 큰 문제없이 운용해왔으나 아래와 같은 단점이 존재했습니다.
- C++의 특성상 라이브러리 입력 포맷이 맞지 않거나 실수로 메모리를 잘못 처리하는 등 비교적 작은 실수에도 전체 서버의 작동이 중단될 위험성이 있었습니다.
- 위와 같은 위험성에도 불구하고 팀 내 개발자들의 C++에 대한 전문성이 Java에 비하여 낮았고 처음 개발한 분도 이제 없어서 코드 유지보수에 어려움이 있었습니다.
- 전사적으로 사용되는 웹 서버 규격과 다른 점이 많아 사내 공통 배포 및 모니터링 시스템을 적용하기가 까다로웠고 업데이트 등이 있을 때에도 방치되기 일쑤였습니다.
이런 상황을 개선하기 위하여 팀 내에서 일반적으로 사용되고 있는 Java 및 Spring Framework 기반으로 전환할 수 있을지 검토해보았습니다.
전환 검토
Java로의 전환에 앞서 두개의 중요한 전제가 있었습니다.
첫 번째는 성능입니다. 컴파일 언어가 아닌 인터프리터 언어인 Java는 JIT(Just-in-time) 컴파일 등의 기법을 활용하여 충분히 빠른 성능을 제공하지만 C++에 비할 수는 없습니다. 또 Java는 메모리 관리를 위해 가비지 컬렉션(garbage collection, GC)을 사용하는데 이는 작지 않은 용량의 S2 기하 데이터를 처리하는 데 장애물이 되었습니다. 유지보수의 편의성을 떠나서 기존 C++에 비해 성능이 지나치게 낮아 서버 비용이 증가한다면 원점부터 다시 검토할 필요가 있었습니다.
두 번째는 장애의 최소화입니다. 전사적으로 사용되는 서버이니만큼 이슈가 생기면 전면 장애로 이어질 가능성이 매우 높습니다. 코드를 작성하는 언어부터 HTTP 요청 처리 구조, 배포 및 모니터링 파이프라인까지 머리부터 발끝까지 새로 작성하는 만큼 이슈는 언제나 발생할 수 있다고 생각했습니다. Canary 배포를 적극적으로 사용하고 여러 유관부서에 협조를 구하였으며 만약 이슈가 발생한다면 빠르게 파악하고 지체 없이 롤백할 수 있는 방법을 찾는 등 영향을 최소화하고자 했습니다.
위 두가지 전제를 염두에 두고 구체적인 전환 및 배포 방식을 검토하였습니다.
기존 C++ 모듈 구조 분석
검토는 기존 모듈의 구조를 상세히 파악하는 것부터 시작하였습니다.
프로젝트 구조 | Makefile 기반의 c++ 프로젝트 |
Web 프레임워크 | HTTP 요청을 처리하기 위해 libevent 하위의 evhttp를 사용. 추상화 레이어가 거의 없고 HTTP GET 요청의 파라미터 파싱까지 직접 수행해야하는 상당히 저수준의 라이브러리 |
멀티스레딩 | 멀티스레드 처리가 되어 있지 않고 이벤트 루프 기반으로 싱글스레드로 동작 |
데이터 저장 구조 | 별도의 DB를 사용하지 않고 대한민국 전역의 S2 기하 데이터를 정렬된 배열 형태로 외부 스토리지 공간에 저장. 서버가 기동할 때마다 데이터를 다운로드하여 메모리에 올리고 이진탐색을 통해 조회 |
코드를 분석하는 과정에서 기존 모듈은 이벤트 루프 기반에 싱글스레드로 동작한다는 것을 알게 되었습니다. 희소식이었습니다. Java가 C++보다 느리다 한들 멀티스레드를 활용할 수 있다면 비슷하거나 더 높은 성능을 제공할 수도 있겠다고 추측하였습니다.
Java 라이브러리 검토
기존 모듈에서는 C++ 기반의 S2 라이브러리를 사용하고 있었는데요.
비슷한 인터페이스의 Java 구현체가 있었고 기능 전부가 호환되는 것은 아니지만 필요한 기능은 모두 있어 그대로 사용할 수 있었습니다.
성능 언급이나 벤치마크 자료는 찾지 못하였으나 멀티스레드로 전환한다면 충분한 성능을 얻지 않을까 추측하였고 이후 성능 테스트를 통해 검증했습니다.
데이터 저장 포맷 검토
기존 C++ 구현에서는 전체 동 데이터를 메모리에 올리고 이진 탐색을 통해 데이터를 조회하였습니다.
전체적인 구조를 개선할지, 구조는 유지하고 안정적으로 Java 전환에 염두를 둘지 검토하였습니다.
1안. 캐시 레이어를 이용한 구조 개선 | Redis 등 캐시 또는 DB 레이어를 만들고 캐시에 관련 데이터를 적재
장점 단점 |
2안. 메모리 조회 구조 유지 | 현재와 같은 메모리 적재 구조 유지 원본 데이터 약 180MB를 서버 기동 시 다운로드 (빈번히 바뀌는 데이터가 아님) 장점 단점 |
팀 내 논의하여 영향을 최소화하기 위해 기존과 같은 2안으로 진행하되, 필요하면 추가 캐시나 DB 레이어 구축을 검토하기로 결정했습니다.
성능 검토
기존 서버와 같은 대수로 피크타임 트래픽을 소화하려면 1대당 2000TPS 이상의 가용성을 확보해야 했습니다.
위 검토 사항을 토대로 Java로의 1차 전환 이후 성능 테스트를 수행했습니다.
nGrinder로 성능에 가장 영향이 큰 API를 대상으로 부하 테스트를 진행하였고 약 최대 3500TPS의 결과를 얻었습니다.
기타 서버 지표들과 대조하여 Java로 전환하여도 성능에 문제가 없을 것으로 최종 판단했습니다.
배포 계획
제일 처음 언급하였듯이 기능 자체는 단순하지만 다양한 지면에서 중요하게 쓰이고 있는 API였기 때문에 장애를 최소화하고 이슈가 생기더라도 빨리 감지할 수 있는 방법을 고민하였고
여러 번으로 나누어 Canary 배포를 진행하고 최대한 서버를 검증하고자 했습니다.
– | 베타환경 검증 | 베타환경에 적용 이후 며칠간 로그 및 지표 확인 |
D-6 (화) 06:30 ~ 07:00 | 1차 Canary 배포 후 롤백 | 1% 트래픽 설정 로그를 통해 인프라 설정 및 최소한의 기능 테스트 |
D-5 (수) 15:00 ~ 16:00 | 2차 Canary 배포 후 롤백 | 5% 트래픽 설정 배포 이후 기존 모듈과 응답 비교, 오류 응답 있는지 확인 |
D-4 (목) 15:00 ~ 16:00 | 일시적 전면 배포 후 롤백 | 20% → 50% → 100% 트래픽 점차 증가 후 유지 비교적 높은 트래픽 하에서 지표 확인 피크타임 전 롤백 후 주말 동안 고객 문의나 이슈가 있었는지 확인 |
D-Day (월) 14:00 | 전면 배포 | 20% → 50% → 100% 트래픽 점차 증가 후 유지 피크타임까지 모니터링 |
Canary 배포는 장애 범위를 줄일 수는 있으나 전면 장애가 아닌 특정 조건에서 발생하는 이슈는 오히려 발견되지 못하고 오래 지속될 수 있기 때문에 Canary를 오래 유지하지 않고 배포와 롤백을 여러 번 반복하였습니다. 또 일시적으로 전면 배포 후 주말을 사이에 두어 고객 문의 등의 피드백을 확인하고자 했습니다.
트래픽을 세밀하게 조절하기 위해서 일반적인 Blue/Green 배포를 사용하지 않고
AWS ALB에 동시에 두 TargetGroup을 등록하고 트래픽을 세밀하게 조정할 수 있게끔 하였으며
이를 통해 문제 발생 시 최대 1분 내에 롤백할 수 있도록 대비하였습니다.
배포 후 모니터링
배포 이후 로그와 서버 지표를 모니터링하였고 중간에 의심스러운 지표가 보여 잠시 롤백하는 등 이슈는 있었으나 결과적으로 장애 없이 마무리되었습니다.
CPU 및 메모리 지표
응답에는 이상 없으나 GC 수행 시점에 CPU가 크게 상승하는 것을 관찰했습니다.
응답 속도
응답속도 max 지표는 눈에 띄게 좋아졌습니다. 기존 C++ 모듈은 이벤트루프 방식의 웹 서버였기 때문에 간혹 지연이 있었던 것으로 추측됩니다.
그 외 지표는 어느 정도 응답 시간이 증가한 것을 확인할 수 있습니다.
단순 퍼센트로 따지면 큰 증가이지만, 목표했던 성능 수치는 만족하였다고 판단하였습니다.
- Java인 것을 감안하면 가용 TPS는 높아도 응답속도는 다소 낮아질 것으로 예상하였습니다.
- 응답 시간이 증가하였다 한들 평균 1ms이기 때문에 서버 간 요청을 주고받는 과정에서 차지하는 비율은 극히 낮고 네트워크 지연 등을 생각하면 사용자가 알아채기 어려운 절대적으로 미미한 수치입니다.
배포 이후 GC 추가 개선
CPU 지표를 살펴보니 주기적으로 CPU가 상승하는 것을 관찰, GC 문제가 있는 것으로 추측하였습니다.
Java로 전환할 때에 GC에 문제가 생길 것을 생각은 했으나 예상보다 영향이 컸습니다.
성능테스트는 낮은 트래픽부터 최고 트래픽까지 Ramp-Up 시키는 방식을 사용했기 때문에 중간 트래픽 수준을 오래 유지할 때에 발생하는 이러한 CPU spike를 발견하지 못했습니다.
그 원인을 찾아보았습니다.
GC 로그 분석
위의 CPU 그래프와 힙메모리 그래프를 자세히 보면 메모리가 정리될 때마다 CPU가 오르는 게 아니라, 메모리 정리 2번에 CPU 1번씩 상승하는 것을 알 수 있습니다.
GC 로그 분석 결과, major GC(Concurrent Cycle)가 빈번하게 발생하고 이 major GC 시점에 CPU가 크게 상승하는 것을 관찰할 수 있었습니다.
전체 데이터를 힙 메모리에 저장하고 있기 때문에 이 데이터 대부분이 old 영역에 위치하고 있어 major GC가 트리거되기 쉽고, 또 마킹하려는 객체 수도 매우 많다 보니 소요시간도 오래 걸린 것으로 추측됩니다.
jstat으로 확인하였을 때 아래와 같이 Old 영역의 사용률이 높았던 것을 확인할 수 있습니다.
$ jstat -gc 5098 1000 S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT 0.0 2048.0 0.0 2048.0 1728512.0 1245184.0 2463744.0 2117884.3 109544.0 106304.7 13668.0 12475.4 1727 19.039 0 0.000 1700 23.571 42.610 0.0 2048.0 0.0 2048.0 1728512.0 1269760.0 2463744.0 2117884.3 109544.0 106304.7 13668.0 12475.4 1727 19.039 0 0.000 1700 23.571 42.610 0.0 2048.0 0.0 2048.0 1728512.0 1292288.0 2463744.0 2117884.3 109544.0 106304.7 13668.0 12475.4 1727 19.039 0 0.000 1700 23.571 42.610CPU 사용량도 사용량이지만 가장 큰 문제점은 major GC가 수행될 때 STW(Stop-the-world) 구간인 Remark가 상당히 오래 걸린다는 점입니다. 길어야 30~40ms이긴 하지만 꼭 시급히 개선이 필요한 부분이라고 판단하였습니다.
개선 방향 검토
IHOP(GC가 언제 수행될지를 조절하는 임계치 값) 파라미터 등의 GC 튜닝보다는 우선 현재 메모리 저장 구조를 개선하는 것이 우선된다고 판단하였습니다.
in-memory data store 방식
단순한 컬렉션 형태가 아닌 in-memory data store 사용을 검토해보았습니다.
hazelcast | In-memory data grid 메모리에 데이터를 저장하고 SQL 문을 사용하여 쿼리도 가능하며 트랜잭션과 lock을 사용 가능하다. peer-2-peer 방식으로 분산 저장이 가능하다. |
chronicle-map | 힙 외부 영역에 데이터를 저장하기 때문에 GC에서 자유롭다. lock 등 기능도 사용 가능하다. |
빠르고 여러 환경에서 검증된 라이브러리들이라고 생각했으나, 단순 key value 조회 이상의 기능을 지원하는 라이브러리들로서 데이터를 변경할 일도 잦지 않은 입장에서는 과하다는 생각이 들었습니다.
오히려 진입 장벽이 높고 라이브러리 이해도가 낮은 부분이 걱정이 되었습니다.
primitive 컬렉션 방식
현재 데이터를 저장하기 위해 굉장히 많은 수의 객체를 사용하고 있지만 비교적 간단한 자료구조이기 때문에 대부분을 primitive 형태로 저장하도록 최적화한다면 GC 성능이 대폭 개선될 것이라고 추측하였습니다.
이미 이러한 기능을 지원하는 라이브러리가 여럿 있는 것으로 파악하여 방안을 검토해보았습니다.
fastutil | 이 분야에선 가장 유명함(Github Star 수나 인터넷 자료 측면에서) |
HPPC | fastutil과 유사하며 특이사항 없음 |
trove | 비교적 역사가 오래된 GNU 라이센스의 라이브러리 |
koloboke | 최초에 trove fork로 시작했으나 현재는 오랜 기간 별도로 개발되었음 |
기능 면에서 라이브러리 간 큰 차이는 없었습니다.
벤치마크 자료 등을 비교해보았을 때 특정 라이브러리가 크게 느리다든가 크게 좋다든가 하는 점은 없었습니다.
따라서 인지도가 가장 높고 (이름도 맘에 들고) 최근 활동도 활발해보이는 fastutil 라이브러리를 사용하여 primitive를 최대한 사용하도록 개선하였습니다.
개선 후 테스트
최고 TPS 테스트 (Ramp Up)
3500 TPS |
3700 TPS |
1500 TPS 테스트
CPU 수치가 기영이 머리 기영이 머리 발생 시 major GC 로그 확인 |
이발 성공 테스트 시간대에 major GC 발생하지 않고 |
추가 테스트
정확하게 어떤 점에서 좋아졌는지 파악할 겸 호기심도 해결할 겸 추가 테스트를 진행하였습니다.
개선 전 힙 영역을 6GB 로 증가 |
힙 영역을 늘리는 것만으로도 이슈 해소 |
개선 전 데이터 컬렉션을 static final로 선언 |
(JDK 11 기준) static final 은 효과 없음 |
개선 후 힙 영역을 3GB로 감소 |
minor GC가 더 빈번하지만 major GC는 발생하지 않기 때문에 여전히 개선된 모습 |
기타 의견
- 개선 전의 gc 지표와 비교하면 원래 old region에 있어야 할 데이터가 humongous region으로 이동하게 되면서 굳이 old region을 GC할 필요가 없다고 판단한 것이 아닐까 추측하고 있습니다.
- old 대신 humongous를 사용하는 게 무조건 좋다고는 생각하기가 어렵고 좀 더 지켜봐야할 것으로 보입니다.
- 이러나저러나 Java 힙 위에 큰 용량의 데이터를 올리는 건 까다로운 일인 것 같고 만약 추가적인 문제가 발견된다면 Redis 등 적용을 검토할 예정입니다.
끗
위와 같이 C++ 서버를 Java로 이관한 경험을 공유드렸습니다.
운 좋게 C++ 모듈이 Java로 전환하기 어렵지 않은 구조여서 큰 무리 없이 전환할 수 있었고 GC 이슈 등 우여곡절은 있었으나 많은 유관부서에서 모니터링을 도와주신 덕분에 문제없이 배포할 수 있었습니다.
운영 중인 C++ 애플리케이션을 Java로 전환하는 경험이 흔한 것이 아니고 배포 또한 커스터마이징한 배포 방식을 사용했으며 마지막엔 GC 디버깅까지 하게 되어서 기억에 남을 과제가 될 것 같네요.
Java로 전환 후 최초에 목표했던 대로 더 적은 비용의 서버로 동일한 트래픽을 문제없이 처리할 수 있고 모니터링 및 유지보수도 더 간편해져서 뿌듯한 작업이었습니다.
혹시 비슷한 고민을 가진 분들께 도움이 되는 글이 되었으면 좋겠습니다.