본문 바로가기

개발

[Java, Spring, Aop] SoftDelete 적용기

Troublog 에서는 일부 테이블의 데이터에 대해 softDelete를 적용하고 있다.

 

적용 대상이 되는 테이블은 terms, users, projects, posts, contents, post_tags, comments (대댓글이 존재하는 경우)로,
단순 데이터 삭제보다 히스토리 보존이 더 중요한 경우 softDelete를 적용했다.

 

그러나 softDelete를 적용한 후에 몇 가지 문제점(불편한 점)을 발견했고, 이를 개선해 나간 내용을 공유하고자 한다.

 

- 기존 구현 방식 -

1. softDelete여부 칼럼을 is_deleted (boolean) 혹은 deleted_at (timestamp)로 둠

 

2. is_deleted : false -> true로 변경 혹은 deleted_at : null -> now()로 변경하는 softDelete메서드를 따로 만듦

 

3. softDelete 된 데이터 조회 방법

- jpa 네이밍 쿼리 : findByIdAndIsDeletedFalse 혹은 AndDeletedAtIsNull로 조회

- jpql : table.isDeleted = false 혹은 table.deletedAt is null로 조회

- nativequery : table.is_deleted = false 혹은 table.deleted_at is null로 조회


- 문제점 -

1. 컨벤션 없이 3명의 개발자가 각자 맡은 모듈에 대해 softDelete를 구현해 구현 방식이 통일되지 않음

- softDelete여부 칼럼을 is_deleted (boolean)으로 두는 경우와 deleted_at (timestamp)로 두는 경우가 혼재

 

2. 데이터를 조회하는 jpa 네이밍쿼리 메서드마다 AndIsDeletedFalse 혹은 AndDeletedAtIsNull과 같은 suffix를 붙여줘야 함

 

3. softDelete 된 데이터와 연관관계를 갖지만, softDelete를 적용하지는 않는 데이터의 존속 여부

-> 해당 문제점은 jpa 연관관계와 더 관련 있는 문제점이라, 추후 다른 글을 통해 다뤄볼 예정이다!

 

- 해결방안 -

해결방법을 찾던 중 jpa에서 몇 가지 어노테이션으로 softDelete를 지원하는 것을 알게 되었다.

 

1. @SQLDelete @SQLRestriction 어노테이션 사용

@SQLDelete(sql = "UPDATE posts SET deleted_at = current_timestamp WHERE post_id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Post extends BaseEntity {
	
    // 인스턴스 필드들...
}

 

@SQLDelete 를 통해 엔티티 삭제 시 Hibernate가 생성하는 delete sql문을 개발자가 지정한 sql로 치환 가능하다.

User엔티티에 대한 remove(), delete() 호출 시 미리 정의해 둔 update문이 나가게 된다.

 

또한 @SQLRestriction 을 통해 엔티티를 조회할 때 Hibernate가 생성하는 select sql문에 항상 개발자가 지정한 where절이 추가된다. 위처럼 'where deleted_at is null'이라는 조건이 붙게 된다.

 

2. @SoftDelete 어노테이션 사용

@SoftDelete
public class Post extends BaseEntity {
	
    // 인스턴스 필드들...
}

 

@SoftDelete 어노테이션은 @SQLDelete @SQLRestriction 을 합친 것이라 생각하면 편하다.

1. delete 요청 -> update문으로 sql문이 나감

2. 데이터 조회 시 where절이 필수적으로 붙음

 

- 무엇을 사용할까 -

@SoftDelete 어노테이션 하나만으로 @SQLDelete, @SQLRestriction 두 어노테이션을 합친 효과를 가져갈 수 있기에 2번 방법을 선택하려 했으나, @SoftDelete 는 아직 LocalDateTime 타입을 지원해 주지 않는다는 것을 알게 되었다. (Hibernate 7부터 지원해 준다)

 

울며 겨자 먹기로 @SQLDelete, @SQLRestriction 를 이용해 구현을 했고, 성공적으로 작동하는 것을 확인했다.

 

그러나 한 가지 문제가 더 있었는데,

Hibernate는 @SQLRestriction 이 붙은 엔티티를 조회하는 모든 로직에 'where deleted_at is null'이라는 조건절을 필수적으로 추가한다는 것이었다. 

 

