December 12, 2020

실시간 Alert Engine 개선기 (1)

요즘 회사에서 재미있게 진행하고 있는 업무가 있다. 이 글의 제목처럼 실시간 Alert Engine 을 개선하는 일인데, 말이 개선이지 아예 갈아엎는 수준으로 진행하고 있다. 애초에 개발 언어부터 갈아엎었다. 볼륨이 작은 작업이 아니기에 대략 내년 초 정도로 데드라인을 잡고 진행하고 있는데, 흥미로운 부분도 많고 기록으로 남겨둘 만한 내용도 있을 것 같아 블로그에 기록을 남긴다.

먼저 레거시 Alert Engine 에 대해 짧게 정리하자면, Kafka 를 통해 실시간으로 유입되는 메트릭들을 대상으로 alert 를 발생시키는 기능을 한다. alert 발생은 기본적으로 임계치 기반의 alert rule 들을 정의해 사용하는데 단순 임계치 뿐만 아니라 duration 도 같이 봐야하는 요구사항도 있었다. 게다가 multi tenancy 라는 특징도 있었고 기타 부가 기능들도 다수 들어가 있어 비즈니스 로직이 다소 복잡한 편이다. 게다가 실시간으로 들어오는 데이터들을 밀리지 않고 처리해야 하기 때문에 성능도 기본적으로 어느정도는 나와줘야 한다.

먼저 개선 계획을 세우기에 앞서 레거시 코드의 분석부터 시작했다. 그 전에도 해당 엔진의 기능에 대해서는 대략적으로 알고 있기도 했고, 엔진을 개발하던 개발자 분으로부터 전체 구조에 대한 설명을 듣기도 했다. 하지만 디테일을 놓치지 않고 개선 계획을 세우기 위해서는 코드 레벨에서 한 번 훑어야겠다고 생각했다. 이 부분에서 꽤 시간을 많이 잡아먹었는데 그럴 수 밖에 없는 것이, 코드의 볼륨이 컸다. 심지어 단일 인스턴스가 아니라 8개의 모듈 들로 나눠져 있었고, 데이터의 흐름에 따라 각 모듈들이 유기적으로 통신을 하는 구조를 가지고 있었다. 언어와 프레임워크는 Java / Spring 으로 대부분 만들어져 있었고, Go 로 만들어진 모듈이 하나 있었다.

원활한 코드의 분석을 위해 먼저 데이터 엔티티 구조에 대한 설명을 들었다. 각 엔티티들이 어떤 식으로 관계를 맺고 있고, 어떤 동작을 했을 때 어떤 엔티티들이 참조되고 영향을 받는지에 대해 중점적으로 파악했다. 그 후 본격적으로 코드 분석을 하기 시작했다. 엔티티에 대한 기본적인 이해를 깔고 가니 코드를 따라가기가 한결 쉬웠다. 각 모듈 별로 코드 분석을 통해 도출된 비즈니스 로직을 간단하게 정리해 기록해두었다. 또, 전체적인 흐름을 한 눈에 확인하기 위해 다이어그램으로 구성했다. 다이어그램을 구성할 때는 PlantUML 도구를 이용하였다.

전체 분석이 어느정도 마무리가 되었을 시점에는 분석한 내용을 가지고 기존 개발자 분에게 브리핑을 하면서 다시 확인을 받기도 했다. 그 과정에서 다소 이해가 안되었던 로직들에 대한 히스토리도 물어보면서 디테일을 하나씩 잡아갔다.

이 정도까지 오니 얼추 레거시 코드의 전체 그림이 눈에 들어오기 시작했다. 더불어 현재 구조의 문제점과 개선 방안도 하나 둘씩 짚어나갔다.

 

레거시 구조의 문제점

1. scale out 불가

사실 이 점 때문에 레거시 엔진의 개선이 결정되었다. 애당초 scale out 을 염두에 두지 않고 코드가 구성되었기 때문에 트래픽이 계속해서 늘어나면 어느 순간에는 한계점에 다다를 수 밖에 없다. 아직까지는 standalone 형태로도 처리가 가능한 양이긴 했지만 가끔 성능 문제로 인한 장애가 발생하기도 했고, 선형적으로 증가하는 트래픽 대응을 위해서는 scale out 이 필수적이었다.

2. 과도한 MSA 구성

