[Spring] 회원탈퇴, Soft Delete로 구현하기 (JPA)
회원탈퇴를 구현하며 ...
일정 관리 Develop 과제를 수행하다가 Delete User, 즉 회원탈퇴를 구현해야 하는 상황이 왔다.
현재 User는 Todo와 Comment의 FK로써 연관관계를 가진다. (그렇지만 Comment는 아직 기능을 구현하지 않았다.)
데이터의 삭제를 구현하는 방법은 두 가지가 있다.
Hard Delete
데이터베이스의 User 테이블에서 실제로 해당 유저의 데이터를 물리적으로 삭제하는 방법이다.
이 경우 외래 키 관계를 주의해서 다루어야 한다. 예를 들어 User 테이블을 삭제하려고 한다면 Todo 테이블에 해당 사용자의 데이터가 남아있다면 DB에서 무결성 오류를 던진다. 이런 오류를 방지하기 위해서는 FK로 연결된 데이터를 먼저 삭제해주거나, 혹은 ``ON DELETE CASCADE`` 설정을 해서 자동으로 해당 테이블의 ID를 FK로 가지는 데이터들을 함께 삭제해주어야 한다.
(🔫 데이터 하나를 삭제했다가 관련 데이터가 폭풍처럼 싹 날아가버릴 수 있기 때문에 주의해야 한다.)
언제 Hard Delete를 써야 할까?
완전히 삭제해야 하는 경우, 복구가 필요 없는 경우 Hard Delete를 사용한다.
- 법적으로 완전히 삭제해야 할 때 ex) 개인정보
- 캐시 데이터, 세션 데이터 등
주의할 점
- 연관된 데이터(FK)가 있을 경우 반드시 함께 삭제하거나 FK 설정을 ``ON DELETE CASCADE``로 관리해야 한다.
Soft Delete
데이터베이스에서 실제로 데이터를 삭제하지 않지만 논리적으로 삭제하여 사용자가 데이터에 접근할 수 없도록 하는 방법이다. 주로 ``deleted = true`` 와 같은 플래그를 설정한다.
언제 Soft Delete를 써야 할까?
데이터를 보존할 필요가 있거나, 삭제 후에도 정보가 남아 있어야 하는 경우
- 회원 탈퇴
- 주문/결제 기록 등
여러 테이블에 FK로 많이 설정되는 유저 테이블의 경우 회원탈퇴는 주로 Soft Delete로 구현한다고 한다.
Hard Delete & Soft Delete 혼합 전략
Soft Delete로 먼저 삭제 처리한 후, 일정 기간 후에 Hard Delete로 완전 삭제하는 전략을 사용할 수도 있다.
결국, 프로젝트 성격과 상황에 맞추어 삭제 전략을 선택하면 된다.
차후 서비스 운영에 필요한 데이터인지, DB 공간을 차지하는 것만큼의 효용이 있을 것인지를 잘 판단하여 적용하여야 할 것 같다.
JPA로 Soft Delete를 구현해보자 !
이번 과제에서 회원탈퇴, 즉 User의 삭제를 soft delete로 구현해보기로 했다.
User를 Soft Delete처리 한 후에 User와 연관된 테이블의 데이터는 어떻게 처리해야 할까?
찾아보니 이 역시 비즈니스 정책 상 여러 방법이 있는 것 같다.
- FK 필드에 null을 허용하고 탈퇴한 사용자가 작성한 데이터의 FK에 null을 넣는다.
- Soft Delete 필드를 추가하여 함께 Soft Delete 처리한다. (ex. deleted_at 컬럼 추가)
- Hard Delete 처리한다.
- 익명 사용자 정보를 넣어 처리한다. 등등
User 테이블과 연관된 Todo 테이블의 경우 직접적으로 사용자의 삭제 요청이 들어올 경우 Hard Delete를 하고 있다.
만일 User가 Soft Delete 처리된 경우, Todo 테이블 역시 Soft Delete 되도록 하고 직접 삭제 요청의 경우 그대로 Hard Delete되도록 구현하기로 했다.
1. delete 마킹 필드 추가
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100)
private String name;
@Column(unique = true)
private String email;
private String password;
@Enumerated(EnumType.STRING)
private AccountStatus accountStatus;
@Builder
public User(String name, String email, String password) {
this.name = name;
this.email = email;
this.password = password;
this.accountStatus = AccountStatus.ACTIVE;
}
public void update(String name, String password) {
this.name = name;
this.password = password;
}
public void setAccountStatus(AccountStatus accountStatus) {
this.accountStatus = accountStatus;
}
}
현재 User Entity 클래스이다.
상속하고 있는 `BaseTimeEntity`는 JPA Auditing 기능을 활용하기 위한 객체로 객체 생성 시각과 수정 시각을 저장한다.
원래 User의 `AccountStatus` 필드를 통해서 Soft Delete 여부를 확인하려고 했으나 이제 User와 Todo 모두 Soft Delete 처리가 필요해졌으므로 공통 필드를 넣어주기로 했다.
공통 필드는 `deleted_at`으로 삭제된 시각을 저장하도록 한다. `BaseTimeEntity`의 필드로 추가해주었다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
}