따라서 사용자의 요구사항이나 admin기능으로 삭제된 데이터를 확인하고자 하는 비즈니스 요구사항이 추가된다면, 이를 무조건 native 쿼리로 구현할 수밖에 없었다. 

 

- 더 나은 방법이? -

@Filter, @FilterDef 을 사용해 보자

 

HIbernate에서는 엔티티 조회 시점에 Session 단위로 동적으로 SQL 조건을 추가하도록 Filter를 제공한다.

@FilterDef(name = "softDeleteFilter")
@Filter(
	name = "softDeleteFilter",
	condition = "deleted_at IS NULL"
)

 

@FilterDef 는 필터를 정의하는 역할을 한다.

'parameter = {} '옵션으로 사용할 파라미터의 스펙을 정의할 수 있다.

@Filter 는 필터의 적용 조건을 설명한다.

@FilterDef 로 정의한 필터에 대해 어떤 조건 (where절)으로 사용할 것인지 정의한다.

 

@Getter
@MappedSuperclass
@FilterDef(name = "softDeleteFilter")
@Filter(
	name = "softDeleteFilter",
	condition = "deleted_at IS NULL"
)
public abstract class SoftDeleteEntity extends BaseEntity {

	@Column(name = "deleted_at")
	protected LocalDateTime deletedAt;

	public boolean isDeleted() {
		return deletedAt != null;
	}
}

 

기존 @SQLRestriction 대신 @Filter, @FilterDef 를 사용할 수 있겠다는 생각이 들었고,

softDelete를 적용하는 모든 엔티티에 두 어노테이션을 붙이기보다, BaseEntity를 상속받는 SoftDeleteEntity를 새로 생성해 softDelete 대상 엔티티들이 이를 상속받도록 했다.

 

@SQLDelete(sql = "UPDATE posts SET deleted_at = current_timestamp WHERE post_id = ?")
public class Post extends SoftDeleteEntity {
	
    // 인스턴스 필드들...
}

 

기존 엔티티는 @SQLRestriction 이 사라지고, SoftDeleteEntity를 상속받게 된다.

 

@Transactional(readOnly = true)
public Page<Post> getSoftDeletedTroubles(Long userId, Pageable pageable) {

	Session session = em.unwrap(Session.class);
	session.enableFilter("softDeleteFilter");

	Page<Post> page = postRepository.findAllByUser_Id(userId, pageable);
	log.info("[Post] 논리삭제된 트러블슈팅 조회: userId={}, total={}, page={}, size={}, elementsInPage={}", userId, page.getTotalElements(), page.getNumber(), page.getSize(), page.getNumberOfElements());

	session.disableFilter("softDeleteFilter");
	return page;
}

그리고 Service의 조회 로직에서 앞서 지정해 둔 "softDeleteFilter"를 적용해 조회를 하면 

 

post조회 시 deleted_at IS NULL 조건이 붙은 것을 볼 수 있다.

 

-  깔끔하게 사용해 보자 -

"softDeleteFilter" 적용 여부에 따라 조회 로직이 달라지도록 작업했는데, 문제는 

 

Session session = em.unwrap(Session.class);
session.enableFilter("softDeleteFilter");

// ..조회로직

session.disableFilter("softDeleteFilter");

조회 메서드마다 해당 로직을 모두 넣어줘야 한다는 것이 상당히 귀찮은 작업이라는 것이다.

 

비즈니스 로직과는 관련이 없지만, 프로젝트 내 대부분의 트랜잭션 경계에 모두 적용되어야 하기에 AOP와 어노테이션을 이용해 공통 로직을 구현하기로 했다.

 

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApplySoftDeleteFilter {
	boolean value() default true;
}

 

@ApplySoftDeleteFilter 라는 어노테이션을 만들고, value()를 지정해 true -> softDelete 적용 명시 / false -> softDelete 적용 X

로 설정해 두었다.

 

@Aspect
@Component
@RequiredArgsConstructor
public class SoftDeleteFilterAspect {

	public static final String SOFT_DELETE_FILTER = "softDeleteFilter";

	private final EntityManager em;