MSA 의 트렌드에 대해서는 기본적으로 동의하고 있다. 하지만 어디까지 한 덩어리로 묶고, 어디부터 쪼갤 것인가에 대한 문제는 충분한 고민이 필요한 부분이라고 생각한다. 사실 이러한 종류의 문제는 정답이 없다. 조직의 규모나 문화, 성격, 도메인 특성에 따라 모두 다른 답이 도출된다. 우리 팀의 경우에는 관리해야 하는 모듈들이 많아 Alert Engine 은 많아야 한 두명 정도의 개발자가 전담해야 하고, 모듈 별로 쪼개야 할 기능적 당위성 또는 설득력이 다소 부족했다. 따라서 기존의 8개의 모듈을 합치고 master - worker 구조로 단순화시키기로 했다.

3. 언어 / 프레임워크

이 부분에 있어서 고민의 시간을 꽤 오래 가졌다. 개인적으로 가지고 있는 가장 첫 번째 기준은 “그 팀이 익숙한 언어를 우선으로” 이다. 별 특이한 요구사항이 아니라면 메이저 언어들로 구현이 어려운 경우는 별로 없다. 언어 간에 성능이나 생산성의 차이는 나겠지만, 그것보다 우선하는 기준이 바로 “해당 언어에 대한 현재 팀원들의 능숙도"라고 생각한다.

우리 팀의 경우에는 Java > Go > Typescript / Javascript 순으로 많이 사용하고 있다. 레거시 엔진의 경우에도 기존 로직들이 전부 Java 들로 짜여있는 만큼 새로운 엔진도 Java 로 구현하는 것이 얻을 수 있는 이점이 확실히 있었다. 하지만 그럼에도 불구하고 좀 더 고민의 시간을 가졌던 것은 과연 Java / Spring 이 실시간 Alert Engine 을 만드는 데 적합한 것인지에 대한 회의가 있었기 때문이다.

실시간 Alert Engine 의 주요 특징 중 하나는, 높은 수준의 동시성 프로그래밍을 요구한다는 것이다. 그리고 Java 언어는 당연하게도 동시성 프로그래밍에서 활용할 수 있는 옵션이 차고 넘친다. 하지만 그에 따라 요구되는 복잡성 또한 작지 않고 이를 능숙하게 다루기 위해 들여야 하는 시간 역시 적지 않다. Kotlin 으로의 포팅 또한 고려해보긴 했지만 드라마틱한 변화를 기대하기는 힘들다는 생각이었다.

그에 반해, Go 언어는 랭기지 설계 단계에서 동시성 문제를 효율적으로 해결하겠다는 의지를 명확하게 보여주는 언어다. goroutine 과 channel 을 기반으로 한 매우 간단한 동시성 모델을 제공하고 있으며, 이를 통해 결과를 예측하기 용이한 방식으로 동시성 문제를 풀어갈 수 있다. 더 적은 리소스 사용량은 덤이다. Java 언어를 유지한 채로 프레임워크만 Spring 외의 것으로 교체하는 옵션도 고려해보았지만, 아이러니하게도 팀원들에게 이는 Go 언어를 선택하는 옵션보다 더 거부감이 드는 선택지일 것이다. 더군더나 Go 언어는 이미 팀 내에서 다른 용도로 사용되고 있었기 때문에 Go 언어를 선택하기로 했다.

4. 비직관적인 Database Schema

직관적이고 가독성이 좋은 시스템을 구성하기 위한 가장 첫 번째 요소는 네이밍이다. 코드 레벨에서는 물론이고, Database Schema 레벨에서도 이는 동일하게 적용되는데 레거시 엔진이 사용하고 있는 스키마는 다소 난해했다. 네이밍과 구조가 직관적으로 그 역할을 파악하기에 적합하지 않았고, 비정규화되어 있는 컬럼들이 너무 많아 중복된 종류의 데이터 여기저기에 산재해 있었다. 물론 성능과 운영상의 이점 때문에 일부로 비정규화를 해야 하는 케이스들도 있다. 하지만 그런 점을 고려하더라도 기존 스키마는 정리가 필요했다.

5. 기능적 확장성의 제한

이건 사실 문제점이라고 꼽기는 애매하다. 레거시 엔진은 최초 정의된 요구사항들을 모두 구현해냈고, 해결하고자 하는 문제들을 모두 해결하고 있었다. 최초 설계 시점에 예상할 수 있는 확장성에 대한 부분도 모두 고려되어 있었다. 하지만 우리는 이제 새로운 기능을 고려하는 단계에 있고, 우리가 고려하고 있는 기능은 기존의 레거시 엔진의 틀에서 풀기에는 제한적이었다.

예를 들어, 기존 코드는 “임계치를 기반으로 alert 를 탐지한다” 라는 대명제 하에 만들어져 있고, 이 틀 안에서 anomaly detection 같은 기능을 추가하는 것은 매우 어렵다. 또한 실시간으로 streaming 되는 데이터 뿐만 아니라 주기적으로 실행되는 scheduled 방식의 탐지 또한 추가해야 했다.