본문 바로가기
백엔드

[우아한 티켓팅] 대기열 시스템 응답 시간 스파이크 해결하기

by hseong 2024. 10. 6.

들어가기에 앞서

본 게시글을 우아한 티켓팅 프로젝트의 대기열 시스템 성능 개선에 관한 이야기를 다룹니다. 이전 게시글 [우아한 티켓팅]대기열 시스템 10,000명 부하 테스트하기를 통해 어떻게 부하 테스트를 했고, 결과가 어땠는지를 다뤘습니다. 해당 게시글에 이어서 이번에는

  1. 테스트 결과를 살펴보던 중 발견한 응답 시간 스파이크
  2. 원인 분석과 개선

에 대해 다룹니다.


응답 시간 스파이크 발견

우아한 티켓티의 대기열 시스템은 [우아한 티켓팅]대기열 시스템 10,000명 부하 테스트하기에서 다룬 것과 것 부하 테스트를 통해 10,000명을 견딜 수 있는지 검증했습니다. 결과는 다음과 같았습니다.

  • 가상 사용자 2,500명, 테스트 시간 5분, 1초 주기 폴링일 때 95%의 요청을 600ms 내로 처리
  • 가상 사용자 10,000명, 테스트 시간 5분, 5초 주기 폴링일 때 99%의 요청을 400ms 내로 처리

이 외에도 1,000명, 5,000명, 스케일 아웃과 같이 다양한 조건을 테스트를 진행했습니다.

그런데 모든 테스트를 종료하고 결과를 정리하던 중 한 가지 의문이 생겼습니다. 서로 조건이 다른 부하 테스트에서 공통적으로 응답 시간 스파이크가 발생하고 있었습니다. 어째서 응답 시간 스파이크가 발생하는지 알기 위해 Grafana 모니터링을 확인했습니다. 언뜻 봤을 때는 JVM Heap 영역 사용률과 유사해보였습니다. 하지만 확신하지는 못했습니다.



원인 분석

좀 더 정확한 결과를 측정하고자 다시 한 번 부하 테스트를 진행하기로 했습니다. 테스트 조건은 다음과 같습니다.

  • 가상 사용자 2,500명
  • 대기열 남은 순서 조회 1초 주기 폴링
  • 테스트 시간 15분

다른 조건은 기존과 동일하지만 테스트 시간은 좀 더 명확한 결과를 얻기 위해 기존 5분에서 15분으로 늘렸습니다. 테스트 결과 JVM Heap 영역에서 Garbage Collection이 발생했을 때와 응답 시간 스파이크가 발생하는 지점이 어느정도 일치하는 것으로 보였습니다.

따라서 대기열 시스템 부하 테스트 시 발생하는 응답 시간 스파이크는 Garbage Collection이 원인이라고 추정했습니다. Garbage Collection 발생 빈도를 줄이면 저절로 응답 시간 스파이크 빈도 역시 줄어들 것입니다.

로직 개선하기

Garbage Collection 빈도를 줄이려면 불필요한 객체 생성을 최대한 줄이는 방향으로 로직을 개선해야 했습니다. 기존 로직에서 이에 해당하는 부분이 어딘지 분석했고 두 가지 개선점을 찾을 수 있었습니다.

레디스 대기열의 사용자 데이터 모델 개선하기

저희는 최초 대기열 시스템 설계 시, WaitingMember라는 클래스를 정의하여 대기열에서 대기중인 사용자 정보를 관리하고자 했습니다.

@Data
@NoArgsConstructor
public class WaitingMember {
    private String email;
    private long performanceId;
    private long waitingCount;
    private ZonedDateTime enteredAt;

    ...
}

레디스 대기열의 경우 WaitingMember를 저장, 조회하는 과정에서 직렬화, 역직렬화 과정이 필요합니다. 만일 수천명의 사용자가 1초에 한 번씩 자신의 남은 순번을 조회하기 위해 대기열 남은 순서 조회 API를 호출하게 된다면 1초마다 수천번의 역직렬화가 발생할 것입니다. 그때마다 새로운 WaitingMember 인스턴스가 생성되고, 버려질 것입니다. 만일 이 과정에서 실행되는 로직에서 사용하지 않는 불필요한 데이터가 WaitingMember에 포함되어 있다면 불필요한 메모리 낭비로 이어질 것입니다. 따라서 레디스 대기열이 관리하는 사용자 데이터 모델에서 불필요한 데이터를 식별하였습니다.

