반응형

같은 방에 참여하고있는 브라우저 채팅방에서 일대다 파일 전송을 해보겠습니다.

절차는 다음과 같습니다.

1. 브라우저에서 spring boot -> s3로 파일 업로드

2. 파일 업로드가 성공적으로 마치면, 열려있는 웹소켓(채팅 통로) 등으로 다른 사용자들에게 업로드된 URL을 알려줌

먼저, 스프링부트에서 aws s3로 파일을 업로드하기 위해 aws 계정을 만들어서 버킷을 생성해줍니다.

그리고 외부에서 접근할 수 있게, 버킷을 선택한 뒤, 권한 -> 버킷 정책 메뉴로 이동해서 정책을 미리 편집해두겠습니다.

AWS S3 설정

정책 생성기를 클릭합니다.

우리는 브라우저에서 파일을 다운로드하고, spring boot를 이용한 rest api 서버를 통해서 파일을 업로드 및 삭제할 것입니다.

AWS S3 설정

먼저 프론트에서 파일을 다운로드하는 statement를 작성해주겠습니다.

위처럼 입력하고, Actions는 GetObject를 선택합니다.

add Conditions를 눌러서, 위처럼 설정하고 Add condition을 눌러서 조건을 추가해줍니다.

referer는 링크 이전에 머물렀던 페이지의 주소를 의미합니다.

파일 업로드 및 삭제는 api 서버에서 발급받은 키로 작업할 수 있으니,

읽기권한을 자신의 도메인에서 넘어온 경우에만 다운로드할 수 있도록 지정해줍니다.

그 후 Add statement를 누르고, Generate Policy를 눌러서 나오는 JSON를 복사해줍니다.

이전에 열었던 버킷 정책에 기입해주면 됩니다.

AWS S3 설정

그리고 버킷의 권한 설정으로 들어가서 제일 아래에 있는 차단을 해제해주었습니다.

다음으로, 프로그래밍을 통해 S3에 접근하기위해 IAM 을 발급받겠습니다.

AWS S3 설정

AWS의 IAM 메뉴로 이동합니다.

AWS S3 설정

좌측 메뉴에서 액세스 관리->사용자로 이동하여 사용자 추가를 눌러줍니다.

AWS S3 설정

이름을 입력하고, 액세스 유형에서 프로그래밍 방식 엑세스를 클릭하고 이동합니다.

AWS S3 설정

기존 정책 직접 연결을 선택하고, AmazonS3FullAccess를 선택해줍니다.

그리고 다음, 다음으로 이동하여 사용자를 만들면 accessKey와 secretKey를 발급해줍니다.

이 키는 본인만 간직해야됩니다. 설정파일에 등록해주겠습니다.

# application-credential.yml

cloud:
  aws:
    s3:
      bucket: bucketname
      baseUrl: s3버킷주소
    region:
      static: ap-northeast-2
    stack:
      auto: false
    credentials:
      accessKey:
      secretKey:
      instance-profile: false

key값이 들어있는 파일은 꼭 .gitignore에 추가해주셔야합니다.

cloud.aws.stack.auto=false는 스프링클라우드 프로젝트를 실행시키면 자동으로 클라우드 관련 구성하는 것을 해제해줍니다.

instance-profile은 aws ec2에서 인스턴스에 등록한 값들을 사용하는 듯한데, 저는 GCP를 이용 중이라 false로 꺼주었습니다.

 

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

aws dependency도 등록해줍니다.

이제 파일 업로드 관련 일을 처리해주는 FileService를 작성하겠습니다.

public interface FileService {

    FileUploadResponseDto uploadFile(FileUploadRequestDto uploadDto);

    void deleteFilesInDirectory(String path);

    default File convertMultipartFileToFile(MultipartFile mFile, String tempPath) throws IOException {
        File file = new File(tempPath);
        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();
    }
}

간단하게 uploadFile 메소드와

각 채팅방(디렉토리)에 생성된 모든 파일들을 제거하는 deleteFilesInDirectory 메소드만 정의하겠습니다.

(s3의 각 버킷은 기본적으로 디렉토리 개념이 없고 플랫구조입니다. 그냥 각 방 별 파일 데이터를 분할한 것입니다.)

필요하신 부분은 추가로 정의하시면 됩니다.

인터페이스에 default로 정의해둔 메소드 두 개는,

컨트롤러단에서 받아온 MultipartFile을 File로 변환시켜서 저장해두고,

