Hibernate와 Soft Delete
TL;DR
- Hibernate와 Soft Delete의 핵심 개념과 용어를 한눈에 정리한다.
- Hibernate와 Soft Delete이(가) 등장한 배경과 필요성을 요약한다.
- Hibernate와 Soft Delete의 특징과 적용 포인트를 빠르게 확인한다.
1. 개념
Hibernate와 Soft Delete은(는) 핵심 용어와 정의를 정리한 주제로, 개발/운영 맥락에서 무엇을 의미하는지 설명한다.
2. 배경
기존 방식의 한계나 현업의 요구사항을 해결하기 위해 이 개념이 등장했다는 흐름을 이해하는 데 목적이 있다.
3. 이유
도입 이유는 보통 유지보수성, 성능, 안정성, 보안, 협업 효율 같은 실무 문제를 해결하기 위함이다.
4. 특징
- 핵심 정의와 범위를 명확히 한다.
- 실무 적용 시 선택 기준과 비교 포인트를 제공한다.
- 예시 중심으로 빠른 이해를 돕는다.
5. 상세 내용
작성일: 2026-02-02 키워드: Hibernate, ORM, JPA, Soft Delete, Hard Delete, @SQLDelete, @SQLRestriction, @SoftDelete, 논리적 삭제, Gavin King
개요
이 문서에서는 Java/Kotlin 진영의 대표적인 ORM 프레임워크인 Hibernate의 탄생 배경과 핵심 개념, 그리고 데이터 삭제 전략인 Soft Delete의 필요성과 구현 방법을 상세히 설명합니다.
1. Hibernate란 무엇인가?
1.1 탄생 배경: 객체-관계 불일치 문제
2000년대 초반 Java 개발자들은 심각한 문제에 직면했습니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Object-Relational Impedance Mismatch │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 📚 당시 상황: │
│ - Java는 객체지향 언어 (클래스, 상속, 다형성, 참조) │
│ - 데이터베이스는 관계형 (테이블, 행, 열, 외래키) │
│ - 둘 사이에 근본적인 패러다임 불일치 존재 │
│ │
│ 이것을 "Object-Relational Impedance Mismatch"라고 부름 │
│ (객체-관계 임피던스 불일치) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
불일치 문제의 구체적 예시
1️⃣ 상속 표현 문제
┌─────────────────────────────────────────────────────────────────────────────┐
│ 상속을 DB에서 어떻게 표현할까? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Java 클래스 구조: DB 테이블은??? │
│ │
│ ┌──────────────┐ 방법 1: 단일 테이블 (Single Table) │
│ │ Animal │ → null 값이 많아짐 │
│ │ - name │ │
│ │ - age │ 방법 2: 조인 전략 (Joined) │
│ └──────┬───────┘ → 조회마다 조인 필요 (성능 이슈) │
│ │ │
│ ┌────┴────┐ 방법 3: 클래스별 테이블 (Table Per Class) │
│ │ │ → 다형성 쿼리 어려움 │
│ ┌─┴──┐ ┌──┴─┐ │
│ │Dog │ │Cat │ 어떤 방법을 선택해야 하지??? │
│ │bark│ │meow│ │
│ └────┘ └────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2️⃣ 관계 표현 문제
┌─────────────────────────────────────────────────────────────────────────────┐
│ 객체 참조 vs 외래키 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Java 객체: DB 테이블: │
│ │
│ class User { users 테이블 │
│ Long id; ┌────────────────────┐ │
│ String name; │ id │ name │ │
│ List<Order> orders; ──┐ └────────────────────┘ │
│ } │ │
│ │ orders 테이블 │
│ class Order { │ ┌───────────────────────────┐ │
│ Long id; └──────→ │ id │ user_id │ product │ │
│ User user; ←─────────────────→│ │ (FK) │ │ │
│ String product; └───────────────────────────┘ │
│ } │
│ │
│ 객체: user.getOrders().get(0) ← 참조로 직접 탐색 │
│ DB: SELECT * FROM orders WHERE user_id = 123 ← 조인 필요 │
│ │
│ → 탐색 방식이 완전히 다름! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3️⃣ 동일성 vs 동등성 문제
┌─────────────────────────────────────────────────────────────────────────────┐
│ 같은 데이터인데 다른 객체? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ // 두 번 조회하면 어떻게 될까? │
│ User user1 = userDao.findById(1); // 첫 번째 조회 │
│ User user2 = userDao.findById(1); // 두 번째 조회 │
│ │
│ // 결과 │
│ user1 == user2 // false! (서로 다른 객체 인스턴스) │
│ user1.equals(user2) // true (같은 데이터) │
│ │
│ 문제점: │
│ - user1의 name을 변경해도 user2에는 반영 안 됨 │
│ - 같은 데이터를 가리키는 객체가 여러 개 → 일관성 문제 │
│ - HashSet/HashMap에서 예상치 못한 동작 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.2 JDBC 직접 사용의 고통
Hibernate 등장 전, JDBC를 직접 사용해야 했습니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2001년경 전형적인 Java 데이터 접근 코드 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ // 단순히 User 하나 조회하는데 이렇게 많은 코드가 필요했음 │
│ │
│ public User findUserById(Long id) { │
│ Connection conn = null; │
│ PreparedStatement ps = null; │
│ ResultSet rs = null; │
│ User user = null; │
│ │
│ try { │
│ conn = dataSource.getConnection(); │
│ ps = conn.prepareStatement( │
│ "SELECT id, name, email, created_at FROM users WHERE id = ?" │
│ ); │
│ ps.setLong(1, id); │
│ rs = ps.executeQuery(); │
│ │
│ if (rs.next()) { │
│ user = new User(); │
│ user.setId(rs.getLong("id")); │
│ user.setName(rs.getString("name")); │
│ user.setEmail(rs.getString("email")); │
│ user.setCreatedAt(rs.getTimestamp("created_at")); │
│ } │
│ } catch (SQLException e) { │
│ throw new RuntimeException(e); │
│ } finally { │
│ // 리소스 정리 (안 하면 커넥션 누수!) │
│ if (rs != null) try { rs.close(); } catch (SQLException e) {} │
│ if (ps != null) try { ps.close(); } catch (SQLException e) {} │
│ if (conn != null) try { conn.close(); } catch (SQLException e) {}│
│ } │
│ return user; │
│ } │
│ │
│ 🔴 문제점: │
│ - 보일러플레이트 코드가 비즈니스 로직보다 많음 │
│ - SQL을 문자열로 직접 작성 (오타 → 런타임 에러) │
│ - 컬럼명과 필드명 수동 매핑 (실수하기 쉬움) │
│ - 리소스 관리 실수 → 커넥션 누수 → 서버 다운 │
│ - 테이블 구조 변경 시 모든 SQL 수정 필요 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.3 Hibernate의 등장 (2001년)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate의 탄생 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 👤 창시자: Gavin King (호주 개발자) │
│ 📅 탄생: 2001년 │
│ 🏢 현재: Red Hat (JBoss)에서 관리 │
│ 🎯 목표: "객체를 저장하는 것처럼 DB를 사용하자" │
│ │
│ Gavin King의 핵심 아이디어: │
│ ──────────────────────────── │
│ "개발자는 SQL이 아닌 객체에 집중해야 한다" │
│ "DB 접근 코드의 80%는 자동화할 수 있다" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Hibernate가 해결한 것
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate (ORM) 의 역할 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Java 객체 ←──── Hibernate (ORM) ────→ 관계형 DB │ │
│ │ │ │
│ │ // 저장 실제 실행 SQL │ │
│ │ User user = new User(); │ │
│ │ user.setName("Kim"); ──→ INSERT INTO users │ │
│ │ session.save(user); (name, email) │ │
│ │ VALUES ('Kim', ...) │ │
│ │ │ │
│ │ // 조회 │ │
│ │ User u = session.get( ←── SELECT * FROM users │ │
│ │ User.class, 1 WHERE id = 1 │ │
│ │ ); │ │
│ │ │ │
│ │ // 수정 (변경 감지!) │ │
│ │ user.setName("Park"); ──→ UPDATE users │ │
│ │ // 별도 save 호출 필요 없음 SET name = 'Park' │ │
│ │ WHERE id = 1 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ORM = Object-Relational Mapping │
│ 객체와 관계형 DB 사이의 자동 변환 기술 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.4 Hibernate의 핵심 개념
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate 핵심 개념 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 엔티티 (Entity) │
│ ─────────────────── │
│ - DB 테이블과 매핑되는 Java/Kotlin 클래스 │
│ - @Entity 어노테이션으로 표시 │
│ │
│ @Entity │
│ @Table(name = "users") │
│ class User { │
│ @Id │
│ @GeneratedValue(strategy = GenerationType.IDENTITY) │
│ Long id; │
│ │
│ @Column(name = "user_name", length = 100) │
│ String name; │
│ │
│ @Column(nullable = false) │
│ String email; │
│ } │
│ │
│ 2️⃣ 세션 (Session) / EntityManager │
│ ──────────────────────────────────── │
│ - DB 연결을 추상화한 객체 │
│ - 1차 캐시 역할 (같은 트랜잭션 내 동일 엔티티는 같은 객체) │
│ - 트랜잭션 관리 │
│ - Spring에서는 EntityManager로 주로 사용 │
│ │
│ 3️⃣ 영속성 컨텍스트 (Persistence Context) │
│ ───────────────────────────────────────── │
│ - 엔티티를 영구 저장하는 논리적 환경 │
│ │
│ 핵심 기능: │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 1차 캐시 │ 같은 ID 재조회 시 DB 안 가고 캐시 반환 │ │
│ │ 동일성 보장 │ 같은 ID = 같은 객체 인스턴스 (== true) │ │
│ │ 변경 감지 │ 객체 수정 시 자동으로 UPDATE SQL 생성 │ │
│ │ 지연 로딩 │ 연관 데이터는 실제 사용 시점에 조회 │ │
│ │ 쓰기 지연 │ SQL을 모아서 한 번에 실행 (성능 최적화) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.5 JPA와 Hibernate의 관계
┌─────────────────────────────────────────────────────────────────────────────┐
│ JPA vs Hibernate │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 📅 2006년: JPA (Java Persistence API) 탄생 │
│ │
│ 배경: │
│ ───── │
│ - Hibernate가 너무 인기 → 다른 ORM들도 등장 │
│ (TopLink, EclipseLink, OpenJPA 등) │
│ - 각 ORM마다 API가 달라서 벤더 종속 문제 발생 │
│ - Java 진영에서 표준 필요성 대두 │
│ - Gavin King(Hibernate 창시자)이 JPA 표준 제정에 참여 │
│ │
│ 결과: JPA = 표준 인터페이스, Hibernate = 구현체 │
│ ─────────────────────────────────────────────── │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ JPA (표준 인터페이스) │ │
│ │ javax.persistence.* 패키지 │ │
│ │ │ │ │
│ │ ┌─────────────────┼─────────────────┐ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ Hibernate EclipseLink OpenJPA │ │
│ │ (구현체) (구현체) (구현체) │ │
│ │ ⭐ │ │
│ │ 가장 많이 │ │
│ │ 사용됨 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 비유: │
│ ───── │
│ - JPA = JDBC처럼 표준 인터페이스 │
│ - Hibernate = MySQL Driver처럼 실제 구현체 │
│ │
│ 현재 (2026년): │
│ ────────────── │
│ - Spring Boot의 기본 JPA 구현체 = Hibernate │
│ - 대부분의 프로젝트에서 Hibernate 사용 │
│ - JPA 3.0 (Jakarta EE) + Hibernate 6.x가 최신 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Soft Delete란?
2.1 배경: 데이터 삭제의 두 가지 방식
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hard Delete vs Soft Delete │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ Hard Delete (물리적 삭제) │
│ ───────────────────────────── │
│ │
│ DELETE FROM users WHERE id = 123; │
│ │
│ 특징: │
│ - 데이터가 DB에서 완전히 사라짐 │
│ - 복구 불가능 (백업에서만 복구 가능) │
│ - 스토리지 공간 확보 │
│ │
│ 2️⃣ Soft Delete (논리적 삭제) │
│ ───────────────────────────── │
│ │
│ UPDATE users SET deleted_at = NOW() WHERE id = 123; │
│ │
│ 특징: │
│ - 데이터는 그대로 존재 │
│ - "삭제됨" 표시만 추가 │
│ - 조회 시 WHERE deleted_at IS NULL 조건 추가 │
│ - 언제든 복구 가능 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.2 왜 Soft Delete가 필요한가?
┌─────────────────────────────────────────────────────────────────────────────┐
│ Soft Delete가 필요한 4가지 상황 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 실수 복구 (Accidental Delete Recovery) │
│ ────────────────────────────────────────── │
│ │
│ 사용자: "아 실수로 삭제했어요! 복구해주세요!" │
│ │
│ Hard Delete: │
│ "죄송합니다, 백업에서 복구하려면 3일 걸리고 DBA 승인 필요합니다..." │
│ │
│ Soft Delete: │
│ "네, 5초만요" │
│ UPDATE users SET deleted_at = NULL WHERE id = 123; │
│ "복구 완료!" │
│ │
│ 2️⃣ 감사/규정 준수 (Audit & Compliance) │
│ ─────────────────────────────────────── │
│ │
│ 법규 요구사항: │
│ - 금융: "거래 기록은 5년간 보관해야 합니다" │
│ - 의료: "진료 기록은 10년간 보관해야 합니다" │
│ - GDPR: "삭제 요청 시 개인정보는 삭제, 거래 기록은 익명화하여 보관" │
│ │
│ Hard Delete: 법규 위반 위험 │
│ Soft Delete: 삭제 표시 + 데이터 보관 = 규정 준수 │
│ │
│ 3️⃣ 참조 무결성 유지 (Referential Integrity) │
│ ────────────────────────────────────────── │
│ │
│ users 테이블 orders 테이블 │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ id: 123 │ ┌────→│ user_id: 123 │ │
│ │ name: Kim │─────┘ │ product: iPhone │ │
│ └──────────────┘ │ amount: 1000000 │ │
│ └──────────────────┘ │
│ │
│ Hard Delete 시나리오: │
│ - FK 제약 → 삭제 불가 (에러 발생) │
│ - CASCADE → 주문도 함께 삭제 (매출 기록 손실!) │
│ - SET NULL → user_id가 NULL (누가 주문했는지 모름) │
│ │
│ Soft Delete: │
│ - User는 삭제 표시, Order는 그대로 유지 │
│ - 주문 히스토리 보존 │
│ - "탈퇴한 사용자" 표시 가능 │
│ │
│ 4️⃣ 데이터 분석 (Analytics) │
│ ────────────────────────── │
│ │
│ 질문: "왜 고객들이 탈퇴하나요? 패턴이 있나요?" │
│ │
│ Hard Delete: │
│ - 탈퇴 고객 데이터 없음 │
│ - 분석 불가 │
│ │
│ Soft Delete: │
│ - 탈퇴 고객 데이터 보존 │
│ - "마지막 활동일", "가입 기간", "사용 패턴" 분석 가능 │
│ - 이탈 방지 전략 수립에 활용 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.3 Soft Delete 구현 방식 비교
┌─────────────────────────────────────────────────────────────────────────────┐
│ Soft Delete 구현 방식 비교 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 방식 1: Boolean 플래그 │
│ ───────────────────── │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ users 테이블 │ │
│ │ id │ name │ email │ is_deleted │ │
│ │ 1 │ Kim │ k@mail.com │ false │ │
│ │ 2 │ Lee │ l@mail.com │ true ← 삭제됨 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 장점: 단순함, 인덱스 효율적 │
│ 단점: "언제" 삭제되었는지 알 수 없음 │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 방식 2: Timestamp ⭐ (권장) │
│ ──────────────────────── │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ users 테이블 │ │
│ │ id │ name │ email │ deleted_at │ │
│ │ 1 │ Kim │ k@mail.com │ NULL │ │
│ │ 2 │ Lee │ l@mail.com │ 2024-01-15 10:30:00 ← 삭제 시각 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 장점: 삭제 시각 기록, 감사 추적 가능, NULL 체크로 간단히 조회 │
│ 단점: NULL 허용 컬럼의 인덱스 고려 필요 │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 방식 3: Status 컬럼 │
│ ────────────────── │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ users 테이블 │ │
│ │ id │ name │ email │ status │ │
│ │ 1 │ Kim │ k@mail.com │ ACTIVE │ │
│ │ 2 │ Lee │ l@mail.com │ DELETED │ │
│ │ 3 │ Park │ p@mail.com │ SUSPENDED │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 장점: 다양한 상태 표현 가능 (ACTIVE, SUSPENDED, DELETED, BANNED 등) │
│ 단점: 삭제 시각은 별도 컬럼 필요, 상태 관리 복잡해질 수 있음 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3. Hibernate의 Soft Delete 지원
3.1 @SQLDelete와 @SQLRestriction
Hibernate는 Soft Delete를 위한 전용 어노테이션을 제공합니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate Soft Delete 기본 구현 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ @Entity │
│ @Table(name = "users") │
│ @SQLDelete(sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?") │
│ @SQLRestriction("deleted_at IS NULL") │
│ class User( │
│ @Id │
│ @GeneratedValue(strategy = GenerationType.IDENTITY) │
│ var id: Long? = null, │
│ │
│ var name: String, │
│ │
│ var email: String, │
│ │
│ @Column(name = "deleted_at") │
│ var deletedAt: LocalDateTime? = null │
│ ) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
동작 방식
┌─────────────────────────────────────────────────────────────────────────────┐
│ @SQLDelete + @SQLRestriction 동작 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 삭제 시 (@SQLDelete가 DELETE를 가로챔) │
│ ───────────────────────────────────────── │
│ │
│ // 개발자 코드 │
│ userRepository.delete(user) │
│ // 또는 │
│ userRepository.deleteById(123) │
│ │
│ // 원래 실행되어야 할 SQL │
│ DELETE FROM users WHERE id = 123 │
│ │
│ // 실제 실행되는 SQL (@SQLDelete로 대체됨) │
│ UPDATE users SET deleted_at = NOW() WHERE id = 123 │
│ │
│ → DELETE가 UPDATE로 변환됨! │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ 조회 시 (@SQLRestriction이 WHERE 조건 추가) │
│ ─────────────────────────────────────────── │
│ │
│ // 개발자 코드 │
│ userRepository.findAll() │
│ userRepository.findById(123) │
│ userRepository.findByEmail("kim@mail.com") │
│ │
│ // 원래 실행되어야 할 SQL │
│ SELECT * FROM users │
│ SELECT * FROM users WHERE id = 123 │
│ SELECT * FROM users WHERE email = 'kim@mail.com' │
│ │
│ // 실제 실행되는 SQL (@SQLRestriction 조건 자동 추가) │
│ SELECT * FROM users WHERE deleted_at IS NULL │
│ SELECT * FROM users WHERE id = 123 AND deleted_at IS NULL │
│ SELECT * FROM users WHERE email = 'kim@mail.com' AND deleted_at IS NULL │
│ │
│ → 삭제된 데이터 자동 제외! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.2 Hibernate 6.4+의 @SoftDelete (최신 방법)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate 6.4+ @SoftDelete 어노테이션 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Hibernate 6.4 (2023년 말)부터 더 간단한 방법이 추가됨: │
│ │
│ // 가장 간단한 형태 │
│ @Entity │
│ @SoftDelete // ← 이것만 추가하면 끝! │
│ class User( │
│ @Id │
│ @GeneratedValue │
│ var id: Long? = null, │
│ │
│ var name: String │
│ // deleted 컬럼이 자동으로 추가/관리됨 │
│ ) │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 커스터마이징 옵션들: │
│ │
│ // Boolean 컬럼 사용 (기본값) │
│ @SoftDelete(columnName = "is_deleted") │
│ │
│ // Timestamp 컬럼 사용 │
│ @SoftDelete( │
│ columnName = "deleted_at", │
│ converter = DeletedAsTimestampConverter::class │
│ ) │
│ │
│ // Status Enum 사용 │
│ @SoftDelete( │
│ columnName = "status", │
│ converter = DeletedAsStatusConverter::class │
│ ) │
│ │
│ 장점: │
│ - @SQLDelete + @SQLRestriction 조합보다 간결 │
│ - 타입 안전한 컨버터 지원 │
│ - Hibernate가 컬럼 관리를 자동으로 처리 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.3 CCK 프로젝트의 Soft Delete 패턴
CCK Mothership 프로젝트에서 실제 사용하는 패턴입니다.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CCK BaseEntity 패턴 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ // 공통 BaseEntity 정의 │
│ @MappedSuperclass │
│ abstract class MothershipBaseEntity { │
│ @Id │
│ @GeneratedValue(strategy = GenerationType.IDENTITY) │
│ var id: Long? = null │
│ │
│ @Column(name = "created_at", nullable = false, updatable = false) │
│ var createdAt: LocalDateTime = LocalDateTime.now() │
│ │
│ @Column(name = "updated_at", nullable = false) │
│ var updatedAt: LocalDateTime = LocalDateTime.now() │
│ │
│ @Column(name = "deleted_at") │
│ var deletedAt: LocalDateTime? = null // ← Soft Delete용 │
│ } │
│ │
│ // 실제 Entity에서 사용 │
│ @Entity │
│ @Table( │
│ name = "users", │
│ indexes = [ │
│ Index(name = "idx_user_deleted_at", columnList = "deleted_at") │
│ ] │
│ ) │
│ @SQLDelete( │
│ sql = "UPDATE users SET deleted_at = NOW() " + │
│ "WHERE id = ? AND deleted_at IS NULL" │
│ ) │
│ @SQLRestriction("deleted_at IS NULL") │
│ class User( │
│ var name: String, │
│ var email: String, │
│ @Enumerated(EnumType.STRING) │
│ var role: UserRole │
│ ) : MothershipBaseEntity() │
│ │
│ 특징: │
│ - createdAt, updatedAt, deletedAt 3개 타임스탬프 표준화 │
│ - deleted_at에 인덱스 추가 (조회 성능 최적화) │
│ - AND deleted_at IS NULL 조건으로 중복 삭제 방지 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Soft Delete 구현 시 주의사항
4.1 Unique 제약 조건 문제
┌─────────────────────────────────────────────────────────────────────────────┐
│ Soft Delete + Unique 제약의 충돌 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 🔴 문제 상황: │
│ │
│ @Entity │
│ @SQLDelete(...) │
│ @SQLRestriction("deleted_at IS NULL") │
│ class User( │
│ @Column(unique = true) // ← 이메일은 유일해야 함 │
│ var email: String │
│ ) │
│ │
│ 시나리오: │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 1. kim@mail.com으로 사용자 생성 │ │
│ │ 2. 해당 사용자 탈퇴 (Soft Delete) │ │
│ │ → DB: deleted_at = '2024-01-15 10:30:00' │ │
│ │ 3. 새 사용자가 kim@mail.com으로 가입 시도 │ │
│ │ → 💥 Unique 제약 위반! │ │
│ │ → 삭제된 데이터도 DB에 존재하므로 중복으로 판단 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 해결책 1: Partial Unique Index (PostgreSQL 권장) │
│ │
│ -- Flyway 마이그레이션에서 설정 │
│ CREATE UNIQUE INDEX unique_user_email_active │
│ ON users(email) │
│ WHERE deleted_at IS NULL; │
│ │
│ 효과: │
│ - deleted_at이 NULL인 (활성) 데이터에만 유일 제약 적용 │
│ - 삭제된 데이터는 같은 email 허용 │
│ - 같은 email로 재가입 가능! │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 해결책 2: 삭제 시 값 변경 (MySQL/MariaDB) │
│ │
│ MySQL은 Partial Index 미지원, 대안 사용: │
│ │
│ -- 삭제 시 email에 접미사 추가 │
│ @SQLDelete(sql = """ │
│ UPDATE users SET │
│ deleted_at = NOW(), │
│ email = CONCAT(email, '_deleted_', id) │
│ WHERE id = ? AND deleted_at IS NULL │
│ """) │
│ │
│ 결과: │
│ - 삭제 전: kim@mail.com │
│ - 삭제 후: kim@mail.com_deleted_123 │
│ - 새 가입: kim@mail.com (가능!) │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 해결책 3: 복합 유니크 (범용) │
│ │
│ @Entity │
│ @Table(uniqueConstraints = [ │
│ UniqueConstraint( │
│ name = "uk_user_email_deleted", │
│ columnNames = ["email", "deleted_id"] │
│ ) │
│ ]) │
│ class User( │
│ var email: String, │
│ var deletedId: Long = 0 // 활성: 0, 삭제: 자신의 id │
│ ) │
│ │
│ UNIQUE(email, deleted_id) │
│ - 활성 사용자: (kim@mail.com, 0) │
│ - 삭제된 사용자: (kim@mail.com, 123) │
│ - 새 가입: (kim@mail.com, 0) 가능! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.2 Cascade와 Soft Delete
┌─────────────────────────────────────────────────────────────────────────────┐
│ orphanRemoval vs CascadeType.REMOVE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ⚠️ 매우 중요한 차이점: │
│ │
│ 1️⃣ orphanRemoval = true │
│ ───────────────────────── │
│ - 컬렉션에서 제거된 자식 엔티티를 자동으로 삭제 │
│ - ⚠️ @SQLDelete를 무시함! │
│ - 항상 물리적 DELETE 실행 │
│ - Soft Delete와 충돌! │
│ │
│ 2️⃣ CascadeType.REMOVE (또는 CascadeType.ALL에 포함) │
│ ─────────────────────────────────────────────── │
│ - 부모 삭제 시 자식도 삭제 │
│ - ✅ @SQLDelete를 존중함! │
│ - UPDATE 실행 (Soft Delete 동작) │
│ - Soft Delete와 호환! │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 잘못된 예 (orphanRemoval이 Soft Delete를 무시): │
│ │
│ @Entity │
│ @SQLDelete(sql = "UPDATE projects SET deleted_at = NOW() WHERE id = ?") │
│ class Project( │
│ @OneToMany( │
│ mappedBy = "project", │
│ cascade = [CascadeType.ALL], │
│ orphanRemoval = true // ← 문제! @SQLDelete 무시됨 │
│ ) │
│ var members: MutableList<ProjectMember> = mutableListOf() │
│ ) │
│ │
│ // 이렇게 하면... │
│ project.members.remove(member) │
│ projectRepository.save(project) │
│ │
│ // 의도: UPDATE project_members SET deleted_at = NOW() ... │
│ // 실제: DELETE FROM project_members WHERE id = ??? 💥 │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 올바른 예 (Soft Delete와 호환): │
│ │
│ @Entity │
│ @SQLDelete(sql = "UPDATE projects SET deleted_at = NOW() WHERE id = ?") │
│ class Project( │
│ @OneToMany( │
│ mappedBy = "project", │
│ cascade = [CascadeType.ALL], // ← @SQLDelete 존중 │
│ orphanRemoval = false // ← 자동 삭제 비활성화 │
│ ) │
│ var members: MutableList<ProjectMember> = mutableListOf() │
│ ) │
│ │
│ // 삭제가 필요할 때는 명시적으로 │
│ projectMemberRepository.delete(member) // → Soft Delete 실행 │
│ project.members.remove(member) // → 컬렉션에서만 제거 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.3 삭제된 데이터 조회하기
┌─────────────────────────────────────────────────────────────────────────────┐
│ 삭제된 데이터도 조회해야 할 때 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ @SQLRestriction은 모든 조회에 적용되므로, │
│ 삭제된 데이터를 보려면 별도 방법 필요 │
│ │
│ 방법 1: Native Query 사용 │
│ ──────────────────────── │
│ │
│ @Query( │
│ value = "SELECT * FROM users WHERE id = :id", │
│ nativeQuery = true // @SQLRestriction 우회 │
│ ) │
│ fun findByIdIncludingDeleted(id: Long): User? │
│ │
│ @Query( │
│ value = "SELECT * FROM users WHERE deleted_at IS NOT NULL", │
│ nativeQuery = true │
│ ) │
│ fun findAllDeleted(): List<User> │
│ │
│ 방법 2: 별도 View Entity 생성 │
│ ──────────────────────────── │
│ │
│ // Soft Delete 필터 없는 별도 Entity │
│ @Entity │
│ @Table(name = "users") │
│ @Immutable // 읽기 전용 │
│ class UserWithDeleted( │
│ @Id │
│ var id: Long, │
│ var name: String, │
│ var deletedAt: LocalDateTime? │
│ ) │
│ // @SQLRestriction 없음 → 삭제된 데이터도 조회 │
│ │
│ 방법 3: Hibernate Filter 사용 (동적 필터링) │
│ ────────────────────────────────────────── │
│ │
│ @Entity │
│ @FilterDef(name = "deletedFilter", │
│ parameters = [@ParamDef(name = "isDeleted", type = Boolean)]) │
│ @Filter(name = "deletedFilter", │
│ condition = "deleted_at IS NULL = :isDeleted") │
│ class User { ... } │
│ │
│ // 사용 │
│ session.enableFilter("deletedFilter") │
│ .setParameter("isDeleted", false) // 삭제된 것도 보기 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. Soft Delete의 단점과 대안
┌─────────────────────────────────────────────────────────────────────────────┐
│ Soft Delete의 단점 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 스토리지 증가 │
│ - 삭제해도 데이터가 남아있음 │
│ - 시간이 지날수록 테이블 크기 증가 │
│ - 인덱스 크기도 증가 │
│ │
│ 2️⃣ 쿼리 복잡도 증가 │
│ - 모든 쿼리에 WHERE deleted_at IS NULL 필요 │
│ - @SQLRestriction 없으면 실수로 삭제된 데이터 조회 가능 │
│ - JOIN 시에도 양쪽에 조건 필요 │
│ │
│ 3️⃣ Unique 제약 복잡도 │
│ - 앞서 설명한 Partial Index 등 추가 처리 필요 │
│ │
│ 4️⃣ 성능 영향 │
│ - 삭제된 데이터도 스캔해야 함 (인덱스 사용 시 완화) │
│ - 통계가 왜곡될 수 있음 │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 💡 해결책: 정기적인 데이터 정리 (Purge) │
│ │
│ -- 90일 이상 지난 삭제 데이터는 완전 삭제 │
│ DELETE FROM users │
│ WHERE deleted_at IS NOT NULL │
│ AND deleted_at < NOW() - INTERVAL 90 DAY; │
│ │
│ -- 또는 아카이브 테이블로 이동 │
│ INSERT INTO users_archive SELECT * FROM users │
│ WHERE deleted_at IS NOT NULL │
│ AND deleted_at < NOW() - INTERVAL 90 DAY; │
│ │
│ DELETE FROM users │
│ WHERE deleted_at IS NOT NULL │
│ AND deleted_at < NOW() - INTERVAL 90 DAY; │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. 요약
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hibernate & Soft Delete 요약 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 📚 Hibernate │
│ ───────────── │
│ - 2001년 Gavin King이 개발한 ORM 프레임워크 │
│ - 객체-관계 불일치(Impedance Mismatch) 문제 해결 │
│ - JDBC 보일러플레이트 코드 제거 │
│ - JPA 표준의 대표적인 구현체 │
│ - 영속성 컨텍스트: 1차 캐시, 변경 감지, 지연 로딩 │
│ │
│ 📚 Soft Delete │
│ ─────────────── │
│ - 물리적 삭제(DELETE) 대신 논리적 삭제(UPDATE deleted_at) │
│ - 장점: │
│ ✓ 복구 가능 │
│ ✓ 감사/규정 준수 │
│ ✓ 참조 무결성 유지 │
│ ✓ 데이터 분석 가능 │
│ │
│ 📚 Hibernate에서 Soft Delete 구현 │
│ ─────────────────────────────── │
│ - 전통적: @SQLDelete + @SQLRestriction 조합 │
│ - 최신 (6.4+): @SoftDelete 어노테이션 │
│ │
│ ⚠️ 주의사항 │
│ ──────────── │
│ - Unique 제약: Partial Index 또는 복합 유니크로 해결 │
│ - orphanRemoval=true는 @SQLDelete 무시 → false 권장 │
│ - 삭제된 데이터 조회: Native Query 또는 별도 Entity 사용 │
│ - 스토리지 관리: 정기적 Purge 또는 Archive 정책 필요 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
참고 자료
- Hibernate ORM 공식 문서: https://hibernate.org/orm/documentation/
- JPA 3.0 명세 (Jakarta Persistence): https://jakarta.ee/specifications/persistence/
- Hibernate @SoftDelete (6.4+): https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#soft-delete
- CCK Mothership CLAUDE.md:
mothership/backend/src/CLAUDE.md
관련 키워드
Hibernate, ORM, JPA, Soft Delete, Hard Delete, @SQLDelete, @SQLRestriction, @SoftDelete, 논리적 삭제, 물리적 삭제, Gavin King, 영속성 컨텍스트, 변경 감지, 지연 로딩, orphanRemoval, Cascade, Partial Index