[Mybatis] 여러 type의 Enum에 사용할 수 있는 공용 TypeHandler 구현하기

lsjbh45·2022년 9월 1일
0

Mybatis와 TypeHandler

public interface TypeHandler<T> {
	void setParameter(PreparedStatement var1, int var2, T var3, JdbcType var4) throws SQLException;

	T getResult(ResultSet var1, String var2) throws SQLException;

	T getResult(ResultSet var1, int var2) throws SQLException;

	T getResult(CallableStatement var1, int var2) throws SQLException;
}

Mybatis에서는 database에서의 column과 Java에서의 enum type 간의 conversion을 수행하기 위해 명시적으로 TypeHandler를 정의해 주어야 한다. org.apache.ibatis.type.TypeHandler interface의 setParametergetResult method들을 구현한 구현체로 어떻게 database에 적절한 java type의 값을 저장하거나 가져올지를 지정해준다. 모든 enum type에 관한 TypeHandler를 일일히 구현해 주는 것은 비효율적인 면이 있기 때문에, 일반적으로는 conversion에 사용되는 codeindex 따위의 공통 속성값을 enum 내부에 정의해둔 뒤 동일한 type의 공통 속성에 대해서는 abstract TypeHandler를 만들어 내부 logic을 구현해 사용하곤 한다.

public interface PreparedStatement extends Statement {
	/* ... */
	void setInt(int parameterIndex, int x) throws SQLException;
	/* ... */
	void setString(int parameterIndex, String x) throws SQLException;
	/* ... */
}

type별로 abstract TypeHandler를 공유하는 방식은 동일한 type의 공통 속성에서만 유효한데, 다시 말하자면 enum의 공통 속성의 type이 int인 경우와 String인 경우는 abstract TypeHandler를 공유할 수 없다는 것이다. 이는 TypeHandler의 내부 method들을 구현할 때 사용하는 method들의 한계에서 기인하는데, PreparedStatement, CallableStatement, ResultSet 등 sql 구문의 실행을 위한 jdbc 객체들의 get/set method들이 type별로 따로 정의되어 있기 때문이다. PreparedStatement 객체의 특정 index에 int 값을 지정하고자 할 때는 setInt를, String 값을 지정하고자 할 때는 setString method를 사용해서 구현해야 하는 것이다. 주로 사용하는 특정 parameter type에 대한 TypeHandler가 아니라 type에 관계 없이 사용할 수 있는 하나의 공통 TypeHandler를 구현할 수 있을까?

공용 TypeHandler

public interface CodeEnum {
	Object getCode();
}

TypeHandler의 구현 목적은 Java enum type의 특정 field들 database column과 compatible하도록 만드는 것이다. CodeEnum interface는 TypeHandler에서 database column과 compatible하도록 구현할 enum field를 code field로 고정하도록 정의되어 있다. CodeEnum interface를 implements한 enum을 만들 때 반드시 code field를 정의해야 한다. 이때 interface의 code field의 type이 Object로 정의되어 있어 구현체에서 여러 type의 code field를 사용할 수 있다.

public abstract class CodeEnumTypeHandler<E extends CodeEnum> implements TypeHandler<CodeEnum> {
	private final Class<E> type;
	private final CodeType codeType;

	enum CodeType {
		INTEGER(INTEGER.class, "Int", int.class),
		STRING(String.class, "String", String.class);

		@Getter
		final Class<?> codeType;
		@Getter
		final String suffix;
		@Getter
		final Class<?> paramType;

		CodeType(Class<?> codeType, String suffix, Class<?> paramType) {
			this.codeType = codeType;
			this.suffix = suffix;
			this.paramType = paramType;
		}

		public static CodeType get(Class<?> codeType) {
			return Arrays.stream(values())
				.filter(type -> type.getCodeType().equals(codeType))
				.findAny()
				.orElseThrow(() -> new TypeException("Code type is not allowed in CodeEnumTypeHandler"));
		}
	}

	public CodeEnumTypeHandler(Class<E> type) {
		if (type == null) {
			throw new IllegalArgumentException("Type argument cannot be null");
		}

		this.type = type;
		this.codeType = CodeType.get(type.getEnumConstants()[0].getCode().getClass());
	}

