토이 프로젝트/Spring&Java 갖고놀기

Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기 (3) S3 기반 채팅 파일 업로드 & 다운로드

TerianP 2022. 9. 7.
728x90

10.29 추가 : 일반(문자) 채팅만 구현하는 코드는 git 의 master 브렌치를 참고해주시기 바랍니다.  master-Webrtc-jpa 는 화상 채팅과 jpa 를 이용한 DB 연결을 포함하는 브렌치입니다.

 

- 최근 스터디에서 파일 업로드와 관련된 프로젝트를 하자고 이야기가 나왔고, 이에 만들어두었던 채팅에 이어서 만들어보고 싶어서 부랴부랴 AWS S3 와 연동해서 S3 기반 파일 업로드를 구현하였다.

 

- 원래는 S3의 bucket 만들기부터 시작해서 ACL 설정 등등 만져줄게 많은데 이것들은 담에 설명하기로 하고, 이번에는 코드만 정리하고 넘어가려고 한다.

=> 혹시나 필요하신 분들을 위해서 이 부분은 아래에 참고 사이트를 넣어두었으니 확인 부탁드립니다!

 

- 아래의 추가된 모든 내용은 해당 git 에서 확인할 수 있습니다!

https://github.com/SeJonJ/Spring-WebSocket-Chatting

 

GitHub - SeJonJ/Spring-WebSocket-Chatting: SpringBoot WebSocket Chatting

SpringBoot WebSocket Chatting. Contribute to SeJonJ/Spring-WebSocket-Chatting development by creating an account on GitHub.

github.com

 

AWS S3

- Amazon S3는 어디서나 원하는 양의 데이터를 저장하고 검색할 수 있도록 구축된 객체 스토리지입니다. 업계 최고 수준의 내구성, 가용성, 성능, 보안 및 거의 무제한의 확장성을 아주 저렴한 요금으로 제공하는 단순한 스토리지 서비스라고 한다.

- 쉽게쉽게 생각하자면 아마존에서 제공하는 FTP 서버라고 생각하면 된다. 다만 기존의 FTP 서버와는 살짝 다른 개념들이 포함되어 있다. 추가적으로 EC2 나 RDS 등 다양한 AWS 서비와 섞어서 함께 쓸 수 있다는 장점이 있다.

 

S3 의 특징

  • 제공하는 단순한 웹 서비스 인터페이스를 사용하여 웹에서 언제 어디서나 원하는 양의 데이터를 저장하고 검색할 수 있다.
  • 개발자는 Amazon이 자체 웹 사이트의 글로벌 네트워크 운영에 사용하는 것과 같은 높은 확장성과 신뢰성을 갖춘 빠르고 경제적인 데이터 스토리지 인프라에 액세스할 수 있다.
  • 단독 스토리지로도 사용할 수 있으며 EC2, EBS, Glacier와 같은 다른 AWS 서비스와도 함께 사용할 수 있어 클라우드 어플리케이션, 컨텐츠 배포, 백업 및 아카이빙, 재해 복구 및 빅데이터 분석을 포함한 다양한 사례에 알맞다 => 아주 중요
  • HTTPS 프로토콜을 사용하여 SSL로 암호화된 엔드포인트를 통해 데이터를 안전하게 업로드/다운로드 할 수 있으며 상주 데이터를 자동으로 암호화 하고 AWS KMS를 통해 S3에서 사용자를 위해 키를 관리하게 하는 방법과 고유한 키를 제공하는 방법 중에서 키 관리 방법을 선택할 수 있는 기능을 제공한다.
  • S3의 버킷은 무한대의 객체를 저장할 수 있으므로 스토리지의 요구를 미리 추정하여 관리할 필요가 없어 확장/축소에 신경쓰지 않아도 된다.
  • 사용한 스토리지 만큼 요금이 청구되며 데이터 전송부분에서는 해당 리전 내에서는 데이터 송수신은 무료(다른 AWS 리전으로는 무료가 아니다!)이고 S3에서 인터넷으로 데이터를 송수신 시에도 가격이 매우 저렴하다.

 

S3 의 기본 개념

객체(Object)

S3에 데이터가 저장되는 기본 단위로써 파일과 메타데이터로 이루어져있다. 객체 하나의 크기는 1Byte부터 5TB까지 허용되며 메타데이터는 MIME 형식으로 파일 확장자를 통해 자동으로 설정되며 사용자 임의로도 지정 가능하다.

 

버킷(Bucket)

