본문 바로가기

프로그래밍/Spring

SpringFramework의 Converter를 이용하여 업로드된 파일을 객체로 변환해보자 (2)

지난번 글에서는 업로드 된 파일의 정보를 어떤 구조의 클래스 형태로 바꾸어서 진행할것인지, 그리고 이 작업을 하기위해 사용될 기존 JDK에서 제공되는 PropertyEditor 클래스와 SpringFramework에서 제공되는 Converter 인터페이스에 대해 알아보았다. 이번 글에서는 실제로 이 Converter 클래스를 통해 변환작업을 수행하는 내용에 대해 알아보도록 하겠다.

 

이전 글에서 업로드 된 파일을 저장하고 있는 MultipartFile 클래스 객체를 이전 글에서 별도로 정의한 FileVO 클래스로 저장할 것이라고 언급했었고 그 반대, 즉 FileVO 클래스 객체를 MultipartFile 클래스 객체로의 변환은 할 수 없다고 언급하면서 Converter 인터페이스를 반드시 양방향을 모두 구현할 필요는 없다고 언급했었다. Converter 인터페이스가 단방향 변환만 지원되다보니 두개를 만들어서 양방향이 되도록 해야 한다는 생각에는 동의한다. 그러나 실제로 양방향이 구현 가능한 것인지, 그리고 그게 과연 의미가 있는 변환이 될지를 먼저 고민하고 이 부분을 접근했으면 한다.

 

그럼 Converter 인터페이스에서 구현해야 할 메소드에 대한 설명을 하도록 하겠다. Converter 인터페이스에서 구현해야 할 메소드는 1개뿐이 없다.

 

메소드

설명

 T convert(S source)

 S 타입의 source를 파라미터로 받아 이를 T타입의 객체로 변환하여 return 한다

이전 글에서도 언급했지만 PropertyEditor의 경우는 PropertyEditorSupport 클래스를 상속받아 구현하고 있으며 변환하고자 하는 객체를 PropertyEditorSupport 클래스의 멤버변수로 저장하는 관계로 인해 멀티스레드에 적합하지 않다고 언급했다. 그러나 Converter 인터페이스의 경우 변환하고자 하는 객체를 파라미터로 받아 이를 변환하여 return 하는 개념이기 때문에 객체의 상태가 저장이 되는 개념이 아니므로 멀티스레드에서 사용이 가능하게 된다(물론 이 인터페이스를 구현한 클래스에서 파라미터로 넘겨받은 객체를 구현한 클래스의 멤버변수에 저장하는 식으로 코딩하면 멀티스레드에 안전할 수 없겠지만..그렇게 설계를 할까..싶다..)

 

이러한 convert 메소드의 성격을 이해한뒤 다음의 이 Convter 클래스를 구현한 소스를 보기 바란다. 이 소스는 실제로 MultipartFile 클래스 객체를 FileVO로 변환하는 Converter Interface를 구현한 내용이다.

 

import org.springframework.core.convert.converter.Converter;
import org.springframework.web.multipart.MultipartFile;

import com.terry.springconfig.common.vo.FileVO;

/**
 * MultipartFile 객체를 FileVO 객체로 바꿔주는 Converter 클래스
 * @author terry
 *
 */
public class MultipartFileToFileVOConverter implements Converter<MultipartFile, FileVO> {

	@Override
	public FileVO convert(MultipartFile source) {
		// TODO Auto-generated method stub
		FileVO result = null;
		if(source == null){
			result = null;
		}else{
			if(source.isEmpty()){
				result = null;
			}else{
				result = new FileVO();
				String orgFileName = source.getOriginalFilename();
				result.setOrgFileName(orgFileName);			// 원본 파일 이름 설정
				result.setMultipartFile(source);			        // 업로드된 파일의 MultipartFile 객체 설정
				result.setFileSize(source.getSize());			// 업로드된 파일의 크기 설정
				
				// 확장자를 구하는 작업을 진행한다
				int idx = orgFileName.lastIndexOf(".");
				if(idx == -1){						                        // 확장자가 없을 경우
					result.setExt(null);
				}else{							                                // 확장자가 있을 경우
					String ext = orgFileName.substring(idx+1);
					result.setExt(ext);
				}
			}
		}
		
		return result;
	}

}

 

