본문 바로가기
jpa

Jpa @PrePersist & @PreUpdate 사용해서 Entity 부가정보와 도메인 관심사 분리하기

by 권성호 2023. 5. 1.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

 

Spring Data Jpa 환경에서 엔티티는 비즈니스 영역의 핵심 도메인이면서 데이터베이스 테이블과 메핑 되는 역할을 한다.

즉, 도메인(비즈니스영역) 관점으로 엔티티를 바라볼 수도 있고 데이터 관점으로 바라볼 수도 있다. 

프로젝트를 진행하면서 엔티티는 최대한 데이터 관점이 아니라 도메인 관점으로 바라보고 싶었다. 하지만 데이터에 대한 변경 이력을 남기는 것이 때로는 필요한 테이블이 존재했고, 이러한 관심사는 도메인의 관심사라기보단 데이터나 모니터링의 관심사로 생각되었다.

 

엔티티의 생성 & 변경에 대한 관리를 수용하면서도 도메인영역(비즈니스로직)에서 이를 노출하지 않는 구조를 고민하던 중 Jpa의 @PrePersist & @PreUpdate를 알게 되었고 이를 적용한 사례를 공유하고자 한다.


구체적인 요구사항

특정 요청으로 인해 생성 & 수정되는 엔티티에 대한 변경 이력 관리.

이력관리를 위한 필드는 아래와 같다.

  • 생성 unique id: 특정 엔티티를 최초 생성한 요청에 대한 unique id
  • 생성 시각: 특정 엔티티가 최초 생성된 시각
  • 변경 unique id: 특정 엔티티를 마지막으로 변경한 요청에 대한 unique id
  • 변경 시각: 특정 엔티티를 마지막으로 변경한 시각

위 4개의 필드는 오로지 데이터에 대한 생성 & 변경 이력을 관리 & 모니터링하기 위한 용도로만 사용되어야 하고 실제 비즈니스로직에서 노출되어어는 안된다. 즉, 엔티티를 생성 & 변경하는 쪽에서는 위 4개의 필드는 전혀 몰라야 한다.


구현

Worker.kt

@Embeddable
data class Worker(
        val uniqueId: String,
        val time: LocalDateTime
)
  • 모니터링을 위한 클래스
  • 요청에 대한 고유 아이디와 시간을 담을 수 있다.

EntityContext.kt

class EntityContext {
    companion object {
        val threadLocalWorker = ThreadLocal<Worker>()
    }
}
  • 엔티티와 관련한 콘텍스트
  • threadLocalWorker 필드를 가짐
    • 엔티티의 worker 정보를 저장하는 콘텍스트

BaseEntity.kt

val log = KotlinLogging.logger {}

/***
 * 요기에 들어갈 녀석들을 완전히 비즈니스와 무관한 놈들이라고 간주한다면 이러한 설계가 이득이 있을듯?!
 */
@MappedSuperclass
open class BaseEntity(

        @AttributeOverrides(
                AttributeOverride(name = "uniqueId", column = Column(name = "CREATE_UUID")),
                AttributeOverride(name = "time", column = Column(name = "CREATE_TIME"))
        )
        private var createWorker: Worker? = null,

        @AttributeOverrides(
                AttributeOverride(name = "uniqueId", column = Column(name = "UPDATE_UUID")),
                AttributeOverride(name = "time", column = Column(name = "UPDATE_TIME"))
        )
        private var updateWorker: Worker? = null
) {

    @PrePersist
    private fun prePersist() {
        log.info { "<============prePersist 실행!============>" }
        val workerContext = EntityContext.threadLocalWorker.get()   // todo <- 밖에서 세팅 못해줬다면? 부가정보로 간주하고 그냥 null 로?
        createWorker = workerContext
        updateWorker = workerContext
        log.info { "<============prePersist 종료!============>" }
    }

    @PreUpdate
    private fun preUpdate() {
        log.info { "<============preUpdate 실행!============>" }
        val workerContext = EntityContext.threadLocalWorker.get()
        updateWorker = workerContext
        log.info { "<============preUpdate 종료!============>" }
    }
}
  • 생성 worker & 변경 worker 필드를 가짐
  • @PrePersist & @PreUpdate에서 생성 & 변경 시 각 worker 필드를 관리
  • 생성 worker & 변경 worker & prePersist & preUpdate는 모두 private로 지정하여 엔티티 밖에서는 모르게 한다.
  • 다만, EntityContext에 Wroker를 Setting 하는 책임은 엔티티를 저장하는 쪽에 있음에 주의해야 한다.

User.kt

@Entity
@Table(name = "USER_TABLE")
class User(

        @Id
        @GeneratedValue
        val id: Long = 0,

        @Column(name = "NAME")
        var name: String

) : BaseEntity() {

    fun modify(name: String? = null) {
        name?.let { this.name = it }
    }
}
  • BaseEntity 를 상속받음
  • User는 private으로 선언된 createWorker & updateWorker를 사실상 모른다

EntityContextInterceptor.kt

@Component
class EntityContextInterceptor : HandlerInterceptor {

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        EntityContext.threadLocalWorker.set(Worker(
                uniqueId = request.getParameter("requestUniqueId"),
                time = LocalDateTime.now()
        ))
        return true
    }
}
  • EntityContext 에 값을 세팅하는 인터셉터

WebMvcConfig.kt

@Configuration
class WebMvcConfig(
        private val entityContextInterceptor: EntityContextInterceptor
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(entityContextInterceptor)
                .addPathPatterns("/test/**")
    }
}
  • "/test/**" 요청에 대해 entityContextInterceptor를 적용하여 EntityContext에 값을 세팅하도록 했다.

TestController.kt

@RestController
@RequestMapping("/test")
class TestController(
        private val userRepository: UserRepository
) {

    @GetMapping("/health-check")
    fun healthCheck(): ResponseEntity<String> {
        return ResponseEntity.ok("health-check")
    }

    @GetMapping("user/save")
    fun saveUser(
            @RequestParam name: String,
            @RequestParam requestUniqueId: String
    ): ResponseEntity<User> {
        val transientUser = User(name = "name")
        val persistUser = userRepository.save(transientUser)
        return ResponseEntity.ok(persistUser)
    }

    @GetMapping("user/update/{user_id}")
    fun updateUser(
            @PathVariable("user_id") id: Long,
            @RequestParam name: String,
            @RequestParam requestUniqueId: String
    ): ResponseEntity<User> {
        val findUser = userRepository.findByIdOrNull(id)
                ?: throw IllegalArgumentException("IllegalArgumentException")
        findUser.modify(name = name)
        val modifyUser = userRepository.save(findUser)
        return ResponseEntity.ok(modifyUser)
    }
}
  • 요청에 의해 User.kt 를 저장 & 수정가능한 엔드포인트를 만들었다.
  • 저장 & 수정의 비즈니스에서는 worker를 전혀 모르지만 알아서 값이 잘 세팅되는 것을 확인할 수 있다.

생각나는 장단점

장점

  • 초기 의도대로 부가정보를 도메인(비즈니스영역)으로부터 숨길 수 있음

단점

  • BaseEntity에 대한 관리가 드러나지 않아서 이러한 방식에 익숙하지 않은 주변 동료가 개발에 참여 시 ThreadLocal에 Context 값을 Setting 하는 것을 누락할 가능성이 있음
  • Thread-pool의 Thread를 재 사용 하는 특성상 ThreadLocal에 값을 세팅하는 것을 누락할 경우, 이전 요청에서 세팅된 값이 잘못 사용될 가능성이 있음 

참고

 

댓글