안녕하세요 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입니다.
일단 먼저 JWT에서 사용자 정보를 추출 할 것이기 때문에 ThreadLocal을 Bean으로 등록을 해줬습니다.
@Configuration
class ThreadLocalConfig {
private val threadLocal = ThreadLocal<Principal>()
@Bean
fun threadLocal(): ThreadLocal<Principal> = threadLocal
}
그리고 바로 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 ")
@Configuration
class PrincipalInterceptorConfig(
private val principalInterceptor: PrincipalInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(principalInterceptor)
}
}
이제 사용자가 변경했을 때 처리하는 로직을 확인해보겠습니다.
먼저 의존성 주입을 생성자 주입이 아닌 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에 저장할지, 로그로 남길지에 대한 처리는 개발자가 유동적으로 하면 되도록 만들었습니다.
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)
삭제에 대한 처리까지 정리는 추후 2부에서 작성하겠습니다!
디테일한 코드가 궁금하신 분들은 아래 Github Link를 통해 확인해주세요!