본문 바로가기

프로그래밍/Spring

Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (7) - HandlerMethodArgumentResolver 분석(3)

저번 글에서는 @DataSet 어노테이션을 이용해서 DataSet 객체를 사용자가 지정한 파라미터 타입으로의 변환에 대한 대강의 윤곽을 잡았다. 약간 요약식으로 여기서 다시 한번 언급한다면 DataSet에 저장되어 있는 insert-update, delete 레코드들을 각각 변환시키는데 이때 사용자가 Collection 인터페이스 계얄 타입으로 파라미터를 설정했는지 확인해서 그 내부에 정의한 Generic 클래스 타입으로 변환한다고까지 얘기했었다. 다만 저번글에서는 이 변환 부분에 대해 구체적인 설명을 하진 않고 XplatformReflectionUtils 클래스에서 정의한 staric 메소드들을 사용하는 것으로 설명했다. 이번글은 이 static 메소드들에 대한 구체적인 설명을 진행해보고자 한다. 이 변환에 대해 Java Reflection을 사용한다고 늘 언급했지만 구체적인 코드를 보여준적은 없었는데 이번글에서 이 코드들을 언급하도록 하겠다.

 

DataSet 객체에서 insert, update 레코드를 사용자가 Generic 클래스로 Map을 사용했을때 XplatformReflection 클래스의 makeDataAsMap 메소드를 사용한다고 설명했었다. 이 메소드 코드를 보자.

 

private static Map<String, Object> makeDataAsMap(Class<?> mapType, DataSet dataSet, int colCount, int rowIdx) throws InstantiationException, IllegalAccessException {    
	Map<String, Object> result = null;    
	if(mapType.isInterface()) {        
		result = new HashMap<String, Object>();    
	} else {        
		result = (Map<String, Object>) mapType.newInstance();    
	}
    
	result.put("rowType", dataSet.getRowType(rowIdx));    
	result.put("storeDataChanges", dataSet.isStoreDataChanges());    
	for(int j=0; j < colCount; j++) {
		// 데이터셋에서 해당 row에 대한 column 값을 Object로 가져온다        
		Object columnValue = dataSet.getObject(rowIdx, j);        
		ColumnHeader columnHeader = dataSet.getColumn(j);        
		String columnName = columnHeader.getName();        
		result.put(columnName, columnValue);    
	}
    
	return result;
}

 

makeDataAsMap 메소드는 XplatformReflectionUtils 클래스 내부에서 사용될 목적으로 만든 메소드라서 접근자는 private 으로 선언했다. 이 메소드는 4개의 파라미터를 받는데, mapType 파라미터는 사용자가 선언한 Generic Class 타입이 들어온다. 예를 들어 Controller의 메소드에서 파라미터로 List<Map<String, Object>>로 선언했으면 mapType 파라미터는 Map.class를 갖고 오게 된다. 파라미터 dataSet과 colCount, rowIdx는 DataSet 객체와 column 갯수, 레코드 idx 값을 갖게 된다. column 갯수만큼 loop를 돌면서 column header 에 있는 column 이름을 가지고 와 이를 Map의 key로 활용하고 거기에 저장되는 value에 column에 저장된 데이터를 저장하게 된다. 여기서도 mapType에 대해 인터페이스 여부를 체크하는데 왜냐면 위에서 언급한대로 Map<String, Object>로 선언했을수도 있고, LinkedHashMap<String, Object> 로 선언했을수도 있다. Map으로 선언했을 경우엔 인터페이스이므로 이에 대한 구현클래스인 HashMap 클래스를 만들게 되고(new HashMap<String, Object>) LinkedHashMap 같은 Map 구현 클래스일 경우엔 이 클래스 객체를 Reflection을 이용해서 생성((Map<String, Object>)mapType.newInstance()) 하게 된다.

 

