Fivefy 프로젝트/트러블슈팅

동시 요청에서 발생할 수 있는 중복 데이터 문제와 DB 유니크 제약으로 해결

sudaruuu 2026. 4. 14. 20:50

문제 상황

플레이리스트 트랙 추가 로직에서 동일 트랙 중복 추가는 서비스 코드로만 막고 있었고,

position 값도 countByPlaylistId() + 1 방식으로 계산하고 있었다.

 

CodeRabbit 리뷰를 통해
동시 요청 상황에서는 서비스 레벨 검증만으로 중복 저장을 완전히 막기 어렵고,
유니크 제약 충돌 시 500 에러로 이어질 수 있다는 점을 확인했다.


코드 리뷰 피드백

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

  • 서비스 레벨의 중복 검증은 동시 요청 상황에서 완전한 보장이 어렵다.
  • DB 레벨에서 유니크 제약을 통해 최종 무결성을 보장해야 한다.
  • 유니크 제약 충돌 시 예외 처리가 없으면 500 에러로 이어질 수 있다.


원인 분석

1. 서비스 레벨 검증만으로는 무결성을 보장할 수 없음

기존에는 다음과 같이 서비스 코드에서만 중복을 검사하고 있었다.

if (playlistTrackRepository.existsByPlaylistIdAndTrackId(playlistId, request.trackId())) {
    throw new BusinessException(PlaylistErrorCode.PLAYLIST_TRACK_ALREADY_EXISTS);
}

 

하지만 이 방식은 사전 검사일 뿐이며, 동시에 여러 요청이 들어오면 모두 검증을 통과한 뒤 저장될 수 있다.

즉, 서비스 코드만으로는 데이터 무결성을 완전히 보장할 수 없다.


2. DB 제약 충돌 시 예외 처리가 없음

(playlist_id, position) 유니크 제약이 있는 상황에서 동시 요청으로 동일한 position 값이 계산되면 DB 제약 조건 위반이 발생한다.

이때 별도의 예외 처리가 없다면 클라이언트에는 500 서버 에러로 전달될 수 있다.


해결 과정

1. DB 유니크 제약 추가

서비스 검증뿐 아니라 DB 레벨에서도 중복을 방지하기 위해 PlaylistTrack 테이블에 유니크 제약을 추가하였다.

@Table(
    name = "playlist_tracks",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_playlist_track_playlist_position",
            columnNames = {"playlist_id", "position"}
        ),
        @UniqueConstraint(
            name = "uk_playlist_track_playlist_track",
            columnNames = {"playlist_id", "track_id"}
        )
    }
)
  • (playlist_id, position)
    → 같은 플레이리스트 내 순서 중복 방지
  • (playlist_id, track_id)
    → 같은 플레이리스트 내 동일 트랙 중복 추가 방지

이를 통해 DB가 최종적으로 데이터 무결성을 보장하도록 개선하였다.


2. 유니크 제약 이름(name) 명시

유니크 제약에 name을 직접 지정하였다.

@UniqueConstraint(
    name = "uk_playlist_track_playlist_track",
    columnNames = {"playlist_id", "track_id"}
)

 

기본적으로 DB는 제약 조건 이름을 자동 생성하지만,
이름을 명시하면 다음과 같은 장점이 있다.

  • 어떤 제약에서 충돌이 발생했는지 식별이 쉬움
  • DB 에러 로그 및 디버깅 시 가독성이 좋아짐
  • 운영 환경에서 제약 조건 관리가 용이해짐

3. DB 예외를 비즈니스 예외로 변환

유니크 제약 위반 시 발생하는 예외를 직접 처리하여 의도한 흐름으로 변경하였다.

try {
    PlaylistTrack savedPlaylistTrack = playlistTrackRepository.save(playlistTrack);
    return PlaylistTrackResponse.from(savedPlaylistTrack);
} catch (DataIntegrityViolationException e) {
    throw new BusinessException(PlaylistErrorCode.PLAYLIST_TRACK_CONFLICT);
}

 

이를 통해 DB 제약 충돌이 발생하더라도 단순 500 에러가 아니라 비즈니스 예외로 처리되도록 개선하였다.


결과

서비스 레벨의 검증은 사용자 요청을 사전에 걸러내는 역할을 수행하고,

DB 유니크 제약은 데이터 무결성을 최종적으로 보장하는 역할을 수행한다.

 

두 레이어는 서로 대체 관계가 아니라, 서로 보완하는 구조로 함께 사용해야 한다.


느낀 점

이전에 엔티티 검증에 대해 고민하면서 서비스 레이어와 엔티티의 역할 차이를 정리한 적이 있었다. 당시에는 "검증은 어디에서 해야 하는가"에 대한 고민이었다면, 이번에는 "검증만으로 충분한가"라는 관점에서 한 단계 더 나아가게 되었다.

 

코드 리뷰를 통해 서비스 레벨 검증만으로는 동시 요청 상황에서 완전한 데이터 보장이 어렵다는 점을 알게 되었고, DB 제약을 통해 무결성을 보장하는 설계가 필요하다는 것을 이해할 수 있었다.

 

결과적으로 검증은 서비스와 엔티티에서 수행하고, 데이터의 최종 보장은 DB가 담당해야 한다는 흐름을 자연스럽게 연결해서 이해하게 되었다. 이 전 글에서 정리했던 개념을 실제 코드에 다시 적용해볼 수 있었고, 이를 통해 단순한 개념 이해를 넘어 설계 관점에서 한 단계 더 성장할 수 있었다고 느꼈다.

 

[이전 글 링크] 2026.04.10 - [CH6 fivefy 프로젝트/트러블슈팅] - 서비스와 엔티티의 검증 책임 분리

 

서비스와 엔티티의 검증 책임 분리

문제 상황프로젝트 초기 단계에서 기본적인 엔티티를 구현하던 중,PR 과정에서 코드 리뷰 도구(CodeRabbit)가 엔티티에 검증 로직 추가를 제안하였다. 기존에는 예외 처리는 서비스 레이어에서 수

sudaruuu.tistory.com