DB 데이터 μ‚­μ œμ— λŒ€ν•œ κ³ λ―Ό, Soft Delete(논리 μ‚­μ œ) vs Hard Delete(물리 μ‚­μ œ)

1. κ°œμš”

κ°œλ°œμ„ ν•˜λ‹€λ³΄λ©΄ μ–΄λ–€ 데이터가 μ‚­μ œλ˜λ©΄ μ—°κ΄€λœ λ‹€μ–‘ν•œ 데이터듀이 ν•¨κ»˜ μ‚­μ œλ˜μ•Ό ν•˜λŠ” κ²½μš°κ°€ μ’…μ’… λ°œμƒν•œλ‹€. 

 

예λ₯Ό λ“€λ©΄ κ²Œμ‹œκΈ€μ΄ μ‚­μ œλ˜λ©΄ ν•΄λ‹Ή κ²Œμ‹œκΈ€μ— 달린 μ’‹μ•„μš”, λŒ“κΈ€, λ‹΅λ³€ λ“±λ“± μ—°κ΄€λœ 데이터도 ν•¨κ»˜ μ‚­μ œλ˜μ•Ό ν•œλ‹€. 

 

 

ν•˜μ§€λ§Œ, ν•¨κ»˜ μ‚­μ œλ˜λ©΄ μ•ˆ λ˜λŠ” κ²½μš°λ„ μžˆλ‹€.

 

예λ₯Ό λ“€μ–΄, μƒν’ˆ 거래 μ„œλΉ„μŠ€μ—μ„œ 판맀자의 정보λ₯Ό μ‚­μ œν•˜λ©΄ νŒλ§€μžκ°€ 올린 μƒν’ˆμ— λŒ€ν•œ 정보도 μ‚­μ œλ  것이닀. κ·ΈλŸ¬λ‚˜, κ΅¬λ§€μžλŠ” μžμ‹ μ΄ κ΅¬λ§€ν–ˆλ˜ μƒν’ˆμ˜ μ •λ³΄λ‚˜ μ˜μˆ˜μ¦μ„ 확인해야 ν•  ν•„μš”κ°€ μžˆμ„ 수 μžˆλ‹€. λ§Œμ•½ 판맀자 정보와 ν•¨κ»˜ ν•΄λ‹Ή μƒν’ˆ 정보도 μ‚­μ œλœλ‹€λ©΄, κ΅¬λ§€μžλŠ” μžμ‹ μ˜ ꡬ맀 μƒν’ˆμ— λŒ€ν•œ 정보λ₯Ό 확인할 수 μ—†κ²Œ λ˜μ–΄ λΆˆνŽΈμ„ κ²ͺ을 수 μžˆλ‹€. λ”°λΌμ„œ μ΄λŸ¬ν•œ κ²½μš°μ—λŠ” 판맀자 정보와 μ—°κ΄€λœ 데이터라도, νŠΉμ • μ‘°κ±΄ν•˜μ—μ„œ λ³΄μ‘΄λ˜κ±°λ‚˜ κ΄€λ¦¬λ˜μ–΄μ•Ό ν•œλ‹€.

 

 

 

μ§€κΈˆκΉŒμ§€ ν”„λ‘œμ νŠΈλ₯Ό μ§„ν–‰ν•΄μ˜€λ©΄μ„œ 데이터 μ‚­μ œ μ–΄λ–»κ²Œ ν•˜μ§€? ν•˜λŠ” 고민만 ν•΄μ˜€λ‹€, 이번 κΈ°νšŒμ— μ •λ¦¬ν•˜κ³  λ„˜μ–΄κ°€λ € ν•œλ‹€.

 

 

 

 

 

 

 

 

2. 데이터 μ‚­μ œ 방법

데이터λ₯Ό μ‚­μ œν•˜λŠ”λ΄ 크게 2가지 방법이 μžˆλ‹€. Soft Delete(논리 μ‚­μ œ), Hard Delete(물리 μ‚­μ œ) 방식이 μžˆλŠ”λ° 두 가지 λ°©μ‹μ˜ μ°¨μ΄λŠ” μ•„λž˜μ™€ κ°™λ‹€.

 

 

- Soft Delete