다음으로 봐야 할 것은 DataSet에서 해당 레코드(Row)에 대한 타입을 읽어오는 부분이다. 예전 글에서도 설명했지만 DataSet에서는 해당 레코드가 insert인지, update인지, delete인지를 구분해주는 타입이 있다고 설명했었다. 그것을 읽어오는 것이 getRowType 메소드이다. 이 메소드에 해당 레코드의 index를 주어서 그 레코드 타입을 읽어오는 것이다. 이렇게 읽어온 레코드 타입에 대한 값을 생성된 Map 객체에 rowType이란 key를 주어서 넣어주게 된다. 다음으로 읽어오는 것은 변경된 데이터 변경 정보에 대한 저장여부를 읽어오는 것이다. 이 부분을 활용하는 부분은 없으나 차후에 이 부분을 활용해서 작업해야 할 상황도 있을듯 해서 일종의 확장개념 차원에서 넣었다. 

 

이렇게 기본적인 Map 객체 설정을 마쳤으면 다음으로 할 것이 해당 레코드에 대한 컬럼 값들을 읽어오는 것이다. 컬럼 갯수 만큼 반복하면서 컬럼에 대한 값을 가져오고(Object columnValue = dataSet.getObject(rowIdx, j)) 컬럼의 이름을 알아내기 위해 컬럼정보를 얻은 뒤에(ColumnHeader columnHeader = dataSet.getColumn(j)) 컬럼 이름을 얻어내어(String columnName = columnHeader.getName()) 해당 컬럼 이름을 key로 삼아 컬럼 값을 넣게 된다(result.put(columnName, columnValue)) 이러한 컬럼 작업을 컬럼 갯수만큼 반복적으로 진행하면 DataSet의 하나의 레코드에 대한 Map 객체가 완성이 되는 것이다.

 

DataSet의 레코드를 Map 객체로 변환하기 위해 사용됐던 메소드인 makeDataAsMap에 대한 설명은 이정도로 마치고 지금은 Map 객체가 아닌 사용자가 별도로 만든 클래스 객체로 변환하기 위해 사용하는 메소드인 makeDataAsObject 메소드에 대한 설명을 하도록 하겠다. 먼저 이 메소드의 코드를 보자

 

private static Object makeDataAsObject(Class<?> innerClass, DataSet dataSet, int colCount, int rowIdx) throws InstantiationException, IllegalArgumentException, IllegalAccessException {
	Object obj = innerClass.newInstance();
	Field rowTypeField = ReflectionUtils.findField(innerClass, "rowType");
	Field storeDataChangesField = ReflectionUtils.findField(innerClass, "storeDataChanges");
	ReflectionUtils.makeAccessible(rowTypeField);
	ReflectionUtils.makeAccessible(storeDataChangesField);
	rowTypeField.setInt(obj, dataSet.getRowType(rowIdx));
	storeDataChangesField.setBoolean(obj, dataSet.isStoreDataChanges());
	
	for(int j=0; j < colCount; j++) {
		String columnValue = dataSet.getString(rowIdx, j);	// 데이터셋에서 해당 row에 대한 column 값을 String으로 가져온다
		
		ColumnHeader columnHeader = dataSet.getColumn(j);
		String columnName = columnHeader.getName();
		
		Field field = ReflectionUtils.findField(innerClass, columnName);
		
		if(field == null) {		// 컬럼이름으로 된 멤버변수를 찾지 못한 경우
			continue;
		} else {
			XplatformReflectionUtils.setField(field, columnHeader, obj, columnValue);
		}
	}
	
	return obj;
}

 

