Fivefy 프로젝트/트러블슈팅

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

sudaruuu 2026. 4. 22. 18:22

문제 상황

플레이리스트는 다음과 같은 구조로 구현되어 있었다.

  • deletedAt을 활용한 soft delete 방식 적용
  • 제목 중복은 서비스 레벨에서 검증
  • 수정 시에도 동일한 방식으로 중복 체크
existsByUserIdAndTitleAndDeletedAtIsNull(...)

 

기능 자체는 문제없이 동작했다.

  • 플레이리스트 생성
  • 삭제
  • 동일 제목 재생성

모두 정상적으로 처리되었지만, 구조를 다시 보니 몇 가지 문제가 있었다.


원인 분석

1. soft delete는 있지만 데이터 정리 기준이 없다

현재 구조에서는 삭제 시

deletedAt = now

 

만 수행되고, 실제 데이터는 계속 DB에 남아있다.

 

이 상태가 지속되면

  • 테이블 데이터가 계속 쌓이고
  • 장기적으로 성능 저하 가능성이 존재한다

즉, soft delete는 적용했지만 데이터를 언제 정리할지에 대한 기준이 없는 상태였다.


2. 중복 검증이 서비스 레벨에만 존재한다

현재 중복 검증은 다음과 같이 처리되고 있었다.

existsByUserIdAndTitleAndDeletedAtIsNull(...)

 

이 방식은 일반적인 상황에서는 문제 없지만

  • 동시 요청이 들어오는 경우
  • 두 요청이 동시에 검증을 통과할 수 있음

결국 DB에는 중복 데이터가 들어갈 수 있다.

즉, 서비스 검증은 사용자 경험을 위한 것이고, 데이터 무결성을 완전히 보장하지는 못한다.


3. 삭제된 플레이리스트 제목 재사용 정책이 명확하지 않다

현재 구조에서는 다음이 가능했다.

  1. 플레이리스트 생성
  2. 삭제
  3. 동일 제목으로 재생성

이 동작은 자연스럽게 허용되고 있었지만,

  • 이게 의도된 정책인지
  • 코드에서 명확하게 드러나는지

는 별개의 문제였다.


해결 방향

1. soft delete 정책 명확화

다음과 같이 정책을 정리했다.

  • 삭제된 플레이리스트는 조회 대상에서 제외
    → deletedAt IS NULL 기준 조회
  • 삭제된 플레이리스트의 제목은 재사용 가능

즉, 활성 데이터 기준으로만 중복을 판단하도록 정리


2. 데이터 정리 정책 추가

soft delete 데이터가 계속 쌓이는 문제를 해결하기 위해 일정 기간 이후 데이터를 삭제하도록 했다.

  • 삭제 후 30일이 지난 데이터 → hard delete
  • 스케줄러로 자동 정리
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "playlistCleanupScheduler_cleanup", lockAtMostFor = "10m", lockAtLeastFor = "1m")
public void cleanup() {
    LocalDateTime threshold = LocalDateTime.now().minusDays(30);
    playlistCleanupService.cleanupDeletedPlaylists(threshold);
}
  • 불필요한 데이터 누적 방지
  • 장기적인 성능 문제 예방

3. 서비스 레벨 중복 검증 유지

DB 제약과 별개로, 서비스 레벨 중복 검증은 유지하였다.

  • 사용자에게 빠른 피드백 제공
  • 명확한 에러 메시지 변환
  • 활성 데이터 기준 중복 체크

코드 수정

1. createPlaylist - DB 예외 처리 추가

기존에는 단순 저장만 수행했다.

Playlist savedPlaylist = playlistRepository.save(playlist);
return PlaylistResponse.from(savedPlaylist);

 

이를 다음과 같이 수정했다.

try {
    Playlist savedPlaylist = playlistRepository.save(playlist);
    return PlaylistResponse.from(savedPlaylist);
} catch (DataIntegrityViolationException e) {
    if (isDuplicatePlaylistTitleException(e)) {
        throw new BusinessException(PlaylistErrorCode.DUPLICATE_PLAYLIST_NAME);
    }
    throw e;
}
  • DB 제약 위반 시 비즈니스 예외로 변환
  • 사용자에게 일관된 에러 응답 제공

2. updatePlaylist - 중복 검증 명확화

if (!playlist.getTitle().equals(request.title())
        && playlistRepository.existsByUserIdAndTitleAndDeletedAtIsNull(userId, request.title())) {
    throw new BusinessException(PlaylistErrorCode.DUPLICATE_PLAYLIST_NAME);
}
  • 제목이 변경된 경우에만 검사
  • 불필요한 쿼리 방지
  • 정책 의도를 코드에 명확히 반

3. cleanup 로직 추가

@Modifying
@Query("delete from Playlist p where p.deletedAt is not null and p.deletedAt <= :threshold")
int deleteAllSoftDeletedBefore(LocalDateTime threshold);
  • soft delete → hard delete로 이어지는 구조 완성

결과

  • soft delete 데이터 자동 정리 구조 완성
  • 활성 데이터 기준 중복 정책 명확화
  • 서비스 검증 + DB 예외 처리 구조 구축
  • 정책이 코드에 드러나는 구조로 개선

느낀 점

이번 작업을 통해 구조를 다시 점검해보니,

  • soft delete는 적용되어 있었지만 데이터 정리 정책이 없었고
  • 중복 검증은 존재했지만 DB 무결성은 보장되지 않았으며
  • 예외 처리 코드는 있었지만 실제로는 동작하지 않는 상태였다.

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

 

이번 경험을 통해 soft delete는 단순한 삭제 방식이 아니라 데이터의 생명주기를 포함한 정책이라는 점을 이해하게 되었고,

서비스 레벨 검증은 사용자 경험을 위한 것이며 데이터 무결성은 DB 제약과 함께 설계해야 한다는 것을 확인할 수 있었다.

 

결과적으로 이번 작업은 단순 기능 구현을 넘어, 데이터를 어떻게 관리하고 보호할 것인지에 대한 설계 경험이었다.