λ°μ΄ν„°λ² μ΄μŠ€μ—μ„œ λ°μ΄ν„°λ₯Ό μ‚­μ œν•˜μ§€ μ•Šκ³ , μ‚¬μš©μž μž…μž₯μ—μ„œλŠ” λ°μ΄ν„°μ— μ ‘κ·Όν•  μˆ˜ μ—†κ²Œ ν•˜λŠ” λ°©μ‹μ„ μ˜λ―Έν•œλ‹€. λ³΄ν†΅ ν…Œμ΄λΈ”에 is_deletedμ»¬λŸΌμ„ λ§Œλ“€μ–΄ booleanκ°’μœΌλ‘œ λ°μ΄ν„°λ₯Ό μ‚¬μš©μ—¬λΆ€λ₯Ό κ²°μ •ν•˜λŠ” λ°©μ‹μ΄λ‹€. is_delete = 0이면 μ‘°νšŒ κ°€λŠ₯, is_deleted = 1이면 μ‘°νšŒ λΆˆκ°€λŠ₯ν•œ κ²ƒμ²˜λŸΌ λ§μ΄λ‹€. μ‘°κ±΄ μ»¬λŸΌμ΄ λ“€μ–΄κ°€λ―€λ‘œ is_deleted의 κ°’을 μ²΄ν¬ν•˜λŠ” μΏΌλ¦¬κ°€ λ“€μ–΄κ°€μ•Ό ν•œλ‹€.

 

soft μ‚­μ œ μ‹œ μ‚¬μš©μžλŠ” λ°μ΄ν„°κ°€ μ‚­μ œλœ κ²ƒμ²˜λŸΌ ν•΄λ‹Ή λ°μ΄ν„°μ— μ ‘κ·Ό λΆˆκ°€λŠ₯ν•˜μ§€λ§Œ, μ• ν”Œλ¦¬μΌ€μ΄μ…˜ DBμ—λŠ” λ°μ΄ν„°κ°€ μ—¬μ „νžˆ μ‘΄μž¬ν•œλ‹€. λ•Œλ¬Έμ— λ‚΄λΆ€μ—μ„œ λ°μ΄ν„°λ₯Ό κ³„속 μ‚¬μš©ν•΄μ•Όν•  κ°€λŠ₯성이 μžˆλ‹€λ©΄ soft delete λ°©μ‹μ„ μ„ νƒν•˜λŠ” κ²ƒμ΄ μ ν•©ν•  μˆ˜λ„ μžˆλ‹€.

UPDATE User SET is_deleted = 1 where userId = ?

 

 

 

- Hard Delete

λ°μ΄ν„°λ² μ΄μŠ€μ—μ„œλ„ 데이터λ₯Ό 직접 μ‚­μ œν•˜λŠ” 방식을 μ˜λ―Έν•œλ‹€. 더이상 μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” 데이터λ₯Ό DB에 μ €μž₯ν•˜λŠ” 것은 μ €μž₯곡간을 λ‚­λΉ„ν•˜λŠ” 것일 수 μžˆλ‹€. 

 

ν”νžˆ μš°λ¦¬κ°€ Delete 쿼리λ₯Ό λ‚ λ¦¬λŠ” 것이닀. 

DELETE FROM User WHERE userId = ?

 

 

 

 

싀무에선 κ²½μš°μ— 따라 λ‹€λ₯΄κ² μ§€λ§Œ 논리 μ‚­μ œλ₯Ό 많이 ν•œλ‹€κ³  ν•œλ‹€. 

 

λ‹€μ–‘ν•œ μ΄μœ κ°€ μžˆκ² μ§€λ§Œ

FK둜 μ‚¬μš©λ˜λŠ” 데이터인 κ²½μš°λ‚˜, 볡원이 κ°€λŠ₯ν•΄μ•Ό ν•˜λŠ” 도메인일 경우 논리 μ‚­μ œλ₯Ό νƒν•œλ‹€.

 

λ˜ν•œ Update 쿼리가 Delete 쿼리보닀 ms λ‹¨μœ„λ‘œ 봀을 땐 더 λΉ λ₯΄κΈ°λ„ ν•˜λ‹€. 

 

