코딩관계론

TPS 2에서 TPS 10,000까지의 험난한 과정 본문

개발

TPS 2에서 TPS 10,000까지의 험난한 과정

개발자_티모 2024. 8. 22. 18:32
반응형

먼저 성능 개선을 하기 전에 목표를 정해야 합니다. 이 이벤트가 열리게 되면 1,00,000명의 사용자가 초당 5번의 질의를 이어가게 된다.

똑똑한 사용자들이 시스템의 허점을 노리고 쿠폰을 받거나 말거나 쿼리를 넣는 것이다.  또한 이 서비스는 10분간만 유요한 서비스다. 따라서 초당 QPS는 8,400을 감당할 수 있어야 합니다. (QPS와 TPS가 동일하다고 가정함)
 
최근에 DDD를 공부하면서 타임딜을 리펙토링 하고 성능 테스트를 진행해 보았습니다. 하지만 웬걸 성능이 아래와 같이 TPS가 2? 가 나오는 상황이 발생했습니다. 2000도 아니고 2가 나오는 숫자가 너무 기괴했습니다.

 
바로 프로메테우스를 보니 DB의 커넥션 풀이 10개인데 사용자의 요청이 몰리면서 10개가 전부 활성화되고, 71개가 커낵션 풀을 얻기 위해서 대기하는 현상을 발견하게 됐습니다.

커넥션 풀이 없는 현상

 
하지만 저는 아래의 그림처럼 Look aside 패턴을 사용하고 있었기 원론적으로 첫 번째 요청만 DB에 접근해서 값을 가지고 오고, 나머지 요청들은 Redis로 향해야 했습니다.  즉 부하테스트가 시작되면 Active는 1이고, Idle 9여야 정상입니다.

