[프로젝트] FTP 파일 업로드

심채원·2024년 1월 10일
0

PROJECT

목록 보기
1/1
post-thumbnail

혹시 바쁘신 분들은 맨 밑으로 내려가세요..^.^ 요약해놓겠습니다.

선택과 결정과정

내가 생각한 프로젝트의 목표는 새로운 것을 하자, 그리고 공통 사안을 추출하여 동일한 작업 코드를 줄여보자였다.
우선 새로운 것을 해봐야 스스로 문제를 해결할 수 있다는 자신감을 얻을 수 있다고 생각했고, 불필요한 중복 코드를 줄이는 것이 궁극적인 팀 프로젝트의 완성이라는 생각이 있었기 때문이다.

우선 우리 웹 사이트의 초기 이미지 업로드나 다운로드 작업은, 리뷰, 펫, 커뮤니티에서 사용할 예정이었다.
물론 중심이 되는 리뷰와 펫 이외 커뮤니티는 추가 구현 사항이었으나 최소 2곳에서 사용할 이미지 관련 저장 작업은 중복으로 있기보다 유틸로 구성하여 사용하는 것이 더 나을 것 같았다.

파일 업로드 방식 고민

우선 유틸을 만들기 이전에 이미지를 어떻게 저장할 것인가는 고민을 해야했고 고민해본 리스트는 다음과 같다.

  • 파일 업로드를 하는 3가지 방법
    1. DB에 바이너리 파일 직접 저장
    2. 로컬 컴퓨터 경로에 binary파일 저장 후, 해당 경로를 DB에 저장
    3. AWS 클라우드에 업로드 후 파일 저장

위 방식 중 가장 먼저 고려해본 것은 3번이다.
node로 하던 프로젝트에서 AWS cloud를 통해 업로드 해본 경험이 있었고, 재밌었던 기억이어서 가장 첫번째 후보로 등극하였으나 업로드나 다운로드 시 소요 시간이 10초 이상 걸렸던 것으로 기억나서 바로 철회했다.

1번의 경우, DB에 바이너리 타입의 데이터를 저장하고 다운로드하는 과정이 과연 효율적인가?라는 고민이 들었다.
실제 서비스에서는 접속자 수가 늘어남에 따라 DB의 속도에 따라 서비스의 속도가 영향을 더 크게 받을 것이고, 그렇다면 이미지 파일을 매개로 통신하는 것은 다른 데이터들을 불러오는 속도에도 영향을 줄 수 있지 않을까라는 생각이 들었다.

2번의 경우에는, 로컬 컴퓨터 경로에 binary 파일을 저장한 후, DB에 해당 경로를 저장하는 방식인데, 나는 팀원의 어떤 컴퓨터에서 접속해도 동일한 파일을 반환하기를 원했다.
가장 간단한 방식의 업로드 방식이라고 생각은 했으나, 팀 활동이기 때문에 최후의 보루 같은 느낌으로 2번을 세이브하고, 다른 방식을 찾아 해맸다.

모든 사람이 그렇겠지만, 아는 대로 보인다고 최근에 컴퓨터 간 통신을 정말 많이 사용했다.
이전 프로젝트를 진행할 때 각자 컴퓨터에 DB를 구축하고, 동일한 DB를 각자 로컬에 구축한 후 분리된 Database를 이용하여 프로젝트를 진행했다.
하지만 이번 프로젝트를 진행하면서 한 컴퓨터에 공동으로 사용할 Database를 구축하고, 해당 데이터베이스와 연결하여 사용했다.

그렇다면 Database처럼, 공용 DB가 설치된 컴퓨터에 파일을 업로드할 수도 있지 않을까?하는 생각이 들었다.
하지만 HTTP만 알던 내게 다른 통신 방법은 생각나는 것이 없었고, FTP라는 파일 통신 방법을 알고 있었으나 정확한 사용법을 알지는 못하니 대안으로 생각했던 것이 미디어 서버였다.
간략하게 생각해본 방법은 공용 컴퓨터에 따로 스프링 서버를 돌리고, 해당 서버를 이용해 파일을 받고 업로드하고 해당 컴퓨터에 저장된 파일 경로를 반환하는 것이었다.
내 머릿속은 서버를 하나 더 파는 것에 매몰되어 있었으나, 선생님께 질문하는 과정을 통해 FTP로 할 수 있을텐데...라는 한 마디에 꽂혀 결론적으로는 FTP 통신을 통해 파일 업로드를 해보자!라는 결정을 하였다.