S3에서 생성할 수 있는 최상위 디렉토리의 개념으로 이름은 S3 리전 중에서 유일해야 한다. 계정별로 100개까지 생성 가능하며 버킷에 저장할 수 있는 객체수와 용량은 무제한이다.

 

 

코드로 알아보기

AWS S3 연결을 위한 정보 셋팅

- gradle 에 AWS 관련 라이브러리를 임포트한다.

// aws s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.1.RELEASE'

 

- application.properties 에 S3 API key 와 bucket 등 각종 정보를 넣어둔다.

## S3 API key
cloud.aws.credentials.accessKey=Key
cloud.aws.credentials.secretKey=secretKey
cloud.aws.stack.auto=false

# AWS S3 Service bucket
cloud.aws.s3.bucket=버킷이름
cloud.aws.region.static=버킷region

# AWS S3 Bucket URL
cloud.aws.s3.bucket.url=버킷 url => 보통 [https://s3.reginon정보.amazonaws.com/버킷이름]을 사용한다.

 

FileUploadDTO

package webChat.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FileUploadDto {

    private MultipartFile file; // MultipartFile

    private String originFileName; // 파일 원본 이름

    private String transaction; // UUID 를 활용한 랜덤한 파일 위치

    private String chatRoom; // 파일이 올라간 채팅방 ID

    private String s3DataUrl; // 파일 링크

    private String fileDir; // S3 파일 경로

}

 

FileService Interface & S3FileService

FileService Interface

package webChat.service;

import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import webChat.dto.FileUploadDto;

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

public interface FileService {
    // 파일 업로드를 위한 메서드 선언
    FileUploadDto uploadFile(MultipartFile file, String transaction, String roomId);

    // 현재 방에 업로드된 모든 파일 삭제 메서드
    void deleteFileDir(String path);

    // 컨트롤러에서 받아온 multipartFile 을 File 로 변환시켜서 저장하기 위한 메서드
    default File convertMultipartFileToFile(MultipartFile mfile, String tmpPath) throws IOException {
        File file = new File(tmpPath);

        if (file.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(file)) {
                fos.write(mfile.getBytes());
            }
            return file;
        }
        throw new IOException();
    }

    // 파일 삭제
    default void removeFile(File file){
        file.delete();
    }

    ResponseEntity<byte[]> getObject(String fileDir, String fileName) throws IOException;
}

 

S3FileService

- 여기서 중요한 것은 몇 가지 있다.

- ${cloud.aws.s3.bucket} 와 ${cloud.aws.s3.bucket.url} 부분은 application.properties 에서 넣어두었던 내용들이 들어오게 된다.

- ResponseEntity 클래스는 HttpEntity 를 상속받아 구현한 클래스로 사용자의 httpRequest 에 대한 응답 테이터를 포함하는 클래스이다. 따라서 Httpbody 뿐만 아니라 Httpheader 와 httpStatus 까지 넣어 줄 수 있다. 이를 통해서 header 에 따라서 다른 동작을 가능하게 할 수 있다 => 파일 다운로드!!

// 임포트 생략

@Service
@RequiredArgsConstructor
@Slf4j
public class S3FileService implements FileService{

    // AmazonS3 주입받기
    private final AmazonS3 amazonS3;

    // S3 bucket 이름
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    // S3 base URL
    @Value("${cloud.aws.s3.bucket.url}")
    private String baseUrl;

    // MultipartFile 과 transcation, roomId 를 전달받는다.
    // 이때 transcation 는 파일 이름 중복 방지를 위한 UUID 를 의미한다.
    @Override
    public FileUploadDto uploadFile(MultipartFile file, String transaction, String roomId) {
        try{

            String filename = file.getOriginalFilename(); // 파일원본 이름
            String key = roomId+"/"+transaction+"/"+filename; // S3 파일 경로

            // 매개변수로 넘어온 multipartFile 을 File 객체로 변환 시켜서 저장하기 위한 메서드
            File convertedFile = convertMultipartFileToFile(file, transaction + filename);

            // 아마존 S3 에 파일 업로드를 위해 사용하는 TransferManagerBuilder
            TransferManager transferManager = TransferManagerBuilder
                    .standard()
                    .withS3Client(amazonS3)
                    .build();

            // bucket 에 key 와 converedFile 을 이용해서 파일 업로드
            Upload upload = transferManager.upload(bucket, key, convertedFile);
            upload.waitForUploadResult();

            // 변환된 File 객체 삭제
            removeFile(convertedFile);

            // uploadDTO 객체 빌드
            FileUploadDto uploadReq = FileUploadDto.builder()
                    .transaction(transaction)
                    .chatRoom(roomId)
                    .originFileName(filename)
                    .fileDir(key)
                    .s3DataUrl(baseUrl+"/"+key)
                    .build();

            // uploadDTO 객체 리턴
            return uploadReq;

        } catch (Exception e) {
            log.error("fileUploadException {}", e.getMessage());
            return null;
        }
    }

