Skip to content

[의사결정] 웨이팅 서비스 구현 #98

@jhssong

Description

@jhssong

웨이팅 서비스 구현을 위한 In-memory Database와 관련하여 몇 가지 논의사항이 있습니다.

어떤 In-memory 저장소를 사용할지

실시간 웨이팅 관리 시스템 구축을 위해 ConcurrentHashMap과 Redis 중 어떤 In-memory 저장소를 활용할지 논의가 필요합니다.

ConcurrentHashMap vs Redis

항목 ConcurrentHashMap Redis
속도 매우 빠름 (JVM 메모리) 빠름 (네트워크 포함)
영속성 없음 RDB(스냅샷)/AOF(로그) 가능
장애 시 데이터 유실 서버 죽으면 끝(데이터 유실) 설정에 따라 복구 가능
락 관리 애플리케이션 레벨 Redis atomic 명령어 사용 가능

만약 성능 향상을 위해 scale-up 혹은 scale-out이 필요한 경우, 각각 어떻게 대응 가능한가?

Scale-Up의 경우 단일 서버 환경이니 JVM 메모리를 사용하는 ConcurrentHashMap 방식이 유리하다고 생각됩니다. 하지만 서버가 재시작되면서 데이터가 모두 유실된다는 문제점이 존재합니다.

Scale-Out을 하는 경우 ConcurrentHashMap은 확장이 불가능합니다. 예를 들어 서버 A와 B가 있다고 하면 서버 A에 등록된 웨이팅 목록을 서버 B에서 볼 수 없습니다. 즉, 데이터 파편화가 발생하여 서비스의 정합성이 깨지게 됩니다. 반면, Redis는 서버 대수와 무관하게 서버가 중앙 Redis 하나만 바라보기에 문제가 없습니다.

결론

프로젝트 초기 회의 때부터 여러 서버를 둔 환경을 고려한 개발의 중요성이 언급되었고, 또 실무에서도 분산 서버 환경을 택하는만큼 단일 JVM에서만 동작하는 ConcurrentHashMap 보단 Redis의 도입이 좋다고 생각합니다.


Redission vs Lua script

Redis를 사용한다고 했을 때 필요한 논의 사항을 정리해보았습니다.

Redis 자료 구조: Sorted set

웨이팅 서비스 관련 요구사항은 다음과 같습니다.

  1. 순서보장
  2. 중복 등록 방지
  3. 동시성 처리
  4. 대기 순번 조회
  5. 취소/노쇼 처리

Redis를 사용한다고 했을 때, 자료구조로는 Sorted Set을 사용하고자 합니다.
Sorted set은 고유한 값과 실수형 점수를 쌍으로 저장하며, 점수를 기준으로 항상 정렬된 상태를 유지하는 데이터 구조입니다.

key: pubwaiting:{storeId}
value: userId
score: timestamp (millisecond)

Sorted Set을 선택한 이유는 다음과 같습니다. 우선 데이터의 자동 정렬이 지원되며, ZRANK를 통해 대기 순번을 실시간으로 조회하기 용이합니다. 또한 ZREM을 활용해 입장 및 취소(노쇼) 처리를 $O(\log N)$의 효율적인 시간 복잡도로 수행할 수 있다는 점 때문입니다.

서비스 요구사항

제가 생각한 서비스 요구사항은 다음과 같습니다.

  • 사용자 관점
  1. 웨이팅 등록
  2. 웨이팅 취소
  3. 특정 주막의 전체 웨이팅 수 확인
  • 관리자 관점
  1. 웨이팅 목록 조회
  2. 테이블 배정 (손님 입장, 세션 시작)
  3. 테이블 세션 종료 (손님 퇴장, 세션 종료)
  • 테이블 세션의 경우 별도의 SQL 테이블로 관리됩니다. (테이블 세션: 테이블 별 사용 시간, 주문 메뉴 정보 등을 기록)

Redission vs Lua script