	@Around(
		"@annotation(troublog.backend.global.common.annotation.ApplySoftDeleteFilter) || "
			+ "@within(troublog.backend.global.common.annotation.ApplySoftDeleteFilter)"
	)
	public Object applyFilter(
		ProceedingJoinPoint pjp
	) throws Throwable {

		Method method = ((MethodSignature)pjp.getSignature()).getMethod();

		ApplySoftDeleteFilter ann =
			AnnotationUtils.findAnnotation(method, ApplySoftDeleteFilter.class);

		if (ann == null) {
			ann = AnnotationUtils.findAnnotation(
				pjp.getTarget().getClass(),
				ApplySoftDeleteFilter.class
			);
		}

		// value=false면 아예 필터 적용 안 함
		if (ann != null && !ann.value()) {
			return pjp.proceed();
		}

		Session session = em.unwrap(Session.class);

		boolean appliedHere = false;

		if (session.getEnabledFilter(SOFT_DELETE_FILTER) == null) {
			session.enableFilter(SOFT_DELETE_FILTER);
			appliedHere = true;
		}

		try {
			return pjp.proceed();
		} finally {
			if (appliedHere) {
				session.disableFilter(SOFT_DELETE_FILTER);
			}
		}
	}
}

 

SoftDeleteFilterAspect라는 aop클래스를 만들어 조회 메서드에 @ApplySoftDeleteFilter 를 붙이는 경우 softDelete 필터링이 적용되고, @ApplySoftDeleteFilter(value= false)를 붙이는 경우 필터링이 적용되지 않도록 지정했다.

 

@within 포인트컷을 이용해 해당 어노테이션이 붙은 클래스에 속한 모든 메서드에도 적용이 된다.

 

@Transactional(readOnly = true)
@ApplySoftDeleteFilter
public Page<Post> getSoftDeletedTroubles(Long userId, Pageable pageable) {
	Page<Post> page = postRepository.findAllByUser_Id(userId, pageable);
	log.info("[Post] 논리삭제된 트러블슈팅 조회: userId={}, total={}, page={}, size={}, elementsInPage={}", userId, page.getTotalElements(), page.getNumber(), page.getSize(), page.getNumberOfElements());
	return page;
}

 

FilterEnable, disable 로직 없이 @ApplySoftDeleteFilter 하나만으로 softDelete적용이 가능해졌다..!

 

getSoftDeletedTroubles에 @Transactional(readOnly=true)가 빠져있는 이유는 해당 메서드가 속한 클래스는 모두 조회메서드만 담고 있어, 클래스레벨에서 @Transactional어노테이션을 붙여주고 있기 때문이다.

 

이를 통해 조회 메서드에서 softDelete관련 코드가 제거되고, 어노테이션 하나로 선언적으로 softDelete를 on/off 할 수 있는 구조를 갖게 되었다.

-  결론 -

softDelete 로직을 리팩토링 하는 과정에서, 다양한 구현 수단들을 비교해 가며 최적의 설계 방향을 잡는 것이 매우 어려웠다.

 

@SoftDelete 어노테이션을 사용하려 했지만 LocalDateTime타입을 지원해 주지 않는 것을 알았을 때, @SQLRestriction 을 사용하지 않고, Filter, aop를 사용하기로 결정했을 때 모두 글로 작성하면 순간인 것처럼 보이지만, 길게는 며칠을 고민하기도 했었다.

 

긴 고민 끝에 원하는 결과를 얻어 기쁘기도 하고, 더 도전적인 경험을 해보고 싶다는 생각도 든다.

 

이런 경험을 바탕으로 회사에서도 기능 구현에만 급급하지 말고 항상 기술적으로, 비즈니스 적으로 서비스에 도움이 될 수 있는 방향으로 작업할 수 있도록 해야겠다~!!

 

-  추가적인 문제점 -

글 업로드 이후, 리팩토링을 마저 진행하던 중 추가적인 문제점들을 발견해 이를 다룬 글을 업로드 할 예정이다.

 

1. ApplySoftDeleteFilter는 개발자가 직접 생성한 jpa네이밍 쿼리, jpql 에만 적용되는 이슈

 

2. ApplySoftDeleteFilter를 사용할 경우 root엔티티에만 softDelete가 적용되는 이슈 

- 일반적으로 문제가 되지는 않지만, 특정 상황에서 문제가 생긴다.