본문 바로가기
Java/SpringBoot

[Spring Boot] FTP Server 구현하기

by 리요_ 2024. 9. 28.
반응형

[Spring Boot] FTP Server 구현하기

 

FTP 서버와 연결돼서파일 업로드  다운로드가 가능한 API 서버를 구축하려 합니다. FTP서버에 있는 영상자료 스트리밍까지 작업까지가 목표이지만,, 우선 차례차례.. ㅎㅎ

 

2024.09.25 - [Windows/FTP] - [Wondows] FTP 서버 구축하기

 

[Wondows] FTP 서버 구축하기

윈도우 환경에서 FTP 서버를 구축하려 합니다.  Windows server 2022 STD 환경에서 구축하였습니다.다른 OS 에서도 세팅법은 동일합니다. 서버에 접근하기 위한 정보와 파일 전송 명령 및 결과 등은 TCP

li-yo.tistory.com

이전에 구축해 둔 FTP 서버에 접근하여 파일 업로드/다운로드 기능을 적용해보려 합니다.


🌟 작업 환경

Java 17, Spring Boot 3.2.2, Gradle 8.0.1,  MSSQL Window11

기존에 생성해 둔 Spring Boot 프로젝트이긴 하지만 세팅이 거의 안 되어있는 상태여서 라이브러리 추가 부분도 함께 적어두었습니다.

implementation group: 'commons-net', name: 'commons-net', version: '3.6'
implementation group: 'org.apache.commons', name: 'commons-pool2', version: '2.11.1'
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'

commons-netcommons-pool2, commons-lang3, lombok 을 추가해 주었습니다.

 

https://mvnrepository.com/


🌟 INDEX


⭐ FTP 설정 클래스 구현

FTP 설정 클래스 구현

 

Srping Boot 의 @ConfigurationProperties 어노테이션을 사용해서 ftp.server 접두사로 시작하는 프로퍼티를 자동으로 바인딩합니다.

 

@Data 어노테이션을 사용해서 Lombok 으로 Getter/Setter 메서드를 자동 생성합니다.


💫 FtpConfig.java

package com.liyo.besys.ftp;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;

@Component
@ConfigurationProperties(prefix = "ftp.server")
@Data
public class FtpConfig {
    private String host;
    private int port;
    private String username;
    private String password;
    private String dir;
    private String encoding;
    private String root;
    private int maxtotal;
    private int minidel;
    private int maxidle;
    private int maxwaitmillis;
}

 


⭐ FTP 설정 프로퍼티 관리

💫 application.yml

spring:
    application:
    profiles:
    main:
    banner:
    messages:
    config:
    security:
    datasource:
    ftp:
        server:
            host: 192.168.0.123		// 호스트 주소
            port: 21				// 포트번호
            username: liyoFTP		// 사용 자명
            password: Liyo2024!		// 패스워드
            encoding: UTF-8
            dir: /
            root: /
            max-total: 100
            min-idle: 2
            max-idle: 5
            max-wait-millis: 3000
    jpa:
    devtools:

 

Gradle 빌드를 사용하고 있어 application.yml 파일에 FTP 서버 정보를 추가해 주었습니다.

spring 안에 DB 정보가 담겨있는 datasource 아래에 추가해 주었습니다.
순서는 관계없습니다.FTP 정보 외에는 생략하였습니다.


⭐ FTP 데이터 전송 객체 모델링

FTP 서버 연결 정보파일 전송 정보, 파일 전송 결과 정보 등을 담는 Value ObjectData Transfer Object 를 구현해 줍니다.

FTP 데이터 전송 객체 모델링


💫 FtpVo.java

package com.liyo.besys.ftp;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = false)
public class FtpVo {
    private String downFile; // UP_ADD_FILE_NM
    private String orginFile;
    private String tableNm;
    private String columnNm;
    private String groupId;

    private String fileNm;
    private String fileId;
    private boolean group;
}

💫 FtpDto.java

package com.liyo.besys.ftp;

import java.sql.Timestamp;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.UUID;

import org.springframework.web.multipart.MultipartFile;

import lombok.Data;