	private Method getMethod(Class<?> objectType, String prefix, Class<?>... paramTypes) {
		String methodName = prefix + codeType.getSuffix();

		try {
			return objectType.getDeclaredMethod(methodName, paramTypes);
		} catch (NoSuchMethodException e) {
			throw new MybatisSystemException(e);
		}
	}

	private Object invokeMethod(Method method, Object instance, Object... params) {
		try {
			return method.invoke(instance, params);
		} catch (IllegalAccessException | InvocationTargetException e) {
			throw new MybatisSystemException(e);
		}
	}

	@Override
	public void setParameter(PreparedStatement ps, int i, CodeEnum parameter, JdbcType jdbcType) throws SQLException {
		Method method = getMethod(PreparedStatement.class, "set", int.class, codeType.getParamType());
		invokeMethod(method, ps, i, parameter.getCode());
	}

	@Override
	public CodeEnum getResult(ResultSet rs, String columnName) throws SQLException {
		Method method = getMethod(ResultSet.class, "get", String.class);
		Object code = invokeMethod(method, rs, columnName);

		return getCodeEnum(code);
	}

	@Override
	public CodeEnum getResult(ResultSet rs, int columnIndex) throws SQLException {
		Method method = getMethod(ResultSet.class, "get", int.class);
		Object code = invokeMethod(method, rs, columnIndex);

		return getCodeEnum(code);
	}

	@Override
	public CodeEnum getResult(CallableStatement cs, int columnIndex) throws SQLException {
		Method method = getMethod(CallableStatement.class, "get", int.class);
		Object code = invokeMethod(method, cs, columnIndex);

		return getCodeEnum(code);
	}

	private CodeEum getCodeEnum(Object code) {
		CodeEnum[] enumConstants = type.getEnumConstants();
		for (CodeEnum codeEnum : enumConstants) {
			if (codeEnum.getCode().equals(code)) {
				return codeEnum;
			}
		}

		return null;
	}
}

앞에서 정의한 interface CodeEnum을 타입 변수로 사용해 TypeHandler를 구현한 CodeEnumTypeHandler를 정의할 수 있다. CodeEnumTypeHandlerCodeEnum의 하위 type만을 타입 변수로 받는 generic class(<E extends CodeEnum>)로 정의되어 CodeEnum의 구현체들에 관한 TypeHandler로 상속해 사용할 수 있다. CodeEnumTypeHandler의 생성자를 호출하는 시점에 conversion의 대상이 되는 type class 정보를 받아 내부 field로 받아와 사용하게 된다.

CodeEnumTypeHandler에서는 CodeType이라는 내부 enum을 활용해 내부적으로 method 호출에 필요한 정보들을 처리한다. code field의 type, 호출할 method 이름의 suffix, method 호출 시에 필요한 인자의 class를 enum field로 사용한다. 생성자 호출 시점에 받아온 type class와 codeType field의 비교를 바탕으로 얻어낸 enum 정보를 마찬가지로 내부 field에 저장해 사용한다. 개발 당시에는 enum을 사용해 database에 get/set하고자 했던 Java type이 두 종류밖에 없었기 때문에 해당 부분만 구현해 둔 상태이지만, 다른 type들도 CodeType enum에 추가해서 충분히 사용 가능하다. 예를 들자면 사용하는 database가 Oracle이라 boolean type을 사용하지 않았지만, boolean type을 추가해 사용할 수도 있을 것이다.

jdbc 객체들이 호출해야 하는 method들을 결정하는 getMethod method, 실제로 해당 method들을 호출해 결과를 받아오는 invokeMethod method는 Java의 reflection 개념을 사용해 구현했다. codeType enum 없이 code field의 type에 따라 각각 처리 방식을 구현할 수도 있겠지만, CodeType enum만 변경해서 사용 가능한 type을 지정할 수 있도록 method를 호출하는 공통적인 로직을 분리하고자 했다. 마침 호출하는 method들이 유사한 naming convention을 가지고 있어서 CodeType enum의 정보들 만으로 충분히 구현 가능했다.

