본문 바로가기
320x100
320x100

정말 오랜~~~만의 글이다..ㅠㅠ 쓸 거리들 넘쳐나니까 딱 기다려요!
 

Soft Delete?

일단 Soft Delete란 논리적으로 필드를 생성해서 지워주는 방식이다.
반대로 Hard Delete는 실제로 DB상에서 물리적으로 레코드를 지우는 방식이다.
 

예전 버전 방식(Deprecated)

Old
6.3부터 deprecated

 
@Where 어노테이션을 예전에는 사용했지만.... 스프링 버전이 엄청 올라오면서 stater로 끌어당기는 JPA내부에 있는 Hibernate 버전도 같이 올라가서 저 어노테이션은 deprecated 되었다.
 

@SQLRestriction 사용

@SQLRestriction("is_active = true")
@Table(name = "users")
@Entity
class UserEntity(
  @Column(unique = false, nullable = false, updatable = false)
  val email: String,
  
  @Column(length = 20, unique = false, nullable = false)
  var name: String,

  var contact: String,
  
  @Enumerated(EnumType.STRING)
  @Column(nullable = false, updatable = false)
  val role: UserRole = UserRole.USER,
	
  var isActive: Boolean = true,
	
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long? = null,
): Auditable() {

  fun softDeleteUser() {
    this.isActive = false
  }
  
}

UserEntity.kt
 

import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository

@Repository
interface UserRepository: JpaRepository<UserEntity, Long>, KotlinJdslJpqlExecutor {
  @Query("SELECT u FROM UserEntity u WHERE u.isActive = :isActive")
  fun findUsers(isActive: Boolean): List<UserEntity>
}

UserRepository.kt
 

@Transactional(readOnly = true)
fun findUsers(isActive: Boolean): List<CommonUserResponse> {
  return userRepository.findUsers(isActive).map(CommonUserResponse::of)
}

UserService.kt
 

@RequestMapping(API_V1_USERS)
@RestController
class UserController(
  private val userService: UserService,
): UserApiSpec {

  @GetMapping
  fun findUsers(
    @RequestParam isActive: Boolean
  ): ResponseEntity<List<CommonUserResponse>> {
    return ResponseEntity.ok().body(userService.findUsers(isActive))
  }

}

UserController.kt
 

테스트

- 기본적으로 User가 2명 존재한다.

 
1. 유저 삭제(1번)

soft delete user

2. 전체 유저 조회

find users

빈 배열로 나옴
 
3. 쿼리 확인

JPQL 1
JPQL 2

JPQL의 파라미터는 원하는대로 잘 들어갔다.
그러나…..
where문에 false로 넣은 파라미터가 맨 뒤에 ?로 들어가있지만…. 앞에 ( ) 쿼리 내부에도 true가 붙으면서
WHERE is_active = true AND is_active = false 가 되어버려서 유저가 없다고 나와서 빈 배열이 응답된다.
true로 파라미터를 보내봤자 어차피 또 WHERE is_active = true AND is_active = true가 되어서 논리적 삭제로 지운 유저는 볼 수 없게 되기때문에 이건 쓸모가 없다.
 
→ 하지만 Admin프로젝트가 따로 존재하거나(다른 애플리케이션/프로세스), Admin 대시보드같은 곳 없이 유저기능으로만 모든 것을 만들고 탈퇴(논리적 삭제)한 유저는 DB로 직접 처리하길 원한다면 이 방법을 택해도 좋다.
하지만 대부분의 회사에서는 Admin페이지에서 다 볼 수 있게 하고싶을 것이다.. 다시 말해 @SQLRestriction는 쓰면 안된다!
 

@FilterDef, @Filter 사용 -