@Data
public class FtpDto {
    private String originalColumnname; // 여러첨부파일요청시에만...
    private String saveColumnName; // 여러첨부파일요청시에만...
    private String fileName; // 파일명
    private String exe; // 확장자
    private String path; // 경로
    private String type; // 파일 종류
    private boolean group; // 그룹 유무
    private long groupId; // 그룹ID
    private long fileGrpId; // 파일그룹ID
    private long fileId; // 파일ID

    private String originalFileName; // 원래 파일명
    private String saveFileName; // 저장 파일명
    private String tableName; // 테이블명
    private String columnName; // 컬럼명
    private MultipartFile file; // 저장할 파일
    private long fileSize; // 파일 크기

    public FtpDto(MultipartFile file, String tableName, String columnName) {
        this.file = file;
        this.tableName = tableName;
        this.columnName = columnName;
        this.originalFileName = file.getOriginalFilename();
        this.fileSize = file.getSize();
        String pattern = "yyyyMMddhhmmssSSS";
        SimpleDateFormat sdfCurrent = new SimpleDateFormat(pattern, Locale.KOREA);
        Timestamp timestamp = new Timestamp(System.currentTimeMillis());
        String saveFileName = sdfCurrent.format(timestamp.getTime()) + UUID.randomUUID().toString().substring(0, 3);
        this.fileName = saveFileName; // 파일명만
        this.saveFileName = saveFileName
                + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
    }

}

⭐ FTP 클라이언트 구현

FTP 클라이언트 구현

 

 

Apache Commons Net 라이브러리를 사용해서 FTP 클라이언트를 구현해 주었습니다.

  • makeObject() : FTP 클라이언트 객체를 생성합니다.
  • destroyObject() : FTP 클라이언트 객체를 종료하고 연결을 끊습니다.
  • validateObject() : FTP 클라이언트 객체가 유효한지 확인합니다.
  • activateObject() : FTP 클라이언트 객체를 활성화하고 FTP 서버에 연결합니다.
  • passivateObject() : FTP 클라이언트 객체를 비활성화하고 FTP 서버에서 연결을 끊습니다.

💫 FtpClientFactory.java

package com.liyo.besys.ftp;

import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.apache.commons.pool2.PooledObjectFactory;

import java.io.IOException;

@Component
public class FtpClientFactory implements PooledObjectFactory<FTPClient> {
    @Autowired
    FtpConfig config;

    @Override
    public PooledObject<FTPClient> makeObject() {
        FTPClient ftpClient = new FTPClient();// Create a client instance
        ftpClient.setRemoteVerificationEnabled(false); // * is not same as server
        return new DefaultPooledObject<>(ftpClient);
    }

    @Override
    public void destroyObject(PooledObject<FTPClient> pooledObject) {
        FTPClient ftpClient = pooledObject.getObject();
        try {
            ftpClient.logout();
            if (ftpClient.isConnected()) {
                ftpClient.disconnect();
            }
        } catch (IOException e) {
            throw new RuntimeException("Could not disconnect from server.", e);
        }
    }

    @Override
    public boolean validateObject(PooledObject<FTPClient> pooledObject) {
        FTPClient ftpClient = pooledObject.getObject();
        try {
            return ftpClient.sendNoOp();
        } catch (IOException e) {
            return false;
        }
    }

    @Override
    public void activateObject(PooledObject<FTPClient> pooledObject) throws Exception {
        FTPClient ftpClient = pooledObject.getObject();
        ftpClient.connect(config.getHost(), config.getPort());
        ftpClient.login(config.getUsername(), config.getPassword());
        ftpClient.setControlEncoding(config.getEncoding());
        ftpClient.changeWorkingDirectory(config.getDir());
        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
    }

    @Override
    public void passivateObject(PooledObject<FTPClient> pooledObject) throws Exception {
        FTPClient ftpClient = pooledObject.getObject();
        try {
            ftpClient.changeWorkingDirectory(config.getRoot());
            ftpClient.logout();
            if (ftpClient.isConnected()) {
                ftpClient.disconnect();
            }
        } catch (IOException e) {
            throw new RuntimeException("Could not disconnect from server.", e);
        }
    }

    public FtpConfig getConfig() {
        return config;
    }
}

⭐ FTP 클라이언트 풀 관리

FTP 클라이언트 풀 관리

 

FTP 클라이언트 풀을 관리하는 FtpPoop 클래스를 생성해 줍니다.

 