    @Override
    public void deleteFileDir(String path) {
        for (S3ObjectSummary summary : amazonS3.listObjects(bucket, path).getObjectSummaries()) {
            amazonS3.deleteObject(bucket, summary.getKey());
        }
    }

    // byte 배열 타입을 return 한다.

    @Override
    public ResponseEntity<byte[]> getObject(String fileDir, String fileName) throws IOException {
        // bucket 와 fileDir 을 사용해서 S3 에 있는 객체 - object - 를 가져온다.
        S3Object object = amazonS3.getObject(new GetObjectRequest(bucket, fileDir));

        // object 를 S3ObjectInputStream 형태로 변환한다.
        S3ObjectInputStream objectInputStream = object.getObjectContent();

        // 이후 다시 byte 배열 형태로 변환한다.
        // 아마도 파일 다운로드를 위해서는 byte 형태로 변환할 필요가 있어서 그런듯하다
        byte[] bytes = IOUtils.toByteArray(objectInputStream);

        // 여기는 httpHeader 에 파일 다운로드 요청을 하기 위한내용
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);

        // 지정된 fileName 으로 파일이 다운로드 된다.
        httpHeaders.setContentDispositionFormData("attachment", fileName);

        log.info("HttpHeader : [{}]", httpHeaders);

        // 최종적으로 ResponseEntity 객체를 리턴하는데
        // --> ResponseEntity 란?
        // ResponseEntity 는 사용자의 httpRequest 에 대한 응답 테이터를 포함하는 클래스이다.
        // 단순히 body 에 데이터를 포함하는 것이 아니라, header 와 httpStatus 까지 넣어 줄 수 있다.
        // 이를 통해서 header 에 따라서 다른 동작을 가능하게 할 수 있다 => 파일 다운로드!!

        // 나는 object가 변환된 byte 데이터, httpHeader 와 HttpStatus 가 포함된다.
        return new ResponseEntity<>(bytes, httpHeaders, HttpStatus.OK);
    }

}

 

 

FileController

- RestAPI 로 작성한 Controller!! post 요청이 오면 파일을 업로드하고, get 요청이 오면 파일을 다운로드 받을 수 있도록 한다.

package webChat.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import webChat.dto.FileUploadDto;
import webChat.service.S3FileService;

import java.io.IOException;
import java.util.UUID;

@RestController
@RequestMapping("/s3")
@Slf4j
public class FileController {

    @Autowired
    private S3FileService fileService;

