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가 적용되는 이슈
- 일반적으로 문제가 되지는 않지만, 특정 상황에서 문제가 생긴다.
'개발' 카테고리의 다른 글
| 분산 DB 환경에서 데이터 정합성 지키기 (0) | 2026.04.01 |
|---|---|
| [Java, Spring, Aop] SoftDelete 적용기 (2) (2) | 2026.02.14 |
| 벤처투자 ERP 개발을 위한 도메인 공부 (1) (1) | 2025.09.20 |
| 이미지(파일) 업로드 개선 (6) | 2025.06.07 |
| 배포 전략 (2) | 2025.05.05 |