취미겸생업

[Spring boot] Soft-Delete와 Unique 제약조건 고찰 본문

IT/트러블슈팅

[Spring boot] Soft-Delete와 Unique 제약조건 고찰

nohumb 2026. 7. 7. 12:10

배달 주문 앱 API를 구현 중, 시스템의 모든 테이블에 대한 Soft-Delete 요구사항과 중복 데이터 방지를 위한 Unique 제약 조건이 서로 충돌했다.

문제 상황

  1. Review 테이블에 대해, Soft-Delete 된 리뷰는 삭제되지 않고 테이블에 남아있는다.
  2. 하나의 Order에 하나의 Review만 허용된다.
  3. Soft-Delete 된 리뷰는 논리적으로는 삭제된 것이지만, 삭제 후 재작성 시 Unique 제약조건과 충돌한다.

예상한 대안

상황 : 리뷰 삭제 후 재작성 시

목표 : Unique 제약조건은 조회 시 성능, 데이터 정합성을 위해 최대한 유지되어야 함

  • Soft-Deleted 된 Row를 다시 활성화하여 재활용하는 것

    • 장점 : Unique 제약조건을 통해 데이터 정합성을 지킬 수 있다
    • 한계 : 삭제 전 데이터는 보존되지 않으며, created_at 필드의 updatable=false 조건으로 인해 작성 시간이 논리적으로 맞지 않는다. (updatable=false 조건은 필수)
    • 결론 : 삭제 후 재작성은 새로운 Create 행위이다. created_at을 재작성 시점으로 유지할 수 없기 때문에 이 방법은 유효하지 않다.
  • Hard-Delete 후 새로 작성 (Trade-off)

    • 장점 : Unique 제약조건을 단순하게 유지할 수 있고, 구현이 단순하다.
    • 한계 : 재작성 시, 삭제된 데이터를 보존할 수 없다.
    • 한계 상황 대안 : 리뷰에 대한 History 테이블을 따로 분리해 기록과 논리 보관 테이블을 따로 둔다.
    • 결론 : Soft-Delete 요구사항의 범위를 타협한다면 간단하게 적용할 수 있지만, 프로젝트 전역에 적용되는 요구사항에 대해 예외를 허용하는 것이므로 기술 부채가 될 확률이 높다.
  • Unique 제약조건에 deletedAt을 포함시키는 것

    • 장점 : Soft-Delete 되는 데이터의 보존이 보장되며, Unique 제약조건을 유지할 수 있다

    • 한계 : 대부분의 RDBMS(특히 MySQL)은 Null을 값으로 보지 않기 때문에 비교 대상으로 사용할 수 없다. 따라서 deleted_at이 Null인 경우 (활성화 된 데이터) 중복 체크가 작동하지 않는다.

    • 한계 상황 대안 : 함수형 인덱스를 사용해 deleted_at의 NULL을 다른 기본값으로 대체해 Unique 제약조건을 건다.

        # MySQL 8.0
        CREATE UNIQUE INDEX uk_review_active ON p_reviews (
            order_id,
            customer_id,
            (CASE WHEN deleted_at IS NULL THEN 1 ELSE NULL END) # NULL은 비교가 안되지만 1은 비교가 된다
        );
    • 결론 : 인덱스를 통해 조회 성능이 향상되고, Unique 제약 조건을 통한 활성화 데이터의 중복체크를 유지할 수 있으며, Soft-Delete 요구사항 또한 만족한다. 단, 특정 DB에 의존하는 방식으로 모든 DBMS에 일관되게 적용할 수는 없다. (예로, MySQL 5.7 이하의 버전에선 사용할 수 없음)

  • 테이블에 version 필드를 추가하고 Unique 제약조건에 포함시킨 뒤, 삭제 시 version에 고유의 값을 넣어 우회

    • 장점 : DB에 종속적이지 않고 JPA 코드 레벨에서 해결 가능

        @Getter
        @NoArgsConstructor(access = AccessLevel.PROTECTED)
        @Table(
            name = "p_reviews",
            uniqueConstraints = {
                @UniqueConstraint(
                    name = "uk_review_order_user",
                    columnNames = {"order_id", "customer_id", "unique_version"}
                )
            }
        )
        @Entity
        public class Review {
            ...
      
            @Column
            @Builder.default
            private UUID uniqueVersion = UUID.fromString("00000000-0000-0000-0000-000000000000");
            // uniqueVersion의 기본값을 두어 중복 방지
      
            public void softDelete() {
                this.setDeletedAt(LocalDateTime.now());
                this.uniqueVersion = this.getId(); // 논리 삭제 시, uniqueVersion을 고유한 pk로 변경
            }
        }
      
    • 한계 : 비즈니스 로직과 무관한 필드가 하나 필요하고, 삭제 시 필드 하나를 추가적으로 조작해야 함

    • 결론 : 초기 팀 단위 개발 단계에서 DDL 쿼리가 정립되지 않고 지속적인 개발 상황이기 때문에 DB 종속적이지 않은 방식으로 우선 구현하는 것이 좋다고 판단했다. DDL 쿼리가 정립된 후, 함수형 인덱스를 적용한 다음 version 필드는 제거되어도 무방하기에 현재는 이 방식이 적절한 것 같다.