FTP 업로드 과정

삽질, 그리고 삽질

우선 FTP 통신을 위해 방화벽 설정을 변경해야 한다는 것 정도는 알고 있었다.
이전에 HTTP 통신을 위해 방화벽 설정을 할 때, FTP 통신이 목록에 있었기 때문이었다.
학원에서는 Window를 사용하므로 Window에서 FTP 통신 설정하는 방법 위주로 찾아봤다.

Window에서 기본적인 FTP 통신 설정만을 한 후에, 1. 실제 통신이 되어야 함, 2. 프로젝트 서버에 설정이 온전히 되었는지를 모두 충족해야 프로젝트에서 FTP 통신이 실행될 수 있다고 생각했다. 만약에 위 조건들이 충족되지 않았을 때, 어디에서 문제가 나왔는지 찾기가 쉽지 않으니 문제를 찾을 수 없는 문제를 해결할 방법이 필요했다. 다른 작업들이 줄줄히 있었기 때문에, 얼마간의 고민과 구글링 끝에 또 한번 선생님을 찾을 수 밖에 없었다.

파일질라라는 프로그램을 사용해보라고 말씀해주셨고, 공용 컴퓨터에는 파일질라 서버를, 그리고 내 맥에는 파일질라 클라이언트를 설치하여 실제 통신이 되었는지 우선 확인해보았다.
설정을 끝마치고 파일 전송이 가능해지기까지 꼬박 하루가 소요되었다.

파일 전송이 확인된 후, 유틸 클래스 작업에 들어갔다.
파일질라를 통해 FTP 통신이 가능하다는 것은 확인을 했으니, 이를 어떻게 코드로 녹여내야 할까를 고민할 차례였다.

코드에 녹여내기

처음 내가 작성한 코드는 다음과 같다.

package com.ohdogcat.util;

import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
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.FTPReply;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Configuration
public class FtpImgLoaderUtil {

    private String host = "{{ip주소}}";
    private Integer port = {{포트번호}};
    private String user = "{{ftp userName}}";
    private String password = "{{ftp password}}";

    private FTPClient ftpClient;

    public FtpImgLoaderUtil() {
        ftpClient = new FTPClient();
    }

    /**
     * DB 서버 내에 파일 저장하여 저장된 경로를 반환하는 메서드
     *
     * @param file 업로드할 MultipartFile
     * @param servletPath  HttpServletRequest 타입의 request
     * @return 업로드된 이미지의 경로
     * @throws IOException 아마자 업로드 시 문제 발생 시 IOException qkftod
     */
    public String upload(MultipartFile file, String servletPath) throws IOException {

        connect();

        String result = null;

        List<String> filePath = mkDirByRequestUri(servletPath);
        InputStream inputStream = file.getInputStream();

        setFtpClientConfig();

        String fileName =
            UUID.randomUUID().toString() + "." + file.getOriginalFilename().split("\\.")[1];

        boolean uploadResult = ftpClient.storeFile(fileName, inputStream);

        if (uploadResult) {
            filePath.add(fileName);
            log.debug("파일 업로드 완료");
            result = String.join("/", filePath);
        }

        disconnect();
        return result;
    }


    /**
     * 경로를 주면 file 정보가 담겨있는 Resource 타입의 객체 반환
     *
     * @param imgUrl upload 메서드 시 제공된 경로
     * @return 해당 파일의 binary 데이터가 담긴 Resource 객체
     * @throws IOException
     */
    public Resource download(String imgUrl) throws IOException {
        connect();
        setFtpClientConfig();
        InputStream imgStream = ftpClient.retrieveFileStream(imgUrl);
        byte[] result = IOUtils.toByteArray(imgStream);

        if (result.length == 0) {
            log.error("::DB에 데이터 조회 실패");
            return null;
        }

        Resource resource = new ByteArrayResource(result);
        imgStream.close();
        disconnect();
        return resource;
    }

    /**
     * 해당 경로의 파일 삭제하는 메서드
     *
     * @param imgUrl 삭제할 파일의 경로
     * @return 성공 시 true, 실패 시 false 반환
     */
    public boolean delete(String imgUrl) throws IOException {
        boolean result = false;

        connect();
        result = ftpClient.deleteFile(imgUrl);
        disconnect();

        return result;
    }