식별을 통해 반드시 관리가 필요한 데이터는 사용자가 발급받은 번호표인 waitingCount뿐이라는 것을 확인했습니다. 나머지 데이터들은 다음의 이유로 필요하지 않았습니다.

  • performanceId는 key로, email의 경우 hashKey로만 사용되고 있음
  • 대기열 입장 시간 enteredAt은 현재 사용처가 존재하지 않음
    • 입장 시간 enteredAt으로 할 수 있는 일은 번호표 waitingCount가 대신할 수 있음(예. sorted set의 score)

이러한 식별 결과에 따라 대기열이 관리하던 데이터를 waitingMember를 직렬화한 json 문자열에서 단순한 숫자 값인 번호표 waitingCount만 관리하도록 변경하였습니다.

대기열 남은 순서 조회 로직에서 반복적으로 발행하는 이벤트 재사용하기

저희 대기열 시스템의 대기열의 사용자를 작업 공간으로 옮겨주는 작업은 사용자의 남은 순서 조회에 의해서 실행됩니다. 이 때, 작업 로직과 조회 로직 간의 느슨한 결합을 위해 스프링의 애플리케이션 이벤트를 사용하고 있었습니다.

부하 테스트 조건에 따라 2,500명의 사용자가 1초 주기로 남은 순서 조회 API를 호출하고 있다고 가정하겠습니다. 그럼 남은 순서 조회 로직은 1초마다 2,500개의 남은 순서 조회 이벤트를 생성하고 발행합니다. 이 때, 남은 순서 조회 이벤트의 인스턴스 변수는 공연 ID 하나 뿐이기 때문에 2,500개의 이벤트는 모두 동등한 객체입니다. 굳이 사용자가 요청할 때마다 새롭게 이벤트 객체를 만들 필요는 없었습니다.

@Getter
@RequiredArgsConstructor
public class PollingEvent implements Event {

    private final long performanceId;
}

따라서 매번 새로운 이벤트를 생성하는 대신, 한 번 생성한 이벤트는 map 인스턴스 변수에 캐싱해 두고 재사용하기로 했습니다.

public class WaitingSystem {

    private final Map<Long, PollingEvent> eventCache = new ConcurrentHashMap<>();  // 이벤트 캐시
    . . .

    public long getRemainingCount(String email, long performanceId) {  // 남은 순서 조회
        . . .
        PollingEvent pollingEvent =
                    pollingEventCache.computeIfAbsent(performanceId, PollingEvent::new);
        eventPublisher.publish(pollingEvent);
    }
}

개선 결과

모든 로직 개선을 완료하고 다시 한 번 동일한 조건(가상 사용자 2,500명, 테스트 시간 15분, 1초 주기 폴링)에서 부하 테스트를 진행하였습니다. 그 결과 로직 개선 전에 비해 눈에 띄게 응답 시간이 개선된 것을 확인할 수 있었습니다.

  • 자바 대기열의 경우

    1. 50% 요청의 응답 시간이 69ms에서 32ms로 감소, 응답 시간이 2.16배 향상되었습니다.
    2. 95% 요청의 응답 시간이 330ms에서 240ms로 감소, 응답 시간이 1.38배 향상되었습니다.
  • 레디스 대기열의 경우

    1. 50% 요청의 응답 시간이 210ms에서 99ms로 감소, 응답 시간이 2.12배 향상되었습니다.
    2. 95% 요청의 응답 시간이 660ms에서 400ms로 감소, 응답 시간이 1.65배 향상되었습니다.

또한, 응답 시간의 스파이크 역시 로직 개선 전에 비해 눈에 띄게 감소한 것을 확인하며 성공적으로 문제를 해결하였습니다.



끝으로

대기열 시스템을 설계할 때는 팀원과 의견 차이를 조율하면서 이해하기 쉬운 시스템을 만들기 위해 노력했습니다. 동료와 함께 한다는 것은 단순히 의견을 관철하는 것이 아니라 의견을 적절하게 타협하면서 최선의 결과를 도출해나가는 과정임을 배웠습니다.

대기열 시스템을 검증할 때는 여러 도구를 사용하면서 원하는 조건으로 테스트할 수 있는 환경을 구성했습니다. 부하 테스트 도구 Locust의 사용법을 재빠르게 익혔습니다. Locust의 부하 분산을 위해 AWS 오토스케일링 그룹을 활용하여 반복적인 인스턴스 생성, 삭제 과정을 단축했습니다.

대기열 시스템을 개선할 때는 테스트 결과에 의문을 품었던 덕분에 성능 개선이라는 결과로 이어졌습니다. 의도하지 않았으나 테스트 결과에 만족하고 넘어갔다면 성능 개선이라는 결과도 없었을 것입니다.

이것으로 우아한 티켓팅 프로젝트의 대기열 시스템을 만들면서 있었던 일들을 모두 다뤘습니다. 이번 프로젝트를 통해 생각했던 것보다 더 많은 것을 배울 수 있었습니다.