사용자들의 수정, 삭제 로깅 with ThreadLocal, EntityListeners

짱구·2024년 5월 14일
1
post-thumbnail

안녕하세요 2년차 개발자 정철희입니다!

현재 회사에서 어떤 사용자가 어떤 테이블의 어떤 필드를 무엇에서 무엇으로 바꿨는지 대상이 되는 회원의 정보까지 저장하는 로직을 구현해야 했습니다.

요구 사항에서 강조 되었던 부분은 관리자가 '하나 하나 바뀐 부분을 리스트업 할 수 있어야한다'였습니다.

변경되는 before, after 값들, 언제 어떤 사용자가 변경 했는지, 사용자의 비밀번호와 같은 민감한 정보들은 어떻게 제외하고, 어떤 사용자의 소유물을 수정, 삭제 했는지에 대한 정보를 데이터베이스에 담아야 했습니다.

비즈니스 로직에서 처리를 하게 된다면 로직 전체를 수정해야 하기에 고민을 정말 많이 했습니다.

왜냐하면 변경하는 사람들의 정보(user id)가 매번 비즈니스 로직에 침투하도록 수정해야하니까요.

그래서 곰곰히 몇일 정도를 어떻게 할지 구상을 해본 결과 interceptor, thread local과 entity listener를 활용하면 될거같다 라는 생각이 들어 바로 실행에 옮겼습니다.

로깅 대상 설정

값이 변경 되었을 때 적용할 대상을 아래처럼 정의하면 됩니다.

@Entity
@Description("공지사항")
@EntityListeners(JpaUpdateEventListener::class)
class Notice(
    @TargetCustomerId
    @Description("등록한 회원 id")
    val customerId: Long,
    
    @Description("제목")
    var title: String,

    @Lob
    @Description("내용")
    @Column(columnDefinition = "TEXT")
    var content: String,

    @Description("카테고리")
    @Enumerated(EnumType.STRING)
    var category: Category,

    @Description("조회수")
    @LoggingDisable
    var viewCount: Long,

    @Description("좋아요 수")
    @LoggingDisable
    var likeCount: Long,
): BaseEntity() {
    @Description("uuid")
    @Column(unique = true)
    val token: String = Generator.token()
}

enum class Category {
    NOTICE,
    EVENT,
    PROMOTION,
    ETC,
}

먼저 entity에 EntityListeners로 JpaUpdateEventListener를 매칭해줍니다. (JpaUpdateEventListener는 아래에서 구현)

@Description을 설정해줘서 description의 value 기준으로 데이터를 쌓습니다.

@TargetCustomerId로 어떤 사용자의 소유물인지 명시해줍니다.

@LoggingDisable로 민감한 정보 혹은 바뀌어도 쌓지 않을 정보들은 제외해줍니다.

@Description, @LoggingDisable, @TargetCustomerId 모두 customizing한 annotation입니다.

Thread Local 설정

일단 먼저 JWT에서 사용자 정보를 추출 할 것이기 때문에 ThreadLocal을 Bean으로 등록을 해줬습니다.

@Configuration
class ThreadLocalConfig {
    private val threadLocal = ThreadLocal<Principal>()

    @Bean
    fun threadLocal(): ThreadLocal<Principal> {
        return threadLocal
    }
}

Interceptor 설정

그리고 바로 Interceptor를 만들었습니다.
이미 security filter에서 검증을 했기 때문에 access token이 없다면 thread local에 등록하지 않고 넘깁니다.
즉, 이 경우는 감사 로직의 대상이 아닌것이죠.
이러면 request가 들어왔을 때 interceptor를 통해 thread local에 사용자 정보가 들어가 있는 상태가 됩니다.

@Component
class PrincipalInterceptor(
    private val tokenProvider: TokenProvider, 
    private val threadLocal: ThreadLocal<Principal>,
): HandlerInterceptor {
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        val jwt = request.accessToken ?: return true
        if (jwt.isBlank()) return true
        
        val principal = tokenProvider.getPrincipal(jwt)
        threadLocal.set(principal)
        return true
    }

    override fun afterCompletion(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
        ex: Exception?,
    ) {
        threadLocal.remove()
    }
}

// 확장 변수로 만들어서 사용해줍니다.
val HttpServletRequest.accessToken: String?
    get() = this.getHeader("Authorization")
        ?.substringAfter("Bearer ")

Interceptor 등록

@Configuration
class PrincipalInterceptorConfig(
    private val principalInterceptor: PrincipalInterceptor,
) : WebMvcConfigurer {
    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(principalInterceptor)
    }
}

Update Event 처리

이제 사용자가 변경했을 때 처리하는 로직을 확인해보겠습니다.

먼저 의존성 주입을 생성자 주입이 아닌 lateinit으로 선언한 이유는 EntityListeners의 대상 Component는 기본 생성자가 필수적으로 있어야하기 때문에 secondary constructor를 생성 해줘야합니다.

일단 @PostLoad 로 entity의 정보를 cache에 저장합니다.

map에 저장한걸 또 cache에 저장하는 이유는 cache는 TTL 설정을 해줄수 있기 때문에 메모리 누수가 생기지 않게 하려고 사용했습니다.

또 @PreUpdate를 사용하지 않은 이유는 PreUpdate가 발생하는 시점은 이미 값은 바뀌고 Flush가 되기 이전이라 원본 데이터가 아닌 바뀐 데이터가 할당되어 버리기 때문입니다.

