실제 데이터의 삭제가 아닌, 논리적인 삭제를 구현하기 위해 @SoftDelete를 사용하며 마주친 사항에 대해 정리해보았습니다.
게시글 - 댓글 관계를 작성하며, 게시글에 @SoftDelete
를 적용했습니다.
이후 댓글에 @ManyToOne(fetch = FetchType.LAZY)
으로 게시글을 지정하니 아래와 같은 오류가 발생했습니다.
To-one attribute cannot be mapped as LAZY as its associated entity is defined with @SoftDelete
헌데 프로젝트의 구조가 변경됨에 따라 해당 문제가 발생하기도, 발생하지 않기도 했습니다.
놀랍게도, 패키지명에 따라 예외가 발생하거나 발생하지 않음을 확인했습니다.
믿을 수 없었기에 프로젝트를 새로 만들어 테스트해보았습니다.
@Entity
@Getter
@SoftDelete
@Table(name = "posts")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@Column(name = "post_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
@Entity
@Getter
@SoftDelete
@Table(name = "comments")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
클래스는 두 개만 작성하였으며, 패키지명을 entity로 설정했습니다.
에러가 발생한 것을 확인할 수 있었습니다.
이번엔 패키지명을 domain으로 변경하고 실행시켜보았습니다.
에러 없이 잘 구동된 것을 확인할 수 있었습니다.
문제는 MappingModelCreationProcess
의 execute()
과정에서 일어납니다.
private void execute() {
for ( EntityPersister entityPersister : entityPersisterMap.values() ) {
if ( entityPersister instanceof InFlightEntityMappingType ) {
( (InFlightEntityMappingType) entityPersister ).linkWithSuperType( this );
}
}
for ( EntityPersister entityPersister : entityPersisterMap.values() ) {
currentlyProcessingRole = entityPersister.getEntityName();
if ( entityPersister instanceof InFlightEntityMappingType ) {
( (InFlightEntityMappingType) entityPersister ).prepareMappingModel( this );
}
}
executePostInitCallbacks();
}
해당 코드를 보시면 entityPersisterMap.values()
를 받아 순회하며 하위 로직을 동작시킵니다.
entityPersisterMap
은 EntityPersisterConcurrentMap
으로, 내부에 ConcurrentHashMap
을 지니고 있으며 해당 Map에 put
또는 putIfAbsent
가 이루어지는 경우 recomputeValues
메서드를 통해 values
를 재조정합니다.
허나 재조정 과정은 ConcurrentHashMap
의 entrySet
을 순회하는 방식이며, 이로 인해 패키지명에 따라 내부 순서가 영향을 받게 됩니다.
private void recomputeValues() {
//Assumption: the write lock is being held (synchronize on this)
final int size = map.size();
final EntityPersister[] newValues = new EntityPersister[size];
final String[] newKeys = new String[size];
int i = 0;
for ( Map.Entry<String, EntityPersisterHolder> e : map.entrySet() ) {
newValues[i] = e.getValue().entityPersister;
newKeys[i] = e.getKey();
i++;
}
this.values = newValues;
this.keys = newKeys;
}
entity, domain의 패키지명에 따른 순서를 확인해 보겠습니다.
보시다시피, 패키지명에 따라 순서가 뒤바뀌게 됩니다.
순서에 따른 검증 로직이 변경되며, 이로 인해 예외가 발생하거나 발생하지 않을 수 있습니다.
더 깊게 확인해보도록 하겠습니다.
패키지명에 따라 Comment가 먼저 올 경우, Comment는 AbstractEntityPersister
내의 prepareMappingModel
메서드에서 softDeleteMapping
값을 작성합니다.
softDeleteMapping = resolveSoftDeleteMapping( this, bootEntityDescriptor, getIdentifierTableName(), creationProcess );
@SoftDelete 어노테이션을 작성했으므로 SoftDeleteMappingImpl(comments.deleted)
값을 지니게 됩니다.
이후 Comment Entity의 필드값을 살펴보며 Post를 획득하게 됩니다.
for ( int i = 0; i < currentEntityMetamodel.getPropertySpan(); i++ ) {
final NonIdentifierAttribute runtimeAttrDefinition = properties[i];
final Property bootProperty = bootEntityDescriptor.getProperty( runtimeAttrDefinition.getName() );
if ( superMappingType == null
|| superMappingType.findAttributeMapping( bootProperty.getName() ) == null ) {
mappingsBuilder.put(
runtimeAttrDefinition.getName(),
generateNonIdAttributeMapping(
runtimeAttrDefinition,
bootProperty,
stateArrayPosition++,
fetchableIndex++,
creationProcess
)
);
}
declaredAttributeMappings = mappingsBuilder.build();
else {
// its defined on the supertype, skip it here
}
}
bootProperty는 Property(post)의 값을 지닙니다.
이후 post는 MappingModelCreationHelper
의 mappingConverter.apply(new ToOneAttributeMapping(.., (ToOne) bootProperty.getValue(), ..)
를 통해 ToOneAttributeMapping
을 생성하며 SoftDelete와 LAZY를 검증합니다.
if ( entityMappingType.getSoftDeleteMapping() != null ) {
// cannot be lazy
if ( getTiming() == FetchTiming.DELAYED ) {
throw new UnsupportedMappingException( String.format(
Locale.ROOT,
"To-one attribute (%s.%s) cannot be mapped as LAZY as its associated entity is defined with @SoftDelete",
declaringType.getPartName(),
getAttributeName()
) );
}
}
하지만 Post는 AbstractEntityPersister
내의 prepareMappingModel
를 통해 SoftDeleteMapping을 아직 설정하지 않았으므로 null을 반환하게 되고, 이에 따라 해당 예외를 회피하게 됩니다.
만약 Post가 Comment보다 앞서 해당 과정을 밟게 되었다면, 위의 검증 로직에서 null이 아닌 SoftDeleteMappingImpl(posts.deleted)
를 반환하게 되고, LAZY 설정으로 인한 DELAYED를 반환하여 예외를 Throw하게 됩니다.
이렇듯, Entity를 구성하는 패키지명이나 기타 상황에 따라 순서가 바뀌어 예외가 발생하거나, 발생하지 않는 경우가 발생하게 됩니다.
이는 예시로 들었던 패키지명인 entity, domain의 문제가 아닙니다. 무조건 entity로 패키지명을 작성했다고 해서 예외가 발생하게 되는 건 아니며, 각자의 프로젝트 환경에 따라 다를 것입니다.
특정 Entity를 추가했더니 예외가 발생하고, 코드를 수정했더니 예외가 발생하지 않을 수 있습니다.
해당 사항은 latest stable 버전인 6.5.1.Final과 development 버전인 6.6.0.Alpha1, 7.0.0.Alpha2에서도 동일합니다. 중간 로직 검증 부분이 바뀐 듯 해 보이지만, 패키지명에 따라 예외가 발생하거나 발생하지 않는 부분은 일치합니다.
제 깃허브 리포지토리에 예외가 발생하거나 발생하지 않는 예시를 작성해 두었습니다.
그건 아닙니다. 간단하게 테스트 해 본 결과, soft delete가 잘 이루어지는 것을 확인할 수 있었습니다.
하지만 해당 예외는 '무조건 발생되어야 한다'는 것이 제 의견입니다.
자세한 내용은 바로 밑 글을 읽어주세요.
해당 코드가 추가된 커밋에서 issue 넘버를 얻을 수 있었고, 해당하는 issue에서 답을 찾을 수 있었습니다.
customer {
id = 1,
removed=true,
...
}
order {
id = 1,
cust_fk = 1,
...
}
customer {
id = 1,
removed=true,
...
}
order {
id = 1,
cust_fk = null,
...
}
기여자는 softDelete 시 두 방식에 대해 고민을 한 것 같습니다. 첫번째 방식은 order가 customer의 proxy를 지니고 있고, 열심히 로직을 수행하다가 LAZY 로딩이 수행된 후에야 해당 order가 삭제되었다는 것을 깨닫게 되는 것을 우려한 것 같습니다. 따라서 @NotFound처럼 사전에 예외를 발생시키거나, 무시하고 동작시킬 수 있는 흐름을 위해서는 order 테이블의 removed 필드를 조회해야 할텐데, 해당 방법에서 쿼리가 발생할 것입니다. 따라서 쿼리 발생을 최대한 늦추는 LAZY의 의도를 지킬 수 없습니다.
앞선 방식을 따르지 않는다면 Hibernate가 강제로 참조무결성을 강제할 수 있도록 해야 할 텐데, 아무리 좋게 봐도 상당량의 작업이 필요할 것 같다고 합니다. order로의 삭제 전파 여부, @SoftDelete 지원 여부, 필드의 NOT NULL 여부 등을 강제하긴 힘들 테니.. 약간은 이해가 될 것 같습니다.
즉, softDelete와 LAZY를 둘 다 사용하면
로 요약이 가능할 것 같습니다.
https://stackoverflow.com/questions/77962258/hibernate-softdelete-problem-with-manytoonefetch-fetchtype-lazy
https://sundries-in-myidea.tistory.com/165?category=1013897
https://www.baeldung.com/java-hibernate-softdelete-annotation
https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#soft-delete