-
Notifications
You must be signed in to change notification settings - Fork 2
대기열 시스템 트러블 슈팅
저희는 시나리오에 기반한 부하 테스트를 통해 대기열 시스템의 성능 측정을 끝마쳤습니다. 서버가 단일 인스턴인 경우 자바 대기열이 더 좋은 성능을 보여주지만, 스케일 아웃이 가능하다면 레디스가 더 우위에 있음을 테스트를 통해 명확히 확인할 수 있었습니다.
그런데 저희는 결과를 정리하던 중, 서로 다른 조건으로 진행한 부하 테스트 결과 지표에서 응답 시간 그래프가 스파이크치는 지점을 공통적으로 발견하였습니다. 저희는 어째서 이런 문제가 발생했는지 원인이 궁금했습니다. 응답 시간 그래프와 Grafana 모니터링을 비교했을 때, 가장 의심스러웠던 것은 JVM의 Heap 영역 사용률이었습니다.
언뜻 봤을 때는 Heap 영역에서 GC(Garbage Collection)이 일어나는 지점과 응답 시간이 스파이크 치는 지점이 유사해보였습니다. 원인을 명확하게 파악하기 위해서는 5분보다 더 길게 테스트를 진행해야 한다고 생각했습니다.
좀 더 정확한 결과를 측정하고자 다시 한 번 부하 테스트를 진행하기로 했습니다. 조건은 가장 안정적이었던 가상 사용자 2,500명, 테스트 시간은 기존 5분에서 15분으로 연장하였습니다. 그 결과 다음과 같이 좀 더 명확한 결과를 얻을 수 있었습니다.

저희는 이 결과를 바탕으로 응답 시간의 스파이크가 발생하는 이유는 Heap 영역의 GC 때문이라고 결론을 내렸습니다. 따라서 GC 빈도를 줄일수만 있다면 대기열 시스템의 안정성이 높아질 것이라 판단했습니다.
저희는 최초 대기열 시스템 설계 시, 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만 관리하도록 변경하였습니다.
저희 대기열 시스템의 대기열의 사용자를 작업 공간으로 옮겨주는 작업은 사용자의 남은 대기 순번 조회에 의해서 실행됩니다. 이 때, 작업 로직과 조회 로직 간의 느슨한 결합을 위해 스프링의 애플리케이션 이벤트를 사용하고 있었습니다.
만일 수천명의 사용자가 남은 대기 순번 조회 즉, 대기열 조회 API를 1초 주기로 폴링한다고 가정한다면 1초 동안 수천개의 대기열 조회 이벤트가 생성될 것입니다. 다만 해당 이벤트는 가지고 있는 데이터가 공연 ID 하나 뿐이었습니다.
@Getter
@RequiredArgsConstructor
public class PollingEvent implements Event {
private final long performanceId;
}설령 수천명의 사용자가 동시에 대기열 조회 API를 호출한다고 해도 대기열 조회 이벤트는 충분히 재사용 가능하다고 판단하였습니다. 따라서 해당 이벤트는 매번 new로 새롭게 생성하는 대신 Map을 사용하여 캐싱하는 것으로 로직을 개선했습니다.
public long getRemainingCount(String email, long performanceId) {
...
PollingEvent pollingEvent =
pollingEventCache.computeIfAbsent(performanceId, PollingEvent::new);
eventPublisher.publish(pollingEvent);
...
}모든 로직 개선을 완료하고 다시 한 번 동일한 조건(가상 사용자 2,500명, 테스트 시간 15분, 1초 주기 폴링)에서 부하 테스트를 진행하였습니다. 그 결과 로직 개선 전에 비해 눈에 띄게 응답 시간이 극적으로 개선된 것을 확인할 수 있었습니다.
-
자바 대기열의 경우
- 50% 요청의 응답 시간이 69ms에서 32ms로 감소, 응답 시간이 2.16배 향상되었습니다.
- 95% 요청의 응답 시간이 330ms에서 240ms로 감소, 응답 시간이 1.38배 향상되었습니다.
-
레디스 대기열의 경우
- 50% 요청의 응답 시간이 210ms에서 99ms로 감소, 응답 시간이 2.12배 향상되었습니다.
- 95% 요청의 응답 시간이 660ms에서 400ms로 감소, 응답 시간이 1.65배 향상되었습니다.
또한, 응답 시간의 스파이크 역시 로직 개선 전에 비해 눈에 띄게 감소한 것을 확인하며 성공적으로 문제를 해결하였습니다.