Redis 환경에서 원자성을 확보하는 방법으로 Redisson과 Lua 스크립트 중 어느 쪽이 우리 상황에 맞을지 이야기해보고 싶습니다.

앞선 시나리오에서 관리자-2의 경우 RDB 작업이 함께 이루어져야 합니다. 즉, 다음과 같은 흐름이 발생합니다.

  1. 락 획득
  2. DB update
  3. Redis REM
  4. 락 해제

DB 작업, 네트워크 지연, WAS 장애 등의 상황에서 모두 락이 유지됩니다. 즉 락의 범위가 Redis를 넘어서 DB까지 확장됩니다.

락이 필요한 핵심적인 부분은 Redis 내부입니다. 순번 경쟁에 대해서만 원자성이 보장되면 되기에 Redission 사용이 불필요하고 오히려 병목 현상이 증가하는 단점이 존재합니다.

또한, 트래픽이 몰리는 경우 Redission은 락 대기 큐가 발생하는 반면 Lua는 Redis 큐 내부에서 순차적으로 처리됩니다.

결론

연산이 Redis내부에서 끝나고, 복잡한 락이 필요 없으며 순번 계산이 핵심이기에 Lua script가 더 단순하고 빨라 이를 채택하는게 좋다고 생각합니다.


의사결정을 위해 ConcurrentHashMap과 Redis(Lua script) 방식 둘 다 구현해보았습니다.

구현 구조

  • 공통 인터페이스
메서드 설명
register(pubBoothId, memberId, phone, guestCount) 웨이팅 등록 (한 멤버는 하나의 주막에만 등록 가능)
cancel(pubBoothId, pubWaitingId) 특정 웨이팅 취소
getWaitingSize(pubBoothId) 현재 대기 인원 수 반환
getAllWaitingIds(pubBoothId) 대기열 전체 목록 반환 (관리자용 상세 정보 포함)
  • ConcurrentHashMap 구현
자료구조 역할
ConcurrentSkipListSet orderedSet 등록 순서 기반 정렬 (AtomicLong sequence 사용)
ConcurrentHashMap<Long, WaitingNode> indexMap pubWaitingId → WaitingNode 인덱스 (O(log N) 삭제)
ConcurrentHashMap<Long, Long> memberBoothMap memberId → pubBoothId (중복 등록 방지)

WaitingQueue는 ConcurrentSkipListSet(순서 보장)과 ConcurrentHashMap(O(log N) 삭제를 위한 인덱스)을 조합하여 구성했습니다. 중복 등록 방지를 위해 memberId → pubBoothId를 추적하는 memberBoothMap을 별도로 관리합니다.

  • Redis

중복 등록 방지와 atomic한 처리를 위해 Lua 스크립트(registerScript, cancelScript)를 사용했습니다.

Redis Key 타입 역할
waiting:{boothId} ZSet 순서 관리 (score = timestamp, member = pubWaitingId)
waiting:member:{memberId} String 중복 등록 방지 (boothId 저장)

성능 비교 (이론적 분석)

항목 ConcurrentHashMap Redis ZSet
register O(log N) - putIfAbsent + SkipListSet add O(log N) - Lua (ZADD + SET atomic)
cancel O(log N) - indexMap 조회 후 SkipListSet remove O(log N) - Lua (ZREM + DEL atomic)
getWaitingSize O(1) - orderedSet.size() O(1) - ZCARD
getAllWaitingIds O(N) - stream 순회 O(N) - ZRANGE
getWaitingList (상세) O(N) - getAllWaitingIds → DB findAllById O(N) - getAllWaitingIds → DB findAllById

컨트롤러(api) 정의

  • 사용자 관점
  1. 웨이팅 등록: /api/v1/pubWaiting/register
  2. 웨이팅 취소: /api/v1/pubWaiting/cancel/{pubWaitingId}
  3. 특정 주막의 전체 웨이팅 수 확인: /api/v1/pubWaiting/size/{pubBoothId}
  • 관리자 관점
  1. 웨이팅 목록 조회: /admin/v1/pubWaiting/list/{pubBoothId}
  2. 테이블 배정 (손님 입장, 세션 시작): /admin/v1/pubTableSession/start
  3. 테이블 세션 종료 (손님 퇴장, 세션 종료): /admin/v1/pubTableSession/end

