실시간 투표/질문 시스템 구축

Posted: Updated:
#Redis#NestJS

Plum 프로젝트에서 실시간성이 생명인 투표와 질문 기능을 구현하면서 가장 고민했던 지점은 "어떻게 하면 수많은 동시 요청 속에서도 데이터 정합성을 유지하고 서버 부하를 최소화할 것인가?"였습니다. 이를 위해 Redis를 활용하여 문제를 해결하였습니다.

1. 자료구조 활용 전략: "적재적소에 배치하기"

단순히 Key-Value로 저장하는 것을 넘어, 서비스 특성에 맞는 자료구조를 선택하여 원자적(Atomic) 연산을 수행하도록 설계했습니다.

기능자료구조활용 목적핵심 명령어
객체 저장Hash투표/질문 상세 정보 및 결과 스냅샷 관리HSET, HGET
관계 매핑List강의실(Room)과 투표/질문 간의 느슨한 결합LPUSH, RPUSH
실시간 집계Hash옵션별 득표수 관리 (동시성 보장)HINCRBY
중복 검사Hash/Set유효성 검증 및 중복 참여 방지HSETNX, SADD
상태 관리String자동 종료 트리거를 위한 TTL 관리 (Shadow Key)SET EX

2. 쓰기 증폭 문제 해결: "결합도 해소"

초기에는 강의실(Room) 객체 내부에 모든 투표 ID를 배열로 담았습니다. 그러나 투표가 하나 추가될 때마다 Room 객체 전체를 다시 써야 하는 문제가 발생했습니다. 이를 해결하기 위해 별도의 List 자료구조를 도입했습니다.

graph TD
	subgraph "Current: Decoupled with List"
    Room2[Room Object]
    PollList[room:roomId:polls]
    Room2 -. "Reference" .-> PollList
    PollList -- "RPUSH" --> NewPoll[New Poll ID]
  end

  subgraph "Legacy: Tightly Coupled"
	  Room[Room Object] -- "Update Array" --> Room
  end

Room 객체에서 질문/투표 정보를 삭제하고, 관계 정보만 List에 추가하는 방식으로 설계를 변경했습니다.

// 관계 매핑을 위한 List 활용 로직
async addPollToRoom(roomId: string, polls: Poll[]): Promise<void> {
  const listKey = `room:${roomId}:polls`;
  const pipeline = client.pipeline();
  
  polls.forEach((poll) => {
    this.addSaveToPipeline(pipeline, poll.id, poll); // Hash 저장
    pipeline.rpush(listKey, poll.id); // 관계는 List로 관리
  });
  await pipeline.exec();
}

3. 동시성 제어: "애플리케이션 대신 Redis에게 맡기기"

여러 사용자가 동시에 응답하는 상황에서 Read-Modify-Write 방식은 데이터 불일치를 초래합니다. Redis의 원자적 명령어를 활용하여 이 문제를 해결했습니다.

투표 중복 방지 및 집계

HSETNX로 참여 기록이 없을 때만 득표수를 올리도록 설계하여 레이스 컨디션을 방지했습니다.

// 투표 집계 로직: HSETNX와 HINCRBY의 원자적 조합
const isNewVoter = await client.hsetnx(voterKey, participantId, `${optionId}:${name}`);
if (isNewVoter === 0) throw new Error('Duplicate vote attempt');

// 원자적 집계
await pipeline.hincrby(countKey, optionId.toString(), 1);

질문 순서 보장

질문 답변은 유입 순서가 중요하므로 RPUSH를 사용하여 별도의 정렬 로직 없이도 성능과 순서를 모두 확보했습니다.

// 질문 답변 저장: 직렬화 후 List에 순차 저장 (O(1))
const answer = JSON.stringify({ participantId, participantName, text });
await client.rpush(answerKey, answer);

4. 라이프사이클 자동화: "Redis Event 기반 종료 트리거"

투표/질문은 설정된 시간이 지나면 스스로 종료되어야 합니다. 발표자의 이탈 등 예외 상황에 대비해 Redis Key-Space Notifications를 도입했습니다.

sequenceDiagram
    participant R as Redis (Active Key)
    participant S as NestJS Server
    participant E as EventEmitter

    Note over R: TTL Expired (timeLimit)
    R->>S: OnEvent('redis.expired.poll')
    S->>S: closePoll(pollId) & Result Aggregation
    S->>E: emit('poll.autoClosed')

투표 활성화 키에 TTL을 부여하고, 만료 시점에 발생하는 expired 이벤트를 서버가 감지하도록 설계했습니다. 이벤트 발생 시 EventEmitter가 서버 전체에 브로드캐스팅하여 최종 결과를 집계하고 방을 정리합니다. 덕분에 서버 리소스를 수동으로 관리할 필요가 없어졌습니다.

// Shadow Key 만료 감지 및 자동 종료 처리
@OnEvent('redis.expired.poll')
async handlePollAutoClose(key: string) {
  const [prefix, pollId, status] = key.split(':');
  if (status !== 'active') return;

  const finalResults = await this.closePoll(pollId); // 결과 취합 및 Hash 업데이트
  this.eventEmitter.emit('poll.autoClosed', { pollId, results: finalResults });
}

5. 가용성 확보: "무제한 시간 설정의 함정"

비즈니스 요구사항으로는 '무제한 시간' 설정이 필요했지만, 인프라 관점에서 TTL이 설정되지 않은 데이터는 메모리 누수를 초래할 수 있습니다.

  • 최대 시간 제한: 무제한 설정 시에도 한 강의의 최대치인 7,200초(2시간)를 강제로 부여했습니다.
  • 자원 회수: 모든 프로세스가 끝나면 임시 집계 데이터를 즉시 삭제하여 Redis 메모리 효율을 극대화했습니다.

마치며: RDB/NoSQL을 넘어 Redis답게 설계하기

Plum 프로젝트의 투표/질문 시스템을 설계하며 가장 집중했던 부분은 'Redis다운 구조'를 만드는 것이었습니다.

MySQL의 테이블 관계나 MongoDB의 계층 구조에 익숙해져 있다 보니, 처음에는 Redis를 단순히 빠른 저장소로만 생각했습니다. 하지만 설계를 진행할수록 Redis의 진가는 단순 저장이 아닌 다양한 데이터 타입(Hash, List, Set)과 그에 최적화된 원자적 명령어에 있다는 것을 깨달았습니다.

RDB의 정규화나 MongoDB의 객체 임베딩 대신, Redis의 특성을 살려 데이터 간 결합도를 낮추는 List 매핑을 도입했습니다. 애플리케이션 레벨의 복잡한 로직을 HINCRBY나 HSETNX 같은 원자적 연산으로 대체하며 성능과 정합성을 동시에 확보할 수 있었습니다.

이번 설계는 단순한 기능 구현을 넘어, 새로운 기술 스택을 마주할 때 그 본질에 맞는 아키텍처를 고민하는 것이 얼마나 중요한지 다시 한번 체감하는 계기가 되었습니다.

Comments (0)

댓글을 불러오는 중...