DataSet의 레코드를 Map 객체로 변환하기 위해 진행했던 작업순서는 비슷하지만 구체적인 코드는 다르다. 그럴수밖에 없는 것이 return 되어야 하는 객체가 Map이 아닌 사용자가 설정한 클래스 객체를 return 해야 하기 때문이다. 파라미터로 넘어오는 것중 innerClass의 경우 Map 객체로 변환할 때는 사용자가 설정한 Map 또는 Map 인터페이스를 구현한 클래스 타입이 넘어오지만 지금은 Map이 아닌 사용자가 설정한 클래스가 넘어오게 된다. 먼저 사용자가 설정한 클래스의 객체를 생성한다(Object obj = innerClass.newInstance()) 다음으로 진행할 것은 Map 객체로 변환할때도 진행했던 작업인 DataSet의 해당 레코드 타입과 변경된 데이터 저장여부 에 대한 값을 읽어와서 이를 생성한 객체에 저장하는 것이다. 이 부분은 Spring에서 Java Reflection 기능을 수행하기 위해 별도로 만든 Util 성 클래스인 ReflectionUtils 클래스를 이용해서 작업했다. 근데 여기서 주목해야 할 내용이 있다. Map의 경우는 해당 key를 직접 주어서 값을 넣었지만 클래스의 경우는 이러한 정보를 저장할 필드가 사전에 있어야 한다. 즉 사용자가 설정한 클래스 타입에 이 정보들을 저장하는 멤버변수가 정의되어 있어야 한다 는 의미다. 이를 위해서 여기서는 일종의 규약(?)식으로 다음의 클래스를 상속 받도록 되어 있다.

 

import com.terry.xplatform.vo.SampleDefaultVO;

import lombok.Getter;
import lombok.Setter;

/** 
* XPlatform의 DataSet에서 변환되는 VO는 이 클래스를 반드시 상속받아야 한다 
* @author Terry Chang * 
*/
@Getter
@Setter
public abstract class XplatformVO extends SampleDefaultVO {
	private int rowType;
	private boolean storeDataChanges;
}

 

DataSet에 저장되는 레코드를 객체로 변환되는데 사용되는 VO의 경우엔 이 XplatformVO 추상 클래스를 상속받게끔 규약을 설정했다. 그래서 이 추상클래스에 있는 rowType 멤버변수와 storeDataChanges 멤버변수에 DataSet의 해당 레코드 타입과 변경된 데이터 저장여부가 저장이 되도록 했다. makeDataAsObject 메소드에서 객체 생성 이후 for문 나오기 전까지의 코드가 XplatformVO 클래스를 상속받은 클래스의 객체에 이 정보를 저장하는 작업을 한다고 보면 되겠다. for문의 내부 내용은 makeDataAsMap 메소드의 for문과 마찬가지로 데이터 셋의 레코드에 있는 컬럼 값들을 생성된 객체의 멤버필드에 설정하는 작업이다. map의 경우는 레코드의 컬럼 이름을 key로 해서 map에 넣었지만 여기서는 레코드의 컬럼 이름과 같은 이름을 갖고 있는 클래스 필드에 값을 설정하는 것이다. 단 이때 차이가 있다면 레코드 컬럼 이름과 같은 클래스 필드가 없을 경우에 대한 처리가 추가된 것이 있다. 예를 들어 레코드에는 cnt란 컬럼이 있는데 막상 이 레코드를 넣을 클래스의 객체엔 cnt란 필드가 없을수가 있다. 그럴때는 예외를 던지지 않고 bypass 하도록 했다(물론 예외를 던질수도 있다. 다만 이런 작업의 프로그래밍은 bypass 하는 것들이 많아서 이렇게 한 것이지 예외를 던지는 식으로 rule을 잡았으면 예외를 던지는 것이 맞다) 해당 필드에 값을 넣는 기능은 XplatformRefelctionUtils 클래스의 setField 메소드가 하도록 했다. 이제 이 부분을 보도록 하자.

 

