16. 투표 종료 및 집계

Hyeseong·2023년 3월 6일
0

실시간설문조사앱

목록 보기
6/6

1️⃣ 들어가기 앞서

투표를 종료할 이벤트를 만들 차례입니다. 투표가 종료되면 서버는 결과를 계산한 다음 이 결과를 다시 클라이언트로 보냅니다! 또한 관리자가 투표를 취소할 수 있는 핸들러를 추가 하겠습니다.

2️⃣ 투표 종료 및 취소

설문 조사가 종료되면 설문 조사의 최종 결과를 저장해야 합니다. 이를 위해 shared/poll-types 타입을 업데이트하겠습니다.

🖼 Poll Type 수정 및 Results 타입 명세

export type Results = Array<{
  nominationID: NominationID,
  nominationText: string,
  score: number,
}>;

export type Poll = {
  id: string;
  topic: string;
  votesPerVoter: number;
  participants: Participants;
  nominations: Nominations;
  rankings: Rankings;
  results: Results;
  adminID: string;
  hasStarted: boolean;
}

initialPoll 변수 초기화

createPoll 메서드의 지역변수 initialPoll의 변수를 재정의해 해줄게요.

    const initialPoll = {
      id: pollID,
      topic,
      votesPerVoter,
      participants: {},
      nominations: {},
      rankings: {},
      results: [],
      adminID: userID,
      hasStarted: false,
    };

🔥 PollsRepository

addResults 메서드
addResults 함수는 Redis에 있는 특정 투표(pollID)에 새로운 결과(results)를 추가하는 함수입니다. JSON.SET 명령어를 사용하여 Redis의 키(key)에 결과 데이터(results)를 추가하고, 그 결과를 Promise로 반환합니다. 만약 작업에 실패하면 InternalServerErrorException을 throw합니다.

deletePoll 메서드
deletePoll 함수는 Redis에 있는 특정 투표(pollID)를 삭제하는 함수이다. JSON.DEL 명령어를 사용하여 Redis의 키(key)를 삭제하고,Promise를 반환한다. 만약 작업에 실패하면 InternalServerErrorException을 throw합니다.

/**
 * Adds the provided results to a poll in Redis.
 *
 * @async
 * @function
 * @param {string} pollID - The ID of the poll to add results to.
 * @param {Results} results - The results to add to the poll.
 * @returns {Promise<Poll>} A Promise that resolves to the full poll object, including the newly added results.
 * @throws {InternalServerErrorException} If the operation fails.
 */
async addResults(pollID: string, results: Results): Promise<Poll> {
    this.logger.log(
      `Attempting to add results to pollID: ${pollID}`,
      JSON.stringify(results),
    );

    const key = `polls:${pollID}`;
    const resultsPath = `.results`;

    try {
      await this.redisClient.send_command(
        'JSON.SET',
        key,
        resultsPath,
        JSON.stringify(results),
      );

      return this.getPoll(pollID);
    } catch (e) {
      this.logger.error(
        `Failed to add add results for pollID: ${pollID}`,
        results,
        e,
      );
      throw new InternalServerErrorException(
        `Failed to add add results for pollID: ${pollID}`,
      );
    }
  }

/**
 * Deletes a poll from Redis.
 *
 * @async
 * @function
 * @param {string} pollID - The ID of the poll to delete.
 * @returns {Promise<void>} A Promise that resolves when the operation is complete.
 * @throws {InternalServerErrorException} If the operation fails.
 */
async deletePoll(pollID: string): Promise<void> {
    const key = `polls:${pollID}`;

    this.logger.log(`deleting poll: ${pollID}`);

    try {
      await this.redisClient.send_command('JSON.DEL', key);
    } catch (e) {
      this.logger.error(`Failed to delete poll: ${pollID}`, e);
      throw new InternalServerErrorException(
        `Failed to delete poll: ${pollID}`,
      );
    }
  }

⚡️ PollsService

computeResults 함수
computeResults 함수는 특정 투표(pollID)의 결과를 계산하는 함수입니다. 먼저, pollID를 이용하여 해당 투표(poll)를 가져오고, getResults 함수를 통해 투표 결과(results)를 계산합니다. 이후, 계산된 결과를 Redis에 추가(addResults)하고, Promise를 반환합니다.

cancelPoll 함수
cancelPoll 함수는 특정 투표(pollID)를 취소하는 메서드입니다. pollID를 이용하여 Redis에서 해당 투표를 삭제(deletePoll)하고, Promise를 반환합니다.

두 함수 모두 Redis에 접근하는 과정에서 예외가 발생할 수 있으므로, 예외 처리가 필요하다.

  async computeResults(pollID: string): Promise<Poll> {
    const poll = await this.pollsRepository.getPoll(pollID);

    const results = getResults(poll.rankings, poll.nominations);

    return this.pollsRepository.addResults(pollID, results);
  }

  async cancelPoll(pollID: string): Promise<void> {
    await this.pollsRepository.deletePoll(pollID);
  }
  • computeResults 메서드에서 호출한 getResults 함수를 정의 명세하겠습니다.