    public boolean connect() throws IOException {
        log.debug("connecting to... {}", host);

        ftpClient.addProtocolCommandListener(
            new PrintCommandListener(new PrintWriter(System.out), true));

        ftpClient.connect(host, port);
        int reply = ftpClient.getReplyCode();

        if (!FTPReply.isPositiveCompletion(reply)) {
            try {
                disconnect();
            } catch (IOException e) {
                log.error("연결에 실패하였습니다. {}", e.getMessage());
                return false;
            }
        }

        return ftpClient.login(user, password);
    }

    public void disconnect() throws IOException {
        log.debug("disconnecting from {}", host);

        ftpClient.logout();
        ftpClient.disconnect();
    }

    public void setFtpClientConfig() throws IOException {
        ftpClient.enterLocalPassiveMode();
        ftpClient.setFileTransferMode(FTP.BINARY_FILE_TYPE);
        ftpClient.setAutodetectUTF8(true);
        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
    }

    public List<String> mkDirByRequestUri(String servletPath)
        throws RuntimeException, IOException {
        List<String> result = new ArrayList<>();

        List<String> paths = Arrays.stream(servletPath.split("/")).toList();

        log.debug("paths={}", paths);
        for (String path : paths) {
            if (path.isEmpty()) {
                continue;
            }
            result.add(path);

            if (!ftpClient.changeWorkingDirectory(path)) {
                ftpClient.makeDirectory(path);
                ftpClient.changeWorkingDirectory(path);
            }
        }

        return result;
    }
}

FTPUtil 클래스는 멤버변수인 ftpClient를 사용하고 upload와 download 메서드 내에서 FTP 연결을 하고, FTP 설정을 한 후, 다운로드 또는 업로드를 한다.
처음 테스트 시에는 성공한 줄 알았다. 테스트에서는 여러 장의 이미지를 받아올 경우를 단순히 하나의 이미지 태그를 30개 정도 복사 붙여넣기 하여 확인했었고, 복수의 요청이 갈 때도 동일하게 일어날 것이라고 생각했기 때문이다.
하지만 머지 후, 펫 리스트를 불러올 때 한장만 제대로 된 데이터를 가져오고 나머지는 데이터를 가져오지 못하는 문제가 발생했다.
하나의 이미지를 여러번 요청할 때 브라우저가 캐싱한 해당 데이터를 다시 제공했기 때문에 하드코딩으로 동일 이미지를 여러번 요청했을 때와 실제 페이지를 보여주는 것은 다르게 동작했다.

최종적으로 내가 발견한 위 코드의 문제점은 2가지였다.
1. 환경 변수가 노출되어 있다.
2. 한 페이지에서 FTP Util의 download 메서드를 여러번 호출하여 사용할 경우, connect와 download, disconnect가 순서대로 실행되는 것이 아니고 현재 ftpClient가 유틸 클래스의 멤버 변수로 등록되어 있어 중간에 연결이 끝나는 문제가 발생한다.

1번의 경우에는, 현재 탈취 가능성이 현저히 적기 때문에 기능 구현을 최우선으로 두었기 때문에 이번 프로젝트에서는 신경 쓰지 않으려고 했다.

물론 2차 프로젝트에서는 비밀번호 암호화와 환경변수 관리를 반드시 하겠습니다.

2번의 경우에는 생각보다 복잡했고, 원인 자체를 해결하지 못했다. 다운과 업로드 할 때마다 connect와 disconnect를 하는 것이 문제라면, 처음 Util 클래스가 생성될 때 단 한번만 connect를 하는 것으로 해결할 수 있지 않을까 생각하여 constructor에서 인스턴스가 생성될 때 connect를 하도록 했다.
하지만 왜인지는 모르겠으나 파일을 가져올 때, 온전한 파일을 가져오지 못하고 계속 에러가 발생했다.

이틀을 꼬박 해당 문제에 매달렸으나, 해결할 수 없었고, 만약 해결하지 못한다면 로컬에 저장하는 방식으로 해결해야 한다고 기한을 정해놓고 우선 구현해야 할 다른 작업들을 먼저 하기 시작했다.

반쪽짜리 성공