public static void setField(Field field, ColumnHeader columnHeader, Object obj, String columnValue)  {

	//Field가 private 등의 접근할 수 없는 상황일때도 접근할 수 있게 한다
	ReflectionUtils.makeAccessible(field); 					

	Class<?> fieldType = field.getType();

	if(fieldType == String.class) {
		ReflectionUtils.setField(field, obj, columnValue);
	} else if(fieldType == char.class || fieldType == Character.class) {
		ReflectionUtils.setField(field, obj, columnValue.charAt(0));
	} else if(fieldType == short.class || fieldType == Short.class) {
		ReflectionUtils.setField(field, obj, Short.parseShort(columnValue, 10));
	} else if(fieldType == int.class || fieldType == Integer.class) {
		ReflectionUtils.setField(field, obj, Integer.parseInt(columnValue, 10));
	} else if(fieldType == long.class || fieldType == Long.class) {
		ReflectionUtils.setField(field, obj, Long.parseLong(columnValue, 10));
	} else if(fieldType == float.class || fieldType == Float.class) {
		ReflectionUtils.setField(field, obj, Float.parseFloat(columnValue));
	} else if(fieldType == double.class || fieldType == Double.class) {
		ReflectionUtils.setField(field, obj, Double.parseDouble(columnValue));
	} else if(fieldType == Date.class) {
		int dataType = columnHeader.getDataType();
		Calendar calendar = Calendar.getInstance();
		if(dataType == DataTypes.DATE) {
			if(columnValue.length() == 8) {	// yyyyMMdd
				String year = columnValue.substring(0, 4);
				String month = columnValue.substring(4, 6);
				String day = columnValue.substring(6, 8);

				calendar.set(Calendar.YEAR, Integer.parseInt(year, 10));
				calendar.set(Calendar.MONTH, Integer.parseInt(month, 10) - 1);
				calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(day, 10));

			} else {
				// DataTypes.DATE 타입에 8자리 숫자가 들어와야 하는데 그러지 않은 것이므로 예외처리 대상이다
				throw new IllegalArgumentException("dataType must be 8 characters in Xplatform DATE type");
			}
		} else if(dataType == DataTypes.TIME) {
			if(columnValue.length() >= 6) {
				String hour = columnValue.substring(0, 2);
				String minute = columnValue.substring(2, 4);
				String second = columnValue.substring(4, 6);
					
				calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(hour, 10));
				calendar.set(Calendar.MINUTE, Integer.parseInt(minute, 10));
				calendar.set(Calendar.SECOND, Integer.parseInt(second, 10));
					
				if(columnValue.length() > 6) {
					String milisecond = columnValue.substring(6, columnValue.length());
					calendar.set(Calendar.MILLISECOND, Integer.parseInt(milisecond, 10));
				}
			} else { 
				// DataTypes.DATE 타입에 6자리 이상의 숫자가 들어와야 하는데 그러지 않은 것이므로 예외처리 대상이다
				throw new IllegalArgumentException("dataType must be 6 charactersin Xplatform TIME type");
			}
		} else if(dataType == DataTypes.DATE_TIME) {
			if(columnValue.length() >= 17) {
				String year = columnValue.substring(0, 4);
				String month = columnValue.substring(4, 6);
				String day = columnValue.substring(6, 8);
				String hour = columnValue.substring(8, 10);
				String minute = columnValue.substring(10, 12);
				String second = columnValue.substring(12, 14);

				calendar.set(Calendar.YEAR, Integer.parseInt(year, 10));
				calendar.set(Calendar.MONTH, Integer.parseInt(month, 10) - 1);
				calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(day, 10));
				calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(hour, 10));
				calendar.set(Calendar.MINUTE, Integer.parseInt(minute, 10));
				calendar.set(Calendar.SECOND, Integer.parseInt(second, 10));

				if(columnValue.length() > 17) {
					String milisecond = columnValue.substring(14);
					calendar.set(Calendar.MILLISECOND, Integer.parseInt(milisecond, 10));
				}

			} else { 
				// DataTypes.DATE 타입에 14자리 이상의 숫자가 들어와야 하는데 그러지 않은 것이므로 예외처리 대상이다
				throw new IllegalArgumentException("dataType must be 14 charactersin Xplatform DATE_TIME type");
			}
		}

		ReflectionUtils.setField(field, obj, calendar.getTime());
	}

}

 