Java reflection과 관련된 exception들은 runtime에 실제로 호출하는 method들이 모두 존재하도록 정확히 구현되어 있기 때문에 실제로 발생할 일은 없다. 하지만 Class.getDeclaredMethod, Method.invoke가 throws 하는 checked exception들은 처리해 주어야 하고, TypeHandler에서 overriding하는 method들은 이미 SQLException만 throws 가능하도록 정의되어 있기 때문에 runtime에 exception handler가 처리할 수 있는 방식인 DataAccessException 하위의 MybatisSystemException을 발생시키도록 지정하였다.

실제 구현체 예시

enum NotificationType implements CodeEnum {
	REGISTER("REGISTER"),
	REPLY("REPLY");

	@Getter
	private final String code;

	NotificationType(String code) {
		return Arrays.stream(values())
			.filter(type -> type.getCode().equals(code))
			.findAny()
			.orElse(null);
	}

	@MappedTypes(NotificationType.class)
	public static class TypeHandler extends CodeEnumTypeHandler<NotificationType> {
		public TypeHandler() {
			super(NotificationType.class);
		}
	}
}

CodeEnumCodeTypeEnumHandler를 실제로 사용하는 구현 예시이다. CodeEnum interface를 implements한 enum type class 내부에 CodeEnumTypeHandler를 extends한 TypeHandler class를 선언해서 사용할 수 있다. CodeEnum의 구현체이기 때문에 code field와 @Getter annotation을 사용한 getCode method를 필수적으로 구현했음을 확인할 수 있다. code field의 return type이 String.classInteger.class가 아닌 경우 runtime에 TypeException이 발생하게 된다. TypeHandler에는 @MappedTypes annotation을 사용해 어떤 type에 관한 TypeHandler인지를 명시해 준다. TypeHandler의 생성자는 인자 없이 호출할 수 있도록 구현해야 한다.

Mybatis Get/Set 예시

	<insert>
	<!-- ... -->
		#{notification.type, javaType=path.NotificationType, 
							 typeHandler=path.NotificationType$TypeHandler}
	<!-- ... -->
	</insert>

insert, update 등 database에 Java type의 data를 set하는 경우, #{} 문법 내부에 javaType과 함께 typeHandler 를 명시해주면 된다.

	<resultMap>
	<!-- ... -->
		<result column="TYPE" property="type"
				typeHandler="path.NotificationType$TypeHandler" />
	<!-- ... -->
	</resultMap>

마찬가지로 select 문을 사용해 database에서 Java type의 data를 get하려는 경우, resultMap에 지정하는 property에 typeHandler를 명시해 주면 된다. 이때 CodeEnumTypeHandler가 abstract class로 구현되었기 때문에 CodeEnumTypeHandler를 상속받아 실제로 구현한 TypeHandler를 명시해 주어야 한다. 만약 CodeEnumTypeHandler를 abstract class가 아닌 일반 class로 구현한다면 CodeEnumTypeHandler를 명시해 주어도 문제 없이 작동하게 된다. 여기에서는 CodeEnumTypeHandler가 아니라 상속받은 TypeHandler를 mapper에 명시하도록 강제하면 initalization 과정에 TypeHandler instance가 만들어지기 때문에 exception을 이른 시점에 확인 가능하다는 점 때문에 abstract class로 CodeEnumTypeHandler를 구현했다.

Response에서 Enum의 형태

이렇게 만들어진 enum을 dto 내부에서 사용해서 response로 보내면 기본적으로 constant name 값이 보내지게 된다. 만약 enum이 가진 내부 field 값들을 json 형태로 response에 포함해 보내고 싶다면, 따로 interface를 구현해 주어도 되지만 @JsonFormat annotation을 명시해 간단히 형태를 변경할 수 있다. 이때 enum 내부에 get으로 시작하는 method가 있다면 해당 method의 return값이 field로 취급되어 함께 json 내부에 포함되어 나타난다. 이 annotation을 CodeEnum에 지정해 준다면 상속받은 모든 enum에 annotation을 적용한 효과를 얻을 수 있다.

public static class Notification {
	@JsonFormat(shape=JsonFormat.Shape.OBJECT)
	private NotificationType type;
}
profile
개발을 공부하며 깊게 고민했던 트러블슈팅 과정을 공유하고자 합니다.

0개의 댓글