부하테스트 (계획)

실제 환경에서 성능 비교를 위해 K6를 이용하여 테스트를 수행합니다. 테스트 초안을 작성해두었고, 실제 테스트는 추후 실제 배포 환경과 동일한 환경에서 테스트를 수행하여야 합니다.

  • k6 측정 지표
    • http_req_duration : 요청 하나가 완료되는 시간

      지표 의미
      avg 평균 응답시간
      p95 95% 요청이 이 시간 이하
      p99 최악에 가까운 응답시간
    • http_reqs : 초당 처리 요청 수

테스트 코드
`k6 run --out json=results.json --summary-export=summary.json script.js`

```jsx
import http from "k6/http";
import { check, group, sleep } from "k6";

const minUsers = 10;
const maxUsers = 100; // the number of vusers
const BASE_URL = "http://localhost:8080";
const PHONE_NUM = "010-1234-1234";
const GUEST_COUNT = 4;

const HEADERS = { "Content-Type": "application/json" };

export let options = {
  stages: [
    { duration: "10s", target: minUsers }, // Ramp-up: 10 -> 100 users
    { duration: "30s", target: maxUsers }, // Hold at 100 users
    { duration: "10s", target: minUsers }, // Ramp-down 100 -> 10 users
  ],
};

export function setup() {
  const payload = JSON.stringify({
    boothName: "Booth A",
    clubName: "Club A",
    description: "This is A",
    accountNum: "123456789",
    memberId: 1,
  });

  const res = http.post(`${BASE_URL}/admin/v1/pub-booths`, payload, {
    headers: HEADERS,
  });

  check(res, {
    "pub booth created": (r) => r.status === 201,
  });

  const location = res.headers["Location"];
  const pubBoothId = parseInt(location.split("/").pop());
  console.log(`생성된 pubBoothId: ${pubBoothId}`);

  return { pubBoothId };
}

export default function (data) {
  const { pubBoothId } = data;
  const memberId = __VU * 10000 + __ITER + 1;
  let pubWaitingId;

  group("1. register waiting", () => {
    const payload = JSON.stringify({
      pubBoothId: pubBoothId,
      memberId,
      phone: PHONE_NUM,
      guestCount: GUEST_COUNT,
    });

    const res = http.post(`${BASE_URL}/api/v1/pubWaiting/register`, payload, {
      headers: HEADERS,
    });

    check(res, {
      "register status 2xx": (r) => r.status >= 200 && r.status < 300,
    });

    if (res.status === 200 || res.status === 201) {
      try {
        const body = res.json();
        pubWaitingId = body.pubWaitingId;
      } catch (_) {}
    }
  });

  group("2. get waiting size", () => {
    const res = http.get(`${BASE_URL}/api/v1/pubWaiting/size/${pubBoothId}`);
    check(res, {
      "size status 2xx": (r) => r.status >= 200 && r.status < 300,
    });
  });

  group("3. admin list (10%)", () => {
    if (Math.random() < 0.1) {
      const res = http.get(
        `${BASE_URL}/admin/v1/pubWaiting/list/${pubBoothId}`,
      );
      check(res, {
        "list status 2xx": (r) => r.status >= 200 && r.status < 300,
      });
    }
  });

  group("4. cancel waiting (50%)", () => {
    if (pubWaitingId && Math.random() < 0.5) {
      const res = http.post(
        `${BASE_URL}/api/v1/pubWaiting/cancel/${pubWaitingId}`,
        null,
        {
          headers: HEADERS,
        },
      );
      check(res, {
        "cancel status 2xx or 4xx": (r) =>
          r.status === 200 || r.status === 204 || r.status === 404,
      });
    }
  });

  sleep(1);
}

```

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions