-
Notifications
You must be signed in to change notification settings - Fork 1
좋아요 토글 동시성 문제 해결
동시에 여러 사용자가 좋아요를 토글할 때 발생하는 Race Condition 문제를 해결해, 데이터 일관성을 유지하고 안정성을 개선한 과정을 정리합니다. 이 문서는 단순한 기능 구현에서 놓치기 쉬운 동시성 이슈를 어떻게 파악하고, 구체적으로 어떻게 해결했는지 기록합니다.
-
동시 요청 시 발생하는 Race Condition
빠른 속도로 동일 사용자가 같은 게시글에 대해 좋아요를 여러 번 누르는 상황에서, 실제로는 단 하나의 레코드만 존재해야 하지만 동일 (questionId, userToken) 조합이 중복 생성되거나 이미 삭제된 레코드를 다시 삭제하려 하여 에러가 발생하는 문제가 있었습니다.
-
부하테스트에서 확인된 500 에러 이슈
부하테스트 결과, 동시에 많은 요청이 들어올 때 "Record to delete does not exist" 또는 중복 삽입으로 인한 500 에러가 다수 발생했습니다. 이는 사용자 경험 저하뿐만 아니라 데이터 무결성을 크게 해치는 치명적인 이슈였고, 반드시 해결이 필요했습니다.
-
문제점 및 요구사항
- 좋아요 중복: (questionId, userToken) 조합이 중복으로 들어가 DB의 일관성이 깨짐
- 삭제 대상 미존재: 이미 삭제된 레코드를 다시 삭제하려고 하여 에러 발생
- 데이터 무결성 보장: 동시성 상황에서도 좋아요가 정확히 한 번만 생성·삭제되어야 함
- 서비스 안정성: 트래픽이 몰리는 환경에서도 에러 없이 빠르고 안정적인 응답 제공
이러한 문제가 해결되지 않으면, 사용자 경험(UX)이 크게 저하될 뿐 아니라 이후 시스템 확장성에도 부정적 영향을 줄 수 있으므로 빠른 시일 내에 근본적인 해결책을 마련해야 했습니다.
동시성 문제가 발생하는 전형적인 케이스인 "토글" 기능(좋아요, 북마크, 팔로우 등)에 대해, DB 레벨과 애플리케이션 레벨에서 접근할 수 있는 여러 방법을 모색했습니다.
-
DB 레벨에서의 해결
-
Unique 제약조건 추가
(questionId, userToken)
조합에 유니크 인덱스를 설정하여 물리적으로 중복 삽입이 불가능하도록 만듦 - 트랜잭션 격리 수준 조정 DB 격리 수준을 높여 데이터 읽기·쓰기에 대한 경쟁을 제어 (예: Repeatable Read, Serializable)
-
Unique 제약조건 추가
-
애플리케이션 레벨에서의 해결
-
트랜잭션 처리
단순
find -> create/delete
로직을 하나의 트랜잭션으로 묶어 원자성 보장 - 분산 락(Distributed Lock) Redis 등 외부 저장소를 활용해 전역(Global) 락을 통해 중복 작업 방지
- 낙관적 락(Optimistic Lock) 버전(Version) 필드를 두고 갱신 충돌 발생 시 재시도 로직 수행
-
트랜잭션 처리
단순
프로젝트 규모나 트래픽 상황, 개발 기간 등을 고려한 결과, DB에 Unique 키를 추가하고 애플리케이션 코드에서 에러 처리를 간소화하는 접근이 가장 빠르고 확실한 해법이라고 판단했습니다.
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 레벨에서 강제로 막음.
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
조회 결과가 이미 바뀌어있을 수 있어 중복 삽입 또는 삭제 실패가 발생.
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
수행 - 이를 통해 동시성 이슈로 인한 중복 삽입이나 삭제 실패를 간단히 해결
- 부하테스트에서 발생하던 500 에러 4건 → 0건 → Race Condition으로 인한 데이터 불일치 문제가 완전히 해결됨
-
좋아요
/api/questions/{id}/likes
- 기존: 28.6ms (p95: 51.9ms)
- 개선 후: 32.3ms (p95: 58.6ms) → 약간의 성능 저하가 있지만 안정성 확보가 우선
-
처리량
- 기존: 초당 평균 40 요청 (총 564건)
- 개선 후: 초당 평균 41 요청 (총 530건) → 유사 수준 유지
- 안정성: 유니크 키와 에러 처리 로직 개선으로 동시성 오류 완전 해결
-
성능:
- 좋아요 기능은 소폭 성능 저하가 있었으나 이는 데이터 일관성과 안정성을 위한 합리적 트레이드오프
위 개선 작업으로 사용자들에게 안정적이고 일관성 있는 좋아요 기능을 제공할 수 있게 되었습니다!
- 참조: 부하테스트 시나리오 및 결과물 레포지토리 : Ask-It-LoadTest