개발자
류준열

동시성으로 인한 중복생성 문제

연인간 일상공유를 도와주는 서비스, 캘린다이어리는 하루에 일기를 하나만 쓸 수 있다.

하네스 만들기를 하다가 중복데이터를 발견했다.

중복데이터의 createdAt을 보면 ms 차이로 생성되어 있었다.

원인

일기 생성 API의 서비스단에서 중복 방지 로직이 있었지만 DB단에서 방어하지 않다보니까 ms단위의 요청에 뜷릴 수 있었다.

// 1. 먼저 기존 엔트리가 있는지 확인 (Check)
const existingEntry = await entryRepo.findOne({
  where: { userId, entryDate: Between(...) }
});

if (existingEntry) {
  return res.status(400).json({ message: "Entry already exists for this date" });
}

// 2. 없으면 새로 생성 (Act)
const entry = entryRepo.create({ userId, entryDate, content });
await entryRepo.save(entry);

동시요청 예시

요청A가 진행중일때 요청B가 들어오면 중복생성이 되는것이다.

  1. 요청A: findOne 실행 -> 결과 없음(아직 저장 안됨)
  2. 요청B: findOne 실행 -> 결과 없음(A가 아직 save 안 함)
  3. 요청A: save -> 성공
  4. 요청B: save -> 성공 (중복생성)

아래처럼 프론트에서 disabled 처리해도 뜷리는걸 확인하면서 프론트에서만 validation하는게 무의미하단걸 직접 확인했다.
(연두색 부분 은 내가 히말라야 갔을때 생긴거다)

해결

Entity에서 userId와 entryDate 조합을 유니크 처리

이렇게 비즈니스 로직에서의 취약함을 방지하기 위헤 entity에서 userId와 entryDate의 조합이 유니크하도록 설정했다.

@Entity('calendar_entries')
@Index(['userId', 'entryDate'], { unique: true })

export class CalendarEntry {
  @Column('uuid')
  userId!: string;
	
  @Column('date')
  entryDate!: Date;
	
	...
	
  @JoinColumn({ name: 'userId' })
  user!: User;
}

이렇게 되면 위 예시 처럼 비즈니스 로직을 뜷고 동시에 두 요청이 들어오더라도, 먼저 save된 데이터가 있으면 그 이후에는 같은 userid + entryDate로 save되지 않는다.

그 후, 중복생성 에러메시지를 추가하고 프론트에서 중복생성 토스트 받도록 해주었다.