2주간의 유예기간 동안 다른 페이지들을 만들어가며 한편으로는 FtpClient가 복수의 이미지를 반환하지 못하는 문제를 해결할 방법에 대해 생각했다.
집을 갈 때도, 계속 생각해봤지만 2주간은 다른 기능들을 고민해야했기 때문에 답이 떠오르지 않았다.

2주가 흐르고 우선 처리해야하는 기능들을 먼저 처리한 이후, 다시 돌아와서 원인이 무엇인지, 그럼 어떤 대안이 있을지를 다시 생각해보았다.

원인은 멤버인 ftpClient의 connect와 disconnect, download 호출 순서가 순서대로 동작하지 않는 것이었고 가장 빠르게 해결할 수 있는 방법으로 멤버 변수가 아니라 지역 변수로 선언하여 사용하는 것이었다.

새로 작성한 코드는 다음과 같다.

package com.ohdogcat.util;

import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
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.FTPReply;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Configuration
public class FtpImgLoaderUtil2 {

    private String host = "";
    private Integer port = port;
    private String user = "";
    private String password = "";

    public FtpImgLoaderUtil2() {

    }

    /**
     * DB 서버 내에 파일 저장하여 저장된 경로를 반환하는 메서드
     *
     * @param file        업로드할 MultipartFile
     * @param servletPath HttpServletRequest 타입의 request
     * @return 업로드된 이미지의 경로
     * @throws IOException 아마자 업로드 시 문제 발생 시 IOException qkftod
     */
    public String upload(MultipartFile file, String servletPath) throws IOException {

        FTPClient ftpClient = new FTPClient();

        connect(ftpClient);

        String result = null;

        List<String> filePath = mkDirByRequestUri(servletPath, ftpClient);
        InputStream inputStream = file.getInputStream();

        setFtpClientConfig(ftpClient);

        String fileName =
            UUID.randomUUID().toString() + "." + file.getOriginalFilename().split("\\.")[1];

        boolean uploadResult = ftpClient.storeFile(fileName, inputStream);

        if (uploadResult) {
            filePath.add(fileName);
            log.debug("파일 업로드 완료");
            result = String.join("/", filePath);
        }

        disconnect(ftpClient);
        return result;
    }


    /**
     * 경로를 주면 file 정보가 담겨있는 Resource 타입의 객체 반환
     *
     * @param imgUrl upload 메서드 시 제공된 경로
     * @return 해당 파일의 binary 데이터가 담긴 Resource 객체
     * @throws IOException
     */
    public Resource download(String imgUrl) throws IOException {
        FTPClient ftpClient = new FTPClient();

        connect(ftpClient);
        setFtpClientConfig(ftpClient);
        InputStream imgStream = ftpClient.retrieveFileStream(imgUrl);
        byte[] result = IOUtils.toByteArray(imgStream);

        if (result.length == 0) {
            log.error("::DB에 데이터 조회 실패");
            return null;
        }

        Resource resource = new ByteArrayResource(result);
        imgStream.close();
        disconnect(ftpClient);
        return resource;
    }

    /**
     * 해당 경로의 파일 삭제하는 메서드
     *
     * @param imgUrl 삭제할 파일의 경로
     * @return 성공 시 true, 실패 시 false 반환
     */
    public boolean delete(String imgUrl) throws IOException {
        boolean result = false;
        FTPClient ftpClient = new FTPClient();

        connect(ftpClient);
        result = ftpClient.deleteFile(imgUrl);
        disconnect(ftpClient);

        return result;
    }

    public boolean connect(FTPClient ftpClient) throws IOException {
        log.debug("connecting to... {}", host);

        ftpClient.addProtocolCommandListener(
            new PrintCommandListener(new PrintWriter(System.out), true));

        ftpClient.connect(host, port);
        int reply = ftpClient.getReplyCode();

        if (!FTPReply.isPositiveCompletion(reply)) {
            try {
                disconnect(ftpClient);
            } catch (IOException e) {
                log.error("연결에 실패하였습니다. {}", e.getMessage());
                return false;
            }
        }

        return ftpClient.login(user, password);
    }

    public void disconnect(FTPClient ftpClient) throws IOException {
        log.debug("disconnecting from {}", host);

        ftpClient.logout();
        ftpClient.disconnect();
    }

