Unique Constraint Duplicate Prevention

좋아요, 팔로우, 신청처럼 "한 사용자당 한 번"이어야 하는 행은 코드에서 먼저 조회해 보고 없을 때 INSERT하는 방식만으로는 지킬 수 없다. 거의 동시에 들어온 두 요청이 둘 다 "없음"을 보고 둘 다 INSERT하기 때문이다. 이 패턴은 그 틈을 DB의 유니크 인덱스가 판정하게 하고, 막힌 요청은 예외를 잡아 정상 응답으로 돌려주는 방식이다. Database Isolation이 말하는 "격리 수준만으로 business rule을 해결하지 말고 unique constraint 같은 명시적 invariant를 함께 설계하라"는 판단의 구체적인 사례로 볼 수 있다.

문제: 조회와 INSERT 사이의 틈

순서 요청 A 요청 B
1 SELECT → 없음
2 SELECT → 없음
3 INSERT
4 INSERT
5 COMMIT COMMIT → 행 2개

트랜잭션과 잠금이 막지 못하는 이유

유니크 인덱스가 판정하는 방식

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이 낀 유니크 인덱스

-- 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;

막힌 요청을 정상 응답으로 바꾸기

트랜잭션 있음: [ 조회 → INSERT 실패 → catch → 조회 ]  → 같은 묶음이라 실패 → 500
트랜잭션 없음: [조회]  [INSERT 실패 → 이것만 롤백]  [조회 → 정상] → 200
DB의 행 A 응답 B 응답
트랜잭션 있음 1개 200 500
트랜잭션 없음 1개 200 200 ("이미 눌림")

구현 예

// 일부러 @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) {
    }
}

200 응답과 토글 결과

타이밍 A B 최종
거의 동시 (둘 다 "없음"을 봄) 추가 → liked=true 추가 시도 → 막힘 → liked=true 좋아요 1개
간격 있음 (B가 A의 커밋 후 조회) 추가 → liked=true "있음" → 삭제 → liked=false 좋아요 0개

한계와 실패 모드

다른 중복 방지 방식과 비교

방식 모양 언제 쓰나
유니크 + 예외 잡기 위 구현 예 JPA를 그대로 쓸 때. 이 노트는 Spring 현업에서 가장 흔한 방식으로 본다
DB가 "있으면 무시" Postgres INSERT ... ON CONFLICT DO NOTHING 예외를 아예 만들지 않을 때. 유니크 충돌만 무시하고 FK 위반은 던진다. DB 전용 SQL이고 JPA를 거치지 않아서 @CreationTimestamp 같은 값은 직접 넣어야 한다
분산 락(Redis 등) 처리 전에 락 여러 테이블이나 외부 API까지 맞춰야 할 때. 좋아요에는 과하다
프론트 버튼 비활성화 UI 보조 수단. 네트워크 재시도나 탭 두 개 때문에 이것만으로는 못 막는다

DB 제약과 코드 검사의 역할 분담

DB 기능 현업 사용 이유
PK, 유니크, NOT NULL 거의 항상 동시 요청이 오면 코드만으로 못 지킨다
외래키(FK) 팀마다 다름. 대규모 서비스는 빼는 경우가 많다 대량 삭제·수정 때 잠금과 성능 문제가 있고, DB를 나누면(샤딩, MSA) 걸 수 없다
트리거, 저장 프로시저 잘 쓰지 않음 로직이 DB에 숨어서 테스트, 배포, 디버깅이 어렵다

Postgres와 MySQL 차이

Postgres MySQL(InnoDB)
조회 후 INSERT 경쟁 생김 생김 (DB와 무관)
유니크 인덱스의 NULL 서로 다름. NULLS NOT DISTINCT(15+)나 partial index로 해결 서로 다름. 둘 다 없어서 생성 컬럼으로 우회
에러 후 트랜잭션 통째로 실패 상태 그 문장만 롤백, 트랜잭션은 계속 사용 가능
@Transactional + catch 500 그래도 500 (Spring 롤백 전용 표시 때문)
-- 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);

방식을 고르는 기준

관련

출처

