엔티티 14개를 하드 삭제에서 소프트 삭제로
보고서에서 참조하는 데이터가 삭제되면 과거 보고서가 깨진다. 그래서 삭제를 is_delete 플래그로 바꾸는 작업을 했다(PR #792). 개념은 단순한데 실제로 걸린 건 두 곳이었다 — 유니크 제약과 @Filter가 적용되지 않는 경로.
(a) 유니크 제약이 삭제된 행에 걸린다
하드 삭제일 때는 UNIQUE(company_id, name) 같은 제약이 자연스럽게 동작한다. 지우면 행이 사라지니 같은 이름을 다시 만들 수 있다.
소프트 삭제로 바꾸면 삭제된 행이 테이블에 남는다. 그래서 “삭제 후 같은 이름으로 재생성”이 유니크 제약에 걸린다. 사용자 입장에서는 지운 이름을 다시 못 쓰는 이상한 동작이다.
MySQL에는 부분 인덱스(PostgreSQL의 WHERE is_delete = 0)가 없다. 대신 VIRTUAL 생성 컬럼을 쓸 수 있다.
ALTER TABLE tb_xxx
ADD COLUMN active_name VARCHAR(255)
GENERATED ALWAYS AS (IF(is_delete = 0, name, NULL)) VIRTUAL;
ALTER TABLE tb_xxx ADD UNIQUE KEY uk_xxx_active (company_id, active_name);
활성 행이면 원래 값, 삭제된 행이면 NULL이 들어간다. MySQL 유니크 인덱스는 NULL을 중복으로 보지 않으므로 삭제된 행끼리는 몇 개가 겹쳐도 괜찮고, 활성 행끼리만 유니크가 걸린다. VIRTUAL이라 저장 공간도 쓰지 않는다.
이름뿐 아니라 계수 값에도 같은 방식을 적용했다(active_name, active_coefficient).
FK가 기존 유니크를 붙들고 있는 경우
여기서 걸린 게 하나 더 있다. 기존 유니크 인덱스를 FK가 지지하고 있으면 그냥 DROP할 수 없다. MySQL은 FK가 참조하는 인덱스를 삭제하려 하면 거부한다.
순서를 이렇게 잡았다.
- 같은 컬럼 조합으로 비유니크 인덱스를 먼저 생성 — FK가 이걸 대신 지지하게 된다
- 그다음 기존 유니크 인덱스를 DROP
active_*기반 새 유니크 인덱스를 생성
SQL은 멱등으로 작성했다. 로컬에서 실증도 했다 — 활성 행 중복은 새 유니크 제약 위반으로 거부되고, 소프트 삭제 후 동일 조합 재생성은 성공한다.
(b) 조회 필터를 @Filter 하이브리드로
5ef40e1a에서 조회 필터링을 Hibernate @Filter로 전환했다. BaseEntity에 @FilterDef를 단일 정의하고 엔티티 15종에 @Filter를 붙인 뒤, AOP로 트랜잭션 경계에서 필터를 활성화한다.
“하이브리드”라 부른 건 전부 필터를 거는 게 아니기 때문이다.
| 경로 | 필터 |
|---|---|
| 목록·검색·드롭다운·유니크 검사 | 적용 |
| 단건 조회(보고서 참조 경로) | 미적용 |
보고서는 삭제된 데이터라도 당시 값을 그대로 보여줘야 한다. 애초에 소프트 삭제로 바꾼 이유가 그거다. 그래서 findById 같은 참조 경로는 필터를 걸지 않는다.
필터를 끄는 코드는 남용되기 쉬워서, 87d016f5에서 봉인 유틸(SoftDeleteFilterSupport)을 만들었다. disable → 실행 → finally enable을 한 메서드에 가두고 raw session.disableFilter 직접 호출을 금지했다. 필터를 끈 채 finally 없이 예외가 나면 같은 세션의 이후 쿼리가 전부 삭제 데이터를 보게 되는데, 그건 조용히 번지는 종류의 사고다.
(c) @Filter는 native SQL에 적용되지 않는다
이게 이번 작업에서 가장 값진 발견이다. 보안 리뷰 중에 나왔다.
Hibernate @Filter는 HQL / JPQL / Criteria에만 적용된다. native SQL에는 적용되지 않는다.
문제가 된 건 관리자용 전체 재동기화 기능이었다. 물질화 테이블을 원본에서 다시 채우는 로직인데, 성능 때문에 native SELECT로 짜여 있었다. @Filter가 안 걸리므로 삭제된 행까지 긁어서 물질화 테이블에 다시 넣고 있었다. 소프트 삭제한 데이터가 조회 화면에 되살아나는 것이다.
43b2346b에서 native SELECT 2곳에 AND ef.is_delete = 0을 명시적으로 추가했다. 그리고 추정으로 끝내지 않고 DB에서 실측했다.
| 쿼리 | 삭제된 행 포함 |
|---|---|
| 수정 전 native SQL | 1건 |
| 수정 후 native SQL | 0건 |
같은 리뷰에서 LEFT JOIN 3곳에도 is_delete = 0 조건을 JOIN 조건에 추가했다(LEFT 특성은 유지). 보안 리뷰 결과는 MEDIUM 등급이었고 CRITICAL / HIGH는 없었다.
남는 교훈
소프트 삭제는 “DELETE를 UPDATE로 바꾸는 것”으로 요약되곤 하는데, 실제로 값을 치르는 곳은 그 주변이다.
제약 조건은 행이 사라진다는 전제 위에 서 있다. 유니크 인덱스뿐 아니라 FK도, 그리고 그 FK가 지지하는 인덱스도 얽혀 있다. 행이 남게 되는 순간 이 전제가 전부 다시 검토 대상이 된다.
필터링은 적용 범위에 구멍이 있다. @Filter는 잘 만든 장치지만 native SQL을 덮지 않는다. 그리고 이 구멍은 조용하다 — 에러가 나지 않고, 삭제한 데이터가 슬그머니 다시 보일 뿐이다. “필터를 걸었으니 안전하다”고 생각한 뒤 native 쿼리를 추가하면 그때부터 샌다.
그래서 이번 작업에서 가장 도움이 된 건 보안 리뷰라는 별도 관점의 검토와, DB 실측(1건 → 0건)이었다. 코드가 “맞게 생겼는지”와 “실제로 그렇게 도는지”는 다른 질문이다.