JPA + Kotlin 환경에서 Entity의 List 객체를 변경할때 UnSupportedException

콜트·2023년 3월 31일
1
post-thumbnail

발단

회사 코드에 작업중 일부 코드를 수정해야했다. 하지만 당연히 잘 동작할 것이라고 생각했던게 뜬금없이 JpaRepository#save() 에서 예상치 못한 예외를 뱉어내는게 아닌가? 그래서 이를 해결하는 과정에서 라이브러리를 뜯어보게 됐다. 하나씩 뜯어보다가 문득 여기에 시간을 쏟게된게 너무 억울해서(내가 짠 코드였으면 억울하지라도 않지) 이렇게 글을 남긴다.

빠른 결론

  • 변경의 여지가 있는 Entity 내부의 List 타입의 프로퍼티에는 MutableList 를 사용하자.
    공식 문서만 읽어봐도 쉽게 알 수 있다(...).

  • JPA는 내부적으로 Entity의 List를 변경한 이후 save를 진행할 때, 이미 List에 데이터가 존재한다면 List 내부를 변경하는게 아니라 아예 새로 할당하는 것인데도 List#clear() 함수를 통해 내부 요소들을 하나씩 remove() 하고 있다는 것이다. 덕분에 내부 동작을 좀 더 알게 되었다. List 의 크기가 크다면.. 좀 위험할지도?

예제

대충 느낌만 냈다. main 함수를 살펴보시라.

package ...

import org.springframework.data.jpa.repository.JpaRepository
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.OneToMany

@Entity
class School(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    @JoinColumn(name = "schoolId")
    @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    var students: List<Student>? = null
) {
    fun updateStudents(newStudents: List<Student>) {
        this.students = newStudents
    }
}

@Entity
class Student(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    var name: String? = null
)

interface SchoolRepository : JpaRepository<School, Long>

fun main() {
	// 실제로는 Spring의 ApplicationContext 로부터 DI 받은 SchoolRepositoy를 사용.
    val schoolRepository = object : SchoolRepository {
        // implements
    }
    
    val oldStudents = listOf(Student(id = 1L, name = "banjjoknim"))
    val school = School(id = 1L, oldStudents) // 실제로 조회해왔을 때의 상태라고 가정.
    
    val newStudents = listOf(Student(id = 2L, name = "colt")) // 조회해온 school의 학생 목록을 새로운 학생 목록으로 교체한다.
    school.updateStudents(newStudents)
    
    schoolRepository.save(school) // throw UnSupportedException.......
}

과정

순서는 다음과 같다.


  1. Entity 내부의 List 타입의 객체에 새로운 List 값을 할당하면 org.hibernate.type.CollectionType#replace() 함수 내부에서 replaceElements() 함수가 호출된다.
package org.hibernate.type;

// ...

/**
 * A type that handles Hibernate <tt>PersistentCollection</tt>s (including arrays).
 *
 * @author Gavin King
 */
public abstract class CollectionType extends AbstractType implements AssociationType {

		// ...

		@Override
		public Object replace(
						final Object original,
						final Object target,
						final SharedSessionContractImplementor session,
						final Object owner,
						final Map copyCache) throws HibernateException {
				if ( original == null ) {
					return null;
				}
				if ( !Hibernate.isInitialized( original ) ) {
						if ( ( (PersistentCollection) original ).hasQueuedOperations() ) {
								if ( original == target ) {
										// A managed entity with an uninitialized collection is being merged,
										// We need to replace any detached entities in the queued operations
										// with managed copies.
										final AbstractPersistentCollection pc = (AbstractPersistentCollection) original;
										pc.replaceQueuedOperationValues( getPersister( session ), copyCache );
								}
								else {
										// original is a detached copy of the collection;
										// it contains queued operations, which will be ignored
										LOG.ignoreQueuedOperationsOnMerge(
														MessageHelper.collectionInfoString(
																getRole(),
																( (PersistentCollection) original ).getKey()
														)
										);
								}
						}
				return target;
		}

		// for a null target, or a target which is the same as the original, we
		// need to put the merged elements in a new collection
		Object result = ( target == null ||
				target == original ||
				target == LazyPropertyInitializer.UNFETCHED_PROPERTY ) ?
				instantiateResult( original ) : target;

		//for arrays, replaceElements() may return a different reference, since
		//the array length might not match
		result = replaceElements( original, result, owner, copyCache, session );

		if ( original == target ) {
			// get the elements back into the target making sure to handle dirty flag
			boolean wasClean = PersistentCollection.class.isInstance( target ) && !( ( PersistentCollection ) target ).isDirty();
			//TODO: this is a little inefficient, don't need to do a whole
			//      deep replaceElements() call
			replaceElements( result, target, owner, copyCache, session );
			if ( wasClean ) {
				( ( PersistentCollection ) target ).clearDirty();
			}
			result = target;
		}

		return result;
	}