테스트 질문


  1. 인증 실패 응답·좋아요 중복 방지 노트 — "더블클릭하면 요청 A와 B가 거의 동시에 들어온다.", 순서 표("COMMIT → 행 2개"), "행이 2개가 되면 이후 Optional 조회가 결과 2개로 터진다(IncorrectResultSizeDataAccessException). 그 사용자와 그 글은 영구히 500이 된다." ↩︎ ↩︎

  2. 인증 실패 응답·좋아요 중복 방지 노트 — "트랜잭션이 보장하는 것은 "내 작업이 다 되거나 다 안 되거나"다. "다른 요청이 사이에 끼어들지 못하게"는 보장하지 않는다.", "기본 격리 수준(READ COMMITTED)에서 일반 SELECT는 커밋된 데이터만 본다. 그래서 둘 다 "없음"을 본다." ↩︎ ↩︎

  3. 인증 실패 응답·좋아요 중복 방지 노트 — "Postgres의 SELECT ... FOR UPDATE는 없는 행을 잠글 수 없다.", "MySQL은 갭 락을 걸지만, 두 요청이 갭 락을 동시에 잡을 수 있다. 그러다 둘 다 INSERT하려 하면 데드락이 나고 한쪽이 롤백된다.", "코드로 먼저 확인해도 "확인과 INSERT 사이의 틈"은 코드로 닫을 수 없다." ↩︎ ↩︎ ↩︎

  4. 인증 실패 응답·좋아요 중복 방지 노트 — "일반 조회와 달리 유니크 인덱스는 커밋 안 된 행까지 안다.", "A가 진행 중이면 B는 기다린다. A가 커밋하면 B는 유니크 위반으로 거절되고, A가 롤백하면 B가 들어간다.", "A가 이미 커밋했으면 B는 바로 거절된다.", "판단은 DB 인덱스가 한다." ↩︎ ↩︎ ↩︎ ↩︎

  5. 인증 실패 응답·좋아요 중복 방지 노트 — "NULL = NULL은 참이 아니라 UNKNOWN이다.", "유니크 인덱스도 NULL끼리를 서로 다른 값으로 본다. 게시글 좋아요는 comment_id가 항상 NULL이라 (user_id, post_id, comment_id) 인덱스가 하나도 못 막았다.", "= NULL은 아무것도 안 나온다. IS NULL을 써야 한다." ↩︎ ↩︎ ↩︎ ↩︎

  6. 인증 실패 응답·좋아요 중복 방지 노트 — NULLS NOT DISTINCT와 partial unique index SQL, "인덱스를 만들기 전에 이미 쌓인 중복 행부터 지워야 한다.", "ddl-auto=update는 이미 있는 인덱스를 고치지 않는다. 수동 DDL을 psql로 실행해야 실제로 적용된다." ↩︎ ↩︎ ↩︎ ↩︎

  7. 인증 실패 응답·좋아요 중복 방지 노트 — "인덱스가 B의 INSERT를 막으면 런타임 에러(DataIntegrityViolationException)가 난다.", "Postgres: 트랜잭션 안에서 에러가 한 번 나면 그 트랜잭션 전체를 버린다.", "Spring: 리포지토리에서 예외가 나는 순간 바깥 트랜잭션을 "롤백 전용"으로 표시한다. 조회를 안 해도 커밋할 때 UnexpectedRollbackException이 난다.", 트랜잭션 유무별 응답 표. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  8. 인증 실패 응답·좋아요 중복 방지 노트 — "중복 방지에는 트랜잭션이 상관없다. 차이는 막힌 요청이 받는 응답뿐이다.", "save()와 delete()에는 Spring Data가 기본으로 @Transactional을 붙여 둔다.", "쓰기가 2개 이상이고 같이 성공하거나 같이 실패해야 할 때(예: 송금의 출금과 입금)." ↩︎ ↩︎ ↩︎

  9. 인증 실패 응답·좋아요 중복 방지 노트 — 최종 코드, "ignored라는 이름은 "일부러 버린다"는 관례적인 표시다. IntelliJ도 이 이름이면 빈 catch 경고를 띄우지 않는다.", "catch (Exception e)처럼 넓게 잡지 않는다. DB 장애까지 "이미 눌림"으로 숨겨진다.", "한계: DataIntegrityViolationException은 FK 위반(그 사이 글이 삭제됨)에서도 난다." ↩︎ ↩︎ ↩︎ ↩︎

  10. 인증 실패 응답·좋아요 중복 방지 노트 — 타이밍별 표, "두 번째 경우는 버그가 아니라 토글의 정상 동작이다. 보통 프론트에서 요청이 끝날 때까지 버튼을 비활성화한다." ↩︎ ↩︎

  11. 인증 실패 응답·좋아요 중복 방지 노트 — "중복 방지 방식 비교 (현업)" 표: 유니크 + 예외 잡기, INSERT ... ON CONFLICT DO NOTHING("유니크 충돌만 무시하고 FK 위반은 던진다"), 분산 락, 프론트 버튼 비활성화. ↩︎

  12. 인증 실패 응답·좋아요 중복 방지 노트 — "DB 제약을 현업에서 얼마나 쓰나" 표, "보통 둘 다 쓴다.", "깨지면 되돌릴 수 없는 데이터 규칙은 DB에, 판단과 흐름(권한, 상태 전이)은 코드에." ↩︎ ↩︎ ↩︎

  13. 인증 실패 응답·좋아요 중복 방지 노트 — "Postgres vs MySQL" 표("그래도 500 (Spring 롤백 전용 표시 때문)"), MySQL 생성 컬럼 SQL. ↩︎ ↩︎

  14. 인증 실패 응답·좋아요 중복 방지 노트 — "애매할 때 고르는 기준": "DB가 보장해 주는 방식을 고른다", "쓰는 프레임워크에서 가장 흔한 방식을 고른다", "코드가 짧은 쪽을 고른다". ↩︎