이전 글에서 FileVO 클래스 소스를 보여줬지만 기억이 안나는 사람들을 위해서 간략하게 설명하자면 FileVO는 MultipartFile 객체를 받아 여기에서 원래 파일명(원본 파일명), 실제 파일명(실제 물리적으로 저장되는 파일명), 업로드 된 파일의 확장자, MultipartFile 객체를 실제 물리적인 Java File 객체로 변환한 File 객체, 사용자가 업로드 한  MultipartFile, 업로드 된 파일의 크기(byte 단위)를 저장하게 된다. 그렇게 저장하는 정보를 생각하고 이 소스를 보자.

 

먼저 클래스 정의시 사용된 implements 구문을 보자. 당연 Converter 인터페이스를 구현하는거지만 여기서는 Java Generic을 사용했다. 즉 implements Converter<MultipartFile, FileVO>로 선언 함으로써 이 인터페이스에 정의되어있는 convert 메소드 정의시 Generic에 정의된 타입을 사용하게 되는 것이다. 실제 Eclipse에서 클래스 생성시 구현하게 될 Interface 정의를 할 때 Converter<MultipartFile, FileVO>로 정의해주면 Eclipse에서 convert 메소드 구현할려고 Source 메뉴 사용시 자동으로 public FileVO convert(MultipartFile source) 로 만들어준다. 정리하자면 implements 사용시 변환할 타입을 Generic을 사용한 정의를 해줌으로써 convert 메소드 사용시 자동으로 변환할 타입과 변환될 타입이 같이 나오게끔 해주면 된다.

 

이 소스에서는 FileVO로 객체화 해서 표현할 수 있을 경우엔 FileVO 객체를 생성해서 return 하지만 그럴수 없는 경우엔 null로 return 하도록 컨셉을 잡았다. 즉 FileVO 객체를 만든뒤 아무런 가공 없이 그대로 return 하는 식으로 return 하게 될 경우 FileVO 객체를 생성할 수 있는 상황인지 아닌지 알수가 없기 때문에 null을 return 하여 그 판단을 하기 쉽도록 했다. 그래서 소스코드를 보면 FileVO 객체로 return 하게 될 변수 result에 대해 FileVO 객체를 생성해서 초기화 하지 않고 null로 셋팅 해둠으로써 FileVO 객체로 만들어서 표현할 수 있을때 그 시점에 초기화하도록 하고 그럴수 없는 경우는 null을 유지하도록 했다. 그걸 생각하고 convert 메소드를 보도록 하자. 파라미터로 받은 MultipartFile 객체인 source가 null인지 if 문으로 체크한다. null 이면 당연 FileVO로 변환할 수 없기 때문에 result 변수를 그냥 null로 셋팅했다. 

 

그 다음으로 하는 것이 MultipartFile 객체가 null이 아닌 실제 객체를 받은 상태인데 이 경우 파일이 비어있는지 isEmpty 메소드를 실행하여 체크하고 있다. 초보 개발자들이 이 부분에서 오해하는 부분이 있는데 일단 이 부분에 대해 설명을 한 뒤에 마저 설명하도록 하겠다. 우리가 html 에서 file 태그 사용했을때 파일을 정하지 않고 submit을 하게 되는 경우도 있다. 게시판의 등록 화면을 생각해보자. 등록 화면에서 첨부 파일을 같이 올려서 글을 등록할 수 있을 것이다. 그러나 한편으로는 첨부파일 없이 그냥 글을 올릴수도 있는 것이다. 즉 사용자가 업로드 할 파일을 지정하지 않고 글을 등록할 수도 있다. 그러면 이렇게 글을 썼을 경우 파라미터로 받은 MultipartFile의 객체가 생성이 안될 것이라고 생각하는 개발자들이 있다. 아니다. 사용자가 파일을 지정하지 않더라도 MultipartFile 객체는 생성이 된다. 대신 사용자가 업로드 한 파일에 대한 정보가 일절 없는 것이 된다. 즉 MultipartFile 객체는 사용자가 파일을 업로드 하든 그렇지 않든 Controller의 메소드에서 MultipartFile로 받겠다고 파라미터에서 정의하게 되면 MultipartFile 객체가 무조건 생성이 된다. 그래서 이 객체가 null인지 아닌지로써 사용자가 업로드 한 파일이 있는지 없는지를 체크하면 안된다. 반드시 MultipartFile 클래스에서 제공하는 isEmpty() 메소드를 이용해서 체크하도록 한다. 그런 의미에서 source.isEmpty() 메소드를 if 문에 사용함으로써 사용자가 실제적으로 파일을 올리지 않은 경우를 체크한뒤 이것이 true이면 result 변수를 null로 설정하도록 했다. 그러나 이 작업은 결과적으로 의미가 없는 부분이 되게 되었는데, 이 부분은 다음 글에서 다루도록 하겠다

 

