https://docs.spring.io/spring-data/rest/reference/data-commons/auditing.html
Spring Data 에서는 엔티티를 생성했거나 변경한 사람의 정보를 투명하게 유지하기 위한 지원을 해준다.
이러한 기능을 잘 활용하기 위해서는 엔티티 클래스를 감사 정보 메타 데이터를 추가할 수 있다.
1. 어노테이션을 사용하는 방법
2. 인터페이스를 구현하는 방법
추가적으로, audit 정보에 필요한 인프라적 컴포넌트를 등록하기 위해서는 어노테이션을 통한 설정 또는 XML 설정이 필요하다.
Spring Data에서는 @CreatedBy
어노테이션과 @LastModifiedBy
어노테이션을 통해 엔티티를 생성하거나 수정한 사람의 정보를 기록할 수 있다.
비슷하게, @CreatedDate
와 @LastModifiedDate
어노테이션은 변화가 언제 발생했는지를 기록한다.
class Customer {
private AuditMetadata auditingMetadata;
// … further properties omitted
}
class AuditMetadata {
@CreatedBy
private User user;
@CreatedDate
private Instant createdDate;
}
위 예시에서 볼 수 있듯이, audit 메타 데이터 어노테이션은 최상위 레벨 엔티티에 있어야 할 필요 없이 내부 필드로써 존재하는 데이터로 존재하여도 된다.
@CreatedBy
나 @LastModifiedBy
어노테이션 중 하나를 사용하는 경우에도 '현재 작업자가 누구인지' 인식할 필요가 있다.
이를 위해 스프링은 애플리케이션과 상호작용하는 현재 사용자 또는 시스템이 누구인지 프레임워크에 알려주기 위한 AuditorAware<T>
인터페이스를 제공한다.
T
는 @CreatedBy
나 @LastModifiedBy
가 어떤 타입을 가져야 하는지를 등록한다.스프링 MVC에서 SecurityContextHolder
를 통해 audit 정보를 얻는 예시는 아래와 같다.
class SpringSecurityAuditorAware implements AuditorAware<User> {
@Override
public Optional<User> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getPrincipal)
.map(User.class::cast);
}
}
한편 스프링 웹플럭스에서도 마찬가지로 ReactiveAuditorAware
인터페이스를 통해 위 예시처럼 audit 정보를 어떻게 얻을 수 있는지 프레임워크에 알려줄 수 있다.
class SpringSecurityAuditorAware implements ReactiveAuditorAware<User> {
@Override
public Mono<User> getCurrentAuditor() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getPrincipal)
.map(User.class::cast);
}
}
CreatedBy
,CreatedDate
,LastModifiedBy
,LastModifiedDate
어노테이션이 있는 클래스들의 정보를 inspect한다.
즉 클래스의 어노테이션으로 존재하는 audit 데이터들을 리플렉션을 사용해 metadata로써 인식하게 해주는 친구들이다.
이렇게 metadata로 인식된 audit 데이터들은 다시 wrapper로 감싸서 필요한 정보들을 채우기 위한 유틸 클래스의 도움을 받게 된다 (MappingAuditableBeanWrapperFactory
)
<T> T markCreated(Auditor auditor, T source) {
Assert.notNull(source, "Source entity must not be null");
return touch(auditor, source, true);
}
<T> T markModified(Auditor auditor, T source) {
Assert.notNull(source, "Source entity must not be null");
return touch(auditor, source, false);
}
private Optional<TemporalAccessor> touchDate(AuditableBeanWrapper<?> wrapper, boolean isNew) {
Assert.notNull(wrapper, "AuditableBeanWrapper must not be null");
Optional<TemporalAccessor> now = dateTimeProvider.getNow();
Assert.notNull(now, () -> String.format("Now must not be null Returned by: %s", dateTimeProvider.getClass()));
now.filter(__ -> isNew).ifPresent(wrapper::setCreatedDate);
now.filter(__ -> !isNew || modifyOnCreation).ifPresent(wrapper::setLastModifiedDate);
return now;
}
touch()
시에 AuditableBeanWrapper
에 실제로 시간 정보를 채워주는 친구이다. (touchDate
)
markModified()
가 호출했다면 lastModifiedDate
를 now로 채운다markCreated()
가 호출했다면 createdDate
를 now로 채운다그럼 둘은 언제 구분되어 호출될까?
PersistentEntity의 신규 (
isNew
) 여부를 판단하여markModified
또는markCreated
호출에 관여하는 핸들러
public Object markAudited(Object object) {
Assert.notNull(object, "Source object must not be null");
if (!isAuditable(object)) {
return object;
}
PersistentEntity<?, ? extends PersistentProperty<?>> entity = entities
.getRequiredPersistentEntity(object.getClass());
return entity.isNew(object) ? markCreated(object) : markModified(object);
}
하지만 isNewAwareAuditingHandler
또한 엔티티의 신규 여부를 따지진 않고, auditable 한 엔티티의 isNew
를 판단하는 친구는 따로 있다.
isNew()
PersistentEntity
의 isNew()
메서드를 구현하는 친구IsNewStrategy
라고 불리는 판단 기준을 통해 엔티티의 신규 여부를 따진다.private PersistentEntityIsNewStrategy(PersistentEntity<?, ?> entity, boolean idOnly) {
Assert.notNull(entity, "PersistentEntity must not be null");
this.valueLookup = entity.hasVersionProperty() && !idOnly //
? source -> entity.getPropertyAccessor(source).getProperty(entity.getRequiredVersionProperty())
: source -> entity.getIdentifierAccessor(source).getIdentifier();
this.valueType = entity.hasVersionProperty() && !idOnly //
? entity.getRequiredVersionProperty().getType() //
: entity.hasIdProperty() ? entity.getRequiredIdProperty().getType() : null;
Class<?> type = valueType;
if (type != null && type.isPrimitive()) {
if (!ClassUtils.isAssignable(Number.class, type)) {
throw new IllegalArgumentException(String
.format("Only numeric primitives are supported as identifier / version field types; Got: %s", valueType));
} }}
@Override
public boolean isNew(Object entity) {
Object value = valueLookup.apply(entity);
if (value == null) {
return true;
}
if (valueType != null && !valueType.isPrimitive()) {
return false;
}
if (value instanceof Number) {
return ((Number) value).longValue() == 0;
}
throw new IllegalArgumentException(
String.format("Could not determine whether %s is new; Unsupported identifier or version property", entity));
}
즉 isNew()
메서드의 신 / 구 엔티티 판별 기준은 다음 차트를 따라 이루어진다.
https://www.mongodb.com/blog/post/building-with-patterns-the-document-versioning-pattern
IsNewAwareAuditingHandler
가 신규 엔티티인지 / 기존 엔티티인지에 따라 createdDate
, lastModifiedDate
의 정보를 채워넣는데 관여한다.PersistentEntityIsNewStrategy
를 통해 엔티티의 신규 여부를 판단한다. (그리고 @Document
어노테이션된 엔티티들은 전부 @Persistent
하다)id
의 여부에 따라 엔티티의 신규 여부를 판단한다. save()
작동 방식어라? 그런데 id 기준으로 동작을 구분하는 이런 행태는 어디선가 본 적이 있는 것 같다.
바로 동서고금을 막론하고 널리 사용되는 spring data CrudRepository
의 save()
메서드이다.
https://www.baeldung.com/spring-data-crud-repository-save
save()
는 update()
로 동작하게 된다.save()
는 insert()
로 동작하게 된다.하지만 직렬화된 엔티티의 현재 데이터는 변한다는 것을 가정한다면, id 값이 중간에 유실되거나 반대로 없어야 하는데 실수로 들어가는 경우가 생길 수 있다.
다음의 테스트 코드처럼...
@Test
void save_id_수정_테스트() {
StepVerifier.create(
employeeMongoRepository.findByEmpNo(testEmpNo)
.map(employee -> {
employee.setId(null);
employee.setName(testName);
return employee;
})
.flatMap(employeeMongoRepository::save)
)
.expectNextCount(1)
.verifyComplete();
}
@Test
void findOneAndUpdate_id_수정_테스트() {
Query query = Query.query(Criteria.where(Employee.Fields.empNo).is(testEmpNo));
Update update = Update.update(Employee.Fields.name, testName).set(BaseMongoEntity.Fields.id, null);
StepVerifier.create(
employeeMongoRepository.findOneAndUpdate(query, update))
.expectNextCount(1)
.verifyComplete();
}
empNo
가 인덱스 걸려 있는 필드이기 때문에 duplicate index error로 인해 insert 실패하게 된다
update
쿼리가 아닌 insert
쿼리를 수행한다empNo
가 이미 DB 상에 존재하므로, insert는 duplicate index 에러로 인해 실패한다.empNo
를 변경 + id null로 설정할 경우 신규 document가 insert 된다.DB 상에 있는 immutable한 필드인 id를 수정할 수 없기 때문에
findAndModify
가 실패하게 된다
com.mongodb.MongoCommandException: Command failed with error 66 (ImmutableField): 'Performing an update on the path '_id' would modify the immutable field '_id'' on server 10.106.93.66:30011
findAndModify
쿼리를 전송하는데는 이상이 없다고 느낀다.update
는 발생하지 않는다.AuditingAware
구현체를 통해 '어떤 작업자에 의해' 일어난 작업인지 정보를 기록하도록 도와준다.id
annotate된 필드의 값이 null인지로 구분한다.