10.29 추가 : 일반(문자) 채팅만 구현하는 코드는 git 의 master 브렌치를 참고해주시기 바랍니다. master-Webrtc-jpa 는 화상 채팅과 jpa 를 이용한 DB 연결을 포함하는 브렌치입니다.
- 최근 스터디에서 파일 업로드와 관련된 프로젝트를 하자고 이야기가 나왔고, 이에 만들어두었던 채팅에 이어서 만들어보고 싶어서 부랴부랴 AWS S3 와 연동해서 S3 기반 파일 업로드를 구현하였다.
- 원래는 S3의 bucket 만들기부터 시작해서 ACL 설정 등등 만져줄게 많은데 이것들은 담에 설명하기로 하고, 이번에는 코드만 정리하고 넘어가려고 한다.
=> 혹시나 필요하신 분들을 위해서 이 부분은 아래에 참고 사이트를 넣어두었으니 확인 부탁드립니다!
- 아래의 추가된 모든 내용은 해당 git 에서 확인할 수 있습니다!
https://github.com/SeJonJ/Spring-WebSocket-Chatting
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가지!!
- 먼저 파일 업로드가 실행되면 /s3/uplaod 로 파일 업로드 요청이 가게된다. 업로드 요청이 성공 - 서버 업로드가 성공 - 하면 서버로부터 data 에 관련 내용을 받고, data 로 넘어온 내용 중 필요한 부분만 chatMessage 에 담아서 다시 서버에 보내게 된다.
- chatMessage 로 넘어온 내용을 클라이언트에 뿌려주는데 이때 chatMessage 안에 s3DataUrl 내용이 null 이 아니라면 채팅에서 파일 업로드가 있는 것으로 간주하고, 채팅창에 파일을 보여주게 된다.
- 업로드된 파일 옆에는 다운로드 버튼을 만들어두었고, 다운로드 버튼을 누르면 /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개의 파일이 올라간 것을 확인할 수 있다.
- Reference
Special Thanks To 스터디조원님
https://a1010100z.tistory.com/174
https://willseungh0.tistory.com/2
https://a1010100z.tistory.com/174
https://a1010100z.tistory.com/174
댓글