    public void setFtpClientConfig(FTPClient ftpClient) throws IOException {
        ftpClient.enterLocalPassiveMode();
        ftpClient.setFileTransferMode(FTP.BINARY_FILE_TYPE);
        ftpClient.setAutodetectUTF8(true);
        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);
    }

    public List<String> mkDirByRequestUri(String servletPath, FTPClient ftpClient)
        throws RuntimeException, IOException {
        List<String> result = new ArrayList<>();

        List<String> paths = Arrays.stream(servletPath.split("/")).toList();

        log.debug("paths={}", paths);
        for (String path : paths) {
            if (path.isEmpty()) {
                continue;
            }
            result.add(path);

            if (!ftpClient.changeWorkingDirectory(path)) {
                ftpClient.makeDirectory(path);
                ftpClient.changeWorkingDirectory(path);
            }
        }

        return result;
    }
}

ftpClient를 download와 upload 할 때마다 선언과 초기화를 하여 각각의 ftpClient의 인스턴스들의 연결이 꼬이지 않게 하는 것이었다.

이 블로그 포스팅을 작성하며 드는 생각은, upload 시에는 문제가 발생하지 않으므로 메모리를 많이 할당하지 않을 수 있도록 upload client는 멤버 변수로 설정하여 쓰는 것이 좋지 않을까라는 생각이다.
하지만 일관성이 없으므로 좋은 방법은 아닌 것 같다.

요약

> 목표

  1. 새로운 것을 하자 => 새로운 것을 해봐야 스스로 문제를 해결할 수 있다는 자신감을 얻을 수 있다고 생각
  2. 공통 사안을 추출하여 동일한 작업 코드를 줄여보자 => 불필요한 중복 코드를 줄이는 것이 궁극적인 팀 프로젝트의 완성이라고 생각

> 파일 업로드 방식 고민

  1. DB에 바이너리 파일 직접 저장 => 실제 서비스에서는 다른 데이터를 불러오는 속도에도 영향을 미칠 것
  2. 로컬 컴퓨터 경로에 binary파일 저장 후, 해당 경로를 DB에 저장 => 다른 팀원의 서버에서 접근 불가능
  3. AWS 클라우드에 업로드 후 파일 저장 => 업로드 시간이 5초 이상 소요되었음. 해당 과정에 시간이 너무 많이 소요

결과적으로, 모든 프로젝트원 서버에서 동일한 파일에 접근을 허용하기 할 수 있어야 하고, 시간이 많이 소요되지 않아야 한다는 나만의 조건을 충족시킬 수 있는 FTP 통신을 사용하기로 결정

> FTP 업로드 과정

  1. 삽질 과정 : Window에서 FTP 통신 설정하는 방법을 통해 구현하려고 함.
    • 충족해야할 2가지 조건
      a. 실제로 FTP 통신이 되어야 함
      b. 프로젝트 서버에 해당 기능의 코드가 정삭적으로 작동한다.
      • 하지만 처음부터 두 가지 문제 모두 발생할 가능성이 있는 상황에서 구현해내기에는 FTP 통신에 대한 지식이 없었음.
    • 해결 : 파일질라라는 프로그램을 사용하여 FTP 통신이 되는지 우선 확인하고 코드 구현
  2. 코드 구현
    • FTPUtil 클래스의 인스턴스를 멤버 변수로 선언하여 업로드, 다운로드 시 connect하고 설정하고 업로드/다운하고 disconnect 함
    • 한 번씩 호출하면 괜찮지만 여러번 호출 할 때 connect, disconnect가 꼬여 문제 발생
      -> connect를 한번만 할 수 있도록 생성자에서 connect 함수 호출
    • 다운로드 시 파일이 깨지는 문제 발생

      connect와 disconnect가 꼬이지 않으면서 생성자를 계속해서 호출할 수 있도록 Util 클래스의 업로드 다운로드 메서드가 호출할 때마다 개별적인 ftp 연결 인스턴스를 생성하여 작업 처리.
      -> 한 페이지에서 복수의 요청을 보내도 문제 없이 동작

> The End

  • 잔여 문제 : 자원 낭비 가능성
  • 다른 방법이 있지 않을까..?

FTP 구현 중 참고한 블로그들

감사합니다 사랑합니다 ㅠㅠㅠㅠ 여러분이 있기에...

oh-dog-cat 프로젝트 깃허브 링크

https://github.com/developPuppies/oh-dog-cat-raise-me.git

profile
인생의 디테일을 추가하는 심채원

0개의 댓글