현재 회사 프로젝트에서는 정해진 시간마다 도는 배치를 스케줄링하고 실행하기 위해 Quartz 스케줄러를 사용한다. 그리고 인증, 인가를 위해 Spring Security와 JPA의 Auditing 기능을 이용하여 생성자, 생성일자, 수정자, 수정일자를 남기는 감사 기능도 사용하고 있다. 여기서 생성일자, 수정일자 같은 경우 현재 시스템 시간으로 판단할 수 있지만 생성자, 수정자는 AuditorAware라는 인터페이스의 객체를 구현하여 직접 제공해야 하기 때문에 현재는 Spring Security 컨텍스트에 등록된 사용자의 아이디로 기록하고 있다.
그런데 이런 경우 HTTP 요청처럼 시스템 외부에서 시작되서 시큐리티 필터를 타고 오는게 아니라 Quartz 라이브러리를 이용한 크론잡(cronjob)처럼 시스템 내부적으로 동작하는 경우 컨텍스트에 인증 객체가 비어있기 때문에 생성자, 수정자를 기록할 수 없다. 그렇기 때문에 프로젝트에서는 공식 문서에서 제안하는 방법처럼 인증 객체를 가져오고 있다. 만약 어떠한 이유로 인해 가져오지 못했다면 "system" 이라는 별도의 문자열로 대체하여 기록하고 있다.
하지만 크론잡이 데이터를 수정하는 경우(외부 데이터 연동, 데이터 변경 예약 등) 문제가 되는데 현재 프로젝트에는 약 20개 정도의 크론잡이 동작하고 있기 때문이다. 어떤 크론잡은 하루에 한 번, 새벽에만 실행되지만 어떤 크론잡은 24시간 내내 5분마다 실행되고 있다. 그래서 정확히 어떤 크론잡이 건드렸는지 알기 어렵고 각 크론잡의 실행시각, 로그 등을 이용하여 추측하는 것은 좀 별로기 때문에 이를 개선하면서 겪었던 시행착오를 적어보려고 한다.
이건 생각만 하고 바로 집어치운 아이디어인데 일단 현재 프로젝트 구조상 크론잡 -> 서비스 -> 어댑터 -> 리포지토리까지 3, 4개의 계층을 뚫고 필드값을 전달해야 한다. 그리고 그 로직을 지금까지 있던 크론잡과 이후에 추가될 크론잡에서 일일히 추가해야 하기 때문에 관리가 어렵고 이후 추가되는 크론잡에서 깜빡할 수도 있다고 생각했다. 게다가 이력관리 프레임워크(Spring Data Commons)에서 제공하는 기능을 사용하고 있기 때문에 직접 필드를 수정해봤자 프레임워크의 동작에 덮어씌워지기 때문에 이를 오버라이드하기 위한 꼼수를 쓰는 것은 바람직하지 않아 최후의 방법이라고 생각했다.
val username = (SecurityContextHolder.getContext().authentication?.principal as? User)?.username
println("[CONTROLLER] Creating product by: $username")
val product = productService.createProduct(description, "controlledUser")
println("[CONTROLLER] Product created by: ${product.createdBy}")
위와 같은 코드를 예시로 들면 실제로 수정자로 기록되는 이름과 서비스 레이어로 넘길 때 수정자의 이름을 다르게 해 보았다. 현재 인증 방식에서는 RequestHeaderAuthenticationFilter를 이용하여 헤더에 있는 값을 뽑아쓰기 때문에 "username1"으로 인증되어 있다. 그렇지만 실제로 createProduct
메서드를 호출하여 데이터를 수정할 때는 별도로 "controlledUser"라는 이름을 넘겨서 해당 필드를 수정자로 등록할 수 있도록 했다.
fun createProduct(description: String, username: String = "system"): ProductEntity {
val product = ProductEntity().apply {
this.code = UUID.randomUUID().toString()
this.description = description
this.createdBy = username
}
println("[SERVICE] Product created by: ${product.createdBy}")
return productRepository.save(product).also {
println("[SERVICE] Product persisted by: ${it.createdBy}")
}
}
서비스에는 @Transactional
이 붙어있고 createProduct
메서드는 위처럼 엔티티를 생성한 후 @CreatedBy
어노테이션이 붙은 createdBy
필드를 파라미터로 받은 값으로 직접 설정하고 있다. 하지만 해당 엔티티를 실제로 저장해보면 어떨까?
[CONTROLLER] Creating product by: username1
[SERVICE] Product created by: controlledUser
[SERVICE] Product persisted by: username1
[CONTROLLER] Product created by: username1
로그에서 볼 수 있듯이 "controlledUser"라는 이름으로 엔티티를 생성했지만 실제로 save
된 후 생성자 필드가 현재 인증된 사용자인 "username1"로 변경된 것을 볼 수 있다.
실제로 테이블 로우에도 해당 이름으로 들어가 있다. 이 문제는 productRepository::save
를 호출한 후에 createdBy
필드에 수정자 이름을 넣어서 변경할 수 있다.
fun createProduct(description: String, username: String = "system"): ProductEntity {
val product = ProductEntity().apply {
this.code = UUID.randomUUID().toString()
this.description = description
this.createdBy = username
}
println("[SERVICE] Product created by: ${product.createdBy}")
return productRepository.save(product).also {
println("[SERVICE] Product persisted by: ${it.createdBy}")
it.createdBy = username
println("[SERVICE] changed createdBy to: ${it.createdBy}")
}
}
이 경우 실제로 다음처럼 createdBy
필드가 원하는대로 등록된 것을 볼 수 있다.
하지만 이 방법의 문제는 1. 굳이 리포지토리에 엔티티를 저장한 후 수정자 필드를 다시 수정해야 한다는 것이고 2. 그로 인해 발생하는 추가쿼리와 다른 이력관리 프레임워크의 오동작을 일으킬 수 있다는 것이다.
[CONTROLLER] Creating product by: username1
[SERVICE] Product created by: controlledUser
Hibernate: insert into product_entity (code,created_by,created_date,description,last_modified_by,last_modified_date,id) values ...
[SERVICE] Product persisted by: username1
[SERVICE] changed createdBy to: controlledUser
Hibernate: update product_entity set code=?,created_by=?,created_date=?,description=?,last_modified_by=?,last_modified_date=? where ...
[CONTROLLER] Product created by: controlledUser
실제로 위의 로직을 적용했을 때 로그를 보면 insert 쿼리만 나가는 것이 아니라 update 쿼리도 발생하고 있다. 해당 쿼리가 발생한 지점을 확인해보면 createProduct
함수에서 리턴한 이후기 때문에 트랜잭션을 커밋하기 전에 더티 체킹으로 변경된 필드 createdBy
를 수정하려고 하는 것을 알 수 있다. 이는 단순히 update 쿼리가 한번 더 발생하는 것 뿐 아니라 Hibernate Envers 같은 이력관리 프레임워크를 사용할 때도 문제가 될 수 있는데 Envers는 JPA 엔티티가 변경되었을 때 이력을 쌓는다. 그렇기 때문에 insert 쿼리의 생성이력, update 쿼리의 수정이력 두 건의 이력이 발생할 수 있으며 특히 두 번째 이력은 수정자가 변경된 것 외에 아무런 변경사항이 없는 무의미한 이력으로 쌓일 가능성이 높다.
그렇기 때문에 이 방법은 적합하지 않다. 그런데 처음에 프로젝트 구조상 크론잡 -> 서비스 -> 어댑터 -> 리포지토리처럼 여러 계층을 뚫고 필드값을 전달해야 한다고 했는데 이건 리액트에서 props drilling이 생각나는 이슈였다. 거기서 든 생각이 useContext
훅처럼 우리도 전역 컨텍스트로 활용할 수 있는 방법이 없을까? 였고, 생각해보면 사실 우리는 이미 전역 컨텍스트가 있었다.
서론에서 언급한 AuditorAware
는 이런 이력관리를 위한 수정자(auditor)를 가져오는 함수 하나만 정의한 간단한 인터페이스다.
/*
* Copyright 2008-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.util.Optional;
/**
* Interface for components that are aware of the application's current auditor. This will be some kind of user mostly.
*
* @param <T> the type of the auditing instance.
* @author Oliver Gierke
*/
public interface AuditorAware<T> {
/**
* Returns the current auditor of the application.
*
* @return the current auditor.
*/
Optional<T> getCurrentAuditor();
}
여기서 우리가 사용하는 모델의 수정자는 문자열 타입(varchar 컬럼)이기 때문에 다음처럼 구현할 수 있다.
override fun getCurrentAuditor(): Optional<String> {
val auth = SecurityContextHolder.getContext().authentication
if (auth.isAuthenticated && auth.principal is UserDetails) {
return Optional.of(auth.principal as UserDetails).map(UserDetails::getUsername)
} else {
throw IllegalStateException("No authenticated user found or principal is not a UserDetails instance")
}
}
Kotlin으로 구성되어 있기 때문에 null
이 가능한 데이터를 나타내려면 String?
처럼 해야 하지만 Java로 정의된 인터페이스가 Optional<String>
을 사용하고 있기 때문에 팩토리 메서드로 말아주고 있는 것을 볼 수 있다. 위의 코드에서 눈여겨 볼 부분은 SecurityContextHolder
에서 어떻게 수정자를 가져오고 있는지 그 과정이다.
SecurityContextHolder
: 스레드에서 SecurityContext
에 접근할 수 있는 헬퍼 클래스.-> getContext()
: SecurityContext
객체 조회-> authentication
: Authentication
객체 조회여기서 Authentication
객체는 어떠한 방식으로든 인증 프로세스를 탔다면 객체가 돌아오고 그렇지 않다면 null
을 반환한다. 눈에 띄었던 부분은 Authentication
객체가 어떻게 구성되어있는지가 아니라 이를 어떻게 가져오는지 1, 2번 과정이었다. HTTP API로 시작되는 요청 핸들러든 쿼츠로 실행되는 크론잡이든 프로젝트 내부의 스프링 데이터 JPA를 이용하면서 스프링 시큐리티의 컨텍스트를 활용하기 때문에 위의 과정을 동일하게 진행할 것이다. 그래서 크론잡이 스레드의 시큐리티 컨텍스트를 이용하여 수정자에 접근할 수 있다면 내가 직접 크론잡에 시큐리티 컨텍스트를 세팅하면 되지 않을까? 라고 생각했다.
먼저 테스트를 위해 다음과 같은 로직을 수행하는 크론잡을 작성했다.
class CronJobA : QuartzJobBean() {
val logger: Logger = LoggerFactory.getLogger(CronJobA::class.java)
override fun executeInternal(context: JobExecutionContext) {
logger.info("Hello World from CronJobA")
SecurityContextHolder.getContext().authentication?.let {
if (it.isAuthenticated) {
val username = it.name
logger.info("CronJobA executed by user: $username")
} else {
logger.info("CronJobA executed by an unauthenticated user")
}
} ?: logger.info("No authentication context available")
logger.info("CronJob ended.")
}
}
아무것도 적용하지 않고 실행할 경우 당연히 다음처럼 출력된다(일부 생략).
코파일럿 자동완성이 문자열을 "No authentication context available" 이라고 생성하긴 했지만 어쨌든 위의 경우는 컨텍스트 자체는 있고 authentication
객체가 비어있는 것을 알 수 있다.
여기서 실제로 수정자를 남길 수 있도록 데이터를 생성하는 서비스를 추가하고 (그리고 자잘한 버그 수정...) 다음처럼 구성해보았다.
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
class CronJobA : QuartzJobBean() {
val logger: Logger = LoggerFactory.getLogger(CronJobA::class.java)
@Autowired // 의존성을 주입받을 수 있도록 @Autowired 어노테이션을 사용한다.
lateinit var productService: ProductService
override fun executeInternal(context: JobExecutionContext) {
logger.info("CronJobA started.")
assert(::productService.isInitialized) { "ProductService must be set before executing the job." }
// 상품을 생성하는 서비스를 호출한다.
productService.createProduct("This product is created by cronjob.")
logger.info("CronJobA ended.")
}
}
그리고 AuditorAware
구현체에서는 시큐리티 컨텍스트에서 인증 객체를 찾을 수 없는 경우 "system"이라는 문자열로 기록하도록 변경하였다.
override fun getCurrentAuditor(): Optional<String> {
val auth: Authentication? = SecurityContextHolder.getContext().authentication
if (auth == null || !auth.isAuthenticated) {
// CronJob 실행중인 경우 이곳
return Optional.of("system")
}
// 그 외(대개 HTTP API 호출)의 경우 이곳
return Optional.of(auth.name)
}
먼저 아무런 작업을 하지 않고 실행시키면 다음처럼 "system"으로 수정자가 기록되는 것을 볼 수 있다.
이후 SecurityContextHolder.getContext()
로 얻을 수 있는 컨텍스트에 authentication
객체를 직접 등록해보았다.
fun initializeAuthenticationObject() {
// 현재 컨텍스트에 Authentication 객체가 없는 경우 등록한다.
val current = SecurityContextHolder.getContext().authentication
if (current != null) return
val predefined = PreAuthenticatedAuthenticationToken("cronjobUser", null, listOf())
SecurityContextHolder.getContext().authentication = predefined
}
override fun executeInternal(context: JobExecutionContext) {
// 작업 시작 전 관련 로직을 수행한다.
initializeAuthenticationObject()
...
}
PreAuthenticatedAuthenticationToken
를 인증 객체로 등록한 결과 크론잡 실행 시에 "system"이 아닌 수정자를 기록할 수 있는 것을 볼 수 있었다.
이를 응용하여 각기 다른 크론잡에서도 자신만의 인증 객체를 등록하여 자신이 한 행위에 대한 기록을 남길 수 있게 되었다.
...그러나 예상하지 못한 문제가 있었다. 위의 코드는 실제로 회사 프로젝트의 운영 코드에도 반영되었던 방식인데 이후 모니터링 중 결함을 발견하여 결국 롤백하게 되었다. 뭐가 문제였을까? 바로 우리가 수정자를 얻어오기 위해 활용하던 스프링 시큐리티의 SecurityContextHolder
는 기본적으로 스레드 단위로 관리된다는 것과 Quartz 스케줄러가 스레드 풀을 활용한다는 것이었다.
스프링 시큐리티는 기본적으로 별도 설정이 없으면 ThreadLocalSecurityContextHolderStrategy 방식으로 SecurityContextHolder
를 관리한다. 이 방식은 ThreadLocal
이라는 자바 클래스를 활용하여 각 스레드별로 고유한 변수를 초기화하고 접근할 수 있도록 한다. 그러나 한번 생성된 스레드가 사라지지 않는다면 어떨까? 한번 ThreadLocal
객체에 할당된 변수는 스레드와 마찬가지로 사라지지 않을 것이다. 이 말인 즉 우리가 initializeAuthenticationObject
메서드로 등록한 authentication
객체가 초기화되지 않는다는 것이다. 이는 멀티스레딩 환경에서 인증 객체가 공유되는 버그의 원인이 되었다.
예시를 보이기 위해 위의 CronJobA
가 사용하는 인증 토큰의 principal을 "cronjobUserA"로 변경하고 쿼츠가 사용하는 스레드 풀을 하나로 줄여보았다.
spring:
quartz:
properties:
org:
quartz:
threadPool:
threadCount: 1
멀티스레딩 이슈를 테스트하기 위해 위의 CronJobA
과 비슷한 역할을 하는 CronJobB
를 아래처럼 추가하였다.
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
class CronJobB : QuartzJobBean() {
val logger: Logger = LoggerFactory.getLogger(CronJobB::class.java)
@Autowired
lateinit var productService: ProductService
fun initializeAuthenticationObject() {
val current = SecurityContextHolder.getContext().authentication
if (current != null) return
val predefined = PreAuthenticatedAuthenticationToken("cronjobUserB", null, listOf())
SecurityContextHolder.getContext().authentication = predefined
}
override fun executeInternal(context: JobExecutionContext) {
initializeAuthenticationObject()
logger.info("CronJobB started.")
assert(::productService.isInitialized) { "ProductService must be set before executing the job." }
productService.createProduct("This product is created by cronjob b.")
logger.info("CronJobB ended.")
}
}
그리고 CronJobA
를 실행한 다음 CronJobB
를 실행해보았다. 이 경우 스레드 풀의 스레드는 단 하나기 때문에 CronJobA
를 실행했던 스레드가 CronJobB
를 실행하게 될 것이다.
문제가 무엇일까? 바로 CronJobA
로 만들어진 PRODUCT_ENTITY와 CronJobB
로 만들어진 PRODUCT_ENTITY의 수정자가 동일하게 "cronjobUserA" 로 기록된 것이다. 만약 반대로 CronJobB
를 먼저 실행하고 CronJobA
를 다음에 실행한다면 어떻게 될까?
예상할 수 있듯이 모든 PRODUCT_ENTITY의 수정자가 "cronjobUserB"로 기록된 것을 볼 수 있다. 즉 이전에 initializeAuthenticationObject
같은 메서드를 이용하여 인증 객체를 등록했던 스레드가 종료되지 않고 스레드 풀에서 계속 살아있기 때문에 해당 스레드로 실행되는 모든 Quartz의 Job들이 같은 인증 객체를 공유하게 됐던 것이다.
이는 차라리 Quartz Job 전용 인증 객체가 별도로 있다면 모르겠지만 지금처럼 각 Job 별로 고유한 인증 객체를 사용하려다가 다른 Job 까지 영향을 미친 케이스기 때문에 역시 적절하지 않았다. 다행히 이는 원인이 명확했기 때문에 다음처럼 크론잡 종료 후 인증 객체를 제거한다면 발생하지 않는다.
fun clearAuthenticationObject() {
SecurityContextHolder.getContext().authentication = null
}
override fun executeInternal(context: JobExecutionContext) {
...
clearAuthenticationObject()
}
하지만 이런 추가 로직의 문제는 clearAuthenticationObject
메서드를 호출하는 것을 잊거나 아예 구현조차 하지 않거나(위의 경우는 QuartzJobBean
을 상속받은 객체에 일일히 메서드를 추가하고 있다) 혹은 예외 상황에 대한 try-catch 누락으로 해당 메서드를 호출할 수 없는 경우 문제가 된다. 특히 try-catch 누락의 경우 실무에서도 @Transactional
메서드 종료 후 커밋 중 발생한 예외(Pessimistic Lock 획득 문제 등)를 잡지 못해 Job이 나머지 로직을 실행하지 못하고 비정상종료되는 케이스가 있었다.
그래서 이런 번거로움이나 사람의 꼼꼼함에 맡기지 않고 자동으로 인증 객체를 넣고 뺄 수 있는 방법이 있다면 어떨까? 이는 '리스너'라는 개념을 떠올리게 만들었고 곧 Quartz 스케줄러에서도 이벤트 리스너를 제공한다는 것을 알게 되었다.
Quartz 공식 문서에서는 JobListener와 TriggerListener에 대해 설명하고 있다. 보통 Job을 실행할 때 Trigger도 같이 동작하기 때문에 언뜻 보기에는 비슷해보이지만 TriggerListener
의 경우 트리거가 실패한 경우(misfire)에 대한 리스너처럼 좀 더 트리거에 특화된 리스너를 정의하고 있다.
지금 상황에서는 JobListener
에서 제공하는 jobToBeExecuted
메서드와 jobWasExecuted
메서드를 활용해서 목적을 달성할 수 있기 때문에 다음처럼 리스너를 작성하고 스케줄러에 등록하였다.
class JobKeyAuthenticationListener() : JobListener {
// 이전에 시큐리티 컨텍스트에 인증객체를 초기화하던 메서드를 그대로 가져왔지만 principal을 파라미터로 받는다.
fun initializeAuthenticationObject(principal: String) {
val current = SecurityContextHolder.getContext().authentication
if (current != null) return
val predefined = PreAuthenticatedAuthenticationToken(principal, null, listOf())
SecurityContextHolder.getContext().authentication = predefined
}
fun clearAuthenticationObject() {
SecurityContextHolder.getContext().authentication = null
}
override fun getName(): String {
return "SpringSecurityAuthenticationHandler"
}
override fun jobToBeExecuted(context: JobExecutionContext?) {
context?.jobDetail?.key?.let {
// Quartz Job을 정의할 때 고유 식별자인 JobKey(Group, Name의 조합)를 이용하여 수정자로 제공한다.
initializeAuthenticationObject(it.toString())
// 인증 객체도 등록하면서 겸사겸사 로그 용도로 작업의 시작을 기록한다.
val logger = LoggerFactory.getLogger(context.jobDetail.jobClass)
logger.info("$it is started.")
}
}
override fun jobExecutionVetoed(context: JobExecutionContext?) {
context?.jobDetail?.key?.let {
val logger = LoggerFactory.getLogger(context.jobDetail.jobClass)
logger.warn("$it is vetoed by trigger.")
}
}
override fun jobWasExecuted(
context: JobExecutionContext?,
jobException: JobExecutionException?
) {
context?.jobDetail?.key?.let {
val logger = LoggerFactory.getLogger(context.jobDetail.jobClass)
// 인증 객체도 해제하면서 겸사겸사 로그 용도로 작업의 종료를 기록한다.
if (jobException == null) {
logger.info("$it is ended successfully.")
} else {
logger.error("$it failed with exception: ${jobException.message}", jobException)
}
clearAuthenticationObject()
}
}
}
위처럼 작업의 실행과 종료, 실행 거절(트리거가 거절하는 케이스라고 하는데 겪어본 적은 없다)의 상황에 대해서 리스너를 작성하였다. 이렇게 되면 Job 클래스에서는 이전에 추가했던 인증 객체를 다루는 로직과 로그성 메시지를 남기는 부분이 필요없기 때문에 좀 더 작업 자체의 비즈니스 로직에 집중할 수 있게 된다.
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
class CronJobA : QuartzJobBean() {
val logger: Logger = LoggerFactory.getLogger(CronJobA::class.java)
@Autowired
lateinit var productService: ProductService
override fun executeInternal(context: JobExecutionContext) {
assert(::productService.isInitialized) { "ProductService must be set before executing the job." }
productService.createProduct("This product is created by cronjob a.")
logger.info("product created by cronjob a.")
}
}
위처럼 initializeAuthenticationObject
, clearAuthenticationObject
같은 비즈니스 로직 외 메서드를 덜어내고 작업의 시작, 종료 로깅같은 반복되는 작업을 리스너로 분리하면서 가독성을 개선하고 작업의 시작 전후로 인증 객체를 등록하고 해제하는 작업도 그 실행을 보장할 수 있게 되었다. 실제로 실행 결과는 다음과 같다.
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobA : CRONJOB.CRONJOB_A is started.
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobA : product created by cronjob a.
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobA : CRONJOB.CRONJOB_A is ended successfully.
...
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobB : CRONJOB.CRONJOB_B is started.
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobB : product created by cronjob b.
[quartz-demo] [eduler_Worker-1] com.quartzdemo.cronjobs.CronJobB : CRONJOB.CRONJOB_B is ended successfully.
로그 출력과 수정자 표기를 모든 Quartz Job에 대하여 일괄로 반영할 수 있었다. 아쉽게도 이 방법은 다른 업무때문에 아직 실제 프로젝트에서는 적용해보지 못한 건인데 나중에 기회가 된다면 반영하고자 한다.
2번의 방법까지는 나름 잔머리를 굴려가면서 좋은 방법으로 개선했다!고 자신했지만 실제로 운영에 반영된 후 모니터링하면서 다른 크론잡의 수정자까지 내가 등록했던 수정자로 기록되는 걸 보고 당황했던 게 아직도 생생하다. 나름대로 백엔드 개발을 잘 해왔다고 생각했지만 이런 기초적인 멀티스레딩 이슈를 예상하지 못했다는 것이 부끄러웠다. Quartz가 어떻게 Job들을 실행시키는지 그 원리는 거의 잊고 작업 구현에만 집중하다보니 그런 것 같은데 왜 사람들이 무언가를 쓸 때는 어떻게 돌아가는지 알고 쓰는게 중요하다고 하는지 알 것 같다.
지금은 2번을 롤백하고 다시 예전처럼 일괄로 "system"으로 기록되는 중이다. 그렇지만 이 상태가 좋다는 것은 아니고 이후 3번 방식을 좀 개선해서(그리고 버그도 예상해보면서) 실제 운영에 다시 반영해보고자 한다. 우선순위가 높은 일은 아니기 때문에 언제가 될 지는 모르겠지만...