Fivefy 프로젝트/트러블슈팅

Soft Delete 구조에서 DB 유니크 제약을 통한 데이터 무결성 보완

sudaruuu 2026. 4. 22. 19:07

들어가며

이전 글에서 soft delete를 적용하면서 발생했던 문제와 데이터 정리 정책, 중복 검증 구조를 개선한 과정을 정리했다.

 

2026.04.22 - [Fivefy 프로젝트/트러블슈팅] - Soft Delete 기반 플레이리스트 정책 개선

 

Soft Delete 기반 플레이리스트 정책 개선

문제 상황플레이리스트는 다음과 같은 구조로 구현되어 있었다.deletedAt을 활용한 soft delete 방식 적용제목 중복은 서비스 레벨에서 검증수정 시에도 동일한 방식으로 중복 체크existsByUserIdAndTitleAn

sudaruuu.tistory.com

 

하지만 코드 리뷰 과정에서 구조적으로 놓치고 있던 중요한 문제가 하나 더 드러났다.

DB 유니크 제약이 없어, 중복 데이터가 실제로는 막히지 않는 상태였다.

이번 글에서는 이 문제를 어떻게 인식하고, 어떻게 해결했는지 정리해보려고 한다.


문제 상황

앞선 개선을 통해 플레이리스트는 다음과 같은 구조로 동작하고 있었다.

  • deletedAt을 활용한 soft delete 방식 적용
  • 삭제 데이터는 스케줄러를 통해 일정 기간 이후 정리
  • 제목 중복은 서비스 레벨에서 검증
  • DB 예외를 비즈니스 예외로 변환하는 로직 추가
try {
    Playlist savedPlaylist = playlistRepository.save(playlist);
    return PlaylistResponse.from(savedPlaylist);
} catch (DataIntegrityViolationException e) {
    throw new BusinessException(PlaylistErrorCode.DUPLICATE_PLAYLIST_NAME);
}

 

겉보기에는 정책, 데이터 정리, 예외 처리까지 갖춰진 완성된 구조처럼 보였다.


코드 리뷰 피드백

CodeRabbit는 다음과 같은 문제를 지적하였다.

  • 서비스 레벨의 중복 검증은 동시 요청 상황에서 완전한 보장이 어렵다
  • DB 레벨에서 유니크 제약을 통해 최종 무결성을 보장해야 한다

이 피드백을 통해 현재 구조를 다시 확인해보니, DB에는 유니크 제약이 존재하지 않았다.


원인 분석

1. DB 유니크 제약이 없어 예외 처리 코드가 동작하지 않는다

createPlaylist()에서는 DB 예외를 잡도록 구현했지만

catch (DataIntegrityViolationException e)

 

실제로는 다음과 같은 상황이었다.

  • playlists 테이블에 유니크 제약조건 없음
  • DB는 동일한 (user_id, title) 데이터를 허용
  • 따라서 예외 자체가 발생하지 않음

결과적으로 해당 catch 블록은 실행될 수 없는 코드에 가까운 상태였다.


2. soft delete 구조에서는 단순 유니크 제약이 동작하지 않는다

단순히 DB에 유니크 제약을 추가하면

UNIQUE (user_id, title)

다음과 같은 문제가 발생한다.

  1. 플레이리스트 생성
  2. soft delete 수행
  3. 동일 제목으로 재생성

삭제된 데이터가 DB에 남아 있기 때문에 유니크 제약 충돌 발생

즉, soft delete 구조에서는 일반적인 유니크 제약을 그대로 사용할 수 없다.


해결 방향

1. DB 유니크 제약 추가 (최종 무결성 보장)

서비스 검증과 별개로, DB에서 최종적으로 중복을 차단하도록 유니크 제약을 추가했다.

@Table(
    name = "playlists",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_playlist_user_title_deleted",
            columnNames = {"user_id", "title", "deleted"}
        )
    }
)

 

DB가 최종 방어선 역할을 수행하도록 구조 보완


2. 삭제 상태를 boolean으로 분리

기존에는 deletedAt만으로 삭제 여부를 판단하고 있었지만, 유니크 제약을 위해 삭제 상태를 명확히 분리했다.

@Column(nullable = false)
private boolean deleted;

 

역할 분리

  • deleted → 현재 활성 여부 (유니크 기준)
  • deletedAt → 삭제 시점 (데이터 정리 기준)

비즈니스 판단과 시간 정보를 분리


3. 활성 데이터 기준 유니크 정책 확립

최종적으로 다음 정책이 적용되었다.

  • (user_id, title, deleted=false) → 유니크
  • (user_id, title, deleted=true) → 허용

즉,

  • 활성 플레이리스트끼리만 중복 금지
  • 삭제된 데이터는 자유롭게 존재 가능

soft delete와 유니크 제약을 함께 만족하는 구조


코드 수정

Repository

Page<Playlist> findAllByDeletedFalse(Pageable pageable);
Optional<Playlist> findByIdAndDeletedFalse(Long id);
boolean existsByUserIdAndTitleAndDeletedFalse(Long userId, String title);

Service

if (playlistRepository.existsByUserIdAndTitleAndDeletedFalse(userId, request.title())) {
    throw new BusinessException(PlaylistErrorCode.DUPLICATE_PLAYLIST_NAME);
}

cleanup 로직

@Modifying
@Query("delete from Playlist p where p.deleted = true and p.deletedAt is not null and p.deletedAt <= :threshold")
int deleteAllSoftDeletedBefore(@Param("threshold") LocalDateTime threshold);

결과

  • DB 레벨에서 중복 데이터 생성 차단
  • 서비스 검증 + DB 제약으로 이중 방어 구조 완성
  • soft delete 구조에서도 유니크 정책 정상 동작
  • 삭제된 플레이리스트 제목 재사용 가능
  • 데이터 정리 정책까지 포함한 안정적인 구조 확보

느낀 점

이번 개선을 통해 구조를 다시 점검해보니,

  • soft delete는 적용되어 있었지만 데이터 무결성은 보장되지 않았고
  • 서비스 검증은 존재했지만 DB 제약이 없어 완전한 방어가 불가능했으며
  • 예외 처리 로직 또한 실제로는 동작하지 않는 상태였다

즉, 기능은 동작하고 있었지만 구조적으로는 완성되지 않은 상태였다.

 

이번 경험을 통해

  • 데이터 무결성은 반드시 DB 레벨에서 보장되어야 하며
  • soft delete 구조에서는 삭제 상태를 명확히 분리해야 하고
  • 서비스 로직과 DB 제약이 함께 설계되어야 한다는 것을 확인할 수 있었다

결과적으로 이번 작업은 단순 기능 개선이 아니라 데이터 무결성을 중심으로 구조를 보완한 경험이었다.