따라서 코드를 확인해 봤습니다. loadEvent함수에서 디비를 접근하기 때문에 이 함수를 사용하는  generateCouponToUser 함수에 Transactional 어노테이션이 붙어있습니다.  이 어노테이션이 시작될 때 TransactionManger를 이용해서 커넥션을 획득해서 옵니다. 

    @Transactional(readOnly = true)
    public Coupon generateCouponToUser(Long eventId, String userPK) {
        String lockKey = LOCK_KEY_PREFIX + eventId;
        redisLock.tryLock(lockKey);

        try {
            Event event = loadEventToMemoryIfNotExists(eventId); //캐시 접근 후 디비 접근
         }

 
따라서 해당 Transaction코드를 서비스가 시작하자마자 걸어주는 것이 아니라 캐시에 접근 후 데이터가 없는 것이 확인되면 해당 함수에서 트랜잭션을 시작하도록 변경했습니다. 
 
따라서 아래의 그림과 같이 처음 요청일 때만 디비 커넥션의 Active가 1이 되고, 이후의 요청에서는 커넥션을 사용하지 않는 것을 볼 수 있습니다. 이렇게 변경했더니 TPS는 100으로 변경됐습니다. 

 
하지만 추가적인 저의 목표는 TPS가 8400까지 도달해야 했으므로 한참 부족했습니다. 따라서 추가적인 병목구간을 찾아야만 했습니다.

저는 쿠폰 발급의 정합성을 위해서 분삭락을 사용하고 있었습니다. 이 덕분에 쿠폰 발급 개수의 정합성은 유지할 수 있었지만, 요청이 많아질수록 대기하는 시간이 길어져 TPS가 떨어지는 것을 확인했습니다.
 
따라서 분산 락을 유지하면서 성능을 개선하기는 어렵다고 판단하였습니다. 이에 정합성과 사용자의 요청 순서를 보장할 수 있는 방법을 고민한 결과, 큐(Queue) 자료구조가 이 모든 조건을 충족할 수 있다는 결론에 도달했습니다.

큐는 사용자의 요청이 도착한 순서대로 처리할 수 있으며, 발급 가능한 쿠폰 개수만큼만 큐에서 요청을 꺼내어 발급하면 되기 때문입니다.
 
따라서 기존의 아키텍처는 다음과 같이 변경됐습니다.

 
그 후 성능테스트를 진행해 보니 TPS가 2400까지 증가하다 이후의 요처들은 자꾸 죽어버리는 현상을 발견했습니다.

 
이 상황이 GC에 의해서 발생한 현상인가를 확인해 보기 위해서 프로메테우스에서 GC가 몇 번 발생했고, 어느 정도의 시간이 소요 됐는지 체크해봐야 했습니다.  요청이 집중되는 구간에 GC count가 0,5회 정도가 있었지만 1.2ms로 성능에 악영향을 미칠 만큼 크지 않았습니다.

 
따라서 ngrinder의 error 로그를 확인해 보니 네트워크 트래픽의 성능이 안 나와서 요청에 실패하는 것을 확인하여 부하테스트 서버를 클라우드 시스템에 설치하여 테스트를 진행했습니다. 

앞서 같은 중간에 TPS가 갑자기 죽는 현상이 없어진 것을 확인 후 1,000,000의 요청 테스트를 위해 vuser를 1000명, vuser당 재요청을 1000을 설정해 주면서 1,000,000명의 요청을 보내는 것처럼 설정했습니다.
 
그랬더니 다음과 같은 TPS가 2000으로 유지되는 것을 보고 더 이상 테스트를 지속하는 것은 의미가 없다고 생각했습니다.

 
따라서 아래와 같은 구조를 사용해 scale-out을 적용했습니다. 여기서 두 가지 문제점이 생겼는데 기대와는 다르게 TPS가 올라가지 않고, 발급 가능한 쿠폰의 개수보다 발급한 쿠폰의 개수가 더 많아지는 현상일 발생했습니다. 
 
일단 첫 번째 이슈의 이유는 비용 부족으로 Nginx와 Infra서버를 같이 두면서 네트워크와 CPU에 병목현상을 유발했기 때문입니다.

사용자가 다량의 요청을 보낼 때, Nginx의 CPU 점유율이 크게 증가합니다. 동시에 Worker는 데이터를 일정량씩 읽어 Redis에 저장합니다. 이후 Redis에서는 TTL(Time To Live)이 만료되면 서버 1과 서버 2에 알림(notification)을 보내고, 이 과정에서 DB에 대량의 Write 작업이 발생합니다. 2024.08.19 - [개발] - Redis 객체가 소멸될 때 DB에 영속화하자 - 꿀팁 있음
 
이러한 상황에서 최악의 경우, Nginx의 CPU와 네트워크 사용량, Redis의 네트워크 및 CPU 사용량, 그리고 Postgres의 CPU와 네트워크 사용량이 동시에 급증할 수 있습니다. 결과적으로, 시스템이 scale-out 되더라도 LoadBalancer 서버에 병목이 발생하여 사용자의 요청을 빠르게 분배하지 못하게 됩니다.
 
따라서 TPS는 서버를 몇 대를 도입하든 간에 고정적인 숫자를 보여주게 됩니다.

 
일단 위의 현상을 제거하기 위해서 프로그램 구조를 아래의 그림처럼 변경하게 됐습니다. 큐에 가져온 객체를 레디스에 다시 저장하여 TTL 만료를 기다리는 방식이 아니라 DB에 직접 저장하는 방식으로 변경하였습니다
 

 
또한 단일 지점 병목으로 인해서 생기는 현상을 방지하기 위해서 Infra 서버를 다음과 같이 scale-up 해주었습니다. 이후에 결과를 확인해 보니 7000 TPS까지 증가하는 것을 확인했습니다.

 
 
이제 남아있는 것은 정합성의 문제였습니다. 서버가 한대일 때는 락을 걸지 않아도 큐에 의해서 정합성이 보장되지만, 다음과 같이 worker 서버가 늘어나면 정합성이 보장되지 않습니다. 

 
Worker 서버로 옮겨진 작업은 클라이언트의 응답과 직접적인 관계가 없기 때문에, 정합성을 보장할 수 있도록 설계되어야 합니다. 이러한 상황에서는 Lock을 도입하여 데이터 정합성을 관리할 수 있습니다. 선택 가능한 Lock의 종류로는 비관적 락(Pessimistic Lock), 낙관적 락(Optimistic Lock), 그리고 분산 락(Distributed Lock)이 있습니다.

저는 이 중에서 분산 락을 선택했습니다. 그 이유는 분산 락이 일반적으로 성능 면에서 우수하기 때문입니다. 그 이유를 간단히 요약하자면, 데이터베이스 락은 주로 디스크 I/O 연산을 포함하는 반면, 분산 락은 주로 메모리 연산을 기반으로 하기 때문에 성능이 더 뛰어납니다.

 

또한 어떤 연산이 경쟁 조건이 발생하는지 생각해봐야 하는데 저의 경우에는 Event 테이블에서 쿠폰을 발급한 개수와 발급 가능한 개수를 관리했고, 쿠폰테이블의 경우에는 Event테이블의 데이터의 정합성만 일치하면 발급하면 됐습니다.

 

따라서 아래의 그림과 같이 큐에서 값을 빼오고 분산락을 획득하여 Event테이블의 쿠폰 개수만 업데이트 후 Lock을 풀어줌으로써 데이터의 정합성도 보장되고, 쿠폰을 발급하는 동안 Lock을 잡고 있지 않기 때문에 성능적 제약도 해결하게 됩니다.

 
그 후 각 worker를 하나 더 생성하여 테스트를 진행해 보았더니 아래와 같이 평균 9000 TPS, 최대 10,000 TPS가 나오는 것을 확인했습니다.

 
 
꿀팁: 우선순위 큐로 Redis에서 제공하는 ScoredSortedSet을 사용하면 몇 가지 장점이 있습니다. 첫째, 중복 사용자 참여가 자동으로 차단됩니다. 또한, ScoredSortedSet의 삽입,삭제, 검색 연산의 성능은 Log(N)으로 꽤나 효율적입니다.

반응형