코드의 살인자: 스프링의 음모와 복잡한 살인 퍼즐

redjen·2024년 2월 28일
3

월간 딥다이브

목록 보기
2/11
post-thumbnail

https://docs.spring.io/spring-data/rest/reference/data-commons/auditing.html

Spring Data Commons의 auditing

Spring Data 에서는 엔티티를 생성했거나 변경한 사람의 정보를 투명하게 유지하기 위한 지원을 해준다.

이러한 기능을 잘 활용하기 위해서는 엔티티 클래스를 감사 정보 메타 데이터를 추가할 수 있다.
1. 어노테이션을 사용하는 방법
2. 인터페이스를 구현하는 방법

추가적으로, audit 정보에 필요한 인프라적 컴포넌트를 등록하기 위해서는 어노테이션을 통한 설정 또는 XML 설정이 필요하다.

어노테이션 기반 auditing 메타 데이터

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 메타 데이터 어노테이션은 최상위 레벨 엔티티에 있어야 할 필요 없이 내부 필드로써 존재하는 데이터로 존재하여도 된다.

인터페이스 기반 auditing 메타 데이터

@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);
  }
}

spring data auditing deep dive: 스프링은 어떻게 메타 데이터를 채울까

AnnotationAuditingMetadata

CreatedBy, CreatedDate, LastModifiedBy, LastModifiedDate 어노테이션이 있는 클래스들의 정보를 inspect한다.

즉 클래스의 어노테이션으로 존재하는 audit 데이터들을 리플렉션을 사용해 metadata로써 인식하게 해주는 친구들이다.

이렇게 metadata로 인식된 audit 데이터들은 다시 wrapper로 감싸서 필요한 정보들을 채우기 위한 유틸 클래스의 도움을 받게 된다 (MappingAuditableBeanWrapperFactory)

AuditingHandlerSupport

<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로 채운다

그럼 둘은 언제 구분되어 호출될까?

IsNewAwareAuditingHandler

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를 판단하는 친구는 따로 있다.

BasicPersistentEntity의 isNew()

  • PersistentEntityisNew() 메서드를 구현하는 친구
  • IsNewStrategy라고 불리는 판단 기준을 통해 엔티티의 신규 여부를 따진다.

PersistentEntityIsNewStrategy

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() 메서드의 신 / 구 엔티티 판별 기준은 다음 차트를 따라 이루어진다.

(참고) MongoDB의 document versioning pattern

https://www.mongodb.com/blog/post/building-with-patterns-the-document-versioning-pattern

중간 정리

  1. Spring Data 에서 Audit 정보를 주입하기 위해 관여하는 여러 빈 중, IsNewAwareAuditingHandler 가 신규 엔티티인지 / 기존 엔티티인지에 따라 createdDate, lastModifiedDate의 정보를 채워넣는데 관여한다.
  2. PersistentEntityIsNewStrategy를 통해 엔티티의 신규 여부를 판단한다. (그리고 @Document 어노테이션된 엔티티들은 전부 @Persistent 하다)
  3. 별도의 버저닝 패턴을 사용하지 않는다면, 핸들링하는 엔티티의 id의 여부에 따라 엔티티의 신규 여부를 판단한다.

spring data의 save() 작동 방식

어라? 그런데 id 기준으로 동작을 구분하는 이런 행태는 어디선가 본 적이 있는 것 같다.

바로 동서고금을 막론하고 널리 사용되는 spring data CrudRepositorysave() 메서드이다.

https://www.baeldung.com/spring-data-crud-repository-save

  • 엔티티의 ID가 존재한다면 이미 존재하는 엔티티로 판단, save()update()로 동작하게 된다.
  • 엔티티의 ID가 존재하지 않는다면 신규 엔티티로 판단, 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();
}

1. save 시 id가 null이면

empNo가 인덱스 걸려 있는 필드이기 때문에 duplicate index error로 인해 insert 실패하게 된다

  • spring data 내부적으로 save하는 엔티티의 id가 null일 경우 update 쿼리가 아닌 insert 쿼리를 수행한다
  • 이 때 인덱스가 걸려 있는 empNo가 이미 DB 상에 존재하므로, insert는 duplicate index 에러로 인해 실패한다.
  • update, insert 모두 발생하지 않는다.
  • 만약 empNo 를 변경 + id null로 설정할 경우 신규 document가 insert 된다.

2. findAndModify 시 id가 null이면

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
  • spring data 내부적으로 findAndModify 쿼리를 전송하는데는 이상이 없다고 느낀다.
  • 하지만 몽고 DB 상 id는 immutable하기 때문에, 수정 쿼리에서 id를 set하는 행위는 금지되어 있다.
  • 때문에 update는 발생하지 않는다.

마치며

  • spring data commons에서는 AuditingAware 구현체를 통해 '어떤 작업자에 의해' 일어난 작업인지 정보를 기록하도록 도와준다.
  • 이 때 해당 엔티티가 신규 엔티티인지, 기존에 존재하던 엔티티인지는 아주 간단하게 말해 해당 엔티티의 id annotate된 필드의 값이 null인지로 구분한다.
profile
make maketh install

0개의 댓글

관련 채용 정보