FtpClientFactory 주입 : 생성자에서 FtpClientFactory 객체를 주입받습니다. FtpClientFactory FTPClient 객체를 생성하는 역할을 합니다.

 

FTPClient 풀 생성: FtpConfig 객체에서 읽어온 설정 값을 사용하여 GenericObjectPool  생성합니다. 이 풀은 FTPClient 객체를 관리합니다.

 

FTPClient 가져오기 : getFTPClient() 메서드를 통해 FTPClient 객체를 풀에서 가져올 수 있습니다. 이 메서드는 풀에서 FTPClient 객체를 빌려오고, 사용이 끝나면 다시 풀에 반환합니다.

 

FTPClient 반환 : returnFTPClient() 메서드를 통해 사용이 끝난 FTPClient 객체를 풀에 반환합니다.

 

풀 종료 : destroy() 메서드를 통해 풀을 종료할 수 있습니다.

 

FTP클라이언트 객체의 생성과 관리를 효율적으로 처리하기 위해 객체 풀 패턴을 사용하고,

FTP클라이언트 객체의 생성 및 해제 비용을 줄이고, 성능을 향상할 수 있습니다.


💫 FtpPool.java

package com.liyo.besys.ftp;

import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class FtpPool {
    FtpClientFactory factory;
    private final GenericObjectPool<FTPClient> internalPool;

    @SuppressWarnings("unchecked")
    public FtpPool(@Autowired FtpClientFactory factory) {
        this.factory = factory;
        FtpConfig config = factory.getConfig();
        @SuppressWarnings("rawtypes")
        GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
        poolConfig.setMaxTotal(config.getMaxtotal());
        poolConfig.setMinIdle(config.getMinidel());
        poolConfig.setMaxIdle(config.getMaxidle());
        poolConfig.setMaxWaitMillis(config.getMaxwaitmillis());
        this.internalPool = new GenericObjectPool<FTPClient>(factory, poolConfig);
    }

    public FTPClient getFTPClient() {
        try {
            return internalPool.borrowObject();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public void returnFTPClient(FTPClient ftpClient) {
        try {
            internalPool.returnObject(ftpClient);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void destroy() {
        try {
            internalPool.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

⭐ FTP 서버 연결 및 파일 전송

FTP 관련 기능을 제공할 FTP 유틸리티 클래스를 만들어 줍니다.

💫 FtpUtil.java

package com.liyo.besys.ftp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.net.ftp.FTPClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import com.liyo.besys.constant.BesysConstants;
import com.liyo.cmm.service.Globals;
import com.liyo.cmm.service.GlobalsProperties;

import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
@Component
public class FtpUtil {

	@Autowired
	FtpConfig config;

	@Autowired
	ResourceLoader resourceLoader;

	@Autowired
	FtpPool pool;

	/**
	 * FTP 업로더
	 * 
	 * @param dto
	 * @param ftpClient
	 * @return
	 * @throws Exception
	 */
	private static FtpDto write(FtpDto dto, FTPClient ftpClient) throws Exception {
		if (StringUtils.isEmpty(dto.getTableName()) || StringUtils.isEmpty(dto.getColumnName())
				|| StringUtils.isEmpty(dto.getSaveFileName())) {
			throw new Exception("필수값이 없습니다.");
		}

		MultipartFile file = dto.getFile();
		InputStream input = file.getInputStream();

		try {
			String extensionsImage = GlobalsProperties.getProperty("globals.extensions.Images");
			String extensionsVideo = GlobalsProperties.getProperty("globals.extensions.Video");
			String extensionsDoc = GlobalsProperties.getProperty("globals.extensions.Doc");
			String fileUploadMaxSizeString = GlobalsProperties.getProperty("globals.extensions.maxSize");
			int fileUploadMaxSize = Integer.valueOf(GlobalsProperties.getProperty("globals.extensions.maxSize"));
			int lastIndex = dto.getOriginalFileName().lastIndexOf(BesysConstants.DOT);
			String fileExt = dto.getOriginalFileName().substring(lastIndex + 1).toLowerCase();

			String rootName = BesysConstants.NOGROUP;
			String fileType = BesysConstants.FILE_TYPE_ETC;

			if (extensionsImage.contains(fileExt)) {
				fileType = BesysConstants.FILE_TYPE_IMG;
			} else if (extensionsVideo.contains(fileExt)) {
				fileType = BesysConstants.FILE_TYPE_VIDEO;
			} else if (extensionsDoc.contains(fileExt)) {
				fileType = BesysConstants.FILE_TYPE_DOC;
			}

			if (dto.isGroup()) {
				rootName = BesysConstants.FILE_TYPE_IMG_NAME;
			}

			dto.setType(fileType);

			if (fileUploadMaxSize < dto.getFileSize()) {
				throw new Exception("첨부 파일은 " + fileUploadMaxSizeString + "byte를 넘을 수 없습니다.");
			}

			StringBuilder path = new StringBuilder();
			path.append(BesysConstants.SLASH).append(rootName).append(BesysConstants.SLASH).append(dto.getTableName())
					.append(BesysConstants.SLASH).append(dto.getColumnName());

			boolean dirExists = true;
			String[] directories = path.toString().split(BesysConstants.SLASH);
			for (String dir : directories) {
				if (path.toString().equals(ftpClient.printWorkingDirectory())) {
					continue;
				}
				if (!dir.isEmpty()) {
					if (dirExists) {
						dirExists = ftpClient.changeWorkingDirectory(dir);
					}
					if (!dirExists) {
						if (!ftpClient.makeDirectory(dir)) {
							throw new IOException("makeDirectory:: " + dir + "::" + ftpClient.getReplyString());
						}
						if (!ftpClient.changeWorkingDirectory(dir)) {
							throw new IOException(
									"changeWorkingDirectory:: " + dir + "::" + ftpClient.getReplyString());
						}
					}
				}
			}
			path.append(BesysConstants.SLASH).append(dto.getSaveFileName());
			dto.setPath(path.toString());
			dto.setExe(fileExt);

			boolean result = ftpClient.storeFile(path.toString(), input);// Execute file transfer
			if (!result) {
				throw new RuntimeException("파일 업로드를 실패했습니다.");
			}

			log.info("path1 : {}", path.toString());

		} catch (Exception e) {
			log.error("error write1 : {}", e);
		} finally {
			dto.setFile(null);
			input.close();
		}
		return dto;
	}

	/**
	 * 파일 업로드
	 * 
	 * @param dto
	 * @return
	 * @throws Exception
	 */
	public Map<String, String> upload(FtpDto dto) throws Exception {
		FTPClient ftpClient = pool.getFTPClient();
		/**
		 * 저장
		 */
		boolean isSave = false;
		try {
			dto = write(dto, ftpClient);
			isSave = true;
		} catch (Exception e) {
			log.error("error upload : {}", e);
		} finally {
			pool.returnFTPClient(ftpClient);
		}
		Map<String, String> map = new HashMap<String, String>();
		map.put(Globals.ORIGIN_FILE_NM, dto.getOriginalFileName());
		map.put(Globals.UPLOAD_FILE_NM, dto.getFileName());
		map.put(Globals.FILE_EXT, dto.getExe());
		map.put(Globals.FILE_PATH, dto.getPath());
		map.put(Globals.FILE_TYPE, dto.getType());
		map.put(Globals.FILE_SIZE, String.valueOf(dto.getFileSize()));
		map.put(Globals.IS_SAVE, isSave ? "TRUE" : "FALSE");
		return map;
	}

	/**
	 * 파일 리스트 업로드
	 * 
	 * @param fptDtoList
	 * @return
	 * @throws Exception
	 */
	public List<Map<String, String>> upload(List<FtpDto> fptDtoList) throws Exception {
		FTPClient ftpClient = pool.getFTPClient();
		/**
		 * 저장
		 */
		boolean isSave = false;
		try {
			for (FtpDto dto : fptDtoList) {
				dto = write(dto, ftpClient);
			}
			isSave = true;
		} catch (Exception e) {
			log.error("error uploadList : {}", e);
		} finally {
			pool.returnFTPClient(ftpClient);
		}
		List<Map<String, String>> fileMap = new ArrayList<Map<String, String>>();
		for (FtpDto dto : fptDtoList) {
			Map<String, String> map = new HashMap<String, String>();
			map.put(Globals.ORIGIN_FILE_NM, dto.getOriginalFileName());
			map.put(Globals.UPLOAD_FILE_NM, dto.getFileName());
			map.put(Globals.FILE_EXT, dto.getExe());
			map.put(Globals.FILE_PATH, dto.getPath());
			map.put(Globals.FILE_SIZE, String.valueOf(dto.getFileSize()));
			map.put("originColumnName", dto.getOriginalColumnname());
			map.put("saveColumnName", dto.getSaveColumnName());
			map.put(Globals.IS_SAVE, isSave ? "TRUE" : "FALSE");
			fileMap.add(map);
		}
		return fileMap;
	}

	/**
	 * 파일 다운로드
	 * 
	 * @param vo
	 * @param resp
	 * @throws IOException
	 */
	public void download(FtpVo vo, HttpServletResponse resp) throws IOException {
		StringBuilder path = new StringBuilder();
		if (StringUtils.isEmpty(vo.getGroupId())) {
			path.append(BesysConstants.SLASH).append(BesysConstants.NOGROUP).append(BesysConstants.SLASH)
					.append(vo.getTableNm()).append(BesysConstants.SLASH).append(vo.getColumnNm())
					.append(BesysConstants.SLASH).append(vo.getDownFile());
		} else {
			path.append(BesysConstants.SLASH).append(BesysConstants.GROUP).append(BesysConstants.SLASH)
					.append(vo.getTableNm()).append(BesysConstants.SLASH).append(vo.getColumnNm())
					.append(BesysConstants.SLASH).append(vo.getDownFile());
		}
		String orgFileName = vo.getOrginFile().replaceAll("\r", "").replaceAll("\n", "");

		FTPClient ftpClient = pool.getFTPClient();
		resp.setContentType("application/force-download");
		resp.setHeader("Content-Transfer-Encoding", "binary");
		resp.setHeader("Pragma", "no-cache");
		resp.setHeader("Expires", "0");
		resp.setHeader("Content-Disposition",
				"attachment;filename=" + URLEncoder.encode(orgFileName, BesysConstants.UTF8) + ";");
		OutputStream out = resp.getOutputStream();
		ftpClient.retrieveFile(path.toString(), out);
		out.flush();
		out.close();
		pool.returnFTPClient(ftpClient);
	}

	/**
	 * 이미지
	 * noGroup :
	 * http://localhost:8080/image/loadAttachImage.do?fileNm=/CR0010/UP_ADD_FILE_NM/20220707104046038c4d.jpg
	 * group : /image/loadImage.do?fileId=175 (SY0100,SY00B0,SY00B1 에서 패스/파일네임 조회)
	 * 
	 * @param vo
	 * @param resp
	 * @throws IOException
	 */
	public void img(FtpVo vo, HttpServletResponse resp) throws IOException {
		String fileNm = vo.getFileNm();
		FTPClient ftpClient = pool.getFTPClient();
		resp.setContentType("application/force-download");
		resp.setHeader("Content-Transfer-Encoding", "binary");
		resp.setHeader("Pragma", "no-cache");
		resp.setHeader("Expires", "0");
		resp.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileNm, "UTF-8") + ";");
		OutputStream out = resp.getOutputStream();
		InputStream inputStream = null;
		if (vo.isGroup()) {
			inputStream = ftpClient.retrieveFileStream(fileNm);
		} else {
			inputStream = ftpClient.retrieveFileStream(BesysConstants.NOGROUP + BesysConstants.SLASH + fileNm);
		}
		if (inputStream == null) {
			throw new IOException(ftpClient.getReplyString());
		}
		int len;
		byte[] buf = new byte[1024];
		while ((len = inputStream.read(buf)) != -1) {
			out.write(buf, 0, len);
		}
		out.flush();
		out.close();
		pool.returnFTPClient(ftpClient);
	}

	/**
	 * show
	 * 
	 * @param fileName
	 * @return
	 */
	public ResponseEntity<?> show(String fileName) {
		String username = config.getUsername();
		String password = config.getPassword();
		String host = config.getHost();
		String work = config.getDir();
		// ftp://root:root@192.168.xx.xx/path+fileName
		return ResponseEntity.ok(
				resourceLoader.getResource("ftp://" + username + ":" + password + "@" + host + work + "/" + fileName));
	}

}

 

 

FTP업로더, 파일 업로드, 파일 리스트 업로드, 파일 다운로드, 이미지 를 처리하는 메서드들로 구성되어 있습니다.

 

각 부분에 대한 정리는 따로 해야 할 것 같습니다..

반응형