    // 프론트에서 ajax 를 통해 /upload 로 MultipartFile 형태로 파일과 roomId 를 전달받는다.
    // 전달받은 file 를 uploadFile 메서드를 통해 업로드한다.
    @PostMapping("/upload")
    public FileUploadDto uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("roomId")String roomId){

        FileUploadDto fileReq = fileService.uploadFile(file, UUID.randomUUID().toString(), roomId);
        log.info("최종 upload Data {}", fileReq);

        // fileReq 객체 리턴
        return fileReq;
    }

    // get 으로 요청이 오면 아래 download 메서드를 실행한다.
    // fileName 과 파라미터로 넘어온 fileDir 을 getObject 메서드에 매개변수로 넣는다.
    @GetMapping("/download/{fileName}")
    public ResponseEntity<byte[]> download(@PathVariable String fileName, @RequestParam("fileDir")String fileDir){
        log.info("fileDir : fileName [{} : {}]", fileDir, fileName);
        try {
            // 변환된 byte, httpHeader 와 HttpStatus 가 포함된 ResponseEntity 객체를 return 한다.
            return fileService.getObject(fileDir, fileName);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

 

HTML

- html 에 대한 내용은 이번에도 패스!! git 참고 부탁드립니다!

 

socket.js

- js 와 관련된 내용은 모두 socket.js 에 몰아서 작성하였습니다. 사실 이렇게 하면 별로 좋은 것 같지는 않고, 따로 작성하는게 맞을 듯 합니다 --> 저도 추후 변경 예정입니다

- 중요한 부분은 3가지!!

  1. 먼저 파일 업로드가 실행되면 /s3/uplaod 로 파일 업로드 요청이 가게된다. 업로드 요청이 성공 - 서버 업로드가 성공 - 하면 서버로부터 data 에 관련 내용을 받고, data 로 넘어온 내용 중 필요한 부분만 chatMessage 에 담아서 다시 서버에 보내게 된다. 
  2. chatMessage 로 넘어온 내용을 클라이언트에 뿌려주는데 이때 chatMessage 안에 s3DataUrl 내용이 null 이 아니라면 채팅에서 파일 업로드가 있는 것으로 간주하고, 채팅창에 파일을 보여주게 된다.
  3. 업로드된 파일 옆에는 다운로드 버튼을 만들어두었고, 다운로드 버튼을 누르면 /s3/download 로 요청을 하게 된다. 이후 서버로부터 responseEntity 객체를 받게 되고 이를 이용해서 파일 다운로드가 진행된다.
// 메시지를 받을 때도 마찬가지로 JSON 타입으로 받으며,
// 넘어온 JSON 형식의 메시지를 parse 해서 사용한다.
function onMessageReceived(payload) {
    //console.log("payload 들어오냐? :"+payload);
    var chat = JSON.parse(payload.body);

    var messageElement = document.createElement('li');

    if (chat.type === 'ENTER') {  // chatType 이 enter 라면 아래 내용
        messageElement.classList.add('event-message');
        chat.content = chat.sender + chat.message;
        getUserList();

    } else if (chat.type === 'LEAVE') { // chatType 가 leave 라면 아래 내용
        messageElement.classList.add('event-message');
        chat.content = chat.sender + chat.message;
        getUserList();

    } else { // chatType 이 talk 라면 아래 내용
        messageElement.classList.add('chat-message');

        var avatarElement = document.createElement('i');
        var avatarText = document.createTextNode(chat.sender[0]);
        avatarElement.appendChild(avatarText);
        avatarElement.style['background-color'] = getAvatarColor(chat.sender);

        messageElement.appendChild(avatarElement);

        var usernameElement = document.createElement('span');
        var usernameText = document.createTextNode(chat.sender);
        usernameElement.appendChild(usernameText);
        messageElement.appendChild(usernameElement);
    }

    var contentElement = document.createElement('p');

    // 만약 s3DataUrl 의 값이 null 이 아니라면 => chat 내용이 파일 업로드와 관련된 내용이라면
    // img 를 채팅에 보여주는 작업
    if(chat.s3DataUrl != null){
        var imgElement = document.createElement('img');
        imgElement.setAttribute("src", chat.s3DataUrl);
        imgElement.setAttribute("width", "300");
        imgElement.setAttribute("height", "300");

        var downBtnElement = document.createElement('button');
        downBtnElement.setAttribute("class", "btn fa fa-download");
        downBtnElement.setAttribute("id", "downBtn");
        downBtnElement.setAttribute("name", chat.fileName);
        downBtnElement.setAttribute("onclick", `downloadFile('${chat.fileName}', '${chat.fileDir}')`);


        contentElement.appendChild(imgElement);
        contentElement.appendChild(downBtnElement);

    }else{
        // 만약 s3DataUrl 의 값이 null 이라면
        // 이전에 넘어온 채팅 내용 보여주기기
       var messageText = document.createTextNode(chat.message);
        contentElement.appendChild(messageText);
    }

    messageElement.appendChild(contentElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}


function getAvatarColor(messageSender) {
    var hash = 0;
    for (var i = 0; i < messageSender.length; i++) {
        hash = 31 * hash + messageSender.charCodeAt(i);
    }

    var index = Math.abs(hash % colors.length);
    return colors[index];
}

usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)

/// 파일 업로드 부분 ////
function uploadFile(){
    var file = $("#file")[0].files[0];
    var formData = new FormData();
    formData.append("file",file);
    formData.append("roomId", roomId);

    // ajax 로 multipart/form-data 를 넘겨줄 때는
    //         processData: false,
    //         contentType: false
    // 처럼 설정해주어야 한다.

    // 동작 순서
    // post 로 rest 요청한다.
    // 1. 먼저 upload 로 파일 업로드를 요청한다.
    // 2. upload 가 성공적으로 완료되면 data 에 upload 객체를 받고,
    // 이를 이용해 chatMessage 를 작성한다.
    $.ajax({
        type : 'POST',
        url : '/s3/upload',
        data : formData,
        processData: false,
        contentType: false
    }).done(function (data){
        // console.log("업로드 성공")

        var chatMessage = {
            "roomId": roomId,
            sender: username,
            message: username+"님의 파일 업로드",
            type: 'TALK',
            s3DataUrl : data.s3DataUrl, // Dataurl
            "fileName": file.name, // 원본 파일 이름
            "fileDir": data.fileDir // 업로드 된 위치
        };

        // 해당 내용을 발신한다.
        stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
    }).fail(function (error){
        alert(error);
    })
}

// 파일 다운로드 부분 //
// 버튼을 누르면 downloadFile 메서드가 실행됨
// 다운로드 url 은 /s3/download+원본파일이름
function downloadFile(name, dir){
    // console.log("파일 이름 : "+name);
    // console.log("파일 경로 : " + dir);
    let url = "/s3/download/"+name;

    // get 으로 rest 요청한다.
    $.ajax({
        url: "/s3/download/"+name, // 요청 url 은 download/{name}
        data: {
            "fileDir" : dir // 파일의 경로를 파라미터로 넣는다.
        },
        dataType: 'binary', // 파일 다운로드를 위해서는 binary 타입으로 받아야한다.
        xhrFields: {
            'responseType': 'blob' // 여기도 마찬가지
        },
        success: function(data) {

            var link = document.createElement('a');
            link.href = URL.createObjectURL(data);
            link.download = name;
            link.click();
        }
    });
}

 

코드 구현 확인

- s3 bucket 확인 : 현재에는 임시로 올려둔 cat.jpg 밖에 보이지 않는다.

 

방을 생성한 후에 채팅 시작!!

 

요렇게 업로드하면...!!

 

 

채팅방을 기준으로 2개의 파일이 올라간 것을 확인할 수 있다.

cat4.jpeg 업로드 확인!!

 

- Reference

Special Thanks To 스터디조원님

https://velog.io/@prm1247

 

prm1247 (JE) - velog

AWS S3 버킷 만들기 및 권한 부여 AWS의 IAM페이지에 접근 후 엑세스관리 -> 사용자 -> 사용자 추가를 선택한다.사용자 이름과 AWS 자격 증명 유형 - 액세스 키를 선택한 후 기존 정책 직접 연결의 Amazon

velog.io

 

https://a1010100z.tistory.com/174

 

[AWS] Amazon Web Service 왕초보 탈출하기 - [1] Region, AZ, VPC, Subnet 편

※ 이 포스팅은 AWS 왕초보 (자바봄 멤바들) 를 위해 작성되었습니다. 나는 VPC를 모른다, EC2 만들때 VPC, Subnet이고뭐고 신경쓴적이없다. 아이피대역 그런거 신경써본적없다. 보안그룹만 막아놓으

a1010100z.tistory.com

 

https://willseungh0.tistory.com/2

 

Spring boot를 이용한 AWS S3에 파일 업로드하기 (2021.08.29 업데이트)

서버를 개발하시면 프로필 사진 업로드 등 파일을 업로드해야 하는 상황이 발생합니다. 간단한 샘플 코드로 AWS Cloud Starter AWS을 이용해서 AWS S3 버킷에 파일을 업로드하는 샘플 예제입니다. 준

willseungh0.tistory.com

https://loosie.tistory.com/217#4._S3_%EB%B2%84%ED%82%B7%EC%97%90_%EC%9E%88%EB%8A%94_%ED%8C%8C%EC%9D%BC_%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0

 

[Spring/ AWS] Spring Boot S3를 이용하여 File 다운로드하기

1. build.gradle에 의존성 추가하기 Spring과 AWS를 연동시키기 위해 spring-cloud-starter-aws을 사용한다. dependencies { // aws s3 implementation group: 'org.springframework.cloud', name: 'spring-cloud-..

loosie.tistory.com

https://a1010100z.tistory.com/174

 

[AWS] Amazon Web Service 왕초보 탈출하기 - [1] Region, AZ, VPC, Subnet 편

※ 이 포스팅은 AWS 왕초보 (자바봄 멤바들) 를 위해 작성되었습니다. 나는 VPC를 모른다, EC2 만들때 VPC, Subnet이고뭐고 신경쓴적이없다. 아이피대역 그런거 신경써본적없다. 보안그룹만 막아놓으

a1010100z.tistory.com

https://a1010100z.tistory.com/174

 

[AWS] Amazon Web Service 왕초보 탈출하기 - [1] Region, AZ, VPC, Subnet 편

※ 이 포스팅은 AWS 왕초보 (자바봄 멤바들) 를 위해 작성되었습니다. 나는 VPC를 모른다, EC2 만들때 VPC, Subnet이고뭐고 신경쓴적이없다. 아이피대역 그런거 신경써본적없다. 보안그룹만 막아놓으

a1010100z.tistory.com

 

댓글