isEmpty() 메소드를 실행하는 if 문에서 else를 타게 된다는 것은 실제 사용자가 업로드 한 파일이 존재한다는 뜻이기 때문에 여기서는 FileVO 객체를 만들어서 원하는 내용을 저장하는 작업을 진행하게 된다. 변수 result를 FileVO 객체로 초기화 한 뒤에 위에서 언급했던 FileVO 객체에 저장하게 될 정보들을 알아낸뒤에 FileVO 클래스에서 제공하는 setter 메소드에 저장하게 된다. 근데 소스를 보면 FileVO 객체에 저장하게 될 정보들 중 해당 정보를 얻기 위한 작업을 하지 않는 부분이 있다. 업로드 된 MultipartFile을 실제 Java File 객체로 변환해야 할 수 있는 작업인 실제 파일명과 그 파일명으로 저장된 Java File 객체이다. 이 부분은 Converter 인터페이스를 구현한 클래스에서 할 성격의 작업이 아니어서 여기서는 하지 않았다. 대신 이 작업을 차후에라도 할 수 있게끔 하기 위해 MultipartFile 객체를 그대로 가지고 가도록 했다. 이 부분을 여기서 차리 하지 않은데는 이유가 있었다. 불가능해서는 아니기 때문에 일단 지금하는 설명을 듣고 자신은 상관없다 하면 내부에 구현해도 상관없다.

 

MultipartFile 객체를 FileVO 객체로 변환이 되는 시점은 Controller에서 Binding 되는 시점, 그래서 메소드에서 FileVO로 바로 이용할 수 있는 것이라고 이전 글에서 언급한 적이 있다. 그러면 생각해 볼 것이 이 시점에서 실제 물리적인 파일 경로를 이용해서 Java File 객체로 저장할 수 있느냐..이다. 이 부분은 기술적으로는 문제는 없다. 그러나 업무 흐름에서 본다면 조금 얘기가 달라진다. 파일의 이름, 그리고 파일이 저장되는 경로가 특정 비즈니스 로직을 이용해서 결정 되는 상황이 오히려 많기 때문이다. 그 로직은 독립적인 로직일수도 있고, 또는 현재 하는 작업에 따른 의존성이 있는 로직일수도 있다. 예를 들어보자. 만약 물리적인 파일 경로와 파일 명을 업로드한 날짜를 이용한 경로와 System.currentTimeMillis() 메소드가 return 한 값으로 파일의 저장 경로와 파일명을 정한다면 Converter 인터페이스를 구현한 클래스에서 이 값들을 구해서 바로 Java File 객체로 만들어버리면 된다. 또는 이러한 값들을 properties 파일에 값을 설정한 뒤 Converter 인터페이스를 구현한 클래스에서 이 properties 파일에 설정한 값을 읽어서 Java File 객체를 만들수도 있다. 즉 Converter 인터페이스를 구현한 클래스에서 파일명과 경로를 결정하는 비즈니스 로직이 수행가능하다면 Converter 인터페이스를 구현한 클래스에서 해당 비즈니스 로직을 수행해서 Java File 객체를 만들면 된다. 그러나 그럴수가 없는 경우, 즉 파일명과 경로를 결정하는 비즈니스 로직이 Converter 인터페이스를 구현한 클래스에서 수행할 수 없는 경우엔 어떻게 해야 할까? 이런 상황일 경우 파일명과 경로를 결정하는 비즈니스 로직을 수행할 수 있는 곳에서 파일명과 경로를 구한뒤에 이를 이용해서 FileVO 객체에서 Java File 객체를 만들어야 할 것이다. 이전 글에서 FileVO 클래스 소스를 보면 public void transferFile(String filePath) throws IllegalStateException, IOException로 정의한 메소드가 있다. 이 메소드가 그런 역할을 하게 된다. 즉 transferFile에 Java File 객체를 만들기 위해 사용될 파일경로와 파일명이 결합된 파일 Full Path를 전달하면 transaferFile 메소드 내부에서 파라미터로 받은 파일 Full Path를 이용해 Java File 객체를 만든뒤 MultipartFile 객체의 transferTo 메소드를 실행시켜서 실제 물리적인 파일을 만들도록 했다.

 

