본문 바로가기

Programing/Framework

[JPA] @Converter 는 어떻게 동작할까?

지니님이 기본 데이터 타입과 String 등을 enum 으로 바꾸는 작업을 진행하셨다.

대부분은 아래와 같이 @Enumerated 으로 enum의 이름을 사용하도록 작업을 했다.

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Table(name = "TEST")
@Getter
@Setter
public class TestEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "SEQ")
    private Integer id;

    @Column(name = "ACTION_TYPE", length = 100)
    @Enumerated(value = EnumType.STRING) // ORDINAL 과 STRING 두 가지가 있다.
    private ActionType actionType;

문제는 enum에서 매핑하고 있는 특정 코드로 매핑되는 부분이 있어서 위와 같은 방법 대신 커스텀 컨버터를 만들어서 적용을 하셨다.

@Entity
@Table(name = "TEST")
@Getter
@Setter
public class TestEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "SEQ")
    private Integer id;

    @Column(name = "COMMAND", length = 10)
    @Convert(converter = CommandTypeConverter.class)
    private CommandType commandType;
}

@Converter 는 어떻게 동작할까?

프레임워크를 사용하다보면 어떤 동작을 할지는 개발자가 정하지만 수행이 되도록 하는 것은 숨겨져있다.

프레임워크에 책임을 위임하기 때문이다.

가끔 프레임워크가 하는 일을 살펴보면 오픈소스 코드를 볼 수 있는 기회도 되고, 개발시 도움이 되는 아이디어를 얻기도 한다.

 

가장 파악하기 쉬운 방법으로는 통합 테스트 코드를 작성해서 커스텀 컨버터에 break를 걸어놓고 프레임워크가 물기를 기다리는 것이다. (낚시하는 것 같다.)

물린 결과는 아래와 같다.

호출 스택이 상당하다.

Spring JPA를 사용해서 일단 CrudRepository 의 findById 가 호출된 것이 보인다.

아래의 빨간줄은 CrudRepository 인터페이스에 매핑된 프록시이고 위의 빨간줄이 실제 구현체인 SimpleJpaRepository이다.

스프링 JPA의 SimpleJpaRepository 는 내부적으로 JPA의 EntityManager 의 find를 호출하게 된다.

package org.springframework.data.jpa.repository.support;

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

	private final EntityManager em;

	@Override
	public Optional<T> findById(ID id) {
		Class<T> domainType = getDomainClass();

		if (metadata == null) {
			return Optional.ofNullable(em.find(domainType, id));
		}

		LockModeType type = metadata.getLockModeType();

		Map<String, Object> hints = getQueryHints().withFetchGraphs(em).asMap();

		return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
	}

위의 호출 스택을 위로 올라가보면 JPA의 구현체로 하이버네이트가 사용되었음을 알 수 있다.

org.hibernate.internal.SessionImpl

사실 말입니다.

위에서는 아래에서 위로 bottom-up으로 진행했는데 @Converter 는 어떻게 동작할까?를 보려면 top-down으로 진행하는 것이 더 유용하다. (테스트를 하면서 글을 쓰다보니 두서없이 진행되고 있음)

JpaAttributeConverter 인터페이스와 구현체

JpaAttributeConverterImpl 는 JpaAttributeConverter 인터페이스의 표준 구현체이다.

attributeConverterBean 이라는 멤버변수에  AttributeConverter인터페이스를 구현한 커스텀 컨버터들을 가지고 있다가

경우에 따라서 관계형 값을 도메인 값으로, 도메인 값을 관계형 값으로 변환을 해준다.

package org.hibernate.metamodel.model.convert.internal;

public class JpaAttributeConverterImpl<O,R> implements JpaAttributeConverter<O,R> {
	private final ManagedBean<AttributeConverter<O,R>> attributeConverterBean;
	// ..

	@Override
	public O toDomainValue(R relationalForm) {
		return attributeConverterBean.getBeanInstance().convertToEntityAttribute( relationalForm );
	}

	@Override
	public R toRelationalValue(O domainForm) {
		return attributeConverterBean.getBeanInstance().convertToDatabaseColumn( domainForm );
	}

이 변환은 값을 추출하는 일을 맡고 있는 ValueExtractor 인터페이스의 extract 과정 안에서 수행된다.

package org.hibernate.type.descriptor;

public interface ValueExtractor<X> {
	public X extract(ResultSet rs, String name, WrapperOptions options) throws SQLException;

	public X extract(CallableStatement statement, int index, WrapperOptions options) throws SQLException;

	public X extract(CallableStatement statement, String[] paramNames, WrapperOptions options) throws SQLException;
}

구현체는 별도로 없이 AttributeConverterSqlTypeDescriptorAdapter 클래스의 익명클래스에 의해 수행되는데 코드의 일부는 아래와 같다.

package org.hibernate.type.descriptor.converter;

public class AttributeConverterSqlTypeDescriptorAdapter implements SqlTypeDescriptor {
	// ..
	@Override
	public <X> ValueExtractor<X> getExtractor(JavaTypeDescriptor<X> javaTypeDescriptor) {
		// Get the extractor for the intermediate type representation
		final ValueExtractor realExtractor = delegate.getExtractor( intermediateJavaTypeDescriptor );

		return new ValueExtractor<X>() {
			@Override
			public X extract(ResultSet rs, String name, WrapperOptions options) throws SQLException {
				return doConversion( realExtractor.extract( rs, name, options ) );
			}

			@Override
			public X extract(CallableStatement statement, int index, WrapperOptions options) throws SQLException {
				return doConversion( realExtractor.extract( statement, index, options ) );
			}

			@Override
			public X extract(CallableStatement statement, String[] paramNames, WrapperOptions options) throws SQLException {
				if ( paramNames.length > 1 ) {
					throw new IllegalArgumentException( "Basic value extraction cannot handle multiple output parameters" );
				}
				return doConversion( realExtractor.extract( statement, paramNames, options ) );
			}

			@SuppressWarnings("unchecked")
			private X doConversion(Object extractedValue) {
				try {
					X convertedValue = (X) converter.toDomainValue( extractedValue );
					log.debugf( "Converted value on extraction: %s -> %s", extractedValue, convertedValue );
					return convertedValue;
				}
				catch (PersistenceException pe) {
					throw pe;
				}
				catch (RuntimeException re) {
					throw new PersistenceException( "Error attempting to apply AttributeConverter", re );
				}
			}
		};

제일 아래에 doConversion에 보면 converter.toDomainValue 부분이 있다.

위의 경우에는 DB -> Object 로 가는 경우라서 ValueExtractor가 사용되는데

반대로 Object -> DB 로 가는 경우라면 ValueBinder 에 의하여 converter.toRelationalValue 가 수행된다.