그래서 @PostLoad로 매번 cache에 이전 값들을 잠시동안 담아둡니다. (바뀐 값들과 비교를 위해)

매번 조회 시점마다 캐시에 값을 넣는건 매우 비효율적이기에 transaction readOnly false 일 때만 동작을 하게 설정해놨습니다.

이제 udpate가 발생하게 되면 cache에 있는 이전 값들과 비교를 하고 바뀐 필드들을 하나 하나 이벤트를 발행해서 저장합니다.

typealias Entity = Any
typealias UserKey = String
typealias FieldName = String

@Component
class JpaUpdateEventListener() {
    private lateinit var threadLocal: ThreadLocal<Principal>
    private lateinit var applicationEventPublisher: ApplicationEventPublisher
    private lateinit var cacheWriteService: CacheWriteService
    private lateinit var cacheReadService: CacheReadService
    val entityMap = ConcurrentHashMap<UserKey, Map<FieldName, ChangeValueCollector>>()

    constructor(
        threadLocal: ThreadLocal<Principal>,
        applicationEventPublisher: ApplicationEventPublisher,
        cacheWriteService: CacheWriteService,
        cacheReadService: CacheReadService,
    ) : this() {
        this.threadLocal = threadLocal
        this.applicationEventPublisher = applicationEventPublisher
        this.cacheWriteService = cacheWriteService
        this.cacheReadService = cacheReadService
    }

    @PostLoad
    fun preUpdateEvent(entity: Entity) {
	    val isReadOnlyTransaction = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
        if (isReadOnlyTransaction) return
        
        val principal = threadLocal.get() ?: return
        val customerId = principal.customerId
        val key = "$customerId-${entity.className}"

        try {
            val stateBeforeUpdate = entity.extractFields()
            entityMap[key] = stateBeforeUpdate
            cacheWriteService.setBeforeValue(key, stateBeforeUpdate)
        } finally {
            entityMap.remove(key)
        }
    }

    @PostUpdate
    fun postUpdateEvent(entity: Entity) {
        val principal = threadLocal.get() ?: return
        val customerId = principal.customerId
        val key = "$customerId-${entity.className}"

        val stateBeforeUpdate = cacheReadService.getBeforeValue(key)
        val stateAfterUpdate = entity.extractFields()

        stateAfterUpdate.forEach { (fieldName, updatedField) ->
            val beforeValue = stateBeforeUpdate[fieldName]?.value
            val afterValue = updatedField.value

            if (beforeValue != afterValue) {
                val history = CustomerHistory.of(
                    customerId = customerId,
                    targetCustomerId = entity.targetCustomerId,
                    type = CustomerHistoryType.UPDATE,
                    entityName = entity.className,
                    entityDescription = entity.classDescription,
                    fieldName = fieldName,
                    fieldDescription = updatedField.description,
                    beforeValue = beforeValue,
                    afterValue = afterValue,
                )

                applicationEventPublisher.publishEvent(history)
            }
        }
    }
}

data class ChangeValueCollector(
    val value: Any,
    val description: String?,
)

바뀐 값들을 비교 연산하고 값이 바뀐 필드는 event를 발행해줬습니다.
이 이벤트 listener를 통해 RDBMS에 저장할지, MongoDB에 저장할지, 로그로 남길지에 대한 처리는 개발자가 유동적으로 하면 되도록 만들었습니다.

Entity Reflection 처리

val Entity.className: String
    get() = this.javaClass.simpleName

val Entity.targetCustomerId: Long?
    get() = this.javaClass.declaredFields
        .first { it.getAnnotation(TargetCustomerId::class.java) != null }
        .let { field ->
            field.isAccessible = true
            field.get(this) as? Long
        }

val Entity.classDescription: String?
    get() = this.javaClass.getAnnotation(Description::class.java)?.value
    
fun Entity.extractFields() =
    this.javaClass.declaredFields.mapNotNull { field ->
        field.isAccessible = true
        field.getAnnotation(LoggingDisable::class.java)
            ?.let { return@mapNotNull null }

        val value = field.get(this)?.toString()
        val description = field.getAnnotation(Description::class.java)?.value

        field.name to ChangeValueCollector(value, description)
    }.toMap()

Any class에게 type alias를 씌우고 확장 함수로 만들었습니다.
Entity 정보를 가져오고 custom한 annotation이 붙은 필드, 클래스에 대한 처리를 따로 해줬습니다.

결과 확인

이제 JpaUpdateEventListener를 EntityListeners로 등록해준 entity들은 변경 작업이 될 때 마다 아래처럼 history가 쌓이게 됩니다.

fun updateNotice(
        id: Long,
        request: UpdateNoticeVo,
) = 
	noticeRepository.findByIdOrNull(id)
		?.apply {
			title = request.title
            content = request.content
            category = request.category
        }
        ?: throw NotFoundException(MessageType.NOTICE)

Github

삭제에 대한 처리까지 정리는 추후 2부에서 작성하겠습니다!

디테일한 코드가 궁금하신 분들은 아래 Github Link를 통해 확인해주세요!

GITHUB LINK

profile
코드를 거의 아트의 경지로 끌어올려서 내가 코드고 코드가 나인 물아일체의 경지

0개의 댓글