안녕하세요. LINE VOOM AI 조직의 서버 개발자 박찬우, 양유성입니다. 이번 글의 제목은 'Massive AI Real-time Embedding을 효율적으로 다루기'입니다. 제목을 간결하게 만들다 보니 추상적인 제목이 되었는데요. 조금 더 자세히 말씀드리자면 AI에 사용되는 실시간 임베딩을 제공하는 서버를 구축하면서 성능은 높게, 인프라 비용은 적게 들도록 구축하기 위한 과정과 결과라고 말씀드릴 수 있습니다.
이 글은 비단 AI뿐만 아니 라 대용량 데이터를 실시간으로 서비스해야 하는 모든 분야의 서버를 구축할 때 필요한 내용을 담고 있습니다(임베딩 자체를 생성하는 내용은 담고 있지 않습니다). 서버를 구축할 때 항상 우리를 고민하게 만드는 성능과 비용 절감에 관한 이 글의 내용이 많은 분들께 참고가 되기를 바라며 글을 시작하겠습니다.
이번 글에서 구체적인 수치는 대부분 생략했습니다. 개발자 각자가 다뤄야 할 데이터가 같지 않으므로 구체적인 수치를 제시하기보다는 문제 접근 방법과 해결 과정을 자세히 설명해 여러분이 쉽게 방법을 재현하고 효과를 체감할 수 있도록 글을 구성했습니다.
프로젝트 소개
이번 프로젝트를 한 문장으로 설명하면 '대용량 임베딩을 실시간으로 AI 모델에 제공하기 위한 프로젝트'입니다. AI 모델의 필요에 맞는 조건의 임베딩을 실시간으로 제공하는 서버를 구축하면서, 빠른 응답 속도와 높은 TPS를 실현하고 동시에 인프라 비용까지 최소화하는 것을 목표로 잡았습니다.
프로젝트 목표
프로젝트 목표를 중요도 순으로 하나씩 구체적으로 소개하겠습니다.
1. 높은 TPS(transactions per second) 달성
이번 프로젝트에서 가장 중요한 목표는 요구 TPS를 달성하는 일이었습니다. TPS가 달성되지 않으면 서비스가 불가능하기 때문입니다. AI 모델에서 요구하는 TPS는 절대적인 수치만 보면 다른 LINE 서비스에서도 평범히 볼 수 있는 수준이었지만, 자세히 분석해 보니 다른 서비스의 TPS에 수십, 수백 배를 곱한 것과 비슷한 수준이었습니다. 그 이유는 아래 임베딩의 데이터 특성을 설명하면서 말씀드리겠 습니다.
2. 빠른 응답 속도
아무리 좋은 서비스도 응답 시간이 느려 사용자가 답답함을 느낀다면 결코 좋은 사용자 경험을 줄 수 없습니다. 아래에서 다시 말씀드리겠지만 데이터 크기가 커서 응답 속도가 느린 편이었기 때문에 이를 단축하는 것이 중요했습니다.
3. 인프라 비용 절감
높은 TPS를 실현하기 위해서는 필연적으로 대규모의 인프라를 사용하게 되는데요. 그럴수록 비용 절감이 중요해지면서 프로젝트의 주요 목표 중 하나가 됩니다. 예를 들어, VM 10대 규모의 서비스에서 서버 성능 향상과 효율화를 통해 5대로 절감하는 것과, VM 100대 규모의 서비스에서 50대로 절감하는 것은 그 비율은 같을지라도 실제 비용 절감 효과는 10배 차이 나기 때문입니다.
임베딩이란?
먼저 이 글에 자주 등장하는 키워드인 임베딩이 무엇인지 설명하겠습니다.
AI와 임베딩
설명을 시작하기 전에 임베딩의 예시를 보여드리겠습니다.
"data": [
{
"embedding": [
1.543545822800004554,
-0.014464245600309352,
-0.021545555220005484,
...
-2.547132266452536e-05,
-1.5454545875425444544,
-1.0452722143541654544
],
}
],
보시다시피 사람이 무슨 뜻인지 알아보기 힘든 숫자 배열인데요. 임베딩은 원래 사람을 위한 것이 아니라 AI를 위한 것입니다. 임베딩은 단어, 이미지, 동영상 등의 실제 개체를 컴퓨터가 처리할 수 있는 형태로 표현한 것인데요. 콤마로 구분된 숫자 하나하나가 차원이고 이 숫자 배열 전체는 벡터라고 생각한다면, 각 임베딩을 n차원의 공간에 표시할 수 있습니다.
이를 기반으로 개체 A의 임베딩과 개체 B의 임베딩을 공간에 표시한 뒤 서로 간의 거리를 측정해 서로 얼마나 유사한지 판단할 수 있습니다. 이미지를 예로 들면, 검은 고양이 사진과 하얀 고양이 사진은 얼룩말 사진보다 서로 인접한 공간에 표시될 것입니다. 이처럼 임베딩은 AI가 개체 간 유사도를 평가할 때 사용하는 필수 요소입니다.
실시간 임베딩이란?
AI에 임베딩이 필요한 시점은 크게 두 가지로 나눌 수 있습니다. 하나는 모델을 학습시킬 때이고, 다른 하나는 학습된 모델을 실제 서비스에 적용한 후 사용자의 요청을 받을 때입니다. 후자의 경우에는 사용자의 요청에 빨리 응답해야 하기 때문에 서버는 모델이 요청한 대량의 임베딩을 매우 빠른 속도로 제공할 수 있어야 하며, 이때 사용하는 임베딩을 실시간 임베딩(real-time embedding)이라고 합니다.
임베딩의 데이터 특성
임베딩이 더 높은 차원으로 구성될수록, 구성하고 있는 값의 범위가 클수록 AI 모델은 보다 정확히 판단할 수 있지만, 이는 임베딩이 더 큰 데이터가 된다는 뜻이기도 합니다. 더욱이 AI 모델은 이런 임베딩을 단건으로 요청하지 않습니다. 서버 관점에서 큰 임베딩이 동시에 여러 건 입출력된다는 것은 대용량 I/O가 발생한다는 의미입니다.
프로젝트에 적합한 DB 찾기
프로젝트를 시작하면서 가장 먼저 진행한 일은 DB 선정이었습니다. 프로젝트의 매우 높은 성능 요구 수준을 달성하기 위해서는 고성능 DB가 꼭 필요했기 때문입니다.
필요한 DB의 특성 정리하기
필요한 DB의 특성을 먼저 파악해야 이 기준에 따라 어떤 DB를 사용할지 판단할 수 있기 때문에 구체적으로 어떤 특성이 필요한지 아래와 같이 정리했습니다.
RDB 혹은 NoSQL 어느 쪽이든 무관
이번 프로젝트는 실시간 임베딩을 키-값 구조로 저장하고 키(서브 키(sub-key) 포함)로 조회하는 것으로 충분했습니다. 저장된 데이터의 관계성을 조회할 용도가 아니기 때문에 NoSQL 종류의 DB도 사용할 수 있었습니다.
샤딩 지원
QPS(query per second) 수준이 변하더라도 탄력적으로 대량의 I/O를 빠른 속도로 지원하고, 확장이 필요할 때 무중단으로 스케일 아웃하기 위해서는 샤딩이 지원돼야 합니다. 특히 네이티브 샤딩을 지원하는 DB가 좋습니다. MySQL과 같이 별도로 샤딩을 구현할 수 있는 경우도 있지만, 효율성과 유지 보수를 고려할 때 네이티브 샤딩을 지원하는 DB가 유리합니다.
네이티브 샤딩을 전제로 설계된 DB는 여러 노드에 걸쳐 데이터가 적재되기 때문에 요청이 노드 전체에 고르게 퍼져 그만큼 네트워크 부하가 나뉜다는 이점이 있는데요. 이 이점은 특히 임베딩처럼 네트워크 대역폭을 많이 점유하는 데이터를 저장할 때 매우 중요한 특성입니다. 이런 DB로 Redis Cluster, MongoDB Sharded Cluster가 대표적인 예입니다.
다 건 입출력 특화 여부
여러 임베딩을 한 번에 대량 조회해야 하므로 DB 차원에서 특화된 명령어를 지원해 주면서, 해당 명령어가 단건 명령어보다 성능이 좋은 쪽이 유리했습니다. 대표적으로 Redis의 mget
이나 hmget
명령어는 개별 get
요청을 반복하는 것보다 수 배에서 수십 배까지 성능이 좋습니다.
빠른 응답 시간
모든 서비스가 그렇지만 실시간 임베딩 서버는 특히 응답 시간이 중요한데요. 일반적으로 메모리를 기반으로 한 DB들이 가장 빠른 응답 시간을 보여주며 Redis Cluster나 Memcached가 대표적인 예입니다.
QPS 단위당 구축비용
스케일 아웃이 가능한 대부분의 DB는 비용을 투입할수록 성능이 향상되기 때문에 어느 DB든 목표 QPS를 달성할 수 있습니다. 하지만 비용을 생각하면 투입 비용 대비 QPS가 높은 DB를 골라야 하며, 특히 이번 프로젝트는 요구되는 QPS가 굉장히 높았기 때문에 QPS 단위당 구축 비용이 더욱 적어야 했습니다. QPS 단위당 구축 비용은 Redis Cluster가 MongoDB나 MySQL보다 월등히 적습니다.
Reactive Driver 지원
서버를 Reactor로 구현하고 DB가 Reactive 드라이버를 지원하면 Reactive Processing의 우수한 성능을 누릴 수 있습니다. Redis와 MongoDB, Cassandra, 아직 일부 기능이 제한적이지만 MySQL도 R2DBC라는 Reactive 드라이버를 지원합니다.
DB 선정 - Redis Cluster
저희에게 필요한 DB 특성을 정리한 결과 Redis Cluster를 메인 DB로 사용하는 것으로 결정했습니다.
Redis Cluster는 캐시용 아닌가요?
이런 질문을 하시는 분도 계실 것이라고 생각합니다. 실제로 Redis Cluster는 메모리 기반 DB이므로 서버가 재부팅되면 데이터가 유실될 수 있기에 일반적으로 캐시 용도로 많이 사용합니다.
하지만 저희는 아래와 같이 조금 더 깊이 고민하고 살펴본 결과 데이터 저장용으로 사용해도 무방하다는 결론을 내렸습니다.
- 먼저 Redis Cluster는 복제를 통해 장 애 극복 기능(fail over)과 고가용성(high availability, HA)을 지원합니다. Primary - Replica 구조로 작동하다가 Primary 장애 시 장애 극복 기능이 작동해 자동으로 Replica가 Primary로 승격되기 때문에 Primary와 Replica에 동시에 문제가 발생하지만 않는다면 큰 문제가 없습니다. 개인적으로 Redis Cluster를 10여 년 전부터 사용해 오면서 장애 극복 기능이 작동한 상황을 겪은 적은 손에 꼽을 정도이고, 완전 장애로 서비스가 장시간 중단되거나 데이터가 유실된 적은 없습니다. 장애 극복 기능 작동 시 서비스 중단 시간은 10~30초 정도이고, 이때 누락된 쓰기 요청이 있다면 별도로 로그 등을 이용해 복구할 수 있습니다.
- 최악의 경우에 데이터가 유실되더라도 복구할 수 있도록 설계할 수 있습니다. 서비스에 따라서 외부에서 데이터를 다시 주입해 유실된 데이터를 채울 수 있으며, 평상 시에 따로 백업용 DB에 이중 쓰기를 해서 이를 활용할 수도 있습니다.
- DB 장애 발생 시 서킷 브레이커를 통해 관련 모듈을 폐쇄하고 사용자에게 대체 데이터를 제공할 수 있습니다. 이렇게 조치하면 장애 레벨이 전면 장애가 아닌 일부 기능 장애 수준이 되므로 서비스에 미치는 영향이 줄어듭니다.
Redis Cluster의 효용(월등한 QPS와 응답 성능)이 메모리 기반 DB라는 위험을 초과한다면, Redis Cluster를 데이터 저장용으로 사용하는 것이 유리합니다. 이때 위험은 위 1번 요소를 감안한 발생 확률이어야 하고, 2번과 3번 방식을 통해서 위험의 정도를 낮출 수 있다는 점을 고려해 평가해야 하는데요. 저희는 평가 결과 실시간 임베딩용 DB로써 Redis Cluster를 채택 시 효용이 위험보다 월등히 크다고 판단했습니다.
Redis Cluster를 캐시로 사용하면 되지 않나요?
아래 두 가지 안을 한 번에 여러 건을 조회하고 모든 응답이 완료됐을 때 데이터를 사용해야 하는 경우라면 체감할 수 있는 차이가 매우 큽니다.
- A: Redis Cluster를 캐시로 사용하고 다른 DB를 메인으로 사용
- B: Redis Cluster를 메인으로 사용하고 다른 DB에 이중 쓰기해 백업으로 사용
예를 들어 키가 서로 다른 100개의 임베딩을 요청하고 응답을 받았을 때 A와 B, 각 상황에 대한 키별 응답 시간이 아래와 같다고 가정하겠습니다.
A: (10, 14, 15, 120, 15, 112, ... ,10)
B: (10, 12, 15, 12, 11, 15, ... , 20)
(단위: ms)
이때 최종 응답 시간이 A는 MAX(A) = 120ms, B는 MAX(B) = 20ms입니다. 여기서 A에만 있는 100ms을 넘는 응답 시간은 캐시 히트가 안 돼 다른 DB에서 응답한 시간이 포함된 시간입니다. 즉 A와 같이 캐시 히트율이 100%가 아닌 상황에서 다른 DB의 응답 속도가 늦다면, B와 비교해 응답 시간 측면에서 매우 불리해집니다. 따라서 빠른 응답 속도가 중요했던 이번 프로젝트에는 A처럼 Redis Cluster를 캐시로 사용하는 방법은 적합하지 않았습니다.
Redis Cluster를 어떻게 사용할 것인가?
선정된 DB인 Redis Cluster를 시스템 요구사항에 맞게 사용하기 위한 과정을 알아보겠습니다.
데이터 모델링
데이터 모델링(이하 모델링)은 Redis Cluster에 실제로 어떤 형태로 데이터를 저장할 것인지 결정하는 단계입니다.
모델링할 때는 아래 관점에서 충분히 고민한 후 결정해야 합니다.
- Redis Cluster는 키를 해싱해 데이터를 분산 적재합니다. 따라서 키가 고르게 분산돼야 높은 성능을 얻을 수 있 습니다. 키가 분산되지 않아 특정 슬롯으로 요청이 몰리는 현상을 핫스팟(hot spot)이라고 하며, 핫스팟이 발생할 경우 성능이 대폭 하락하고 샤딩의 의미가 퇴색됩니다.
- 'Big Key Issue'(참고)가 발생하지 않도록 해야 합니다. Big Key Issue가 발생하면 성능 저하는 물론 서비스 장애까지 발생할 수 있습니다.
- 서비스의 데이터 접근 패턴을 고려해 최적화해야 합니다.
- Redis에서 지원되는 데이터 타입을 고려해 설계해야 합니다.
저희는 위와 같은 사항을 고려해 모델링했고, Redis hashes를 데이터 타입으로 채택했습니다.
Redis hashes를 선택한 이유
Redis hashes를 선택한 이유는 다음과 같은 이점이 있기 때문입니다.
- AI에서 사용하는 임베딩에는 여러 종류가 있고, 한 개의 키로 동시에 여러 종류의 임베딩을 조회하는 경우가 많습니다. 이때 Redis hashes를 사용하면 키 아래 서브 키를 둘 수 있기 때문에 여러 종류의 임베딩을 한 번에 읽을 수 있습니다.
- Redis hashes의 단점으로 지적되는 서브 키별로 TTL(time to live)을 설정할 수 없다는 문제는 서비스 요건상 크게 문제 되지 않았습니다.
- Redis에서 지원하는 hmget 명령어를 사용하면 필요한 서브 키의 전체 혹은 부분 집합으로 데이터를 읽을 수 있습니다. 이런 특 징은 데이터 접근 패턴 관점에서 큰 이점입니다.
mget vs hmget
Redis hashes를 사용하지 않고 임베딩 유형별로 키를 더 분산해서 적재한 다음 mget 명령어로 한 번에 읽는 방법도 가능했는데요. 임베딩 유형이 추가될 수록 키가 비례해서 증가한다는 단점이 있었고, Redis hashes를 사용하는 것이 키를 기준으로 데이터를 한 번에 관리하기 편하다는 점을 고려해 데이터 타입으로 Redis hashes를 선택하고 명령어로 hmget을 선택했습니다.
네트워크 트래픽과 데이터 크기 줄이기
네트워크가 문제가 된다고요?
서버 개발을 하면서 네트워크 대역폭을 신경 쓸 일이 흔히 발생하지는 않습니다. 여기서 네트워크란 호출 서버와 응답 서버 구간, 로드 밸런서와 서버 구간, DB와 서버 구간, 서버 내 개별 파드의 네트워크 등 모든 것을 의미합니다.
네트워크가 문제 될 정도라면 사용자의 요청이 수십만 TPS 이상이고 서버와 DB 구간의 QPS는 수십만에서 100만 단위가 되는 게 일반적입니다. 하지만 이번에는 훨씬 적은 TPS에서도 네트워크 트래픽이 염려됐습니다. 이유는 매우 큰 데이터를 대량 조회해야 했기 때문입니다.
이를 체험해 보려면 성능 좋은 DB군과 서버군을 준비하고 서버에서 제한 조건 없이 대량의 데이터를 읽도록 한 후 네트워크와 서버의 반응을 관찰해 보면 됩니다. 감시 설정이 잘 돼 있는 인프라 환경이라면 수많은 경고 메시지와 연락을 받게 되실 겁니다. 그만큼 중대한 문제를 야기할 수 있습니다(설마 운영 환경에서 시도하지는 않으시리라 믿습니다).
데이터 크기와 성능의 관계
설령 네트워크 대역폭을 다 차지하지 않는다고 해도 다른 문제가 있습니다. 입출력 데이터의 크기가 클수록 응답시간은 비례해서 증가합니다. DB의 데이터 입출력 성능에도 영향을 주고 네트워크 전송 시간도 늘어나기 때문입니다. 응답 시간이 늘어나면서 TPS가 낮아지는 문제도 발생합니다.
이런 문제들을 해결하기 위해서는 데이터 크기를 줄여야 합니다. 하지만 필수 데이터만 포함하도록 데이터 구조를 설계해도 임베딩 같이 따로 제외할 수 있는 부분이 없는 데이터인 경우가 있습니다. 이런 경우 결국 데이터를 압축해야 합니다. 압축에는 간단히 약어를 사용해서 길이를 줄이는 방식부터 압축 포맷을 사용해서 본격적으로 데이터를 압축하는 방식도 있습니다.
하지만 압축이 만능 해결사가 되지는 못합니다. 압축과 압축 해제를 위해서는 CPU 자원을 소모해야 하고, 추가로 처리 시간이 발생해 전체적인 성능 저하로 이어질 수 있기 때문입니다. 효율적인 압축 방법을 찾지 못한다면 오히려 성능이 하락하게 됩니다.
정보 엔트로피란?
데이터 압축에 대한 얘기를 하려면 먼저 정보 엔트로피에 대해 설명해야 합니다. 정보 엔트로피(이하 엔트로피)라는 개념은 미국의 컴퓨터 과학자 클로드 섀넌이 1948년에 논문에서 소개한 개념인데요. 데이터 압축에서 엔트로피는 압축 알고리즘에 입력하는 데이터의 무작위성을 의미합니다.
엔트로피 공식은 다음과 같습니다.
H(X) = - ∑p(x)logP(x)
여기서 H(X)는 엔트로피를, P(x)는 확률 변수 X의 각 상태의 확률을 의미합니다.
실제로 엔트로피 를 계산해 보겠습니다. 아래 문장의 엔트로피는 1.80입니다.
abcaacbbbaaabdcdbbaa
반면에 아래 문장의 엔트로피는 4.12입니다.
abhijklmcdefgxyzuvux
한눈에 봐도 무작위성이 높을수록 엔트로피도 높습니다.
압축과 정보 엔트로피의 관계
압축은 원본 데이터의 엔트로피와 밀접한 관련이 있습니다. 압축과 엔트로피의 관계에 대해서 많은 논문이 있는데요. 간략히 말하면 엔트로피를 기반으로, 데이터 압축으로 달성할 수 있는 압축 후 용량의 하한선을 알 수 있습니다.
아래 그래프는 원본의 엔트로피 수준과 데이터 타입, 압축 방식별 압축 효율을 나타낸 것입니다(수치는 설명을 위해 임의로 조정했습니다).
위 그래프에서 X축의 엔트로피가 증가하면서 Y축의 압축 효율이 감소하는 것을 확인할 수 있습니다. BEP(Break Even Point) 선은 압축 효과와 비용이 동일한 지점입니다. 원본 데이터의 엔트로피가 높을수록 압축 효율은 떨어지고, 극단적으로 높은 데이터의 경우 압축 효과가 없습니다. 압축 효율이 BEP 선인 1.0이 되지 않는다면 압축하더라도 오히려 성능이 감소합니다.
위 그래프에서 물음표로 표시된 Type4에 대해서는 아래 섹션에서 살펴보겠습니다.
임베딩이 압축이 되나요?
임베딩의 경우 엔트로피 수준이 제각각이고, 엔트로피가 극단적으로 높은 데이터도 있습니다. 만약 임베딩의 엔트로피가 위 그래프에서 3 이상이라면 압축을 적용할 방법이 없는 것일까요?
꼭 그렇지는 않습니다. 위 그래프에서 미지의 방식으로 표기한 Type4의 압축 효율이 BEP 선을 넘기면 됩니다. 압축 효율을 높이기 위해서는 압축 효과를 높이던지 같은 압축 효과를 내면서 압축 비용을 줄이면 되는데요. 이번 프로젝트에서는 압축 비용을 줄이는 방법으로 압축 효율을 높인 결과 획기적인 성능 향상을 달성할 수 있었습니다.
압축 비용을 조금이라도 더 줄이기 위해 데이터 타입과 압축 방식을 함께 변수로 놓고 새로운 조합을 찾기 위한 수많은 테스트를 실시했습니다. 여기서 데이터 타입이란 프로그래밍 언어나 DB의 데이터 타입만을 의미하지 않으며 임베딩을 데이터화하기 위한 모든 방식을 의미합니다.
위 그래프에서 Type1과 Type2는 같은 압축 방식을 적용했지만 효율면에서 많은 차이가 나는 것을 볼 수 있습니다. 데이터 타입과 압축 방식은 데이터 크기를 줄일 때 각각 단독으로도 효과가 있지만, 압축 시에는 상호 작용하며 시너지를 내기도 합니다. 따라서 효율이 높은 새로운 조합을 찾는다면 더 많은 종류의 임베딩에 압축을 적용할 수 있고 크기를 줄일 수 있습니다.
물론 이 경우에도 임베딩이 압축하기에 너무 엔트로피가 높다면 압축은 채택하지 않고 데이터 타입 변경만을 고려할 수도 있는데요. 하지만 임베딩에는 여러 종류가 있고 많은 경우 AI가 여러 임베딩을 동시에 요구하기 때문에 압축도 변수로 고려해야 합니다.
임베딩 압축으로 얻은 효과
임베딩 압축은 앞서 말씀드린 프로젝트 목표인 응답 시간 단축과 TPS 달성, 인프라 비용 절감을 달성하는 데 많은 부분을 공헌했습니다. 특히 응답 시간 단축은 Redis Cluster를 메인 DB로 채택한 이점을 제외하고는 대부분이 압축을 통해 얻은 결과였습니다.
그밖에 중요한 효과가 한 가지 더 있는데요. 같은 인프라 환경에서 프로젝트를 보다 쉽게 확장할 수 있게 되었다는 점입니다. 인프라 환경, 예를 들어 네트워크는 대역폭의 한계가 정해져 있는데요. 압축하면서 데이터의 크기가 줄어들어 마치 대역폭이 그만큼 올라간 것과 같은 효과가 나타났으며, 덕분에 프로젝트의 확장 여지도 늘어났습니다.
인프라 비용 절감을 위한 추가 고민
지금까지 소개한 내용만으로도 임베딩 서버의 성능을 향상시키고 인프라 비용을 절감할 수 있었지만, 이것만으로 충분하지 않다고 생각해 인프라 비용을 절감할 수 있는 추가 방안을 고민했습니다.
코드 최적화
프로젝트의 전체 모듈을 가장 최신 버전의 Reactive Stack으로 구성했습니다. 매번 Reactor를 사용해 왔지만 이번에는 유독 비즈니스 로직 구현 부분에서 대안을 고민할 여지가 많았는데요. 성능 향상의 여지가 있다면 몇 번이고 코드를 고친 뒤 개발자 간 상호 코드 리뷰를 거쳤습니다. Reactor는 가장 높은 성능을 발휘할 수 있는 기술 중 하나이지만 잘못 사용하면 눈에 띄게 성능이 저하되는 기술이기도 합니다. 이를 방지하기 위한 방법으로 개발자 간 코드 리뷰를 진행했고, 이를 위해서 무엇보다 코드 리뷰에 부담이 없는 분위기를 형성하기 위해 노력했습니다. 또한 리뷰 속도를 높이기 위해 PR(pull request) 내용이 많고 복잡할 때에는 수시로 ZOOM을 통해 화상으로 토론을 진행했습니다.
서버 프로파일링 및 GC 방식 선택
서버에 프로파일링 툴을 설치한 뒤 특히 메모리와 GC(garbage collector) 작동을 주의 깊게 관찰했습니다. GC로는 개발 초기에는 ZGC를 사용하다가 운영 시작 무렵에 Generational ZGC의 성능 리포트를 보고 저희 서비스에 중요한 몇 가지 측면에서 성능이 향상될 것이라고 기대해서 Generational ZGC로 변경했습니다.
Generational ZGC는 ZGC보다 효율을 높이기 위해 'young'과 'old'로 세대를 구분하고 각 영역에 대해 GC를 수행합니다. Generational ZGC를 사용하기 위해서는 JDK21 이상에서 아래 옵션을 추가하면 됩니다(기존에 ZGC를 사용하고 있는 상황이었다면 -XX:+ZGenerational만 추가하면 됩니다).
java -XX:+UseZGC -XX:+ZGenerational
Generational ZGC가 작동하면 두 개의 GC가 각각 실행되는 것을 관측할 수 있는데요. 서버 부하가 낮을 때는 ZGC와 큰 차이가 없지만 부하가 올라갈 때는 성능의 차이를 느낄 수 있습니다.
쿠버네티스 환경에서 최적의 리소스 할당량 찾기
이번 프로젝트도 쿠버네티스 환경에서 작동하도록 개발했기에 리소스를 더욱 세밀하게 조정해 할당할 수 있었습니다.
최근 수년간 진행한 다른 프로젝트 역시 쿠버네티스 환경에서 개발한 것은 마찬가지였지만, 이번 프로젝트는 네트워크 사용량이 유난히 크다는 게 차이점이었습니다. 그동안 프로젝트를 진행하면서 항상 성능 테스트와 최적화 과정을 거쳤기에 경험상 대략의 최적 설정값을 알고 있었지만, 네트워크 사용량이 유난히 크다는 것을 고려해 아래와 같은 사항들을 백지에서부터 전부 다시 검토해 새로운 최적 설정값을 찾아야 했습니다.
- 서버 스펙
- 노드 하나에 올리는 파드의 수
- 파드당 CPU, 메모리 등의 자원 할당량
- JVM 옵션
- 새로 채택한 기술들의 특정 리소스 사용량
결과적으로 앞서 설명한 압축 다음으로 이 부분에서 가장 많은 인프라 절감 효과를 얻었는데요. 이를 통해 데이터에 근거해 판단하기 위해서는 모든 팩터의 영향을 측정하기 위한 성능 테스트를 진행한 뒤 이를 근거로 최종 설정값을 결정해야 한다는 결론에 이를 수 있었습니다.
효율적으로 성능 테스트하기
성능 테스트는 일반적으로 성능 테스트 플랫폼에서 진행합니다. 실제와 유사하게 요청을 발생시켜 시스템에 부하를 걸면서 이에 따른 성능의 변화를 측정합니다. 요청을 만들 때 실제 서비스와 유사하게 재현할수록 성능 측정 정확도가 높아집니다.
이 방식의 가장 큰 단점은 시간과 노력이 많이 소요된다는 점입니다. 그래서 개발자의 로컬 환경에서 테스트를 진행하기도 하는데요. 아무래도 로컬 환경은 실제 서버 환경과 많은 차이가 있기에 결과 역시 많은 차이가 발생하곤 합니다.
이번 프로젝트는 테스트해야 할 성능 팩터가 워낙 많았고, 그에 따라 검증해야 할 테스트 시나리오도 다양했습니다. 이에 각 테스트 방식의 장단점을 고려해 두 가지 방식을 혼합해서 시간도 절약하고 정확도도 높이는 방향으로 성능을 테스트하기로 결정했습니다.
성능 팩터 간 상대 비교는 효율을 고려해 JMH를 이용한 로컬 테스트에서
로컬 테스트는 정확도를 높이기 위해 JMH(Java Microbenchmark Harness)를 사용했으며, 주로 성능 팩터 간의 상대적인 비교를 수행했습니다.
예를 들어 앞서 설명한 데이터 타입과 압축 방식에 관한 테스트는 그 후보군이 매우 많았는데요. 압축 방식만 해도 어떤 압축 방식은 압축 레벨을 몇 단계로 설정할 수 있기 때문에 이를 모두 성능 테스트 플랫폼에서 수행한다면 지나치게 시간이 오래 걸려 전체 프로젝트 일정에 차질을 발생시킬 것으로 예상됐습니다.
따라서 이를 전부 테스트 플랫폼에서 수행하기 전에 후보군을 어느 정도 추릴 필요가 있었고, 이와 같은 작업을 JMH를 이용한 로컬 테스트에서 수행했습니다.
본격적인 성능 테스트는 충분한 스펙으로 준비한 성능 테스트 플랫폼에서
JMH를 이용한 로컬 테스트로 후보군을 추린 뒤에는 성능 테스트 플랫폼에서 본격적으로 성능 테스트를 수행했습니다.
성능 테스트 플랫폼을 구축할 때 주의한 점은 임베딩 서버에 충분히 부하를 줄 수 있을 정도의 서버를 확보하고 각 인스턴스가 충분한 네트워크 대역폭을 확보하도록 배치하는 것이었습니다. 실제로 성능 테스트 중 정체 구간이 발생해 확인해 보니 임베딩 서버의 문제가 아니라 테스트를 위해 부하를 발생시키던 서버의 문제로 확인돼 테스트 도중 클러스터를 증설하기도 했습니다.
저희는 목표 성능(TPS, 응답시간) 달성과 리소스 사용 절감의 균형점을 찾기 위해 다른 프로젝트보다 훨씬 많이 성능 테스트를 수행했으며, 결국 모든 성능 테스트를 마치고 결정한 최종 설정값들은 그동안 수행했던 다른 프로젝트들의 설정값과는 꽤 차이가 큰 값들이었습니다.
결과 해석이 주관적이 되지 않도록 결과 정리 및 해석 작업도 다 함께
테스트 시나리오가 많다는 것은 결과를 정리하고 해석해야 하는 일도 많다는 것을 의미합니다. 이때 테스트 결과를 주관적으로 해석하지 않도록 결과를 놓고 함께 토론도 진행했는데요. 이와 같이 결과 정리 작업을 진행한 덕분에 성능 테스트 리포트를 일목요연하게 정리할 수 있었고, 이는 그 어떤 말보다도 설득력 있는 근거가 되었습니다.
적용 효과
이번 프로젝트는 결과적으로 완전히 새로 서버를 개발하긴 했지만 최초에는 기존 레거시 서버를 단계적으로 개선해 나가는 작업과 병행해서 진행했는데요. 레거시 서버에 앞서 소개한 내용이 적용될 때마다 크고 작은 성능 개선이나 인프라 비용 절감 효과가 있었습니다. 이후 최종적으로 이번 프로젝트로 레거시 서버군을 완전히 대체한 후에는 개별 서버의 스펙을 대폭 낮추고 필요 대수도 줄일 수 있었습니다. 앞서 소개한 과정을 거치지 않았다면 높은 스펙의 서버를 몇 배로 늘렸을 테고 그만큼 추가 비용이 발생했을 것입니다.
아래 표는 최초 프로젝트 목표와 각 항목별 적용 효과를 표시하면 것입니다.
목 표 | 목표 달성에 기여한 정도 | ||
---|---|---|---|
많음 | 보통 | 적음 | |
높은 TPS 달성 |
|
|
|
빠른 응답 속도 달성 |
|
| |
인프라 비용 절감 |
|
|
|
이외에도 목표 달성을 위해 시도했던 방법 중 이번에는 효과가 없어서 이번 글에서는 소개하지 않은 여러 방법들이 있는데요. 실패한 시도 자체는 경험으로 축적돼 앞으로 다른 프로젝트에서 활용할 수 있을 것이라고 기대합니다.
마치며
이번 글에서 소개한 내용이 고성능 서버 구축과 인프라 비용 절감에 관심이 많은 독자분들께 조금이라도 도움이 되기를 바랍니다. 짧은 시간 안에 프로젝트를 완료할 수 있도록 도와주신 모든 분들께 감사드리며 이만 마치겠습니다.