또한 이 부분은 이전에 설명하려고 했던 부분, 즉 왜 DB 작업을 먼저 한 뒤에 파일 작업을 하는가에 대한 부분과도 연결이 된다. DB 작업 하는 부분은 Spring의 Transaction 하에 실행이 된다. Spring에서 Transaction 관리를 하게 되는데 관리 대상이 되는 코드는 Runtime Exception이 발생하게 될 경우 Spring Transaction이 DB 관련 작업을 rollback 하도록 한다. 그럼 @Service 어노테이션이 붙은 클래스의 @Tramsactional 어노테이션이 설정된 메소드에서 이 작업을 한다고 생각해보자. 그리고 FileVO 클래스의 transaferFile 메소드가 던지는 IllegalStateException은 RuntimeException을 상속받은 클래스이고, IOException은 Exception을 상속받은 클래스이다. 이 부분을 먼저 기억하고 설명을 시작하겠다.

 

먼저 DB 작업을 먼저 하고 다음에 FileVO의 Java File 관련 작업을 한다고 생각해보자. 

1. DB 작업을 먼저 하는 과정에 있어서 Exception이 발생하면 당연 그 다음에 진행할 FileVO 관련 작업은 가지 않고 바로 rollback되면서 호출한 메소드로 Exception이 던져질 것이다.

2. DB 작업을 하는 부분에서는 정상적으로 진행이 되었고 FileVO 관련 작업을 하는 과정에서 예외가 발생될 경우를 생각해보자. 위에서 언급했던 transaferFile 메소드가 던지는 IllegalStateException은 RuntimeException을 상속받은 클래스이기 때문에 별도 설정을 하지 않아도 rollback이 되지만 IOException은 RuntimeException을 상속받은 클래스가 아니기 때문에 @Transactional 어노테이션에서 rollbackFor 옵션을 두어 별도로 설정을 해야 한다.

 

위에서 언급했던 2가지 상황 모두 별도 코드를 작성할 필요가 없다. 즉 Spring에서 제공하는 DB 관련 작업 메소드를 실행한 뒤에 파일경로와 파일 이름을 구하는 비즈니스 로직을 실행하고 이를 이용해서 바로 FileVO 클래스 객체의 transaferFile 메소드를 실행하면 된다.  위의 내용을 대강 코드로 만들면 다음과 같은 식이 된다.

 

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.terry.springconfig.common.exception.DataNotFoundException;
import com.terry.springconfig.dao.NotMemberBoardDao;
import com.terry.springconfig.service.NotMemberBoardService;
import com.terry.springconfig.vo.NotMemberBoardVO;

@Service
public class NotMemberBoardServiceImpl implements NotMemberBoardService {

	@Autowired
	NotMemberBoardDao notMemberBoardDao;
	
	@Override
	@Transactional(rollbackFor={DataAccessException.class, IllegalStateException.class, IOException.class})
	public void insertNotMemberBoard(NotMemberBoardVO notMemberBoardVO, FileVO fileVO) throws DataAccessException, IllegalStateException, IOException {
		// TODO Auto-generated method stub
		notMemberBoardDao.insertNotMemberBoard(notMemberBoardVO);
		fileVO.transaferFile("/home/upload/image.gif");
	}
}

 

