Soft Delete 적용하기 | 삭제된 데이터에 접근하기 위한 방법

qpwoeiru·2024년 5월 4일
0
post-thumbnail

프로젝트에서 게시글을 삭제하는데 500 에러가 발생했다.

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (tripbros.bookmarked_board, CONSTRAINT FK1kp8lt2optw3jqjnnarhb85yx FOREIGN KEY (board_id) REFERENCES board (id))

bookmarked_board에서 해당 board를 참조하고 있으므로 board를 삭제할 수 없다는 것이다. 참조 무결성 때문인데, 설계할 때 cascading remove를 미처 생각 못해버렸다..

위를 해결하기 위해 먼저 Board 엔티티와 관련된 엔티티들 중 서비스 상에서 연쇄적으로 삭제해야 할 것들과 하면 안될 것들의 경우를 판단하기로 했다.

  1. Schedule : 유지 (cascading remove X)
  2. Chatroom : 유지 (cascading remove X)
  3. BookmarkedBoard : 삭제 (cascading remove O)
  4. ReportedBoard : 삭제 (cascading remove O)

cascading remove를 하면 안되는 엔티티들이 존재하기에, board를 삭제할 때 관련된 엔티티들에 대해서 delete 쿼리를 날려주면 된다.
하지만 서비스 상에서 Chatroom의 경우, 삭제된 Board의 정보를 조회할 수 있으므로 이를 고려할 수 있는 삭제 방법을 찾아야 했다. 찾다가 Soft delete 방식을 발견했다.


Hard Delete vs Soft Delete

Hard deleteSoft delete는 특정 데이터를 삭제하는 방식에 대한 차이가 있다.

Hard Delete (물리 삭제)

DELETE FROM Board WHERE id = ?;

Hard delete란, DELETE 쿼리를 날려 실제 데이터베이스의 데이터를 삭제하는 방법이다. 삭제 할 데이터가 추후에 필요가 없는 경우 사용한다.

Soft Delete (논리 삭제)

UPDATE Board SET deleted = true WHERE id = ? 

Soft delete란, 삭제 여부를 판단할 수 있는 컬럼을 추가해 데이터가 삭제되었는지, 아닌지에 대한 값을 넣어서 표시한다. 삭제 할 데이터가 추후에 조회해야 하거나, 실수로 삭제했을 시 복원 가능해야 하는 중요한 도메인일 경우에 사용한다.

  • 단점
    삭제된 데이터도 DB에는 여전히 존재하기에, DB 용량이 매우 커진다. 또한, 불필요한 컬럼을 차지하게 되므로 해당 데이터를 SELECT 조회할 시 WHERE을 통한 필터링이 꼭 필요하게 된다.

Soft delete 구현 방법

Soft delete 구현 방법에 대한 여러 의견

  1. boolean으로 delete flag 필드를 추가해 삭제되면 true로 update 하기

    • 조회 시, 삭제 여부 필터링
    WHERE board.deleted = false
    • 삭제 시, UPDATE 쿼리
    UPDATE Board SET deleted = true WHERE id = ?
  1. DateTime deleted_at 필드를 추가해 삭제되면 삭제한 날짜/시간 정보 넣기
    • 조회 시, 삭제 여부 필터링
    WHERE board.deleted_at IS null
    • 삭제 시, UPDATE 쿼리
    UPDATE Board SET deleted_at = {삭제된_날짜_시간} WHERE id = ?

Stackoverflow에서는 위 두 방법 중 삭제된 날짜/시간 정보로 표현하는 것이 좋은 의견이라는 사람들이 많은 것 같다.


Soft Delete 적용하기

Soft Delete를 도입한 이유?

현재 서비스에서 게시글(Board)은 삭제되어도, 채팅방(Chatroom)에서 게시글 정보를 조회할 수 있어야 한다. 즉, 삭제 된 엔티티를 추후에 조회할 수 있어야 하는 것이다. 또한, Board에 대해 Soft Delete를 사용한다면 연쇄 삭제 되어야 하는 BookmarkedBoard, ReportedBoard도 연쇄 삭제할 필요가 없기 때문에 Soft Delete를 도입해보고자 한다.


Soft delete를 적용할 때 가장 많이 쓰이는 애노테이션이 두 가지 있다.

@SQLDelete

특정 엔티티에 @SQLDelete 애노테이션을 사용하면 해당 엔티티에 대해 DELETE 쿼리가 날라갈 경우, 내가 지정한 SQL 쿼리가 대체되어 나가도록 설정할 수 있다.

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

@SQLRestriction

특정 엔티티에 @SQLRestriction 애노테이션을 사용하면 해당 엔티티에 대해 작성한 조건을 일괄적으로 적용할 수 있다. (deprecated 된 @Where 어노테이션의 대안이다.)

