Unique Constraint Duplicate Prevention
좋아요, 팔로우, 신청처럼 "한 사용자당 한 번"이어야 하는 행은 코드에서 먼저 조회해 보고 없을 때 INSERT하는 방식만으로는 지킬 수 없다. 거의 동시에 들어온 두 요청이 둘 다 "없음"을 보고 둘 다 INSERT하기 때문이다. 이 패턴은 그 틈을 DB의 유니크 인덱스가 판정하게 하고, 막힌 요청은 예외를 잡아 정상 응답으로 돌려주는 방식이다. Database Isolation이 말하는 "격리 수준만으로 business rule을 해결하지 말고 unique constraint 같은 명시적 invariant를 함께 설계하라"는 판단의 구체적인 사례로 볼 수 있다.
문제: 조회와 INSERT 사이의 틈
- 더블클릭처럼 요청 A와 B가 거의 동시에 들어오면 아래 순서가 생길 수 있다.[1]
| 순서 | 요청 A | 요청 B |
|---|---|---|
| 1 | SELECT → 없음 | |
| 2 | SELECT → 없음 | |
| 3 | INSERT | |
| 4 | INSERT | |
| 5 | COMMIT | COMMIT → 행 2개 |
- 행이 2개가 되면 이후
Optional로 받는 조회가 결과 2개 때문에IncorrectResultSizeDataAccessException으로 실패한다. 그 사용자와 그 글의 조합은 이후 계속 500을 받는다.[1:1] - 한 번 생긴 중복 행은 요청을 다시 보내도 저절로 사라지지 않으므로, 이 문제는 일시적인 오류라기보다 데이터가 깨진 상태로 남는 문제로 볼 수 있다.
트랜잭션과 잠금이 막지 못하는 이유
- 트랜잭션이 보장하는 것은 "내 작업이 다 되거나 다 안 되거나"이고, "다른 요청이 사이에 끼어들지 못하게"는 보장하지 않는다.[2]
- 기본 격리 수준인 READ COMMITTED에서 일반 SELECT는 커밋된 데이터만 보므로, 두 요청 모두 "없음"을 본다.[2:1]
- Postgres의
SELECT ... FOR UPDATE는 아직 없는 행을 잠글 수 없다.[3] - MySQL은 갭 락을 걸지만 두 요청이 같은 갭 락을 동시에 잡을 수 있고, 둘 다 INSERT하려 하면 데드락이 나서 한쪽이 롤백된다.[3:1]
- 코드에서 먼저 확인하더라도 "확인과 INSERT 사이의 틈"은 코드로 닫을 수 없다.[3:2]
유니크 인덱스가 판정하는 방식
- 일반 조회와 달리 유니크 인덱스는 커밋되지 않은 행까지 안다. A가 INSERT하는 순간 A의 값이 인덱스에 "진행 중"으로 올라간다.[4]
- B가 같은 값으로 INSERT하면, A가 진행 중일 때 B는 기다린다. 이후 A가 커밋하면 B는 유니크 위반으로 거절되고, A가 롤백하면 B가 들어간다.[4:1]
- A가 이미 커밋했다면 B는 바로 거절된다.[4:2]
- 앱 코드는 A가 진행 중인지 알 수 없으며, 판단은 DB 인덱스가 한다.[4:3]
sequenceDiagram
participant A as 요청 A
participant IDX as 유니크 인덱스
participant B as 요청 B
A->>IDX: INSERT (user, post) — 진행 중으로 등록
B->>IDX: 같은 값 INSERT
IDX-->>B: A가 끝날 때까지 대기
alt A 커밋
IDX-->>B: 유니크 위반으로 거절
else A 롤백
IDX-->>B: INSERT 성공
end함정: NULL이 낀 유니크 인덱스
- SQL의 NULL은 "값 없음"이 아니라 "모름"이라서
NULL = NULL은 참이 아니라 UNKNOWN이다.[5] - 유니크 인덱스도 NULL끼리를 서로 다른 값으로 본다. 게시글 좋아요는
comment_id가 항상 NULL이어서(user_id, post_id, comment_id)인덱스가 중복을 하나도 막지 못했다.[5:1] - 같은 이유로 조회에서
= NULL은 아무 행도 돌려주지 않으므로IS NULL을 써야 한다. Spring Data 메서드 이름의...CommentIsNull은IS NULL조건으로 바뀐다.[5:2] - Postgres에서는 두 가지로 해결할 수 있다.[6]
-- Postgres 15+: NULL끼리도 같은 값으로 본다
CREATE UNIQUE INDEX user_like_index
ON "like" (user_id, post_id, comment_id) NULLS NOT DISTINCT;
-- 대안: partial unique index
CREATE UNIQUE INDEX like_post_unique ON "like" (user_id, post_id) WHERE comment_id IS NULL;
- 인덱스를 만들기 전에 이미 쌓인 중복 행부터 지워야 한다. 중복 행이 남아 있으면 인덱스 생성이 실패한다.[6:1]
ddl-auto=update는 이미 있는 인덱스를 고치지 않으므로, 수동 DDL을 psql로 실행해야 실제로 적용된다.[6:2]
막힌 요청을 정상 응답으로 바꾸기
- 인덱스가 B의 INSERT를 막으면 컴파일 에러가 아니라 런타임 예외인
DataIntegrityViolationException이 난다. 코드는 멀쩡하고 동시 요청일 때만 터진다.[7] - B 입장에서는 결과가 "이미 눌림"이므로, 이 예외를
catch로 잡아 정상 응답을 주는 것이 자연스럽다.[7:1] - 그런데 서비스 메서드에
@Transactional이 붙어 있으면 catch해도 500이 된다. 메서드 전체가 하나의 묶음이라, INSERT 실패로 묶음이 실패 상태가 된 뒤의 조회도 같은 묶음 안에서 실패하기 때문이다.[7:2]- Postgres는 트랜잭션 안에서 에러가 한 번 나면 그 트랜잭션 전체를 버리고, 이후 쿼리는
current transaction is aborted로 실패한다.[7:3] - Spring은 리포지토리에서 예외가 나는 순간 바깥 트랜잭션을 "롤백 전용"으로 표시하므로, 조회를 하지 않더라도 커밋할 때
UnexpectedRollbackException이 난다.[7:4] 이 경계는 Spring Transaction의 롤백 규칙 절에 정리했다.
- Postgres는 트랜잭션 안에서 에러가 한 번 나면 그 트랜잭션 전체를 버리고, 이후 쿼리는
@Transactional을 빼면 리포지토리 호출마다 따로 트랜잭션이 돌아서, 실패한 INSERT만 롤백되고 다음 조회는 정상으로 동작한다.[7:5]
트랜잭션 있음: [ 조회 → INSERT 실패 → catch → 조회 ] → 같은 묶음이라 실패 → 500
트랜잭션 없음: [조회] [INSERT 실패 → 이것만 롤백] [조회 → 정상] → 200
| DB의 행 | A 응답 | B 응답 | |
|---|---|---|---|
| 트랜잭션 있음 | 1개 | 200 | 500 |
| 트랜잭션 없음 | 1개 | 200 | 200 ("이미 눌림") |
- 중복 방지 자체에는 트랜잭션이 상관없다. 두 경우 모두 행은 1개이고, 차이는 막힌 요청이 받는 응답뿐이다.[8]
@Transactional을 빼도 쓰기는 보호된다. Spring Data가save()와delete()에 기본으로@Transactional을 붙여 두기 때문이다.[8:1]- 서비스 메서드 단위의 큰 트랜잭션은 쓰기가 2개 이상이고 같이 성공하거나 같이 실패해야 할 때(예: 송금의 출금과 입금) 필요하다. 좋아요 토글은 쓰기가 하나(추가 또는 삭제)라 묶을 짝이 없다.[8:2]
구현 예
// 일부러 @Transactional을 붙이지 않는다 (유니크 위반을 catch한 뒤에도 다음 조회가 돌아야 하므로)
public LikeStatusDto toggleLike(Integer postId, User user) {
Post post = postService.getVisiblePost(postId);
Optional<Like> existingLike = likeRepository.findByPostAndUserAndCommentIsNull(post, user);
if (existingLike.isPresent()) {
likeRepository.delete(existingLike.get());
} else {
addLikeIfAbsent(post, user);
}
return statusOf(post, user);
}
// 동시에 들어온 다른 요청이 먼저 넣었으면 유니크 인덱스가 막는다. 결과는 같은 "눌림"이라 그대로 둔다
private void addLikeIfAbsent(Post post, User user) {
try {
likeRepository.save(Like.builder().post(post).user(user).build());
} catch (DataIntegrityViolationException ignored) {
}
}
- 빈 catch는 메서드로 빼서 의도를 메서드 이름으로 드러낸다.[9]
- 예외 변수 이름
ignored는 "일부러 버린다"는 관례적인 표시이고, IntelliJ도 이 이름이면 빈 catch 경고를 띄우지 않는다.[9:1] catch (Exception e)처럼 넓게 잡으면 DB 장애까지 "이미 눌림"으로 숨겨지므로 피한다.[9:2]
200 응답과 토글 결과
- 두 요청이 모두 200을 받았다고 해서 토글이 두 번 일어난 것은 아니다. 결과는 두 요청의 간격에 따라 달라진다.[10]
| 타이밍 | A | B | 최종 |
|---|---|---|---|
| 거의 동시 (둘 다 "없음"을 봄) | 추가 → liked=true | 추가 시도 → 막힘 → liked=true | 좋아요 1개 |
| 간격 있음 (B가 A의 커밋 후 조회) | 추가 → liked=true | "있음" → 삭제 → liked=false | 좋아요 0개 |
- 두 번째 경우는 버그가 아니라 토글의 정상 동작이며, 보통 프론트에서 요청이 끝날 때까지 버튼을 비활성화해 둔다.[10:1]
한계와 실패 모드
DataIntegrityViolationException은 유니크 위반뿐 아니라 FK 위반(예: 그 사이 글이 삭제됨)에서도 난다. 그래서 이 catch는 FK 위반까지 "이미 눌림"으로 처리하게 된다.[9:3]- NULL이 들어갈 수 있는 컬럼을 유니크 인덱스에 넣고
NULLS NOT DISTINCT나 partial index를 쓰지 않으면, 인덱스가 있어도 중복을 막지 못한다.[5:3] - 서비스 메서드에
@Transactional을 남겨 둔 채 catch만 추가하면, 중복은 막히지만 막힌 요청이 500을 받는다.[7:6] - 인덱스를 엔티티 매핑에만 추가하고
ddl-auto=update에 맡기면 기존 인덱스가 바뀌지 않아 운영 DB에는 적용되지 않는다.[6:3]
다른 중복 방지 방식과 비교
| 방식 | 모양 | 언제 쓰나 |
|---|---|---|
| 유니크 + 예외 잡기 | 위 구현 예 | JPA를 그대로 쓸 때. 이 노트는 Spring 현업에서 가장 흔한 방식으로 본다 |
| DB가 "있으면 무시" | Postgres INSERT ... ON CONFLICT DO NOTHING |
예외를 아예 만들지 않을 때. 유니크 충돌만 무시하고 FK 위반은 던진다. DB 전용 SQL이고 JPA를 거치지 않아서 @CreationTimestamp 같은 값은 직접 넣어야 한다 |
| 분산 락(Redis 등) | 처리 전에 락 | 여러 테이블이나 외부 API까지 맞춰야 할 때. 좋아요에는 과하다 |
| 프론트 버튼 비활성화 | UI | 보조 수단. 네트워크 재시도나 탭 두 개 때문에 이것만으로는 못 막는다 |
- 표는 원본 노트의 비교를 옮긴 것이다.[11]
ON CONFLICT DO NOTHING은 FK 위반을 그대로 던지므로, 위 "한계"에서 말한 FK 위반 오인 문제가 생기지 않는다는 점이 유니크 + 예외 잡기와의 차이로 읽힌다.
DB 제약과 코드 검사의 역할 분담
| DB 기능 | 현업 사용 | 이유 |
|---|---|---|
| PK, 유니크, NOT NULL | 거의 항상 | 동시 요청이 오면 코드만으로 못 지킨다 |
| 외래키(FK) | 팀마다 다름. 대규모 서비스는 빼는 경우가 많다 | 대량 삭제·수정 때 잠금과 성능 문제가 있고, DB를 나누면(샤딩, MSA) 걸 수 없다 |
| 트리거, 저장 프로시저 | 잘 쓰지 않음 | 로직이 DB에 숨어서 테스트, 배포, 디버깅이 어렵다 |
- 표의 사용 빈도는 원본 노트가 정리한 현업 경향이며, 조사 자료로 확인한 수치는 아니다.[12]
- 보통 둘 다 쓴다. 코드가 먼저 확인해 대부분의 경우를 처리하고 친절한 응답을 주며, DB 제약은 그 틈으로 새는 동시 요청을 마지막에 막는다.[12:1]
- 기준은 "깨지면 되돌릴 수 없는 데이터 규칙은 DB에, 판단과 흐름(권한, 상태 전이)은 코드에"다.[12:2]
Postgres와 MySQL 차이
| Postgres | MySQL(InnoDB) | |
|---|---|---|
| 조회 후 INSERT 경쟁 | 생김 | 생김 (DB와 무관) |
| 유니크 인덱스의 NULL | 서로 다름. NULLS NOT DISTINCT(15+)나 partial index로 해결 |
서로 다름. 둘 다 없어서 생성 컬럼으로 우회 |
| 에러 후 트랜잭션 | 통째로 실패 상태 | 그 문장만 롤백, 트랜잭션은 계속 사용 가능 |
@Transactional + catch |
500 | 그래도 500 (Spring 롤백 전용 표시 때문) |
- MySQL에서는 NULL을 0으로 바꾼 생성 컬럼에 유니크를 건다.[13]
-- MySQL 우회: NULL을 0으로 바꾼 생성 컬럼에 유니크
ALTER TABLE `like`
ADD comment_key INT AS (IFNULL(comment_id, 0)) STORED,
ADD UNIQUE KEY user_like_unique (user_id, post_id, comment_key);
- MySQL은 에러가 난 문장만 롤백하지만, Spring이 이미 롤백 전용으로 표시하므로
@Transactional+ catch 조합은 DB와 관계없이 500이 된다.[13:1]
방식을 고르는 기준
- 애매할 때는 아래 순서로 고른다.[14]
- DB가 보장해 주는 방식을 고른다.
- 그중에서 쓰는 프레임워크에서 가장 흔한 방식을 고른다.
- 그래도 비슷하면 코드가 짧은 쪽을 고른다.
관련
- Database Isolation: 격리 수준만으로 business invariant를 지킬 수 없다는 판단의 구체 사례가 이 패턴이다.
- Spring Transaction: 예외를 catch해도 롤백 전용 표시가 남는 이유와, 큰 트랜잭션이 필요한 기준을 다룬다.
- Transaction Propagation: 리포지토리 메서드가 서비스 트랜잭션에
REQUIRED로 참여한다는 점에서, 리포지토리의 예외가 바깥 트랜잭션까지 롤백 전용으로 만드는 이유를 설명하는 배경으로 볼 수 있다. - B+ Tree 인덱싱과 순차 키 삽입: 유니크 판정이 일어나는 인덱스 자료구조를 다룬다.
출처
테스트 질문
- 조회 후 INSERT 방식에
@Transactional을 붙여도 동시 요청의 중복 INSERT를 막지 못하는 이유는 무엇인가? - 유니크 인덱스는 커밋되지 않은 다른 요청의 INSERT를 어떻게 처리하는가?
(user_id, post_id, comment_id)유니크 인덱스가 게시글 좋아요 중복을 막지 못한 이유와 Postgres·MySQL 각각의 해결책은 무엇인가?- 유니크 위반을 catch하는 서비스 메서드에서
@Transactional을 빼는 이유는 무엇이며, 빼도 쓰기가 보호되는 근거는 무엇인가? - 두 요청이 모두 200을 받았는데 좋아요가 0개인 것은 버그인가?
인증 실패 응답·좋아요 중복 방지 노트 — "더블클릭하면 요청 A와 B가 거의 동시에 들어온다.", 순서 표("COMMIT → 행 2개"), "행이 2개가 되면 이후
Optional조회가 결과 2개로 터진다(IncorrectResultSizeDataAccessException). 그 사용자와 그 글은 영구히 500이 된다." ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "트랜잭션이 보장하는 것은 "내 작업이 다 되거나 다 안 되거나"다. "다른 요청이 사이에 끼어들지 못하게"는 보장하지 않는다.", "기본 격리 수준(READ COMMITTED)에서 일반 SELECT는 커밋된 데이터만 본다. 그래서 둘 다 "없음"을 본다." ↩︎ ↩︎
인증 실패 응답·좋아요 중복 방지 노트 — "Postgres의
SELECT ... FOR UPDATE는 없는 행을 잠글 수 없다.", "MySQL은 갭 락을 걸지만, 두 요청이 갭 락을 동시에 잡을 수 있다. 그러다 둘 다 INSERT하려 하면 데드락이 나고 한쪽이 롤백된다.", "코드로 먼저 확인해도 "확인과 INSERT 사이의 틈"은 코드로 닫을 수 없다." ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "일반 조회와 달리 유니크 인덱스는 커밋 안 된 행까지 안다.", "A가 진행 중이면 B는 기다린다. A가 커밋하면 B는 유니크 위반으로 거절되고, A가 롤백하면 B가 들어간다.", "A가 이미 커밋했으면 B는 바로 거절된다.", "판단은 DB 인덱스가 한다." ↩︎ ↩︎ ↩︎ ↩︎
인증 실패 응답·좋아요 중복 방지 노트 — "
NULL = NULL은 참이 아니라 UNKNOWN이다.", "유니크 인덱스도 NULL끼리를 서로 다른 값으로 본다. 게시글 좋아요는comment_id가 항상 NULL이라(user_id, post_id, comment_id)인덱스가 하나도 못 막았다.", "= NULL은 아무것도 안 나온다.IS NULL을 써야 한다." ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 —
NULLS NOT DISTINCT와 partial unique index SQL, "인덱스를 만들기 전에 이미 쌓인 중복 행부터 지워야 한다.", "ddl-auto=update는 이미 있는 인덱스를 고치지 않는다. 수동 DDL을 psql로 실행해야 실제로 적용된다." ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "인덱스가 B의 INSERT를 막으면 런타임 에러(
DataIntegrityViolationException)가 난다.", "Postgres: 트랜잭션 안에서 에러가 한 번 나면 그 트랜잭션 전체를 버린다.", "Spring: 리포지토리에서 예외가 나는 순간 바깥 트랜잭션을 "롤백 전용"으로 표시한다. 조회를 안 해도 커밋할 때UnexpectedRollbackException이 난다.", 트랜잭션 유무별 응답 표. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "중복 방지에는 트랜잭션이 상관없다. 차이는 막힌 요청이 받는 응답뿐이다.", "
save()와delete()에는 Spring Data가 기본으로@Transactional을 붙여 둔다.", "쓰기가 2개 이상이고 같이 성공하거나 같이 실패해야 할 때(예: 송금의 출금과 입금)." ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — 최종 코드, "
ignored라는 이름은 "일부러 버린다"는 관례적인 표시다. IntelliJ도 이 이름이면 빈 catch 경고를 띄우지 않는다.", "catch (Exception e)처럼 넓게 잡지 않는다. DB 장애까지 "이미 눌림"으로 숨겨진다.", "한계:DataIntegrityViolationException은 FK 위반(그 사이 글이 삭제됨)에서도 난다." ↩︎ ↩︎ ↩︎ ↩︎인증 실패 응답·좋아요 중복 방지 노트 — 타이밍별 표, "두 번째 경우는 버그가 아니라 토글의 정상 동작이다. 보통 프론트에서 요청이 끝날 때까지 버튼을 비활성화한다." ↩︎ ↩︎
인증 실패 응답·좋아요 중복 방지 노트 — "중복 방지 방식 비교 (현업)" 표: 유니크 + 예외 잡기,
INSERT ... ON CONFLICT DO NOTHING("유니크 충돌만 무시하고 FK 위반은 던진다"), 분산 락, 프론트 버튼 비활성화. ↩︎인증 실패 응답·좋아요 중복 방지 노트 — "DB 제약을 현업에서 얼마나 쓰나" 표, "보통 둘 다 쓴다.", "깨지면 되돌릴 수 없는 데이터 규칙은 DB에, 판단과 흐름(권한, 상태 전이)은 코드에." ↩︎ ↩︎ ↩︎
인증 실패 응답·좋아요 중복 방지 노트 — "Postgres vs MySQL" 표("그래도 500 (Spring 롤백 전용 표시 때문)"), MySQL 생성 컬럼 SQL. ↩︎ ↩︎
인증 실패 응답·좋아요 중복 방지 노트 — "애매할 때 고르는 기준": "DB가 보장해 주는 방식을 고른다", "쓰는 프레임워크에서 가장 흔한 방식을 고른다", "코드가 짧은 쪽을 고른다". ↩︎