@FilterDef(name = "activeUserFilter", parameters = [ParamDef(name = "isActive", type = Boolean::class)])
@Filter(name = "activeUserFilter", condition = "is_active = :isActive")
@Table(name = "users")
@Entity
class UserEntity(
  @Column(unique = false, nullable = false, updatable = false)
  val email: String,
  
  @Column(length = 20, unique = false, nullable = false)
  var name: String,

  var contact: String,
  
  @Enumerated(EnumType.STRING)
  @Column(nullable = false, updatable = false)
  val role: UserRole = UserRole.USER,
	
  var isActive: Boolean = true,
	
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long? = null,
): Auditable() {

  fun softDeleteUser() {
    this.isActive = false
  }
  
}

UserEntity.kt
 
이제는 관리자와 유저(비관리자) 코드도 한번 살펴보자.
관리자에서는 파라미터를 받아서 활성화/비활성화된 유저를 볼 수 있고, 관리자가 아닌 다른 서비스에서는 active된 유저만 보는 것이 요구조건이라고 해보자.
먼저 단순 유저쪽(비관리자) 조회코드부터 살펴보자.

/* 기존 */
@Transactional(readOnly = true)
fun findUsers(isActive: Boolean): List<CommonUserResponse> {
  return userRepository.findUsers(isActive).map(CommonUserResponse::of)
}

/* 수정 */
@Transactional(readOnly = true)
fun findUsers(): List<CommonUserResponse> {
  val session = entityManager.unwrap(org.hibernate.Session::class.java)
  session.enableFilter("activeUserFilter").setParameter("isActive", true)

  return userRepository.findAll().map(CommonUserResponse::of)
}

UserService.kt
 

@Service
class UserService(
  private val userRepository: UserRepository,
  private val entityManager: EntityManager,
) {
  ....
}

UserService.kt
당연히 생성자 주입으로(코틀린의 경우 주생성자) EntityManager를 주입받으면 된다.
 

테스트

1. 유저 삭제(1번)

soft delete user

 
2. 전체 유저 조회

find users

조회하면 (논리적) 삭제된 1명의 유저만 나온다(최초는 2명)
쿼리도 한번 보면…..
 
3. 쿼리 확인

query

아무런 파라미터도 넘겨주지 않았는데 true로 들어간 것을 볼 수 있다!!
 
 
이번에는 관리자 조회 코드를 살펴보자.
관리자쪽은 HTTP Method의 파라미터를 넘겨받아서 하이버네이트 세션 필터에 파라미터로 넘겨주면 된다.

@GetMapping
override fun findUsers(
  query: UserSearchCond,
  @SortDefault(sort = ["createdAt"], direction = Sort.Direction.DESC) sort: Sort,
  @RequestParam isActive: Boolean,
): ResponseEntity<List<CommonUserResponse>> {
  return ResponseEntity.ok().body(adminService.findUsers(query, sort, isActive))
}

AdminUserController.kt
 

@Transactional(readOnly = true)
fun findUsers(query: UserSearchCond, sort: Sort, isActive: Boolean): List<CommonUserResponse> {
  val session = entityManager.unwrap(org.hibernate.Session::class.java)
  session.enableFilter("activeUserFilter").setParameter("isActive", isActive)

  val supportedProperties = mapOf(
    "id" to UserEntity::id,
    "email" to UserEntity::email,
    "createdAt" to UserEntity::createdAt,
    "updatedAt" to UserEntity::updatedAt,
  )

  return userRepository.findAll {
    select(entity(UserEntity::class))
      .from(entity(UserEntity::class))
      .whereAnd(
        query.searchType
          ?.takeIf { it != UserSearchType.ALL }
          ?.run {
            query.searchText
              ?.takeIf { it.isNotBlank() }
              ?.let {
                when (this) {
                  UserSearchType.ALL -> null
                  UserSearchType.ID -> path(UserEntity::email).equal(it)
                  UserSearchType.NAME -> path(UserEntity::name).equal(it)
                  UserSearchType.CONTACT -> path(UserEntity::contact).equal(it)
                }
              }
          }
      )
      .orderBy(
        *sort.toList().mapNotNull { order ->
          val kProperty = supportedProperties[order.property] ?: return@mapNotNull null
          val pathExpr = path(kProperty)
          if (order.isAscending) pathExpr.asc() else pathExpr.desc()
        }.toTypedArray()
      )
  }.filterNotNull().map(CommonUserResponse::of)
}