먼저 이 메소드는 DataSet에 있는 레코드의 컬럼값을 해당 레코드가 변환될 클래스 객체의 필드에 매핑하는 기능을 한다는 설명을 해놓고 이 setField에 넘어오는 파라미터에 대한 설명부터 하겠다. 첫번째 파라미터로 오는 Field 객체는 변환될 클래스 객체의 Field 객체가 된다. 예를 들어 DataSet의 cnt라는 컬럼을 cnt라는 클래스의 멤버변수에 매핑할때 cnt 멤버변수의 정보(ex : 이름, 타입 등)가 저장되어 있는 객체가 바로 이 Field 클래스 객체가 되는 것이다. 두번째 파라미터로 오는 ColumnHeader 객체는 DataSet의 특정 컬럼 정보가 들어있는 객체가 된다. 위의 예를 인용하자면 DataSet의 cnt 라는 컬럼의 정보(ex : 이름, 타입 등)를 가지고 있는 객체가 되겠다. 세번째 Object 객체는 변환되는 클래스 객체가 여기에 들어온다. 컬럼 값을 실제 객체인 Object 객체에 넣게 되는 것이다. 네번째 String 객체는 해당 DataSet 컬럼에 들어있는 값을 String 객체로 받아온 것이다. String 객체로 받게끔 한 이유는 DataSet의 컬럼이 어떤 타입이든 해당 컬럼에 대한 값은 String 객체로는 표현이 가능하기 때문에 String 객체로 받아온뒤 이를 상황에 맞춰 변환하기 위해서이다.

 

setField 메소드 코드를 보면 별다른 것은 없다. 코드를 보면 알겠지만 Field 객체에 저장되어 있는 멤버변수의 타입을 읽어와서 해당 타입에 따라 String으로 받아온 컬럼 값을 해당 타입으로 변환한 뒤 넘겨받은 Object 객체에 컬럼 값을 넣는 그런 식이다. 그래서 따로 주석을 달아놓은것은 없다. 대신 객체의 멤버변수로 Date 타입에 대한 상황을 다룰때에 대한 설명은 해야 할 것 같아서 별도 설명을 하겠다. Xplatform에서는 컬럼에 날짜 & 시간을 저장할때 3가지의 타입을 가지고 있는데 날짜만 저장하는 타입(DataTypes.DATE), 시간만 저장하는 타입(DataTypes.TIME), 날짜와 시간을 같이 저장하는 타입(DataTypes.DATE_TIME) 이렇게 3가지를 사용하고 있다. 이를 Java의 Date 타입으로 변환해야 하는 것이다. 그래서 DataTypes.DATE일 경우엔 String으로 받은 컬럼값의 자리수가 8자리(yyyyMMdd)인지 체크하는 것이고, DataTypes.TIME일 경우엔 컬럼값의 자리수가 6자리 이상인지(HHmmssSSS), DataTypes.DATE_TIME일 경우엔 컬럼값이 컬람값의 자리수가 14자리 이상인지(yyyyMMddHHmmss)인지 체크하여 Calendar 클래스를 이용해 Date 객체를 얻어오게 된다. DataTypes.TIME 일 경우엔 6자리 이상의 문자열을 받아 6자리까지는 시분초로 변환하고 6자리가 넘어가는 부분은 밀리세컨드로 변환해서 작업하고 DataTypes.DATE_TIME의 경우엔 14자리 이상의 문자열을 받아 14자리끼지는 연월일시분초로 변환한뒤 14자리가 넘어가는 부분은 밀리세컨드로 변환해서 작업한다. 그리고 이렇게 setField 메소드를 통해 컬럼 값을 객체에 설정한 뒤에 객체는 return 되어지는 것이다.

 

makeDataAsMap 메소드와 makeDataAsObject 메소드가 DataSet에서 insert, update 되어야 할 레코드들을 변환하는 것이었다면 makeRemoveDataAsMap 메소드와 makeRemoveDataAsObject 메소드는 DataSet에서 delete 되어야 할 레코드들을 Map이나 객체로 변환하는 메소드이다. 이 두 메소드도 DataSet에서 삭제되어야 하는 레코드들을 대상으로 하는 것 말고는 makeDataAsMap 메소드와 makeDataAsObject 메소드와 별 차이가 없어서 설명은 생략하도록 하겠다. 이렇게 convertDataSetToCollection 메소드에서 List 또는 Set 인터페이스로 선언한 것에 대해 알아보았으니 이제 convertDataSetToCollection 메소드에서 Collection 계열 인터페이스가 아닌 이를 구현한 클래스 타입으로 선언했을 경우에 대해 알아보자

 