λ§ˆμ§€λ§‰μœΌλ‘œ λ°μ΄ν„°λŠ” μžμ‚°μ΄κΈ° λ•Œλ¬Έμ΄λ‹€. μœ μ €λ“€μ΄ μ„œλΉ„μŠ€λ₯Ό μ‚¬μš©ν•˜λ©΄μ„œ μŒ“κ³  μžˆλŠ” λ§Žμ€ 데이터듀은 λ²•μ˜ 경계λ₯Ό λ²—μ–΄λ‚˜μ§€ μ•ŠλŠ” μ„ μ—μ„œ 데이터 뢄석을 톡해 λ§ˆμΌ“νŒ… 및 기획 개발, 디버깅 λ“± λ‹€μ–‘ν•œ λ°©λ©΄μ—μ„œ μ‚¬μš©λœλ‹€. 

 

 

 

ν•˜μ§€λ§Œ, 단점 λ˜ν•œ λͺ…ν™•ν•˜λ‹€. 

 

데이터가 μƒμ„±λ˜κΈ°λ§Œ ν•˜κ³  μ‚­μ œλ˜μ§€ μ•ŠλŠ” ꡬ쑰이기 λ•Œλ¬Έμ— λ°μ΄ν„°λ² μ΄μŠ€μ˜ μš©λŸ‰μ΄ 맀우 컀질 수 밖에 μ—†λ‹€. 이 데이터듀 μ€‘μ—λŠ” μ •λ§λ‘œ ν•„μš” μ—†λŠ” 데이터듀도 μžˆμ„ 수 μžˆλ‹€. 

 

λ˜ν•œ, 논리 μ‚­μ œλŠ” μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ„±λŠ₯ μΈ‘λ©΄μ—μ„œ 생각해봐도 SELECT 쑰회 μ‹œ λΆˆν•„μš”ν•œ 검색 쑰건을 μΆ”κ°€ν•΄μ•Ό ν•œλ‹€. 

SELECT ν•˜λŠ” λͺ¨λ“  쿼리의 WHERE μ ˆμ— μ‚­μ œ 확인 μ—¬λΆ€κ°€ λ“€μ–΄κ°„λ‹€λ©΄ κ½€λ‚˜ 직관적이지 μ•Šμ„ 것이닀. 

 

 

 

 

 

 

 

 

 

3. Springμ—μ„œμ˜ Soft Delete 방법듀

- @SQLDelete

λ¨Όμ € @SQLDeleteλ₯Ό μ‚¬μš©ν•˜μ§€ μ•Šκ³  Soft Deleteλ₯Ό ν•˜λ €λ©΄ μ•„λž˜μ²˜λŸΌ ν•˜κ²Œ 될 것이닀. 

@Entity
public class Board {

    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    private boolean isDeleted = false;
}

 

 

public void deleteBoard(Long boardId) {
    Optional<Board> findBoard = boardRepository.findById(boardId);
    findBoard.ifPresent(board -> {
        board.setDeleted(true);
    });
}

 

 

 

 

 

μœ„ 방식을 SQLDelete μ–΄λ…Έν…Œμ΄μ…˜μ„ μ‚¬μš©ν•˜λ©΄ μ•„λž˜ 처럼 λ³€κ²½ν•  수 μžˆλ‹€. 

@Entity
@SQLDelete(sql = "UPDATE board SET is_deleted = true WHERE id = ?")
public class Board {

    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    private boolean isDeleted = false;
}

 

public void deleteBoard(Long boardId) {
    boardRepository.deleteById(boardId);
}

 

 

 

- @Where

μœ„μ—μ„œ Soft Delete μ‹œ SELECT ν•˜λŠ” λͺ¨λ“  쿼리의 WHERE μ ˆμ— μ‚­μ œ 확인 μ—¬λΆ€λ₯Ό ν™•μΈν•΄μ•Όν•˜λŠ” 맀우 번거둜운 단점이 μžˆμ—ˆλ‹€. 

 

@Where μ–΄λ…Έν…Œμ΄μ…˜μ€ 이런 비직관적인 λ¬Έμ œμ™€ λ²ˆκ±°λ‘œμ›€μ„ 해결해쀄 수 μžˆλ‹€.  

 

@Entity
@Where(clause = "is_deleted = false")
@SQLDelete(sql = "UPDATE board SET is_deleted = true WHERE id = ?")
public class Board {