@SQLRestriction("deleted = false")
@SQLDelete(sql = "UPDATE board SET deleted = true WHERE id = ?")
public class board{ ...

이렇게 작성하면 Board 엔티티를 조회할 때 삭제되지 않은 Board 데이터에 대해서만 조회할 수 있다.
@SQLRestriction은 모든 조회에 대해 적용되고 부분적으로 비활성화도 할 수 없다. 따라서 유연성이 매우 떨어지므로 확실히 적용해도 될 상황에 대해서만 사용해야 한다.

@SQLRestriction이 너무 강한 조건이라 생긴 어노테이션이 @FilterDef이다. 필요한 것들에 대해서 필터링을 동적으로 만들 수 있는데, 현재 엔티티를 유지하기 위해 해당 어노테이션은 적용하지 않기로 했다.


일단 Board entity에 boolean deleted 컬럼을 추가하기로 했다. 처음 Board를 생성할 땐 deleted의 default 값은 false이다.

아까 Board와 관련된 엔티티들 중에서, 조회할 때 Board가 삭제된 것도 조회되어야 하는 경우가 있는지에 대해 판단했다.

  1. Schedule : Board 조회 필요 (삭제된 Board여도 동행 Schedule은 유지 된다.)
  2. Chatroom : Board 조회 필요 (삭제된 Board여도 Chatroom은 유지된다.)
  3. BookmarkedBoard : Board 조회 X
  4. ReportedBoard : Board 조회 X

삭제된 Board도 조회될 수 있음을 고려해, @SQLRestriction 어노테이션은 사용하지 않기로 했다. 따라서 필요한 조회 쿼리들에 대해서 WHERE 조건을 추가해줘야 한다.

Board 엔티티에서 수정된 부분은 아래와 같다.

...
@SQLDelete(sql = "update board deleted = true where id = ?")
public class Board{
	...
	private boolean deleted = false;
    ...
}

이후, Board 엔티티와 관련된 조회 쿼리들 중에서, 삭제되지 않은 Board만 조회해야 하는 쿼리들은 WHERE 조건을 추가했다. 아래는 해당 일정과 매핑된 게시글을 찾는 BoardRepository 메서드의 쿼리이다.

@Query("SELECT b FROM Board b WHERE b.schedule = :schedule AND b.deleted = FALSE")
Optional<Board> findBySchedule(Schedule schedule);

WHERE 조건을 추가해 조회 시 쿼리 오류 발생

삭제되지 않은 Board에 대해서만 조회하는 메서드들에 대해 조회 조건을 추가하고 테스트 했는데 아래와 같은 에러가 발생했다.


java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '0) and b1_0.deleted=0' at line 1

b.deleted = false 조건의 SQL 문법이 잘못됐다고 한다. 왜 문제가 되나 싶었는데, MySQL에서 boolean을 지원하지 않아 boolean 타입은 DB에서 TINYINT(1) 타입으로 저장해 false는 0, true는 1로 들어간다. b.deleted = 0으로 변경하면, 필드 타입과 맞지 않아 컴파일 에러가 발생한다. 이를 해결할 다양한 방식을 찾아보기로 했다.

1) 조회 메서드의 인자로 deleted 여부 넘기기

나랑 같은 고민을 한 사람의 질문에 답변으로 달린 코드이다. 메서드의 인자로 deleted 여부를 넘기는 것인데, 불필요하게 메서드 인자가 늘어나는 것 같아 일단 이 방법은 최후의 보루로 남겨두기로 했다.

2) deleted 필드의 타입을 int로 바꾸기

현재 deleted 필드가 boolean 타입이므로 이를 int 형태로만 바꿔서 false는 0, true는 1로 두면 해결된다. 가장 편리한 방법이긴 하지만 int형으로 선언해 0,1 외의 값이 들어갈 수 있는 가능성 우려와, 필드의 정체성이 추상적이게 되진 않을까 싶어 고민하고 있었다.

3) Native Query 사용하기

JPQL이 아닌 SQL 문장을 사용하는 Native Query 적용 방법도 있다.

@Query("SELECT b FROM Board b WHERE b.schedule = :schedule AND b.deleted = 0", nativeQuery = true)
Optional<Board> findBySchedule(Schedule schedule);

Native Query를 사용해서 조회할 수도 있긴 하지만, 일부 메서드는 JPQL을 사용해 DTO 형태로 return 해야 하는 쿼리들이 있어서 적용할 수 없다고 판단했다.

세 가지 방법 중 가장 적용할만 한 방식으로는 2) deleted 필드 타입을 int로 바꾸기 가 가장 적절하다고 생각했다. 그래서 Board의 deleted 필드 타입을 int로 바꾸고 @SQLDelete 쿼리와 WHERE 조건문도 변경했다.

// Board Entity 수정 
...
@SQLDelete(sql = "update board deleted = 1 where id = ?")
public class Board{
	...
	private int deleted = 0;
    ...
}

// WHERE 조건 변경
@Query("SELECT b FROM Board b WHERE b.schedule = :schedule AND b.deleted = 0")
Optional<Board> findBySchedule(Schedule schedule);

int로 변경하니 모든 시나리오에서 의도한대로 작동되었다!


이번 서비스에서는 Soft delete가 적용되어야 할 필수적인 상황이었다. 이렇게 확실한 경우가 아닌 이상, Soft delete와 Hard delete가 상황에 따라 서비스에서 적용했을 때 장단점을 제대로 판단하고 적용을 해야할 것 같다.


참고
https://abstraction.blog/2015/06/28/soft-vs-hard-delete
https://resilient-923.tistory.com/419
https://thorben-janssen.com/hibernate-filter/
https://docs.jboss.org/hibernate/orm/6.4/javadocs/org/hibernate/annotations/SQLRestriction.html

0개의 댓글