1. 문제상황
오먹추 프로젝트에서 "사용자가 하루에 한 번 특정 카테고리의 메뉴에만 투표할 수 있다"는 요구사항이 있었다.
예를 들어 BREAKFAST 카테고리에서 투표는 1번만 진행 가능, 하지만 LUNCH 때는 투표를 다시 1회 가능.
그래서 다음과 같은 제약을 의도했다
- 한 유저는 하루에 한 번만 투표할 수 있어야 한다
- 같은 날 같은 카테고리에 중복 투표가 불가능해야 한다.
왜 이 제약 조건이 중요한가?
예를 들어 오먹추에서 투표 결과를 집계할 때
user_id + category + vote_date가 중복되면 잘못된 순위 계산이나 추천 음식의 왜곡이 발생할 수 있다.
처음엔 이 제약을 서비스 로직에서만 체크했다.
if (voteRepository.existsByUSerIdAndCategoryAndVoteDate(...)) {
throw new ILLegalStateException("이미 투표를 완료하셨습니다");
}
하지만 테스트 중 Postman으로 빠르게 2번 요청을 보내보니 중복 투표가 DB에 저장되는 상황이 발생했다.
2. 원인분석
표면적으로는 서비스 로직에서 중복을 체크하고 있었지만 사실 이는 트랜잭션 경합이나 동시성에 취약한 구조였다.
문제 핵심
- 사용자는 투표 API에 두 번 요청을 보냄(동시에)
- 컨트롤러 -> 서비스 -> existsByUserIdAndCategoryAndVoteDate(...) 검사
- 두 요청이 동시에 false를 반환함 (DB에는 아직 저장된 데이터가 없음)
- 두 요청이 각각 voteRepository.save(...) 호출
- 둘 다 저장됨
=> 동일한 (userId, category, date) 조합이 두 개 DB에 저장됨
즉 애플리케이션 레벨에서는 중복을 막을 수 없고, 가장 마지막 방어선인 DB에 안전장치가 필요했다.
3. 해결과정
해결 방법은 바로 복합 유니크 제약 조건을 DB 테이블에 직접 설정하는 것이었다.
JPA에서는 @Column(unique = true) 로는 복합 조건을 걸 수 없기 때문에 @Table(uniqueConstraints = ...) 를 사용해야 했다.
적용한 코드
@Entity
@Table(name = "votes",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "category", "vote_date"}))
@Getter
@NoArgsConstructor
public class Vote {
...
}
이렇게 하자 다음과 같은 장점이 생겼다.
- 중복 요청이 동시에 들어와도 DB에서 제약 조건 위반으로 막아줌
- 서비스 코드에서의 중복 검사와 DB의 무결성 제약이 함께 작동함
- 이로써 데이터 정합성이 보장됨
4. 결론 및 회고
- JPA에서 단일 필드의 유일성은 @Column(unique=true)로 해결되지만
비지니스 로직에 해당하는 복합 제약 조건은 반드시 @Table 수준에서 명시해야 한다.
'Project' 카테고리의 다른 글
| Kakao API를 사용하여 키워드로 장소 검색하기 (0) | 2025.04.09 |
|---|---|
| Spring Security와 JWT로 로그인 구현하기 (0) | 2025.04.03 |
| 스프링 프로젝트에서 공통 응답 + 페이징 + 전역 예외처리 (0) | 2025.03.30 |
| 결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(2) (0) | 2025.03.17 |
| 결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(1) (0) | 2025.03.17 |