    @Id
    @GeneratedValue
    private Long id;

    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    private boolean isDeleted = false;
}

 

 

μœ„μ™€ 같이 @Where μ–΄λ…Έν…Œμ΄μ…˜μ„ μ‚¬μš©ν•˜λ©΄ Board μ—”ν‹°ν‹°λ₯Ό μ‘°νšŒν•  λ•Œ 항상 is_deleted = false 쑰건이 whereμ ˆμ— μΆ”κ°€λœλ‹€. 

 

μ΄λ•Œ μ—”ν‹°ν‹° μ‘°νšŒλΌλŠ” 건 
"select b from Board b" 같이 μ—”ν‹°ν‹° μ „λΆ€λ₯Ό μ‘°νšŒν•˜λŠ” 경우λ₯Ό λ§ν•œλ‹€. 

 

즉, "select b.id, b.title from Board b" 같이 μ—”ν‹°ν‹° 전체가 μ•„λ‹Œ νŠΉμ • ν•„λ“œλ§Œ μ‘°νšŒν•˜λŠ” κ²½μš°μ—” @Where μ–΄λ…Έν…Œμ΄μ…˜μ— λͺ…μ‹œν–ˆλ˜ 쑰건이 쿼리에 λ°˜μ˜λ˜μ§€ μ•ŠλŠ”λ‹€. 

 

* 주의
- μ–΄λ…Έν…Œμ΄μ…˜μ— μ‚¬μš©λ˜λŠ” SQL문은 SQLλ¬Έ κΈ°μ€€μœΌλ‘œ μž‘μ„±ν•΄μ•Ό ν•œλ‹€. 예λ₯Ό λ“€μ–΄ isDeleted κ°€ μ•„λ‹ˆλΌ id_deleted둜 μž‘μ„±ν•΄μ•Ό 함. 
- hibernate, JPAλ“± λΌμ΄λΈŒλŸ¬λ¦¬κ°€ μ„€μΉ˜λ˜μ–΄ μžˆμ–΄μ•Ό ν•œλ‹€.
- @SQLDelete 의 ? μ—λŠ” ν•΄λ‹Ή 엔티티에 @Id μ–΄λ…Έν…Œμ΄μ…˜μ„ μ‚¬μš©ν•œ ν”„λ‘œνΌν‹°μ˜ 값이 μ‚¬μš©λ˜λ―€λ‘œ, pkλ₯Ό idλΌλŠ” 컬럼으둜 μ‚¬μš©ν•  λ•Œ μœ„μ™€ 같이 μ‚¬μš©ν•΄μ•Ό 함. μ•„λ‹ˆλΌλ©΄ κ°μžμ— 맞게 λ³€κ²½.

 

 

 

 

 

 

4. ν”„λ‘œμ νŠΈμ— 적용

μœ„μ— μ†Œκ°œν•  땐 is_deleted 같은 boolean ν˜•μ‹μ˜ ν•„λ“œλ₯Ό μ‚¬μš©ν–ˆμ§€λ§Œ ν”Œμ μ—” LocalDateTime νƒ€μž…μ˜ deleted_at ν•„λ“œλ₯Ό μ‚¬μš©ν–ˆλ‹€. 

 

 

λ‹¨μˆœ μ‚­μ œμΈμ§€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄μ„  boolean을 μ‚¬μš©ν•˜λ©΄ λ˜μ§€λ§Œ, 좔후에 디버깅을 μœ„ν•΄ μ‚­μ œ μ‹œκ°„μ„ κΈ°λ‘ν•΄λ‘μ—ˆλ‹€. κΌ­ ν•„μš”κ°€ μ—†λ‹€λ©΄ boolean이 DB 쑰회 μ‹œ 더 λΉ λ₯΄κΈ° λ•Œλ¬Έμ— boolean을 μ‚¬μš©ν•˜λŠ”κ²Œ 더 쒋을 μˆ˜λ„ μžˆλ‹€. 

 

 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@SQLDelete(sql = "UPDATE job_post SET deleted_at = NOW() WHERE job_post_id = ?")