그 File을 삭제해주기 위한 메소드입니다.

(default method는 예전에 처음 접하고 학습용으로 사용했던건데, 굳이 그대로 하실 필요는 없습니다.)

파일 업로드 DTO는 다음과 같습니다.

@AllArgsConstructor
@NoArgsConstructor
@Data
public class FileUploadRequestDto {
    @NotNull
    private MultipartFile file;

    @NotBlank
    private String transaction;

    @NotBlank
    private String room;
}

요청에 사용되는 dto 클래스입니다.

transaction은 각 방에 동일한 파일명이 여러 개 있을 수도 있으므로 추가적으로 정의해준 랜덤 스트링값입니다.

중복이 없는 범위 내에서 편한대로 지정하시면 될 듯합니다. room은 각 채팅방을 의미합니다.

 

@AllArgsConstructor
@NoArgsConstructor
@Data
public class FileUploadResponseDto {
    private String dataUrl;
}

응답에 사용되는 dto 클래스입니다.

저장된 파일의 URL을 반환해줄 것입니다.

 

아까 정의해둔 FileService의 구현체인 S3FileService는 다음과 같습니다.

@RequiredArgsConstructor
@Service
public class S3FileService implements FileService {

    private final AmazonS3 amazonS3;

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

    @Value("${cloud.aws.s3.baseUrl}")
    private String baseUrl;

    @Override
    public FileUploadResponseDto uploadFile(FileUploadRequestDto requestDto) {
        try {
            MultipartFile file = requestDto.getFile();
            String transaction = requestDto.getTransaction();
            String filename = requestDto.getFile().getOriginalFilename();
            String key = requestDto.getRoom() + "/" + transaction + "/" + filename;
            File convertedFile = convertMultipartFileToFile(file, transaction + filename);
            TransferManager transferManager = TransferManagerBuilder
                    .standard()
                    .withS3Client(amazonS3)
                    .build();
            Upload upload = transferManager.upload(bucket, key, convertedFile);
            upload.waitForUploadResult();
            removeFile(convertedFile);
            return new FileUploadResponseDto(baseUrl + key);
        } catch(Exception e) {
            throw new FileUploadFailureException();
        }
    }

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

AmazonS3의 구현체를 주입받아서 TransferManasger를 빌드해줍니다.

upload의 arguments는 transferManager.upload(버킷명, 파일명, File)입니다.

이 메소드의 리턴 값으로 Upload 오브젝트를 반환해주는데, 기본적으로 upload메소드는 비동기적으로 일어나기때문에,

Upload객체의 waitForUploadResult();를 호출해주면, 업로드가 마칠 때까지 기다리게 됩니다.

업로드가 끝나면 File을 삭제해줍니다.

key는 각 방을 접두사 디렉토리로 하여 파일명을 만들어주었습니다.

deleteFilesInDirectory에서는 해당 버킷에 path를 접두사로 갖는 모든 파일을 삭제합니다.

예를 들어, ["1234/abc/test.txt", "1234/cde/test2.txt"]와 같은 파일이 있고,

매개변수 path == "1234"라면 위 두개의 파일을 삭제합니다.

 

 

 

@RestController
@RequiredArgsConstructor
public class FileController {

    private final ResponseService responseService;
    private final FileService fileService;

    @PostMapping(value = "/files")
    public Result uploadFile(@Valid @ModelAttribute FileUploadRequestDto requestDto) {
        return responseService.getSingleResult(fileService.uploadFile(requestDto));
    }

