DataBase 서버를 성공적으로 구축하여 팀 프로젝트에서 잘 사용중이었다.
문득 파일 업로드 기능도 웹서버를 구축하여 입/출력 하면 어떨까 생각이 들었다.
간할거라 생각했는데 몇일동안 머리를 싸매고 시행착오를 겪었다...

 

Mark Ⅲ에서 파일을 FTP 서버에 입력(업로드) 하는 것을 성공했고,

Mark Ⅳ에서 파일을 출력(다운로드) 하는 것을 성공했다.

아래는 Mark Ⅰ~ Ⅳ 까지의 해결 과정이다.

 

프로젝트의 기능 중에 파일을 업로드를 담당하는 class

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private String resourcePath = "/upload/**";
    private String savePath = "file:///c:/image/";

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry){
        registry.addResourceHandler(resourcePath)
                .addResourceLocations(savePath);
    }
}

 

이런 식으로 Webconfig class를 이용해서 c:/image라는 로컬 경로에서 업로드된 파일을 관리한다.

처음에는 단순하게 Synology Nas에서 제공하는 WebDav나 FTP를 네트워크 드라이브로 연결해서 사용하면 되겠지 했다.

 

 

Mark Ⅰ

RaiDrive 등의 FTP 연결 프로그램을 설치해서, FTP서버를 로컬 z드라이브로 할당하고, 위의 경로만 z://image로 바꿔서 사용하면 다른 설정은 건드릴 필요 없이 간단하게 FTP서버를 이용할 수 있다.

 

문제점

  1. 프로그램을 설치해서 z드라이브를 할당해야하기 때문에 z드라이브를 사용중인 컴퓨터에서는 환경을 또 바꾸어주어야 한다.
  2. 팀원 모두가 RaiDrive같은 프로그램을 따로 설치해야하기 때문에 번거롭다.. (이런 과정이귀찮은 사람이 생길 수 있음)
  3. 추후에 만약 완성된 프로젝트를 배포한다고 했을 때... 그때는 어떻게 해야할 것인가에 대한 문제점.

실제로 이 방법으로 프로젝트를 완성하긴 했다. 다만 3번 문제가 제일 신경쓰여서 나 혼자 develop 해보기로 결정했다.

 

Mark Ⅱ

FTP를 쓰면 어쨌든 네트워크 드라이브를 할당 해주어야 하기 때문에 이거 말고 WebDav 서비스를 이용해서 c:/ , z:/ 이런 경로 말고 http 프로토콜을 이용해보려고 했다.

 

이렇게 기본 5005, 5006 포트를 개방하고, 

http://시놀로지id.synology.me:5005/ 경로로 이동하면 미리 설정한 폴더로 접속이 돼야하는데...

 

문제점

  1. 이 방법은 출력(다운로드)은 가능한데 입력(업로드)이 안되는 것 같다...
    • FTP서버처럼 네트워크 드라이브를 할당해서 업로드를 하는 방식임..
    • image.jpg라는 파일을 출력할 때 http://시놀로지id.synology.me:5005/image.jpg 이렇게 하면 출력이 돼야하는데 익멱 WebDav를 활성화 해도 자꾸 로그인을 하라고 나온다... 아이디에 anonymous를 입력해야 정상 출력됨
  2. http://anonymous@시놀로지id.synology.me:5005/image.jpg 이런식으로 아이디 값을 지정해주면 출력은 가능한데 입력은 불가능하고, 익명에 대한 폴더와 권한을 지정해주긴 했지만 인증 정보를 포함하는 HTTP Basic Authentication은 보안상의 이유로 권장되지 않는다고 한다. 

짧게 정리되어 있지만 실제로 제일 시간을 많이 뺏긴 부분이었다. 결국 이 방법은 사용하지 못하고 이것저것 검색하다가 눈에 띈 방법이 있다...

 

Mark Ⅲ (파일 업로드 성공)

Spring Boot에 FTP를 사용할 수 있는 라이브러리가 있다는 것... 그러면 네트워크 드라이브를 따로 할당하지 않고 FTP 서버로 접근이 가능하다. 

 

버전은 각자 맞게 최신 버전으로 검색해서 [Apache Commons Net] 라이브러리를 dependencies에 추가해준다.

implementation 'commons-net:commons-net:3.8.0'

 

원래는

implementation 'commons-io:commons-io:2.11.0

 

이 라이브러리를 사용했는데,