@Where(clause = "deleted_at IS NULL")
public class JobPost extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "job_post_id")
    private Long id;

    // μƒλž΅    

    private LocalDateTime deletedAt; // 논리 μ‚­μ œ

 

 

public void deleteJobPost(Long companyId, Long jobPostId) {
    Member company = memberRepository.findById(companyId)
        .orElseThrow(() -> new JikgongException(ErrorCode.MEMBER_NOT_FOUND));

    JobPost jobPost = jobPostRepository.findById(jobPostId)
        .orElseThrow(() -> new JikgongException(ErrorCode.JOB_POST_NOT_FOUND));

    List<WorkDate> workDateList = jobPost.getWorkDateList();

    // WorkDate.getDate() κ°’ 쀑 κ°€μž₯ 큰 λ‚ μ§œλ₯Ό μ°ΎκΈ°
    LocalDate maxDate = findMaxDate(workDateList);

    // 였늘 λ‚ μ§œ
    LocalDate today = LocalDate.now();

    // 7일 이상 μ§€λ‚œ 곡고: μ‚­μ œ
    // μ•„λ‹Œ 곡고: ν™•μ •λœ μ§€μ›μž μ—†λ‹€λ©΄ μ‚­μ œ
    validationBeforeDelete(today, maxDate, workDateList);

    // 쑰건이 좩쑱되면 논리 μ‚­μ œ μ‹€ν–‰
    jobPostRepository.delete(jobPost);
}

 

 

μ΄λ ‡κ²Œ 섀정해두고 μ‚­μ œ μš”μ²­μ„ 보내면 쿼리가 μ•„λž˜μ™€ 같이 λœ¬λ‹€.

 

2024-08-22 19:16:05.727 [http-nio-8080-exec-4]  INFO p6spy - [statement] | 40 ms | 
    delete       
    from
        pickup       
    where
        pickup_id=14
2024-08-22 19:16:05.776 [http-nio-8080-exec-4]  INFO p6spy - [statement] | 46 ms | 
    delete       
    from
        work_date       
    where
        work_date_id=22
2024-08-22 19:16:05.826 [http-nio-8080-exec-4]  INFO p6spy - [statement] | 48 ms | 
    delete       
    from
        work_date       
    where
        work_date_id=23
2024-08-22 19:16:05.884 [http-nio-8080-exec-4]  INFO p6spy - [statement] | 56 ms | 
    delete       
    from
        work_date       
    where
        work_date_id=24
2024-08-22 19:16:05.979 [http-nio-8080-exec-4]  INFO p6spy - [statement] | 93 ms | 
    UPDATE
        job_post       
    SET
        deleted_at = NOW()       
    WHERE
        job_post_id = 8

 

Update μΏΌλ¦¬λŠ” 잘 λ‚˜κ°€λŠ”λ° 연관관계 맺어진 workdate 와 pickup은 DELETE 쿼리가 λ‚˜κ°€λŠ” κ±Έ 확인할 수 μžˆμ—ˆλ‹€. 

 

 

cascade, orphanRemoval μ„€μ •κ³Ό @SQLDeleted λ₯Ό ν•¨κ»˜ μ‚¬μš©ν•œλ‹€λ©΄ λΆ€λͺ¨ μ—”ν‹°ν‹°λŠ” 논리 μ‚­μ œλ˜μ§€λ§Œ μžμ‹ μ—”ν‹°ν‹°λŠ” 가차없이 DELETE 쿼리가 λ‚˜κ°”λ‹€. 

 

 

μ˜ˆμ „μ— μž„μ‹œλ‘œ λͺ¨μ§‘곡고 μ‚­μ œ λ‘œμ§μ„ κ΅¬ν˜„ν•΄λ’€μ„ λ•Œ ν•¨κ»˜ μ‚­μ œν•˜κΈ° μœ„ν•΄ μ•„λž˜μ™€ 같이 μ„€μ •ν•΄λ’€λŠ”λ°,, 덕뢄에 ν•˜λ‚˜ λ°°μ›Œκ°„λ‹€.. γ…‹γ…‹γ…‹

 

 

 

cascade, orphanRemoval μ„€μ • 제거 ν›„μ—” μ˜λ„ν–ˆλ˜λŒ€λ‘œ 쿼리가 잘 λ‚˜κ°„λ‹€.