    @DeleteMapping(value = "/files/{filename}")
    public Result deleteFile(@PathVariable("filename") String filename) {
        fileService.deleteFilesInDirectory(filename);
        return responseService.getSuccessResult();
    }
}

컨트롤러는 다음과 같습니다. @ModelAttribute로 multipart/form-data를 받아옵니다.

이제 리액트로 프론트단 코드를 작성해보겠습니다.

리액트 컴포넌트 이미지

 

먼저, 다음과 같이 파일 송수신을 위한 간단한 다이얼로그 폼을 만들어주었습니다.

material-ui를 이용하였는데, 이 부분은 편하게 작성하시면 되고, 파일 업로드 요청에 관한 부분을 보겠습니다.

저는 redux를 사용하고, redux-saga를 이용해서 비동기 요청을 하였습니다.

 

import produce from "immer";

export const initialState = {
  ...,
  receiveFiles: [ // 수신한 파일
    /*
      {
        display: '쿠케캬캬', // 송신자
        dataUrl: 'dataUrl', // s3 파일 경로
        filename: 'filename', // 파일명
      }
    */
  ],
  sendFiles: [ // 수신한 파일
    /*
    {
      dataUrl : 'dataUrl', // s3 파일 경로
      filename: 'filename', // 파일명
      loading: false, // 업로드 중
      done : false, // 다운 완료
      transaction: '' // 트랜잭션
    }
    */
  ],
};

export const SEND_FILE_REQUEST = "SEND_FILE_REQUEST";
export const SEND_FILE_SUCCESS = "SEND_FILE_SUCCESS";
export const SEND_FILE_FAILURE = "SEND_FILE_FAILURE";

export const ADD_RECEIVE_FILE = "ADD_RECEIVE_FILE";


... // switch문
      case SEND_FILE_REQUEST:
        draft.sendFiles.push({
          loading: true,
          filename: action.payload.file.name,
          dataUrl: "",
          transaction: action.payload.transaction,
          done: false,
        });
        break;
      case SEND_FILE_SUCCESS: {
        const sendFile = draft.sendFiles.find(
          (f) => f.transaction === action.payload.transaction
        );
        sendFile.loading = false;
        sendFile.dataUrl = action.payload.dataUrl;
        sendFile.done = true;
        break;
      }
      case SEND_FILE_FAILURE: {
        const sendFile = draft.sendFiles.find(
          (f) => f.transaction === action.payload.transaction
        );
        sendFile.loading = false;
        break;
      }
      case ADD_RECEIVE_FILE:
        draft.receiveFiles.push({
          display: action.payload.display,
          filename: action.payload.filename,
          dataUrl: action.payload.dataUrl,
        });
        break;

기본적인 상태와 액션의 정의는 다음과 같습니다.

파일 전송을 요청하면, loading: true로 새로운 요소를 push해줍니다.

파일 전송이 완료되면, loading: false, done: true로 바꾸고 서버에서 응답받은 dataUrl을 넣어줍니다.

파일 전송이 끝나면, 클라이언트 간에 열려있는 웹 소켓 등의 방식을 이용해서 업로드된 파일의 dataUrl을 다른 사용자들에게 전송해줍니다.

그러면 ADD_RECEIVE_FILE 액션이 발행되어 수신한 파일이 등록됩니다.

서버와의 요청을 처리하는 redux-saga 부분입니다.

function uploadFileAPI(file, accessToken, room, transaction) {
  const formData = new FormData();
  formData.append("file", file);
  formData.append("transaction", transaction);
  formData.append("room", room);
  return axios.post("/api/files", formData, {
    headers: {
      Authorization: accessToken,
      "Content-Type": "multipart/form-data",
    },
  });
}

async function letOtherPeersKnowUploadFile(nickname, dataUrl, filename) {
  ... // 클라이언트 간의 웹소켓으로 업로드된 파일 정보를 전송해줌
}

function* sendFile(action) {
  try {
    const { file, transaction } = action.payload;
    const { room } = yield select((state) => state.videoroom);
    const { nickname, accessToken } = yield select((state) => state.user);
    const result = yield uploadFileAPI(file, accessToken, room, transaction);
    const { dataUrl } = result.data.data;
    yield letOtherPeersKnowUploadFile(nickname, dataUrl, file.name);
    yield put(sendFileSuccess({ transaction, dataUrl }));
  } catch (err) {
    yield put(
      sendFileFailure(...)
    );
  }
}

function* watchSendFile() {
  yield takeEvery(SEND_FILE_REQUEST, sendFile);
}

export default function* videoroomSaga() {
  yield all([
    ...,
    fork(watchSendFile),
  ]);
}

 

SEND_FILE_REQUEST 액션이 발행되면, sendFile 함수를 실행합니다.

먼저 uploadFileAPI를 호출해서, s3에 파일을 업로드해줍니다.

폼 데이터는 new FormData()로 객체를 생성하고 위 양식처럼 담아주면 됩니다.

파일을 업로드하기 위해서 multipart/form-data 헤더를 추가해주어야합니다.

파일 업로드가 성공적으로 되면, letOtherPeersKnowUploadFile 라는 함수가 호출되는데,

다른 사용자들에게 업로드된 파일의 url, 송신자 등을 전송해준 뒤, 업로드 요청을 마칩니다.

맨 처음 파일을 전송을 수행하는 아래의 컴포넌트는 다음과 같습니다.

리액트 컴포넌트 이미지

import React, { useCallback, useState } from "react";
import { TextField, IconButton } from "@material-ui/core";
import AttachFileIcon from "@material-ui/icons/AttachFile";
import SendIcon from "@material-ui/icons/Send";
import { sendFileRequest } from "../reducers/videoroom";
import { useSelector, useDispatch } from "react-redux";
import { generateRandomString } from "../utils/utils";

const FileTransferForm = () => {
  const dispatch = useDispatch();
  const [selectedFile, setSelectedFile] = useState(null);

  const onClickFileTransfer = useCallback(() => {
    if (!selectedFile) return;
    dispatch(
      sendFileRequest({
        file: selectedFile,
        transaction: generateRandomString(12),
      })
    );
  }, [selectedFile]);

  const onChangeFile = useCallback((e) => {
    setSelectedFile(e.target.files[0]);
  }, []);

  return (
    <div>
      <input
        type="file"
        onChange={onChangeFile}
        hidden
        id="transfer-file-upload"
      />
      <label htmlFor="transfer-file-upload">
        <IconButton component="span">
          <AttachFileIcon />
        </IconButton>
      </label>
      <TextField
        InputProps={{
          readOnly: true,
        }}
        variant="outlined"
        value={selectedFile ? selectedFile.name : "파일을 선택해주세요"}
      />
      <IconButton onClick={onClickFileTransfer}>
        <SendIcon />
      </IconButton>
    </div>
  );
};

export default FileTransferForm;

"file" 타입의 input태그에 onChange를 정의해서 선택된 파일을 추적할 수 있습니다.

generateRandomString은 그냥 랜덤한 스트링을 만드는 함수를 정의한 것입니다.

중복을 고려해서 만든거라 편하게 만드시면 될 것 같습니다. (서버단에서 처리해주는게 더 좋을 듯 합니다.)

 

 

이제 파일 송신 테스트를 해보겠습니다.

송신자가 파일을 송신하면, 방 인원들은 파일을 수신할 수 있어야합니다.

리액트 컴포넌트 이미지

 

송신을 해보면, 수신함에 잘 담겨있습니다.

 

송신함 컴포넌트의 각 ListItem 컴포넌트는 다음과 같은 형태입니다.

import React, { memo } from "react";
import {
  ListItem,
  ListItemText,
  ListItemSecondaryAction,
  IconButton,
  CircularProgress,
} from "@material-ui/core";
import SaveIcon from "@material-ui/icons/Save";
import BlockIcon from "@material-ui/icons/Block";

const SendFileItem = ({ file }) => {
  const { loading, done, dataUrl, filename } = file;
  return (
    <ListItem variant="contained" color="primary">
      <ListItemText primary={filename} />
      <ListItemSecondaryAction>
        {loading ? (
          <CircularProgress />
        ) : done ? (
          <IconButton
            component="a"
            href={dataUrl}
            download={filename}
            target="_blank"
          >
            <SaveIcon />
          </IconButton>
        ) : (
          <IconButton>
            <BlockIcon />
          </IconButton>
        )}
      </ListItemSecondaryAction>
    </ListItem>
  );
};

export default memo(
  SendFileItem,
  (prevProps, nextProps) => prevProps.file === nextProps.file
);

<a href={dataUrl} download={filename} target="_blank">다운</a> 와 같은 형태로 작성하시면 됩니다.

저 같은 경우, 빈 방은 자동으로 서버에서 삭제하기 때문에, 그 때 각 방에 저장된 파일도 서버에서 같이 삭제하고 있습니다.

파일 접근 권한에 대한 s3 bucket 정책이 미숙할 수도 있는데, 그 부분은 별도로 설정하여 보완해야할 것입니다.

referer로 최소한의 보안 설정만 되어있기 때문에, 파일에 접근할 수 있는 url만 있다면 누구든 접속할 수 있기 때문입니다. 이 부분은 마땅한 해결책이 필요할 듯 싶습니다.

+

로컬에서 잘 업로드되던 파일이 배포환경에서 잘 안되었는데 nginx 문제였습니다.

server {
        ...
        client_max_body_size 300M;
       ...
}

nginx에서 default 1MB 값으로 파일 업로드를 제한하고 있었습니다.

client_max_body_size 값을 조절하니 해결되었습니다.

 

 

* 개인 프로젝트를 진행하며 작성했던 내용이라, 부족한 점이 있을 수 있습니다.

반응형

+ Recent posts