[What To Eat] 메뉴 투표 기능 구현하기

My_Code·2025년 6월 17일
0

What To Eat

목록 보기
6/6
post-thumbnail

이 프로젝트는 이것저것 적용해서 테스트하기 위한 프로젝트입니다. 게시물 CRUD를 기반으로 식사 메뉴 투표하는 기능을 구현했습니다.

이번에는 기존 게시물 시스템에 투표 기능을 추가하는 과정을 상세히 다루어보겠습니다. Express.js, TypeScript, Prisma를 사용하여 게시물 생성(투표 생성), 투표하기, 투표 취소, 투표 결과 조회 등의 기능을 구현했습니다.


데이터베이스 스키마 설계

최종 스키마 구조

  • Post 모델의 투표 관련 필드
    • isPoll: 해당 게시물이 투표 게시물인지 여부를 나타냅니다. false가 기본값으로, 일반 게시물과 투표 게시물을 구분합니다.
    • isPollActive: 투표가 활성화 상태인지 여부를 나타냅니다. 관리자가 투표를 일시 중지할 수 있습니다.
    • pollExpiresAt: 투표 만료 시간을 설정할 수 있습니다. null인 경우 만료 시간이 없음을 의미합니다.
  • Vote 모델
    • text: 투표 항목의 텍스트를 저장합니다.
    • postId: 어떤 게시물의 투표 항목인지 연결합니다.
    • userVotes: 해당 투표 항목에 대한 모든 투표 기록을 참조합니다.
  • UserVote 모델 (중간 테이블)
    • @@unique([userId, voteId]): 한 사용자가 같은 투표 항목에 중복 투표하는 것을 방지합니다.
    • @@index: 조회 성능 향상을 위한 인덱스를 설정합니다.
// backend/prisma/schema.prisma

model User {
  id                    String    @id @default(uuid())
  email                 String    @unique
  password              String
  nickname              String?
  socialId              String?   @unique
  refreshToken          String?   @unique
  refreshTokenExpiresAt DateTime?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt
  deletedAt             DateTime?

  posts     Post[]
  userVotes UserVote[]

  @@map("users")
}

model Post {
  id        String    @id @default(uuid())
  title     String // 게시물/투표 제목
  content   String    @db.Text // 게시물/투표 내용
  authorId  String
  author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  deletedAt DateTime?

  // 투표 관련 필드
  isPoll        Boolean   @default(false) // 투표 여부
  isPollActive  Boolean   @default(true) // 투표 활성화 상태
  pollExpiresAt DateTime? // 투표 만료 시간

  votes Vote[] // 투표 항목들

  @@map("posts")
}

model Vote {
  id        String     @id @default(uuid())
  text      String // 투표 항목 텍스트
  postId    String
  post      Post       @relation(fields: [postId], references: [id], onDelete: Cascade)
  userVotes UserVote[] // 투표 기록들
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt

  @@index([postId])
  @@map("votes")
}