import { Nominations, Rankings, Results } from 'shared';

export default (
  rankings: Rankings,
  nominations: Nominations,
  votesPerVoter: number,
): Results => {
  return [];
};

🚪 PollsGateway

  /**
 * Closes a poll and computes the results.
 *
 * @async
 * @function
 * @param {SocketWithAuth} client - The client that initiated the close_poll request.
 * @returns {Promise<void>} A Promise that resolves when the operation is complete.
 * @throws {UnauthorizedException} If the client is not authorized to perform this operation.
 */
  @UseGuards(GatewayAdminGuard)
  @SubscribeMessage('close_poll')
  async closePoll(@ConnectedSocket() client: SocketWithAuth): Promise<void> {
    this.logger.debug(`Closing poll: ${client.pollID} and computing results`);

    const updatedPoll = await this.pollsService.computeResults(client.pollID);

    this.io.to(client.pollID).emit('poll_updated', updatedPoll);
  } 
  
  /**
 * Cancels a poll.
 *
 * @async
 * @function
 * @param {SocketWithAuth} client - The client that initiated the cancel_poll request.
 * @returns {Promise<void>} A Promise that resolves when the operation is complete.
 * @throws {UnauthorizedException} If the client is not authorized to perform this operation.
 */
  @UseGuards(GatewayAdminGuard)
  @SubscribeMessage('cancel_poll')
  async cancelPoll(@ConnectedSocket() client: SocketWithAuth): Promise<void> {
    this.logger.debug(`Cancelling poll with id: "${client.pollID}"`);

    await this.pollsService.cancelPoll(client.pollID);

    this.io.to(client.pollID).emit('poll_cancelled');
  }
  • @UseGuards(GatewayAdminGuard) 데코레이터를 사용하여 해당 함수에 접근할 수 있는 클라이언트가 관리자인지 검증한다.

closePoll 함수

  • closePoll 함수는 close_poll 이벤트를 구독(subscribe)하고, 클라이언트 소켓(client)을 매개변수로 받는다. 이후, 투표를 종료하고 결과를 계산하는 computeResults 함수를 호출하고, 결과를 다시 클라이언트에게 emit하여 전달한다.

cancelPoll 함수

  • cancelPoll 함수는 cancel_poll 이벤트를 구독하고, 클라이언트 소켓(client)을 매개변수로 받는다. 이후, 해당 투표를 취소하는 cancelPoll 함수를 호출하고, 투표가 취소되었음을 다시 클라이언트에게 emit하여 전달한다.

🍎 getResults

이제 결과 계산 방법을 살펴보겠습니다. 투표를 하고 꼴찌 후보를 제거하는 방식으로 진행하려 합니다.

다음 이미지와 같이 Voter 당 총 투표 수에 따라 각 투표에 대해 일종의 가중 공식을 적용합니다

N : 참가자당 총 투표 수
n : 0에서 N-1까지 사용자의 n번째 순위
R_n: n번째 투표의 값

위 공식을 getResults.ts 모듈에 구현하겠습니다.

import { Nominations, Rankings, Results } from 'shared';

export default (
  rankings: Rankings,
  nominations: Nominations,
  votesPerVoter: number,
): Results => {
  // 1. Each value of `rankings` key values is an array of a participants'
  // vote. Points for each array element corresponds to following formula:
  // r_n = ((votesPerVoter - 0.5*n) / votesPerVoter)^(n+1), where n corresponds
  // to array index of rankings.
  // Accumulate score per nominationID
  const scores: { [nominationID: string]: number } = {};

  Object.values(rankings).forEach((userRankings) => {
    userRankings.forEach((nominationID, n) => {
      const voteValue = Math.pow(
        (votesPerVoter - 0.5 * n) / votesPerVoter,
        n + 1,
      );

      scores[nominationID] = (scores[nominationID] ?? 0) + voteValue;
    });
  });

  // 2. Take nominationID to score mapping, and merge in nominationText
  // and nominationID into value
  const results = Object.entries(scores).map(([nominationID, score]) => ({
    nominationID,
    nominationText: nominations[nominationID].text,
    score,
  }));

  // 3. Sort values by score in descending order
  results.sort((res1, res2) => res2.score - res1.score);

  return results;
};

🧪Test with POSTMAN

  1. 설문 조사 만들기
  2. redisClient 안에 nomination이 포함된 poll를 확인
  3. 관리자가 아닌 사람이 close_poll 이벤트를 보내면 인증 오류가 발생
  4. 관리자가 close_poll을 보내고 결과를 클라이언트에게 보내고 서버에 저장
  5. 관리자가 다른 투표를 만들고 cancel_poll을 제출
profile
어제보다 오늘 그리고 오늘 보다 내일...

0개의 댓글