AdminUserService.kt
 
나는 코틀린을 사용한 이후로 Querydsl보다는 JDSL을 주로 사용하고 있다.
Java로 만들어지는 QClass도 필요없고, 따로 gradle task가 필요하지도 않다.

테스트

다시 최초 유저가 2명이 되도록 서버를 재시작했다.
 
1. 유저 삭제(1번)

delete user

2. 활성화된 유저 조회

find users/active

  2 - a. 쿼리 확인

query

is_active = ? 이후에 바인딩된 값을 보면 true로 들어갔다.
 
3. 비활성화된 유저 조회

find users/non-active

  3 - a. 쿼리 확인

query

is_active = ? 이후에 바인딩된 값을 보면 false로 들어갔다.
 
어쨌든 이렇게 관리자페이지에서는 활성화된 유저/비활성화된 유저를 볼 수 있개 됐다.
혹자는 모든 유저를 볼 수 있는 ALL, TRUE, FALSE 3개를 만들겠지만…. 조금만 더 생각해보자.
관리자가 굳이 활성화/비활성화된 유저들을 한 테이블 내에서 같이 볼 일은 없을거라고 생각한다.
default value를 true로 하고, FE에서 Dropdown(select)에서 비활을 선택하면 false가 넘어가서 탈퇴한 유저를 따로 보는 그 2가지면 충분하다고 생각한다.
 
 
그럼 여기서 질문이 있을 수 있다.
음……그럼 Admin은 잘 알겠어요. 다만 관리자를 제외한 다른 서비스로직에 모두

// 하이버네이트 세션에 필터 적용
val session = entityManager.unwrap(org.hibernate.Session::class.java)
session.enableFilter("activeUserFilter").setParameter("isActive", true)

// 로직

이런 코드를 작성해야 하나요..? 라는 질문이 있을 수 있다!
 
그러면... 이제 간단하게 흠. 생성자 또는 초기화 로직에 추가하면 되겠지? 라고 생각할 수 있다.
일단 고민하지 말고 해보자ㅎㅎ

@Service
class UserService(
  private val userRepository: UserRepository,
  private val entityManager: EntityManager,
) {

  init {
    val session = this.entityManager.unwrap(org.hibernate.Session::class.java)
    session.enableFilter("activeUserFilter").setParameter("isActive", true)
  }
  
  @Transactional(readOnly = true)
  fun findUsers(): List<CommonUserResponse> {
    return userRepository.findAll().map(CommonUserResponse::of)
  }
  
}

과연…… 잘 작동할까??ㅎ
 

테스트

서버 재가동하고....(DB 초기화)
 
1. 삭제

delete user/1

2. 조회

find users

조회해보면 2명으로 나온다
결론적으로 적용이 안됐다.
왜냐?

  1. Hibernate의 Filter는 Session Scope이다.(Hibernate Session)
  2. Spring은 @Transactional마다 새로운 Session이 열린다.
  3. init 시점에 enableFilter를 해도, 트랜잭션에서 생성되는 Session에는 적용되지 않는다.
  4. 따라서 트랜잭션 경계(또는 요청 경계)마다 enable/disable 처리해야 한다.

그니깐 init 블록에선 살아있었지만, 새로운 트랜잭션이 생성될 때마다 하이버네이트 Session이 새로 생기기 때문에 거기서는 Filter가 붙지 않은 채로 쿼리가 나가게 되는 것이다.
 
음......그럼 모든 메서드 상단에 저 코드를 넣어줘야돼!??
 