model UserVote {
  id        String   @id @default(uuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  voteId    String
  vote      Vote     @relation(fields: [voteId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, voteId]) // 한 사용자는 한 투표 항목에 한 번만 투표 가능
  @@index([userId])
  @@index([voteId])
  @@map("user_votes")
}


타입 정의 및 인터페이스

DTO (Data Transfer Object) 패턴

  • CreatePostDto: 게시물 생성 시 필요한 데이터를 정의합니다. 투표 관련 필드들은 선택사항으로 설정하여 일반 게시물도 생성할 수 있습니다.
  • UpdatePostDto: 게시물 수정 시 사용되며, 모든 필드가 선택사항입니다. 부분 업데이트를 지원합니다.
  • VoteDto: 투표하기/취소 시 필요한 투표 항목 ID를 정의합니다.

응답 타입 설계

  • PostResponse: 클라이언트에게 반환되는 게시물 정보를 정의합니다. 투표 관련 정보는 선택적으로 포함됩니다.
  • VoteResponse: 각 투표 항목의 상세 정보와 통계를 포함합니다.
  • PostsResponse: 페이지네이션이 적용된 게시물 목록 응답을 정의합니다.
// backend/src/types/post.types.ts

import { Post, User, Vote, UserVote } from '@prisma/client';

// 투표 항목 응답 타입
export interface VoteResponse {
  id: string;
  text: string;
  voteCount: number;    // 해당 항목의 투표 수
  percentage: number;   // 전체 대비 투표 비율
  userVoted: boolean;   // 현재 사용자가 투표했는지 여부
}

// 투표 기록 응답 타입
export interface UserVoteResponse {
  id: string;
  userId: string;
  voteId: string;
  createdAt: Date;
}

// 게시물 생성 DTO
export interface CreatePostDto {
  title: string;
  content: string;
  isPoll?: boolean;           // 투표 여부 (선택사항)
  isPollActive?: boolean;     // 투표 활성화 상태 (선택사항)
  pollExpiresAt?: Date;       // 투표 만료 시간 (선택사항)
  votes?: string[];           // 투표 항목 텍스트 배열 (선택사항)
}

// 게시물 수정 DTO
export interface UpdatePostDto {
  title?: string;
  content?: string;
  isPoll?: boolean;
  isPollActive?: boolean;
  pollExpiresAt?: Date | null;
  votes?: string[];           // 투표 항목 수정 시 기존 항목을 모두 교체
}

// 투표 DTO
export interface VoteDto {
  voteId: string;             // 투표할 항목의 ID
}

// 게시물 응답 타입
export interface PostResponse {
  id: string;
  title: string;
  content: string;
  author: {
    id: string;
    nickname: string;
  };
  createdAt: Date;
  updatedAt: Date;
  isPoll: boolean;
  isPollActive: boolean;
  pollExpiresAt: Date | null;
  votes?: VoteResponse[];     // 투표 항목 정보 (상세 조회 시에만 포함)
  totalVotes?: number;        // 전체 투표 수
  userVoted?: boolean;        // 현재 사용자 투표 여부
}

// 게시물 목록 응답 타입
export interface PostsResponse {
  posts: PostResponse[];
  total: number;              // 전체 게시물 수
  page: number;               // 현재 페이지
  limit: number;              // 페이지당 게시물 수
  totalPages: number;         // 전체 페이지 수
}


서비스 레이어 구현

PostService 클래스 구조

// backend/src/services/post.service.ts

export class PostService {
  private prisma: PrismaClient;

  constructor() {
    this.prisma = new PrismaClient();
  }

  // 게시물 생성
  async createPost(userId: string, dto: CreatePostDto): Promise<PostResponse> { ... }

  // 게시물 수정
  async updatePost(postId: string, userId: string, dto: UpdatePostDto): Promise<PostResponse> { ... }

  // 게시물 삭제
  async deletePost(postId: string, userId: string): Promise<void> { ... }

  // 게시물 목록 조회
  async getPosts(page: number = 1, limit: number = 10): Promise<PostsResponse> { ... }

  // 게시물 상세 조회
  async getPost(postId: string, userId: string | null): Promise<PostResponse> { ... }

  // 투표 게시물 검증 (공통 메서드)
  private async validateVotePost(postId: string, userId: string, voteId: string) { ... }

  // 투표하기
  async vote(postId: string, userId: string, dto: VoteDto): Promise<PostResponse> { ... }

  // 투표 취소
  async cancelVote(postId: string, userId: string, dto: VoteDto): Promise<PostResponse> { ... }

  // 응답 포맷팅 메서드들
  private formatPostResponse(post: PostWithDetails, userId: string | null): PostResponse { ... }
  private formatPostListResponse(post: PostWithDetails): PostResponse { ... }
}

게시물 생성 로직 상세 분석

  • 1단계: 투표 게시물인 경우, 투표 항목이 2개 이상 10개 이하인지 검증합니다.
  • 2단계: 투표 항목에 중복이 있는지 검사합니다.
  • 3단계: 게시물(Post)과, 투표 게시물이라면 투표 항목(Vote)까지 함께 생성합니다.
  • 4단계: author, votes, userVotes 등 관련 데이터를 포함하여 생성된 게시물을 조회합니다.
  • 5단계: 응답 포맷팅 메서드(formatPostResponse)를 통해 클라이언트에 반환할 형태로 가공합니다.
// backend/src/services/post.service.ts

export class PostService {
  private prisma: PrismaClient;

  constructor() {
    this.prisma = new PrismaClient();
  }

  // 게시물 생성
  async createPost(userId: string, dto: CreatePostDto): Promise<PostResponse> {
    const { title, content, isPoll, isPollActive, pollExpiresAt, votes } = dto;

    // 투표 게시물인 경우 투표 항목 검증
    if (isPoll) {
      if (!votes || votes.length < 2) {
        throw new HttpException(400, '투표 항목은 최소 2개 이상 필요합니다.');
      }
      if (votes.length > 10) {
        throw new HttpException(400, '투표 항목은 최대 10개까지 가능합니다.');
      }
    }

    // 투표 게시물인 경우 투표 메뉴 중복 체크
    if (dto.isPoll && dto.votes) {
      const uniqueVotes = new Set(dto.votes);
      if (uniqueVotes.size !== dto.votes.length) {
        throw new HttpException(400, '투표 메뉴에 중복된 항목이 있습니다.');
      }
    }

    const post = await this.prisma.post.create({
      data: {
        title,
        content,
        authorId: userId,
        isPoll: isPoll || false,
        isPollActive: isPollActive ?? true,
        pollExpiresAt,
        votes: isPoll
          ? {
              create: votes!.map((text) => ({ text })),
            }
          : undefined,
      },
      include: {
        author: {
          select: {
            id: true,
            nickname: true,
          },
        },
        votes: {
          include: {
            userVotes: {
              include: {
                user: {
                  select: {
                    id: true,
                  },
                },
              },
            },
          },
        },
      },
    });

    return this.formatPostResponse(post, userId);
  }
  
  ...
  
}

투표 게시물 검증 로직

게시물 존재 여부, 투표 게시물 여부, 활성화 상태, 만료 여부, 투표 항목 존재 여부를 모두 검증합니다. 아래 메서드는 투표하기/투표 취소 등에서 공통적으로 사용되어 중복 코드를 줄입니다.

// backend/src/services/post.service.ts

...

// 투표 게시물 검증
  private async validateVotePost(postId: string, userId: string, voteId: string) {
    try {
      const post = await this.prisma.post.findUnique({
        where: { id: postId },
        include: {
          votes: {
            include: {
              userVotes: {
                include: {
                  user: {
                    select: {
                      id: true,
                    },
                  },
                },
              },
            },
          },
        },
      });

      if (!post) {
        throw new HttpException(404, '게시물을 찾을 수 없습니다.');
      }

      if (!post.isPoll) {
        throw new HttpException(400, '투표 게시물이 아닙니다.');
      }

      if (!post.isPollActive) {
        throw new HttpException(400, '종료된 투표입니다.');
      }

      // 만료 시간 확인 로직 개선
      if (post.pollExpiresAt && post.pollExpiresAt < new Date()) {
        throw new HttpException(400, '만료된 투표입니다.');
      }

      const vote = post.votes.find((v) => v.id === voteId);
      if (!vote) {
        throw new HttpException(404, '투표 항목을 찾을 수 없습니다.');
      }

      return { post, vote };
    } catch (error) {
      console.error('투표 게시물 검증 중 오류 발생:', error);
      if (error instanceof HttpException) {
        throw error;
      }
      throw new HttpException(500, '투표 게시물 검증 중 오류가 발생했습니다.');
    }
  }

...

투표하기 기능

  • 투표 게시물 검증: 앞서 만든 validateVotePost로 게시물과 투표 항목의 유효성을 확인합니다.
  • 중복 투표 방지: 이미 해당 게시물에 투표한 기록이 있으면 예외를 발생시킵니다.
  • 투표 기록 생성: UserVote 테이블에 투표 기록을 추가합니다.
  • 최신 게시물 정보 반환: 투표 결과가 반영된 게시물 정보를 반환합니다.
// backend/src/services/post.service.ts

async vote(postId: string, userId: string, dto: VoteDto): Promise<PostResponse> {
  const { voteId } = dto;

  // 투표 게시물 검증
  await this.validateVotePost(postId, userId, voteId);

  // 이미 투표했는지 확인
  const existingVote = await this.prisma.userVote.findFirst({
    where: {
      userId,
      vote: { postId }
    }
  });

  if (existingVote) {
    throw new HttpException(400, '이미 투표했습니다.');
  }

  // 투표 생성
  await this.prisma.userVote.create({
    data: { userId, voteId }
  });

  // 업데이트된 게시물 조회 및 반환
  const updatedPost = await this.prisma.post.findUnique({
    where: { id: postId },
    include: {
      author: { select: { id: true, nickname: true } },
      votes: {
        include: {
          userVotes: {
            include: { user: { select: { id: true } } }
          }
        }
      }
    }
  });

  return this.formatPostResponse(updatedPost!, userId);
}

투표 취소 기능

  • 투표 게시물 검증: validateVotePost로 게시물과 투표 항목의 유효성을 확인합니다.
  • 투표 기록 확인: 해당 사용자가 해당 항목에 투표한 기록이 있는지 확인합니다.
  • 투표 취소: UserVote 테이블에서 해당 기록을 삭제합니다.
  • 최신 게시물 정보 반환: 투표 취소가 반영된 게시물 정보를 반환합니다.
// backend/src/services/post.service.ts

async cancelVote(postId: string, userId: string, dto: VoteDto): Promise<PostResponse> {
  const { voteId } = dto;

  // 투표 게시물 검증
  await this.validateVotePost(postId, userId, voteId);

  // 사용자의 투표 확인
  const userVote = await this.prisma.userVote.findUnique({
    where: {
      userId_voteId: { userId, voteId }
    }
  });

  if (!userVote) {
    throw new HttpException(400, '해당 항목에 투표한 기록이 없습니다.');
  }

  // 투표 취소
  await this.prisma.userVote.delete({
    where: {
      userId_voteId: { userId, voteId }
    }
  });

  // 업데이트된 게시물 조회 및 반환
  const updatedPost = await this.prisma.post.findUnique({
    where: { id: postId },
    include: {
      author: { select: { id: true, nickname: true } },
      votes: {
        include: {
          userVotes: {
            include: { user: { select: { id: true } } }
          }
        }
      }
    }
  });

  return this.formatPostResponse(updatedPost!, userId);
}

응답 포맷팅

  • totalVotes: 게시물의 전체 투표 수를 계산합니다.
  • userVoted: 현재 사용자가 이 게시물에 투표했는지 여부를 계산합니다.
  • votes: 각 투표 항목별로 투표 수, 비율, 사용자의 투표 여부를 포함한 정보를 제공합니다.
  • 응답 최적화: 게시물 목록 조회 시에는 투표 정보(votes)를 제외하고, 상세 조회 시에만 포함합니다.
// backend/src/services/post.service.ts

private formatPostResponse(post: PostWithDetails, userId: string | null): PostResponse {
  const totalVotes = post.votes.reduce((sum, vote) => sum + vote.userVotes.length, 0);
  const userVoted = userId
    ? post.votes.some((vote) => 
        vote.userVotes.some((userVote) => userVote.user.id === userId)
      )
    : false;

  return {
    id: post.id,
    title: post.title,
    content: post.content,
    author: {
      id: post.author.id,
      nickname: post.author.nickname || '',
    },
    createdAt: post.createdAt,
    updatedAt: post.updatedAt,
    isPoll: post.isPoll,
    isPollActive: post.isPollActive,
    pollExpiresAt: post.pollExpiresAt,
    votes: post.votes.map((vote) => ({
      id: vote.id,
      text: vote.text,
      voteCount: vote.userVotes.length,
      percentage: totalVotes > 0 ? (vote.userVotes.length / totalVotes) * 100 : 0,
      userVoted: userId ? 
        vote.userVotes.some((userVote) => userVote.user.id === userId) : false,
    })),
    totalVotes,
    userVoted,
  };
}

게시물 목록 조회 포멧팅 (votes 데이터 제외)

  • 목적: 게시물 목록 조회 시 사용되는 응답 포맷팅 함수입니다.
  • 기본 정보만 반환: 게시물의 id, 제목, 내용, 작성자, 생성/수정일, 투표 여부 및 상태, 만료 시간 등 핵심 정보만 반환합니다.
  • 투표 상세 정보 제외: 투표 항목(votes), 투표 수(totalVotes), 사용자 투표 여부(userVoted) 등 상세 정보는 포함하지 않습니다.
  • 장점: 목록 조회 시 불필요한 데이터 전송을 줄여 성능을 최적화하고, 클라이언트에서 빠르게 목록을 렌더링할 수 있습니다.
// backend/src/services/post.service.ts

private formatPostListResponse(post: PostWithDetails): PostResponse {
  return {
    id: post.id,
    title: post.title,
    content: post.content,
    author: {
      id: post.author.id,
      nickname: post.author.nickname || '',
    },
    createdAt: post.createdAt,
    updatedAt: post.updatedAt,
    isPoll: post.isPoll,
    isPollActive: post.isPollActive,
    pollExpiresAt: post.pollExpiresAt,
  };
}

이처럼 formatPostListResponse는 게시물 목록 조회에 최적화된 간결한 응답을 제공하여, 상세 조회와 역할을 분리하고 전체 API의 효율성을 높입니다.



실행 결과

  • 게시물 생성 (투표 포함)
  • 게시물 상세 조회
  • 게시물 수정

  • 메뉴 투표
  • 메뉴 투표 취소


트러블 슈팅

N:M 관계 설계 문제

문제:

  • Prisma에서 User와 Vote 간 N:M 관계 구현 시 복잡한 쿼리 작성 필요
  • 투표 기록의 생성 시간 등 메타데이터 저장 불가
  • 중복 투표 방지 로직 구현 복잡

원인:

// 문제가 있던 초기 설계
model User {
  votes Vote[] // 직접적인 N:M 관계
}

model Vote {
  users User[] // 직접적인 N:M 관계
}

해결:

// 중간 테이블을 사용한 개선된 설계
model User {
  userVotes UserVote[]
}

model Vote {
  userVotes UserVote[]
}

model UserVote {
  id        String   @id @default(uuid())
  userId    String
  voteId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  vote      Vote     @relation(fields: [voteId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, voteId]) // 중복 투표 방지
  @@index([userId])
  @@index([voteId])
  @@map("user_votes")
}

투표 항목 수정 시 데이터 무결성 문제

문제:

  • 투표 항목 수정 시 기존 투표 기록과 연결이 끊어짐
  • 투표 통계가 부정확해짐
  • 데이터 무결성 위반

원인:

// 문제가 있던 코드
async updatePost(postId: string, userId: string, dto: UpdatePostDto) {
  // 투표 항목을 직접 수정하면 기존 투표 기록과 불일치
  await this.prisma.vote.updateMany({
    where: { postId },
    data: { text: dto.votes } // 기존 투표 기록과 연결 끊어짐
  });
}

해결:

// 삭제 후 재생성 방식으로 변경
async updatePost(postId: string, userId: string, dto: UpdatePostDto) {
  if (post.isPoll && dto.votes) {
    const existingVotes = post.votes;
    const newVotes = dto.votes;

    // 삭제할 투표 항목 찾기
    const votesToDelete = existingVotes.filter(
      (existing) => !newVotes.includes(existing.text)
    );

    // 추가할 투표 항목 찾기
    const votesToAdd = newVotes.filter(
      (newVote) => !existingVotes.some((existing) => existing.text === newVote)
    );

    // 삭제할 투표 항목의 모든 투표 기록 삭제
    if (votesToDelete.length > 0) {
      await this.prisma.userVote.deleteMany({
        where: {
          voteId: { in: votesToDelete.map((vote) => vote.id) }
        }
      });

      await this.prisma.vote.deleteMany({
        where: {
          id: { in: votesToDelete.map((vote) => vote.id) }
        }
      });
    }

    // 새로운 투표 항목 추가
    if (votesToAdd.length > 0) {
      await this.prisma.vote.createMany({
        data: votesToAdd.map((text) => ({
          postId,
          text,
        }))
      });
    }
  }
}
profile
조금씩 정리하자!!!

0개의 댓글