위의 라이브러리를 사용하면 FTP, SMTP, POP3, IMAP, Telnet, NTP와 같은 네트워크 프로토콜을 쉽게 구현할 수 있게 해준다고 한다.

  • 일단 Synology에서 FTP 기능을 활성화 시킨다.
    • 기본포트 21을 사용해도 무방하지만, 랜덤 공격에 당할 수 있다고 하니 임의로 2200포트를 사용했다.
    • 기본 포트 범위 사용으로 하면 라우터구성 할 때 자꾸 에러나서 그냥 55555포트를 임의로 설정.

제어판 - 파일서비스 - FTP

 

  • 그리고 조금 아래로 내려보면 '고급 설정'이 있다.
    • 익명 FTP 활성화 체크
    • 익명 루트 변경 체크
    • 업로드된 파일을 관리할 폴더 하나를 지정해준다 (나는 web 이라는 폴더 만들어서 지정.)
    • 이렇게 되면 FTP를 anonymous(익명)으로 접속하면 web 폴더에만 읽기/쓰기가 가능해진다.

web이라는 공유폴더를 사용중이다.

 

  • 이제 라우터 구성, 방화벽, 포트폴리오 설정을 해주어야 한다.
    • 제어판 - 외부 액세스 - 생성 -  내장 응용 프로그램 - FTP 파일 서버 선택 - 적용

 

 

  • 사용중인 공유기 설정 페이지에서 2200포트도 포트포워딩 해준다.

 