위에서 언급한 대로 Transactional 어노테이션의 rollbackFor 옵션을 이용해서 해당 예외 클래스가 던져졌을 경우 rollback이 되도록 설정을 했다. rollback을 하기 위한 별도의 코드를 추가한 부분이 없다. 그럼 이제 이 처리 순서를 거꾸로..즉 파일 처리를 먼저 하고 DB를 나중에 하는 작업에 대해 설명하도록 하겠다. 순서를 바꿔서 작업을 한다면 다음과 같은 흐름으로 전개가 될 것이다.  

 

1. transferFile 메소드에서 파일을 처리하는 과정에서 문제가 발생하게 되면 IllegalStateException 또는 IOException이 던져지게 될 것이다. 그러면 당연 그 다음에 진행하게 되는  DB 작업을 수행하지 않고 바로 호출된 메소드로 예외가 던져질 것이다.

2. transferFile 메소드는 정상적으로 진행이 되었고 DB 작업을 하는 부분에서 예외가 발생될 경우를 생각해보자. 파일은 이미 해당 위치에 복사되었지만 DB는 처리작업에서 문제가 발생했기 때문에 DB는 rollback이 될 것이다. 그러나 파일 처리에 대한 부분은 rollback 같이 자동으로 이전 상황으로 돌릴수는 없다. 그렇기 때문에 인위적으로 복사된 파일을 지우는 작업을 수행해야 한다.

 

이러한 내용을 대강 코드로 만들면 다음과 같은 형태가 된다.

 

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.terry.springconfig.common.exception.DataNotFoundException;
import com.terry.springconfig.dao.NotMemberBoardDao;
import com.terry.springconfig.service.NotMemberBoardService;
import com.terry.springconfig.vo.NotMemberBoardVO;

@Service
public class NotMemberBoardServiceImpl implements NotMemberBoardService {

	@Autowired
	NotMemberBoardDao notMemberBoardDao;
	
	@Override
	@Transactional(rollbackFor={DataAccessException.class, IllegalStateException.class, IOException.class})
	public void insertNotMemberBoard(NotMemberBoardVO notMemberBoardVO, FileVO fileVO) throws DataAccessException, IllegalStateException, IOException {
		// TODO Auto-generated method stub
		fileVO.transaferFile("/home/upload/image.gif");
		try{
			notMemberBoardDao.insertNotMemberBoard(notMemberBoardVO);
		}catch(DataAccessException dae){
			try{
				File file = new File("/home/upload/image.gif");
				file.delete();
			}catch(SecurityExcetion se){

			}
			throw dae;
		}
	}
}

 

코드를 보면 DB 관련 작업 메소드를 try로 감싸서 일단 거기서 예외를 catch 한 다음에 예외가 던져진 것이 확인이 되면 transferFile 메소드에서 생성한 파일을 다시 File 객체로 만든뒤에 delete 메소드를 실행시켜서 생성한 파일을 지우는 작업을 거치는 형태로 파일 작업에서의 rollback 작업을 수행하고 있다. 그래서 이런 인위적인 코드 작성을 하지 않을려는 의도로 먼저 DB 작업을 진행하고 파일 작업을 그 후에 하는 식으로 작업 순서를 가지도록 한다. 그러나 이 부분은 항상 이렇게 처리해야 하는 것은 아니다. 파일 처리 시간과 DB 처리 시간을 비교해보고 그 시간에 따라 순서를 바꿔서 처리하면 더 좋은 퍼포먼스를 보이는 부분도 있다.

 

이번 글에서는 Converter 인터페이스에 대한 설명과 이를 구현한 클래스 소스, 그리고 이를 Spring의 Service 단에서 처리시의 순서에 대한 내용을 설명했다. 다음글은 이 글의 마지막으로 이 Converter 인터페이스를 구현한 클래스를 Spring에 어떤 형태로 등록하고 Controller에서 이를 어떻게 사용하는지를 다루도록 하겠다