Skip to content

좋아요 토글 동시성 문제 해결

이지호 edited this page Jan 24, 2025 · 4 revisions

📄 좋아요 토글 동시성 문제 해결

동시에 여러 사용자가 좋아요를 토글할 때 발생하는 Race Condition 문제를 해결해, 데이터 일관성을 유지하고 안정성을 개선한 과정을 정리합니다. 이 문서는 단순한 기능 구현에서 놓치기 쉬운 동시성 이슈를 어떻게 파악하고, 구체적으로 어떻게 해결했는지 기록합니다.

🧩 배경 및 필요성

  • 동시 요청 시 발생하는 Race Condition

    빠른 속도로 동일 사용자가 같은 게시글에 대해 좋아요를 여러 번 누르는 상황에서, 실제로는 단 하나의 레코드만 존재해야 하지만 동일 (questionId, userToken) 조합이 중복 생성되거나 이미 삭제된 레코드를 다시 삭제하려 하여 에러가 발생하는 문제가 있었습니다.

  • 부하테스트에서 확인된 500 에러 이슈

    부하테스트 결과, 동시에 많은 요청이 들어올 때 "Record to delete does not exist" 또는 중복 삽입으로 인한 500 에러가 다수 발생했습니다. 이는 사용자 경험 저하뿐만 아니라 데이터 무결성을 크게 해치는 치명적인 이슈였고, 반드시 해결이 필요했습니다.

  • 문제점 및 요구사항

    1. 좋아요 중복: (questionId, userToken) 조합이 중복으로 들어가 DB의 일관성이 깨짐
    2. 삭제 대상 미존재: 이미 삭제된 레코드를 다시 삭제하려고 하여 에러 발생
    3. 데이터 무결성 보장: 동시성 상황에서도 좋아요가 정확히 한 번만 생성·삭제되어야 함
    4. 서비스 안정성: 트래픽이 몰리는 환경에서도 에러 없이 빠르고 안정적인 응답 제공

이러한 문제가 해결되지 않으면, 사용자 경험(UX)이 크게 저하될 뿐 아니라 이후 시스템 확장성에도 부정적 영향을 줄 수 있으므로 빠른 시일 내에 근본적인 해결책을 마련해야 했습니다.

🔍 기술적 분석 및 비교

동시성 문제가 발생하는 전형적인 케이스인 "토글" 기능(좋아요, 북마크, 팔로우 등)에 대해, DB 레벨애플리케이션 레벨에서 접근할 수 있는 여러 방법을 모색했습니다.

  1. DB 레벨에서의 해결
    • Unique 제약조건 추가(questionId, userToken) 조합에 유니크 인덱스를 설정하여 물리적으로 중복 삽입이 불가능하도록 만듦
    • 트랜잭션 격리 수준 조정 DB 격리 수준을 높여 데이터 읽기·쓰기에 대한 경쟁을 제어 (예: Repeatable Read, Serializable)
  2. 애플리케이션 레벨에서의 해결
    • 트랜잭션 처리 단순 find -> create/delete 로직을 하나의 트랜잭션으로 묶어 원자성 보장
    • 분산 락(Distributed Lock) Redis 등 외부 저장소를 활용해 전역(Global) 락을 통해 중복 작업 방지
    • 낙관적 락(Optimistic Lock) 버전(Version) 필드를 두고 갱신 충돌 발생 시 재시도 로직 수행

프로젝트 규모나 트래픽 상황, 개발 기간 등을 고려한 결과, DB에 Unique 키를 추가하고 애플리케이션 코드에서 에러 처리를 간소화하는 접근이 가장 빠르고 확실한 해법이라고 판단했습니다.

🗺️ 문제 해결 과정

1. DB 스키마 수정

model QuestionLike {
  questionLikeId      Int               @id @default(autoincrement())
  questionId          Int
  createUserToken     String
  question            Question         @relation("QuestionLikes", fields: [questionId], references: [questionId], onDelete: Cascade)
  createUserTokenEntity UserSessionToken @relation("TokenQuestionLikes", fields: [createUserToken], references: [token])

  @@unique([questionId, createUserToken])
}
  • @@unique([questionId, createUserToken])를 추가해 중복 삽입을 DB 레벨에서 강제로 막음.

2. 애플리케이션 로직 수정

2.1 애플리케이션 수정 전

async toggleLike(questionId: number, createUserToken: string) {
  const exist = await this.questionRepository.findLike(questionId, createUserToken);
  if (exist) await this.questionRepository.deleteLike(exist.questionLikeId);
  else await this.questionRepository.createLike(questionId, createUserToken);
  return { liked: !exist };
}
  • 이전에는 exist 여부에 따라 create 또는 delete를 분기 처리.
  • 동시에 요청이 들어오는 경우, exist 조회 결과가 이미 바뀌어있을 수 있어 중복 삽입 또는 삭제 실패가 발생.

2.2 애플리케이션 수정 후

async toggleLike(questionId: number, createUserToken: string) {
  try {
    // 1) 무조건 create를 시도
    await this.questionRepository.createLike(questionId, createUserToken);
    return { liked: true };
  } catch (error) {
    // 2) UNIQUE_CONSTRAINT_VIOLATION 에러면 이미 존재하므로 delete 수행
    if (
      error instanceof PrismaClientKnownRequestError &&
      error.code === PRISMA_ERROR_CODE.UNIQUE_CONSTRAINT_VIOLATION
    ) {
      await this.questionRepository.deleteLike(questionId, createUserToken);
      return { liked: false };
    }
    throw error;
  }
}
  • 핵심 아이디어: 유니크 제약이 있는 상태에서 create를 시도하고, 에러가 나면(이미 존재) 그때 delete 수행
  • 이를 통해 동시성 이슈로 인한 중복 삽입이나 삭제 실패를 간단히 해결

📈 결과 및 성과

1. 안정성 개선

  • 부하테스트에서 발생하던 500 에러 4건0건 → Race Condition으로 인한 데이터 불일치 문제가 완전히 해결됨

2. 엔드포인트별 성능 비교

  • 좋아요 /api/questions/{id}/likes
    • 기존: 28.6ms (p95: 51.9ms)
    • 개선 후: 32.3ms (p95: 58.6ms) → 약간의 성능 저하가 있지만 안정성 확보가 우선

3. 부하 처리 능력

  • 처리량
    • 기존: 초당 평균 40 요청 (총 564건)
    • 개선 후: 초당 평균 41 요청 (총 530건) → 유사 수준 유지

종합 결론

  • 안정성: 유니크 키와 에러 처리 로직 개선으로 동시성 오류 완전 해결
  • 성능:
    • 좋아요 기능은 소폭 성능 저하가 있었으나 이는 데이터 일관성과 안정성을 위한 합리적 트레이드오프

위 개선 작업으로 사용자들에게 안정적이고 일관성 있는 좋아요 기능을 제공할 수 있게 되었습니다!

  • 참조: 부하테스트 시나리오 및 결과물 레포지토리 : Ask-It-LoadTest