내부 ip 등 자세한 포트포워딩 정보는 (https://nowcow.tistory.com/1) 이곳에 포스팅 해두었다.

이렇게 FTP 서버를 구축하고, 외부 접속이 가능하게 하는 설정은 모두 끝났고 이제 프로젝트에 적용해보자.

 

 

이제 Spring 프로젝트에서 FTP 클래스를 하나 만든다. 나는 그냥 FtpUtil 이렇게 이름 지었다.

  • 나는 의존성 주입 없이 Java 클래스를 객체로 생성하여 사용하는 방식으로 했다.
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class FtpUtil {
    private static final String FTP_SERVER = "시놀로지ip.synology.me"; // FTP 서버 주소
    private static final int FTP_PORT = 2200; // FTP 포트 번호
    private static final String FTP_USER = "anonymous";    // FTP 사용자명
    private static final String FTP_PASS = "";    // FTP 비밀번호

    private FTPClient ftpClient;

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

    // FTP 서버에 연결
    public void connect() throws IOException {
        ftpClient.connect(FTP_SERVER, FTP_PORT); // 서버 주소와 포트
        boolean success = ftpClient.login(FTP_USER, FTP_PASS);
        if (!success) {
            throw new IOException("FTP 서버 로그인 실패");
        }
        ftpClient.enterLocalPassiveMode();  // Passive 모드 사용
        ftpClient.setFileType(FTP.BINARY_FILE_TYPE);  // 바이너리 파일 타입 설정
    }

    // 파일 업로드
    public boolean uploadFile(String remoteFilePath, File localFile) throws IOException {
        try (InputStream inputStream = new FileInputStream(localFile)) {
            boolean done = ftpClient.storeFile(remoteFilePath, inputStream);
            if (!done) {
                throw new IOException("파일 업로드 실패");
            }
            return true;
        }
    }

    // 파일 다운로드
    public void downloadFile(String remoteFilePath, String localFilePath) throws IOException {
        try (FileOutputStream fos = new FileOutputStream(localFilePath)) {
            boolean success = ftpClient.retrieveFile(remoteFilePath, fos);
            if (!success) {
                throw new IOException("파일 다운로드 실패");
            }
        }
    }

    // 파일 삭제
    public boolean deleteFile(String remoteFilePath) {
        try {
            // FTP 서버에서 파일 삭제 로직
            ftpClient.deleteFile(remoteFilePath);
            return true; // 삭제 성공 시 true 반환
        } catch (IOException e) {
            e.printStackTrace();
            return false; // 삭제 실패 시 false 반환
        }
    }


    // FTP 연결 종료
    public void disconnect() {
        if (ftpClient.isConnected()) {
            try {
                ftpClient.logout();
                ftpClient.disconnect();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

 

익명 로그온을 활성화 해두었기 때문에 사용자명에 anonymous만 입력해주어도 접근이 가능해진다. 

이제 이렇게 contoller 혹은 service 에서 newFtpUtil()로 인스턴스를 생성해서 사용하면 된다.

 

실제로 적용된 코드 ↓

더보기

Contoller

    @PostMapping("/post/save")
    public String save(@ModelAttribute PostDTO postDTO, HttpSession session) throws IOException {

        // 게시물 저장
        postService.save(postDTO);

        // 세이브 된 글 번호로 리다이렉트
        int firstPostNum = postService.findFirstPostNum(); // 첫 번째 게시물 번호 가져오기
        System.out.println("firstPostNum = " + firstPostNum);
        return "redirect:/post/" + firstPostNum;
    }

 

Service

public void save(PostDTO postDTO) throws IOException {
        if (postDTO.getPost_upLoadFile().isEmpty()) {
            PostEntity postEntity = PostEntity.toSaveEntity(postDTO);
            postRepository.save(postEntity);
        } else {
            PostEntity postEntity = PostEntity.toSaveFileEntity(postDTO);
            Integer postNum = postRepository.save(postEntity).getPostNum();
            PostEntity post = postRepository.findById(postNum).get();

            FtpUtil ftpUtil = new FtpUtil();
            try {
                ftpUtil.connect(); // FTP 서버 연결
                for (MultipartFile postFile : postDTO.getPost_upLoadFile()) {
                    String originalFilename = postFile.getOriginalFilename();

                    if (originalFilename != null && !originalFilename.isEmpty()) {
                        // 확장자 추출
                        String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));
                        // 랜덤 파일 이름 생성
                        String randomFileName = UUID.randomUUID().toString() + fileExtension;

                        // 임시 파일로 서버에 저장
                        File tempFile = new File(System.getProperty("java.io.tmpdir") + "/" + randomFileName);
                        postFile.transferTo(tempFile);

                        // FTP 서버에 파일 업로드
                        ftpUtil.uploadFile("/marketFile/" + randomFileName, tempFile);

                        // FileEntity 생성 및 저장
                        FileEntity fileEntity = FileEntity.toFileEntity(post, randomFileName);
                        fileRepository.save(fileEntity);

                        // 업로드 후 임시 파일 삭제
                        if (tempFile.exists()) {
                            tempFile.delete();
                        }
                    } else {
                        System.out.println("파일이 입력되지 않았습니다! 파일명: " + originalFilename);
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
                // 파일 업로드 중 발생한 오류 처리
            } finally {
                ftpUtil.disconnect(); // FTP 서버 연결 해제
            }
        }
    }

 

Contoller는 Service를 불러오는 부분이고, 실제로 Service의 public void save 메소드에서 FtpUtil을 불러온다.

나는 혹시나 파일명이 겹치게 될까봐 UUID 라는 라이브러리를 사용해서 파일의 이름을 랜덤하게 지정하고 

업로드하는 방식을 사용했다. 순서는 이렇다

 

FtpUitl 초기화 - FTP서버 연결 - 파일이 업로드 되면 랜덤으로 이름 생성 - 임시파일을 로컬 저장소에 저장 - FTP로 업로드 - 임시파일 삭제 - FTP 연결해제

 

이 로직으로 돌아가는데, 중간에 괜히 임시파일을 로컬에 저장하는 이유는 FTP로 파일을 전송할 때 파일의 경로가 필요하기 때문이라고 한다. 또 에러 관리 및 유지보수를 위해서 라고도 하는데 이 부분은 조금 더 서치가 필요할 것 같다.

각자의 프로젝트에 맞게 코드를 알맞게 수정 및 적용하면 될 것 같고, 

// FTP 서버에 파일 업로드
ftpUtil.uploadFile("/marketFile/" + randomFileName, tempFile);

나는 /web/ 디렉토리 안에 /markerFile/ 이라는 폴더에 저장하고싶어서 저렇게 디렉토리 경로를 추가했다.

이렇게 파일 업로드(입력)하는데에 성공하고 이제 이걸 다운로드(출력) 하려고 하는데,,, 이게 또 쉽지않다...

 

Mark Ⅳ (파일 업로드/다운로드 모두 가능)

<div style="width: 700px; display: flex; justify-content: center">
	<div th:each="index : ${#numbers.sequence(0, detail.file_url.size() - 1)}">
		<img th:id="'thumbnail_' + ${index}" th:src="@{|/upload/${detail.file_url[__${index}__]}|}"
			style="width: 80px; height: 80px; margin: 15px; box-shadow: 0px 0px 8px -1px;
			border-radius: 8px; cursor: pointer" th:onclick="'extend('+ ${index} +')'" th:onmouseover="'extend('+ ${index} +')'">
	</div>
</div>

 

원래는 이 글 맨 위에 보여준 WebConfig 클래스를 만들고, 이런식으로 /upload/ 경로로 요청되는 것들을 c:/image/로

대치해서 로컬의 c:/image 디렉토리 안에 있는 파일을 불러왔었다.

그렇게 되면 http://localhost:8080/image/사진.jpg 이런 식의 경로가 된다.

우리는 로컬환경을 쓰지 않으려고 했던 것이기 때문에 FTP를 이용해 Synology에 업로드했던 파일을 가져와야한다.

 

어떻게.....?

진짜 이 부분에서 애를 많이 먹었다.

http://localhost:8080/ 부분을 내 WebDav 경로로 대치했을때 사용자 정보가 없어서 아무 이미지도 로드되지 않았다..

http://anonymous@시놀로지id.synology.me:5005/image.jpg 이 url로 접속하면 사진이 불러와 졌던걸 생각해서

http://anonymous@시놀로지id.synology.me:5005/ 이 경로 대치를 했는데도 계속 이미지가 로드되지 않았다...

Thymeleaf에서 anonymous@ 이 부분을 인식하지 못하는 것 같기도 하고 고민이 참 많았다.

FTP에선 익명 로그인을 지원하는데 WebDav에서는 익명 로그인을 사용하지 못하는건지...

Thymeleaf 탬플릿에서 WebDav 연결하는 유틸리티 클래스를 연결해줘야하는건지.... 도무지 감이 잡히지 않다가 

결국 https://digitalogia.tistory.com/367 이 블로그에서 해답을 찾았다...

자세한 세팅법은 위의 블로그를 참고하시면 된다. 

Web Staion

나는 정확히 뭔지도 모르는 Web Staion이 이미 세팅되어있었고, 방화벽 규칙에 추가해주고

iptime 설정에서 80포트를 포트포워딩 해주었다.

내가 /web/marketFile/ 이라는 디렉토리 경로를 사용하는것에 대한 의문이 풀렸을 것이다.

http://시놀로지id.synology.me/image.jpg 경로를 주소창에 입력하면

시놀로지 파일스테이션에 있는 /web/ 이라는 공유폴더가 Root 디렉토리로 설정되어서

/web/image.jpg 라는 파일이 출력되는걸 알 수 있다.. 정말 이렇게 간단한 일일 줄이야...

/web/ 폴더 안에 모든 파일이 계속 업로드 된다면 관리가 쉽지 않을 것 같아서 진행중인 프로젝트의 이름을 따서

/marketFile/ 이라는 디렉토리를 이 프로젝트 전용 디렉토리로 사용했다.

 

이제 저 디렉토리 안에 파일이 있으면 손쉽게 출력이 가능할 것이다.

 

<div style="width: 700px; display: flex; justify-content: center">
	<div th:each="index : ${#numbers.sequence(0, detail.file_url.size() - 1)}">
		<img th:id="'thumbnail_' + ${index}" th:src="@{|http://시놀로지id.synology.me/marketFiles/${detail.file_url[__${index}__]}|}"
			style="width: 80px; height: 80px; margin: 15px; box-shadow: 0px 0px 8px -1px;
			border-radius: 8px; cursor: pointer" th:onclick="'extend('+ ${index} +')'" th:onmouseover="'extend('+ ${index} +')'">
	</div>
</div>

 

그리고 코드를 바꿔서 Mark Ⅳ 단계에서 프로젝트의 DataBase와, 파일 입/출력 기능을 로컬에서 웹서버로 전환 완료했다.


우선 테스트 목적으로 진행된 작업이라 보안에 크게 신경쓰지 않았다.

anonymous 계정에 지정된 폴더만 접근할 수 있게 권한을 주는 정도로만 진행했는데

보안을 생각한다면 익명이 아니라 프로젝트에 맞는 계정을 새로 생성하고 접근 권한도 따로 부여하고,

GitHub에 공개되지 않게 ignore에 추가도 해야할 것이다.

 

조금 더 나은 방법이 있을 것이고 틀린 방법일 수도 있지만 개인 프로젝트를 진행하기에는 충분할 정도의 기능을 구현했다.

 

이제 이렇게 완성된 프로젝트를 Synology를 이용해서 배포까지 해 볼것이다!

'웹서버' 카테고리의 다른 글

Synology Nas에 Docker로 Node.js 서버 구축하기  (5) 2024.10.09

+ Recent posts