if(collectionType.isInterface()) {	// Colleciton 계열 인터페이스로 선언한 경우
	...
} else {	// List, Set 같은 인터페이스가 아니라 ArrayList, HashSet 같은 Collection 인터페이스 구현 클래스로 받은 경우
	Object checkObject = collectionType.newInstance();
	Class<?> checkType = checkObject.getClass();
	if(Collection.class.isAssignableFrom(checkType)) {

		// 저장(insert, update)되어야 할 DataSet의 row들에 대한 처리
		for(int i=0; i < insertUpdateRowCount; i++) {
			if(Map.class.isAssignableFrom(genericClass)) {
				Map<String, Object> obj = makeDataAsMap(genericClass, dataSet, columnCount, i);
				((Collection)(checkObject)).add(obj);
			} else {
				Object obj = makeDataAsObject(genericClass, dataSet, columnCount, i);
				((Collection)(checkObject)).add(obj);
			}
		}

		// 삭제(delete)되어야 할 DataSet의 row들에 대한 처리
		for(int i = 0; i < removeRowCount; i++) {
			if(Map.class.isAssignableFrom(genericClass)) {
				Map<String, Object> obj = makeRemovedDataAsMap(genericClass, dataSet, columnCount, i);
				((Collection)(checkObject)).add(obj);
			} else {
				Object obj = makeRemovedDataAsObject(genericClass, dataSet, columnCount, i);
				((Collection)(checkObject)).add(obj);
			}
		}
	}

	result = checkObject;
}

 

convertDataSetToCollection 메소드에서 인터페이스인 경우는 지금까지 설명했으니 ... 으로 생략해놨다. 이제 인터페이스가 아닌 경우를 보도록 하자. 인터페이스가 아닌 클래스이면 선언한 타입으로 객체 생성이 가능하다. 그래서 newInstance() 메소드를 통해 해당 타입에 대한 객체를 생성한다. 그리고 생성한 객체에서 Class 타입을 알아낸 뒤에 이 Class 타입이 Collection 인터페이스를 구현한 클래스(Collection.class.isAssignableFrom(checkType)) 타입이면 인터페이스 때와 마찬가지로 앞에서 설명했던 makeDataAsMap, makeDataAsObject, makeRemovedDataAsMap, makeRemovedDataAsObject 메소드를 사용하면서 레코드들을 자바 객체로 변환하여 Collection 인터페이스 계열 클래스 객체에 넣게 된다. 

 

이전 글과 지금 글 2개를 통해 XplatformArgumentResolver 클래스의 resolveArgument 메소드에서 @RequestDataSet 어노테이션이 선언되었을 경우의 DataSet 처리 방법에 대해 설명했다. 다음에는 XplatformArgumentResolver 클래스의 resolveArgument 메소드에서 @RequestVariable 어노테이션이 선언되었을 경우에 대한 설명을 하도록 하겠다.

 

 1. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (1) - 개요

 2. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (2) - 사전지식

 3. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (3) - HttpServletRequestWrapper

 4. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (4) - Spring Controller에서 하려는 것

 5. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (5) - HandlerMethodArgumentResolver 분석(1)

 6. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (6) - HandlerMethodArgumentResolver 분석(2)

 7. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (7) - HandlerMethodArgumentResolver 분석(3)

 8. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (8) - HandlerMethodArgumentResolver 분석(4)

 9. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (9) - XplatformView 분석

 10. Xplatform과 Spring Framework 연동 템플릿으로 보는 HandlerMethodArgumentResolver와 ViewResolver (10) - 예외처리