필자가 맡고있는 서비스에서 사용자 정보로 pdf 파일(계약서)을 생성해 이용기관 ftp 서버에 저장을 시켜줘야하는 기능을 추가로 개발해야하는 상황이였습니다.
우선 필자는 서버에 들어온 사용자 정보를 이용해 서버에서 pdf 파일을 생성 후 바로 이용기관의 ftp 서버로 보내주면 되지 않을까? 하는 가벼운 마음으로 시작했습니다.
기능을 구현하며 알게된 내용이나, 어려웠던 점을 기록하기위해 이 포스트를 작성합니다.
사용기술
iTextPDF (compile 'com.itextpdf:itextpdf:5.5.13.3')
텍스트나 html 등의 문서를 pdf로 만들어주는 자바 라이브러리
Commons-Net (compile 'commons-net:commons-net:3.9.0')
다양한 프로토콜(FTP, TFTP, SMTP, ...)에 대한 지원을 할 수 있도록 도와주는 자바 라이브러리
클라이언트 모듈을 제공한다.
JAVA 1.8
Gradle 4.10.3
SpringBoot 2.1.5
구현 목표
서버로 들어온 사용자 정보를 이용해 pdf파일(계약서)을 생성 후 ftp 서버에 업로드 시킨다.
iTextPDF는 자바에서 PDF 파일 생성 및 조작을 위한 오픈 소스 라이브러리입니다. 다양한 기능을 제공하며, PDF 파일 생성, 내용 수정, 템플릿 사용, 이미지 및 테이블 추가, 서명 및 암호화 등이 가능합니다.
주요 기능:
장점:
활용 예시:
Commons-Net은 Apache에서 제공하는 자바 라이브러리로, FTP, SMTP, POP3, Telnet 등 다양한 네트워크 프로토콜을 지원합니다. FTP 서버 연결, 파일 업로드 및 다운로드, 디렉토리 관리 등의 기능을 제공합니다.
주요 기능:
장점:
활용 예시:
PDF 파일 생성:
- iTextPDF Document 객체 생성
- PDF 내용 추가 (텍스트, 이미지, 테이블 등)
- PDF 파일 저장
FTP 서버 업로드:
- Commons-Net FTPClient 객체 생성
- FTP 서버 연결
- PDF 파일 업로드
- FTP 서버 연결 종료
필자는 위와같이 구성해보았습니다.
ContractSaveService
계약서를 만들고, 업로드 시키는 비즈니스 로직을 수행하기위한 클래스
FtpFileSender
Ftp 연결을 하고 업로드시켜주는 클래스
Controller
사용자 정보를 받아 비즈니스 로직(ContractSaveService)을 호출한다.
PdfMaker
Pdf파일에 내용을 채워 최종 업로드 될 pdf 파일을 만드는 클래스
아래의 메서드를 통해 Document 객체 생성과 PDF 내용을 추가해주었습니다.
public byte[] createPdf(Map<String, Object> data) throws IOException, DocumentException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { // ByteArrayOutputStream -> AutoCloseable 하므로 try-with-resources 사용
// PDF 파일 자체를 나타내는 Document 객체 초기화
Document document = new Document(PageSize.A4, 5, 5, 10, 10);
// Document 객체와 ByteArrayOutputStream 을 연결하여 PDF 파일을 메모리에서 생성하도록 함
PdfWriter.getInstance(document, out);
// Document 객체를 열고 작성 후 닫음
document.open();
document.add(contractMaker(data));
document.close();
// PDF 파일을 FTP 서버로 보낼 수 있도록 PDF 파일의 내용이 저장된 바이트 배열 반환
return out.toByteArray();
}
}
PDF 내용추가 상세코드(예시)
private PdfPTable contractMaker(Map<String, Object> data) throws DocumentException, IOException {
// 문서에 사용될 폰트 설정
BaseFont baseFont = BaseFont.createFont("/fonts/malgun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
BaseFont boldFont = BaseFont.createFont("/fonts/malgunbd.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
Font font = new Font(baseFont, 7);
Font bold = new Font(boldFont, 15);
// PdfPTable 을 사용해 계약서를 그리기위해 객체 초기화 PdfPTable(컬럼수) = 8개의 컬럼셀을 가진 테이블
PdfPTable table = new PdfPTable(8);
table.setWidthPercentage(90); // 페이지에서 테이블이 차지할 너비 비율을 설정
// 셀 초기화
PdfPCell cell = new PdfPCell(new Phrase("A", font));
table.addCell(cell); // 테이블에 초기화된 셀 입력
table.completeRow(); // 테이블에 입력된 셀들을 가지고 행 마무리
return table;
}
위코드는 PDF에 테이블을 그리는 예시 입니다. (실제 코드는 비즈니스와 관련이 있어 예시 코드로 대체했습니다.)
PdfPTable, PdfPCell 클래스의 메서드를 사용하시면 더 멋진 테이블을 완성하실 수 있습니다.
package com.imax.biz.controller.kakaoCert;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import org.apache.commons.net.PrintCommandListener;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPConnectionClosedException;
import org.apache.commons.net.ftp.FTPReply;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class FtpFileSender {
private static final FTPClient ftpClient = new FTPClient();
@Value("${FTP.user}")
private String user;
@Value("${FTP.pw}")
private String pw;
@Value("${FTP.addr}")
private String addr;
// FTP 서버 연결
public void open() throws IllegalAccessException {
ftpClient.setControlEncoding("UTF-8"); // 파일 인코딩 설정
ftpClient.addProtocolCommandListener(
new PrintCommandListener(new PrintWriter(System.out), true)); // 연결할 때 일반적인 응답 출력용
try {
ftpClient.connect(addr);
// 정상적으로 연결이 되었는지?
int reply = ftpClient.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
ftpClient.disconnect();
}
// 타임아웃 설정
ftpClient.setSoTimeout(30000);
ftpClient.enterLocalPassiveMode();
// 로그인 정보
ftpClient.login(user, pw);
// 파일 타입 설정(default FTP.ASCII_FILE_TYPE)
ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
boolean temp = ftpClient.changeWorkingDirectory("/MIS/Kakao/InterTalk/");
} catch (IOException e) {
throw new IllegalAccessException("FTP 연결 설정 중 오류가 발생했습니다.");
}
}
// FTP 서버 연결 후 파일 업/다운 로드 후 서버 연결 종료
public void close() throws IllegalAccessException {
try {
ftpClient.logout();
ftpClient.disconnect();
} catch (IOException e) {
throw new IllegalAccessException("FTP 연결 해제 중 오류가 발생했습니다.");
}
}
// 파일 업로드
public boolean upload(String remoteFilePath, byte[] pdfBytes) throws IllegalAccessException, IOException {
open();
ftpClient.enterLocalPassiveMode();
String pwd = ftpClient.printWorkingDirectory();
String uploadFolder = remoteFilePath.substring(0, 6);
if (!pwd.substring(pwd.length() - 6).equals(uploadFolder)) {
// 현재 디렉토리가 uploadFolder 아니면 현재월 디렉토리 만들고 그 안에 업로드
ftpClient.makeDirectory(uploadFolder);
ftpClient.changeWorkingDirectory(uploadFolder);
}
// 현재 디렉토리가 uploadFolder 이고 현재 월이 맞다면? 업로드
System.out.println("업로드 시작");
return execute(remoteFilePath, pdfBytes);
}
private boolean execute(String remoteFilePath, byte[] pdfBytes) throws IllegalAccessException {
try (InputStream inputStream = new ByteArrayInputStream(pdfBytes)) {
OutputStream outputStream = ftpClient.storeFileStream(remoteFilePath);
if (outputStream != null) {
// Set buffer size (e.g. 8192)
int bufferSize = 8192;
byte[] buf = new byte[bufferSize];
int bytesRead;
while ((bytesRead = inputStream.read(buf)) != -1) {
outputStream.write(buf, 0, bytesRead);
}
outputStream.close();
System.out.println("업로드 성공" + remoteFilePath);
return true;
} else {
System.out.println("업로드 실패" + remoteFilePath);
return false;
}
} catch (FTPConnectionClosedException e) {
System.err.println("FTP 서버 연결중 오류 " + e.getMessage());
return false;
} catch (IOException e) {
System.err.println("업로드 하는 중 오류 " + e.getMessage());
return false;
} finally {
close();
}
}
} // end class
FTP의 접속정보 같은 경우 .properties 파일에서 가져와 설정 해주었습니다.
open()
연결설정을 위한 메서드입니다.
FTP 서버와의 연결을 수행합니다.
close()
사용했으면 닫아줘야겠죠? FTP 서버와의 연결을 닫아주기위한 메서드입니다.
upload(String remoteFilePath, byte[] pdfBytes)
저장될 FTP서버의 경로, 문서의 byte 를 파라미터로 받아
원하는 디렉토리 경로 확인 및 생성 후
.excute() 메서드를 통해 FTP서버의 해당경로에 byte 데이터를 Stream을 사용해 써줍니다.(업로드)
execute(String remoteFilePath, byte[] pdfBytes)
실제 업로드를 수행하는 메서드
FTP 서버에 pdf 문서를 저장하기위해 .storeFileStream() 메서드를 사용했지만 시도할 때마다 타임아웃이 나올때까지 동작하지 않는 문제가 있었습니다.
원인은 mode 설정이였습니다.
FTP 서버에 연결할 때 사용할 수 있는 두 가지 모드는 Active Mode 와 Passive Mode 입니다.
Active Mode : 클라이언트의 접속 요청 / 서버의 데이터 채널 연결
1. 클라이언트가 서버에 접속 요청을 보냅니다.
2. 클라이언트는 데이터 전송을 위해 임의의 포트를 열고 서버에 알립니다.
3. 서버는 클라이언트의 IP 주소와 포트 번호를 사용하여 데이터 연결을 요청합니다.
장점
서버 설정이 간단합니다.
방화벽 설정이 용이합니다.
단점
클라이언트가 임의 포트를 사용하기 때문에 방화벽에 의해 차단될 가능성이 높습니다.
클라이언트 IP 주소가 공개되어야 합니다.
Passive Mode : 접속과 데이터채널의 연결을 모두 클라이언트가 수행
1. 클라이언트가 서버에 접속 요청을 보냅니다.
2. 서버는 임의의 포트를 열고 클라이언트에게 알립니다.
3. 클라이언트는 서버의 IP 주소와 포트 번호를 사용하여 데이터 연결을 요청합니다.
장점
방화벽에 의해 차단될 가능성이 낮습니다.
클라이언트 IP 주소가 공개되지 않습니다.
단점
서버 설정이 더 복잡할 수 있습니다.
서버에는 사용하는 임의 포트를 알아야 하기 때문에 방화벽 설정이 더 복잡할 수 있습니다.
Active mode의 경우 위에서 설명한데로 데이터 채널을 서버가 요청하기 때문에
기본 포트인 20(변경을 하였다면 변경된 데이터 채널 포트) 이 서버쪽에는 아웃바운드, 클라이언트 쪽에는 인바운드 설정이 되어 있어야 합니다.
Passive mode의 경우 데이터 채널을 클라이언트가 임의로 지정하게 됩니다. (1024 ~ 65535 사이의 사용 가능한 포트)
혹은 포트의 범위를 설정할 수 있습니다. 그렇게 되면 해당 범위 내의 포트가 FTP 서버에 인바운드 설정이 되어 있어야 합니다.