아니지 아니지~ Spring을 공부한 사람들이라면 여기서 어떻게 해야할지 알 것이다.
Annotation을 만들고 AOP에서 이 필터를 체크하고, 없더라도 전역으로 모든 트랜잭션마다 하이버네이트 커스텀 필터를 활성화하도록 해준다.
그리고 AdminService에서만 직접 코드로 필터에 파라미터를 넘겨받는 방식으로 작성하면 된다.
그럼 ㄱㄱ 해보자!!
 

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ActiveUserFilter(val value: Boolean = true)

ActiveUserFilter.kt
 

@Aspect
@Component
class HibernateActiveUserFilterAspect(private val em: EntityManager) {

  @Around("@annotation(transactional)")
  fun applyDefaultFilter(pjp: ProceedingJoinPoint, transactional: Transactional): Any? {
    val method = (pjp.signature as MethodSignature).method

    // 메서드 -> 클래스 순으로 조회
    val override =
      AnnotationUtils.findAnnotation(method, ActiveUserFilter::class.java)
        ?: AnnotationUtils.findAnnotation(pjp.target.javaClass, ActiveUserFilter::class.java)

    // 없으면 기본값 true로 지정
    val useFilter = true
    val isActive = override?.value ?: useFilter

    // 현재 트랜잭션에 바인딩된 Session
    val session = em.unwrap(Session::class.java)
    // 커스텀 필터 활성화
    session.enableFilter("activeUserFilter").setParameter("isActive", isActive)
    try {
      return pjp.proceed()
    } finally {
      // 작업이 끝난 이후에는 항상 해제
      session.disableFilter("activeUserFilter")
    }
  }

}

HibernateActiveUserFilterAspect.kt
포인트컷 메서드를 별도로 분리하지 않고, @Around 어드바이스에 포인트컷 표현식을 인라인으로 작성했다.
이 기능으로 인해 모든 트랜잭션에서 활성화된 유저만 조회될 것임을 예상해볼 수 있다.
 

테스트

1. 회원 삭제

delete user/1
h2

API 응답도 잘 왔고, 로컬환경이라 h2에서 본 결과도 목표로 한 1번 id를 가진 유저만 비활성화되었다.
 
2. 단순 유저 API 조회의 경우(/api/v1/users)

find users

비활성화된 회원은 제외하고, 1명의 회원만 조회되었다.
 
3. 관리자 API 조회의 경우(/api/v1/admin/users)

@Transactional(readOnly = true)
fun findUsers(
  query: UserSearchCond,
  sort: Sort, 
  isActive: Boolean
): List<CommonUserResponse> {
  val session = entityManager.unwrap(org.hibernate.Session::class.java)
  session.enableFilter("activeUserFilter").setParameter("isActive", isActive)
  
  ...
}

위에서 작성한 코드 그대로 건드릴게 없다.
왜냐하면, 관리자를 제외한 다른 트랜잭션에서 생성된 하이버네이트 세션에서만 필터를 추가해줬으니깐..
다시 말해, 관리자에서만 수동으로 필터옵션을 파라미터로 넣어준 셈이다.
 
- 활성화된 유저만 조회(default)

admin users/active

- 비활성화된 유저만 조회

admin users/non-active

둘 다 잘 작동하는 것을 볼 수 있다!!
 

@Query("SELECT u FROM UserEntity u WHERE u.isActive = :isActive")
fun findUsers(isActive: Boolean): List<UserEntity>

이제 예전에 작성한 이 함수가 필요 없어도 이제 관리자 권한이 필요한 API에서만 비활성화된 유저가 조회된다.
 


결론적으로 내가 원하는 목표는 달성했다.

만약에 활성화/비활성화 관계없이 모든 유저를 조회하고 싶다면 어노테이션에 파라미터를 추가하고, 그 파라미터로 AOP에서 해당 파라미터를 검증하면서 하이버네이트 세션에 필터를 추가해도 되고 안해도 된다!! 그건 본인/회사 기획 마음~!!
 
그럼…. 끝~!!
다음에 또 재밌고 유익한 기술/일상 글로 찾아오겠습니다~^^

728x90
320x100

댓글