	/**
	 * Replace the elements of a collection with the elements of another collection.
	 *
	 * @param original The 'source' of the replacement elements (where we copy from)
	 * @param target The target of the replacement elements (where we copy to)
	 * @param owner The owner of the collection being merged
	 * @param copyCache The map of elements already replaced.
	 * @param session The session from which the merge event originated.
	 * @return The merged collection.
	 */
	public Object replaceElements(
			Object original,
			Object target,
			Object owner,
			Map copyCache,
			SharedSessionContractImplementor session) {
		// TODO: does not work for EntityMode.DOM4J yet!
		java.util.Collection result = ( java.util.Collection ) target;
		result.clear();

		// copy elements into newly empty target collection
		Type elemType = getElementType( session.getFactory() );
		Iterator iter = ( (java.util.Collection) original ).iterator();
		while ( iter.hasNext() ) {
			result.add( elemType.replace( iter.next(), null, session, owner, copyCache ) );
		}

		// if the original is a PersistentCollection, and that original
		// was not flagged as dirty, then reset the target's dirty flag
		// here after the copy operation.
		// </p>
		// One thing to be careful of here is a "bare" original collection
		// in which case we should never ever ever reset the dirty flag
		// on the target because we simply do not know...
		if ( original instanceof PersistentCollection ) {
			if ( result instanceof PersistentCollection ) {
				final PersistentCollection originalPersistentCollection = (PersistentCollection) original;
				final PersistentCollection resultPersistentCollection = (PersistentCollection) result;

				preserveSnapshot( originalPersistentCollection, resultPersistentCollection, elemType, owner, copyCache, session );

				if ( ! originalPersistentCollection.isDirty() ) {
					resultPersistentCollection.clearDirty();
				}
			}
		}

		return result;
	}

// ...

}

  1. org.hibernate.type.CollectionType#replaceElements() 함수 내부에서 result.clear() 가 호출된다.

  2. 이때 result.clear() 는 실제로는 package.org.hibernate.collection.internal.PersistentBag#clear() 함수이다(result.clear() 가 실제로 문제가 되는 부분임).

package org.hibernate.collection.internal;

/**
 * An unordered, unkeyed collection that can contain the same element
 * multiple times. The Java collections API, curiously, has no <tt>Bag</tt>.
 * Most developers seem to use <tt>List</tt>s to represent bag semantics,
 * so Hibernate follows this practice.
 *
 * @author Gavin King
 */
public class PersistentBag extends AbstractPersistentCollection implements List {

		// ...

		protected List bag;

		@Override
		@SuppressWarnings("unchecked")
		public void clear() {
				if ( isClearQueueEnabled() ) {
						queueOperation( new Clear() );
				}
				else {
						initialize( true );
						if ( !bag.isEmpty() ) {
								bag.clear();
								dirty();
						}
				}
		}

		// ...
}

  1. 해당 함수 내에서 bag.clear() 를 호출하는데, 이는 java.util.List#clear() 함수이다.

  2. 이때 실제로 호출 되는 함수는 AbstractList#clear() 이다.

package java.util;

public interface Collection<E> extends Iterable<E> {

// ...

		/**
     * Removes all of the elements from this collection (optional operation).
     * The collection will be empty after this method returns.
     *
     * @throws UnsupportedOperationException if the {@code clear} operation
     *         is not supported by this collection
     */
    void clear();

// ...

}
package java.util;

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {

		// ...

		/**
     * Removes all of the elements from this list (optional operation).
     * The list will be empty after this call returns.
     *
     * @implSpec
     * This implementation calls {@code removeRange(0, size())}.
     *
     * <p>Note that this implementation throws an
     * {@code UnsupportedOperationException} unless {@code remove(int
     * index)} or {@code removeRange(int fromIndex, int toIndex)} is
     * overridden.
     *
     * @throws UnsupportedOperationException if the {@code clear} operation
     *         is not supported by this list
     */
    public void clear() {
        removeRange(0, size());
    }

		// ...

		/**
     * Removes from this list all of the elements whose index is between
     * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
     * Shifts any succeeding elements to the left (reduces their index).
     * This call shortens the list by {@code (toIndex - fromIndex)} elements.
     * (If {@code toIndex==fromIndex}, this operation has no effect.)
     *
     * <p>This method is called by the {@code clear} operation on this list
     * and its subLists.  Overriding this method to take advantage of
     * the internals of the list implementation can <i>substantially</i>
     * improve the performance of the {@code clear} operation on this list
     * and its subLists.
     *
     * @implSpec
     * This implementation gets a list iterator positioned before
     * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
     * followed by {@code ListIterator.remove} until the entire range has
     * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
     * time, this implementation requires quadratic time.</b>
     *
     * @param fromIndex index of first element to be removed
     * @param toIndex index after last element to be removed
     */
    protected void removeRange(int fromIndex, int toIndex) {
        ListIterator<E> it = listIterator(fromIndex);
        for (int i=0, n=toIndex-fromIndex; i<n; i++) {
            it.next();
            it.remove();
        }
    }
}

package java.util;

public interface ListIterator<E> extends Iterator<E> {

		// ...