데이터 생성 시 `deletedAt`에는 null값이 들어가며, 논리 삭제 시 컬럼에 삭제 시각을 넣어 update 처리하여 저장하면 된다.
2. Soft Delete 처리
이제 실제로 update 쿼리를 보내는 로직을 작성하여야 한다.
이 때 Hibernate가 제공하는 ``@SQLDelete`` 어노테이션을 사용할 수 있다.

JPA delete 작업 시 대신 실행할 DML문을 작성해주면 된다.
다음과 같이 작성해보았다.
@SQLDelete(sql = "UPDATE users SET deleted_at = NOW() where id = ?")
UserService
@Transactional
public void unregister(Long userId) {
User user = getUserById(userId);
userRepository.delete(user);
}
이제 userRepository.delete(user); 작업을 수행하면 delete문 대신 update문이 나갈 것이다!
확인해보자.

회원탈퇴 요청을 보냈고,

예상한대로 select 쿼리 하나와 update 쿼리 하나가 나가는 것을 확인하였다.
``@SQLDelete`` 어노테이션을 사용하면 해당 엔티티를 삭제할 때 무조건 직접 지정한 SQL문이 실행된다.
따라서 삭제 시 Soft Delete의 로직만 수행하는 User의 경우 문제가 없지만, Hard/Soft Delete를 모두 수행하여야 하는 Todo의 경우 적용할 수 없다.
따라서 Repository에 쿼리문을 직접 작성하여 softDelete 메서드를 따로 구현하는 방법을 선택했다.
UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
@Modifying
@Query(
value = "UPDATE users SET deleted_at = NOW() where id = ?",
nativeQuery = true
)
void softDelete(Long userId);
}
TodoRepository
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Override
Page<Todo> findAll(Pageable pageable);
@Modifying
@Query(
value = "UPDATE todos SET deleted_at = NOW() WHERE user_id = ?",
nativeQuery = true
)
void softDeleteByUserId(Long userId);
}
이제 사용자로부터 todo 삭제 요청이 들어온다면 delete(todo)를,
회원탈퇴로 인한 논리 삭제라면 softDeleteByUserId(userId)를 사용하면 된다.
삭제 로직
@Transactional
public void unregister(Long userId) {
userService.unregister(userId);
todoService.softDeleteByUserId(userId);
}
3. 데이터 조회 시 논리삭제 데이터 제외하기
User 조회시 탈퇴한 회원의 데이터를 제외시켜야 한다.
Todo 역시 조회 시 회원탈퇴로 인해 논리삭제된 데이터를 제외하고 조회하여야 한다.
이를 위해 Hibernate가 제공하는 ``@SQLRestriction``어노테이션을 사용할 수 있다. (🙇🏻♀️🙇🏻♀️ 감삼다 감삼다)

해당 엔티티를 조회(select문)할 때 where 절에 들어갈 SQL문을 작성하면 된다.
Todo, User 엔티티에 다음 어노테이션을 추가해주었다.
@SQLRestriction("deleted_at is NULL")
TEST
다음과 같이 탈퇴 테스트를 위한 유저를 생성하고, Todo를 하나 생성해주었다.

로그인 후 Todo를 생성했다.

전체를 조회하면 아래와 같이 방금 생성한 Todo가 포함되어서 잘 나온다.

이제 회원탈퇴를 진행해보자.


- user select 쿼리
- user update 쿼리 (accountStatus)
- user update 쿼리 (deleted_at)
- todo update 쿼리 (deleted_at)
총 4개의 쿼리가 발생하는 것을 확인할 수 있다.
accountStatus update 쿼리가 발생하는 이유는 UserService의 unregister 메서드에서 set으로 `accountStatus`를 변경하는 로직을 포함하기 때문이다.
// UserService
public void unregister(Long userId) {
User user = getUserById(userId); // 첫 번째 쿼리 발생
user.setAccountStatus(AccountStatus.DELETED); // 두 번째 쿼리 발생
userRepository.softDelete(userId); // 세 번째 쿼리 발생
}
native query에서 accountStatus를 업데이트하면 3개의 쿼리로 줄일 수 있을 것이다.
그런데 굳이 세터로 업데이트하도록 작성한 이유는, 두 작업의 책임이 미묘하게 다르다고 생각했기 때문이다.
AccountStatus의 종류가 지금은 `ACTIVE / DELETED` 밖에 없지만 추후 `SUSPEND` 등과 같이 여러 개로 늘어날 수 있고, 이 경우 setAccountStatus 메서드를 통해 업데이트 작업을 수행할 것이다. native query로 뺀 soft delete 로직은 deleted_at을 업데이트하는 것까지만 책임을 갖는 것이 더 명확하다고 생각했다. AccountStatus 종류 중 'DELETE'만 native query로 업데이트 되는 것은 적절하지 못한 것 같다 🤔
테이블을 확인해보자


예상대로 잘 deleted_at이 설정되었고, users 테이블의 경우 account_status까지 업데이트 되었다.
이제 다시 조회해보자.


정상적으로 동작하는 것 완료 !
