프로젝트에서 게시글을 삭제하는데 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 엔티티와 관련된 엔티티들 중 서비스 상에서 연쇄적으로 삭제해야 할 것들과 하면 안될 것들의 경우를 판단하기로 했다.
Schedule
: 유지 (cascading remove X)Chatroom
: 유지 (cascading remove X)BookmarkedBoard
: 삭제 (cascading remove O)ReportedBoard
: 삭제 (cascading remove O)
cascading remove를 하면 안되는 엔티티들이 존재하기에, board를 삭제할 때 관련된 엔티티들에 대해서 delete 쿼리를 날려주면 된다.
하지만 서비스 상에서 Chatroom의 경우, 삭제된 Board의 정보를 조회할 수 있으므로 이를 고려할 수 있는 삭제 방법을 찾아야 했다. 찾다가 Soft delete 방식을 발견했다.
Hard delete와 Soft delete는 특정 데이터를 삭제하는 방식에 대한 차이가 있다.
DELETE FROM Board WHERE id = ?;
Hard delete란, DELETE 쿼리를 날려 실제 데이터베이스의 데이터를 삭제하는 방법이다. 삭제 할 데이터가 추후에 필요가 없는 경우 사용한다.
UPDATE Board SET deleted = true WHERE id = ?
Soft delete란, 삭제 여부를 판단할 수 있는 컬럼을 추가해 데이터가 삭제되었는지, 아닌지에 대한 값을 넣어서 표시한다. 삭제 할 데이터가 추후에 조회해야 하거나, 실수로 삭제했을 시 복원 가능해야 하는 중요한 도메인일 경우에 사용한다.
boolean으로 delete flag 필드를 추가해 삭제되면 true로 update 하기
WHERE board.deleted = false
UPDATE Board SET deleted = true WHERE id = ?
WHERE board.deleted_at IS null
UPDATE Board SET deleted_at = {삭제된_날짜_시간} WHERE id = ?
Stackoverflow에서는 위 두 방법 중 삭제된 날짜/시간 정보로 표현하는 것이 좋은 의견이라는 사람들이 많은 것 같다.
현재 서비스에서 게시글(Board
)은 삭제되어도, 채팅방(Chatroom
)에서 게시글 정보를 조회할 수 있어야 한다. 즉, 삭제 된 엔티티를 추후에 조회할 수 있어야 하는 것이다. 또한, Board
에 대해 Soft Delete를 사용한다면 연쇄 삭제 되어야 하는 BookmarkedBoard
, ReportedBoard
도 연쇄 삭제할 필요가 없기 때문에 Soft Delete를 도입해보고자 한다.
Soft delete를 적용할 때 가장 많이 쓰이는 애노테이션이 두 가지 있다.
특정 엔티티에 @SQLDelete
애노테이션을 사용하면 해당 엔티티에 대해 DELETE 쿼리가 날라갈 경우, 내가 지정한 SQL 쿼리가 대체되어 나가도록 설정할 수 있다.
@SQLDelete(sql = "UPDATE board SET deleted = true WHERE id = ?")
public class Board{ ...
특정 엔티티에 @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가 삭제된 것도 조회되어야 하는 경우가 있는지에 대해 판단했다.
Schedule
: Board 조회 필요 (삭제된 Board여도 동행 Schedule은 유지 된다.)Chatroom
: Board 조회 필요 (삭제된 Board여도 Chatroom은 유지된다.)BookmarkedBoard
: Board 조회 XReportedBoard
: 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);
삭제되지 않은 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
으로 변경하면, 필드 타입과 맞지 않아 컴파일 에러가 발생한다. 이를 해결할 다양한 방식을 찾아보기로 했다.
나랑 같은 고민을 한 사람의 질문에 답변으로 달린 코드이다. 메서드의 인자로 deleted 여부를 넘기는 것인데, 불필요하게 메서드 인자가 늘어나는 것 같아 일단 이 방법은 최후의 보루로 남겨두기로 했다.
현재 deleted 필드가 boolean 타입이므로 이를 int 형태로만 바꿔서 false는 0, true는 1로 두면 해결된다. 가장 편리한 방법이긴 하지만 int형으로 선언해 0,1 외의 값이 들어갈 수 있는 가능성 우려와, 필드의 정체성이 추상적이게 되진 않을까 싶어 고민하고 있었다.
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