		/**
     * Removes from the list the last element that was returned by {@link
     * #next} or {@link #previous} (optional operation).  This call can
     * only be made once per call to {@code next} or {@code previous}.
     * It can be made only if {@link #add} has not been
     * called after the last call to {@code next} or {@code previous}.
     *
     * @throws UnsupportedOperationException if the {@code remove}
     *         operation is not supported by this list iterator
     * @throws IllegalStateException if neither {@code next} nor
     *         {@code previous} have been called, or {@code remove} or
     *         {@code add} have been called after the last call to
     *         {@code next} or {@code previous}
     */
    void remove();

		// ...
}
package java.util;

public interface Iterator<E> {
		
		// ...

		/**
     * Removes from the underlying collection the last element returned
     * by this iterator (optional operation).  This method can be called
     * only once per call to {@link #next}.
     * <p>
     * The behavior of an iterator is unspecified if the underlying collection
     * is modified while the iteration is in progress in any way other than by
     * calling this method, unless an overriding class has specified a
     * concurrent modification policy.
     * <p>
     * The behavior of an iterator is unspecified if this method is called
     * after a call to the {@link #forEachRemaining forEachRemaining} method.
     *
     * @implSpec
     * The default implementation throws an instance of
     * {@link UnsupportedOperationException} and performs no other action.
     *
     * @throws UnsupportedOperationException if the {@code remove}
     *         operation is not supported by this iterator
     *
     * @throws IllegalStateException if the {@code next} method has not
     *         yet been called, or the {@code remove} method has already
     *         been called after the last call to the {@code next}
     *         method
     */
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }

		// ...
}
  1. AbstractList#clear() 의 내부 구현을 살펴보면, removeRange() 함수를 호출하고 그 내부를 살펴보면 remove() 함수를 호출하고 있다.

  2. 이때 호출되는 remove() 함수는 ListIterator#remove() 인데, 설명을 읽어보면 현재 호출자 List 객체에 remove() 함수가 지원되지 않는 경우 UnSupportedException 을 던진다(Iterator#remove() 가 조상인데, 기본 구현으로 UnSupportedException 을 던지게 되어있다).


package kotlin.collections

/**
 * A generic ordered collection of elements. Methods in this interface support only read-only access to the list;
 * read/write access is supported through the [MutableList] interface.
 * @param E the type of elements contained in the list. The list is covariant in its element type.
 */
public interface List<out E> : Collection<E> {
    // Query Operations

    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>

    // Bulk Operations
    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean

    // Positional Access Operations
    /**
     * Returns the element at the specified index in the list.
     */
    public operator fun get(index: Int): E

    // Search Operations
    /**
     * Returns the index of the first occurrence of the specified element in the list, or -1 if the specified
     * element is not contained in the list.
     */
    public fun indexOf(element: @UnsafeVariance E): Int

    /**
     * Returns the index of the last occurrence of the specified element in the list, or -1 if the specified
     * element is not contained in the list.
     */
    public fun lastIndexOf(element: @UnsafeVariance E): Int

    // List Iterators
    /**
     * Returns a list iterator over the elements in this list (in proper sequence).
     */
    public fun listIterator(): ListIterator<E>

    /**
     * Returns a list iterator over the elements in this list (in proper sequence), starting at the specified [index].
     */
    public fun listIterator(index: Int): ListIterator<E>

    // View
    /**
     * Returns a view of the portion of this list between the specified [fromIndex] (inclusive) and [toIndex] (exclusive).
     * The returned list is backed by this list, so non-structural changes in the returned list are reflected in this list, and vice-versa.
     *
     * Structural changes in the base list make the behavior of the view undefined.
     */
    public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
  1. 그럼 여기서 Kotlin 의 List 를 살펴보자. Kotlin의 ListCollection 인터페이스 타입을 상속받았고 remove() 함수가 없다(설명에도 대놓고 read-only 라고 적혀있고 write 까지 하고 싶으면 MutableList 를 사용하라고 친절하게 적혀있다).

package kotlin.collections

/**
 * A generic collection of elements. Methods in this interface support only read-only access to the collection;
 * read/write access is supported through the [MutableCollection] interface.
 * @param E the type of elements contained in the collection. The collection is covariant in its element type.
 */
public interface Collection<out E> : Iterable<E> {
    // Query Operations
    /**
     * Returns the size of the collection.
     */
    public val size: Int

    /**
     * Returns `true` if the collection is empty (contains no elements), `false` otherwise.
     */
    public fun isEmpty(): Boolean

    /**
     * Checks if the specified element is contained in this collection.
     */
    public operator fun contains(element: @UnsafeVariance E): Boolean

    override fun iterator(): Iterator<E>

    // Bulk Operations
    /**
     * Checks if all elements in the specified collection are contained in this collection.
     */
    public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}
  1. 다시 한번, Kotlin의 List 타입이 상속받은 Collection 을 살펴보자. 여기에도 마찬가지로 remove() 함수가 없다.

  1. 결론적으로 List 타입은 remove() 와 같은 write 용도의 함수를 지원하지 않기 때문에 UnSupportedException 이 발생하게 된다.

참고자료

profile
개발 블로그이지만 꼭 개발 이야기만 쓰라는 법은 없으니, 그냥 쓰고 싶은 내용이면 뭐든 쓰려고 합니다. 코드는 깃허브에다 작성할 수도 있으니까요.

0개의 댓글