ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 채팅방에서 사용자가 업로드한 파일을 NHN Cloud의 Object Storage를 통해 관리하기
    SpringBoot 2024. 2. 7. 01:53

    파일 업로드, 삭제, 다운로드 코드 작성

    아래 NHN Cloud API 가이드에서 목차를 보면,

    오브젝트 부분에 업로드 / 다운로드 / 복사 / 삭제등에 대한 작성법이 자세히 나와있다. 

     

    API 가이드 - NHN Cloud 사용자 가이드

    Storage > Object Storage > API 가이드 사전 준비 오브젝트 스토리지 API를 사용하려면 먼저 인증 토큰(token)을 발급 받아야 합니다. 인증 토큰은 오브젝트 스토리지의 REST API를 사용할 때 필요한 인증 키

    docs.nhncloud.com

     

    가이드에서는 RestTemplate을 사용하여 설명해주고 있는데

    나의 경우 아래 업로드와 삭제 부분 코드를 보다시피 해당 부분에 대해서는 WebClient 방식으로 작성하였다.  

    다만 다운로드 부분은 작성하는데 어려움을 겪어 RestTemplate 방식으로 작성하였다.

    (나중에 리펙토링 해야지.....ㅠ) ---> 2024.02.09 아래에 WebClient 방식 추가

     

    WebClient란?

    WebClient는 Spring 5에서부터 등장한 HTTP 클라이언트 라이브러리이다.

    WebClient가 등장하기 이전까지는 Spring에서 HTTP 클라이언트로 RestTemplate를 자주 사용하였다.

    RestTemplate과 비교했을 때, webClient가 가지는 장점

    비동기 처리 (Asynchronous Processing)

    webClient는 비동기적으로 요청하는 non-blocking 방식으로, 

    RestTemplate은 여러 요청이 동시에 처리되지 않는 동기 방식이다.

     

    리액티브 프로그래밍 지원

    WebClient는 리액티브 스트림을 지원하여 더 효율적인 리소스 관리 및 더 높은 처리량을 제공할 수 있다.

    이 점은 대량의 요청을 처리해야 하는 상황에서 유용하다.

     

     

     

    ObjectStorageService.java

    @Service
    @RequiredArgsConstructor
    public class ObjectStorageService {
    
        private final WebClient webClient;
        private final ObjectUploadContext uploadContext;
    
        /**
         * ~/chatroom/roomId/image/objectName 경로에 파일이 있는지 확인합니다.
         */
        public boolean isInChatImagePath(String roomId, String objectName) {
            try {
                webClient.get()
                        .uri(uploadContext.getChatImageUrl(roomId, objectName))
                        .retrieve()
                        .bodyToMono(byte[].class)
                        .block();
            } catch (WebClientResponseException e) {
                if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
                    return false;
                }
            }
            return true;
        }
    
        /**
         * ~/chatroom/roomId/file/objectName 경로에 파일이 있는지 확인합니다.
         */
        public boolean isInChatFilePath(String roomId, String objectName) {
            try {
                webClient.get()
                        .uri(uploadContext.getChatFileUrl(roomId, objectName))
                        .retrieve()
                        .bodyToMono(byte[].class)
                        .block();
            } catch (WebClientResponseException e) {
                if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
                    return false;
                }
            }
            return true;
        }
    
        /**
         * ~/chatroom/roomId/image/objectName 경로에 파일을 업로드합니다.
         */
        public void uploadChatImage(String tokenId, String roomId, String objectName, final InputStream inputStream, @Nullable MediaType contentType) {
            try {
                WebClient.RequestBodySpec spec = webClient.put()
                        .uri(uploadContext.getChatImageUrl(roomId, objectName))
                        .header("X-Auth-Token", tokenId);
    
                if (contentType != null) {
                    spec = spec.header("Content-Type", contentType.toString());
                }
    
                spec.body(BodyInserters.fromResource(new InputStreamResource(inputStream)))
                        .retrieve()
                        .bodyToMono(Void.class)
                        .block();
            } catch (Throwable e) {
                throw new InvalidAccessObjectStorageException(e);
            }
        }
    
        /**
         * ~/chatroom/roomId/file/objectName 경로에 파일을 업로드합니다.
         */
        public void uploadChatFile(String tokenId, String roomId, String objectName, final InputStream inputStream, @Nullable MediaType contentType) {
            try {
                WebClient.RequestBodySpec spec = webClient.put()
                        .uri(uploadContext.getChatFileUrl(roomId, objectName))
                        .header("X-Auth-Token", tokenId);
    
                if (contentType != null) {
                    spec = spec.header("Content-Type", contentType.toString());
                }
    
                spec.body(BodyInserters.fromResource(new InputStreamResource(inputStream)))
                        .retrieve()
                        .bodyToMono(Void.class)
                        .block();
            } catch (Throwable e) {
                throw new InvalidAccessObjectStorageException(e);
            }
        }
    
        /**
         * 특정 채팅방에 존재하는 특정 파일을 삭제합니다.
         */
        public void deleteChatFileByDirectUrl(String tokenId, String fileUrl) {
            try {
                webClient.delete()
                        .uri(fileUrl)
                        .header("X-Auth-Token", tokenId)
                        .retrieve()
                        .bodyToMono(Void.class)
                        .block();
            } catch (Throwable e) {
                throw new InvalidAccessObjectStorageException(e);
            }
        }
    }

     

     

    ObjectUploadContext.java

    @Component
    @RequiredArgsConstructor
    public class ObjectUploadContext {
    
    	...
        
        @Value("${nhn.os.api-chat-image-path}")
        private final String apiChatImagePath;
    
        @Value("${nhn.os.api-chat-file-path}")
        private final String apiChatFilePath;
    
        ...
    
        public String getChatImageUrl(String roomId, String objectName) { return String.format(apiChatImagePath, roomId, objectName); }
        public String getChatFileUrl(String roomId, String objectName) { return String.format(apiChatFilePath, roomId, objectName); }
    
        public String makeImageId(String prefix, String extension) {
            return makeObjName(prefix, UUID.randomUUID() + "." + extension);
        }
    
        public String makeFileId(String prefix, String extension) {
            return makeObjName(prefix, UUID.randomUUID() + "." + extension);
        }
    }

     

     

    ChatFileUploadService.java

    @Service
    @RequiredArgsConstructor
    public class ChatFileUploadService {
    
        private final NHNAuthService nhnAuthService;
        private final ObjectStorageService s3service;
        private final ObjectUploadContext uploadContext;
    
        public Context newContext() {
            String token = nhnAuthService.requestToken();
            return new Context(token);
        }
    
        public class Context {
            private final String token;
    
            private Context(String token) { this.token = token; }
    
            public ArrayList<ChatUploadedFile> uploadChatFiles(List<FileRequest> files, String roomId, Long userId) {
                ArrayList<ChatUploadedFile> chatFiles = new ArrayList<>();
                for (FileRequest req : files) {
                    chatFiles.add(uploadChatFile(req, roomId, userId));
                }
                return chatFiles;
            }
    
            public ChatUploadedFile uploadChatFile(FileRequest file, String roomId, Long userId) {
                String originName = file.getOriginalFilename();
                if (originName == null) originName = "";
    
                String prefix = originName.substring(0, originName.lastIndexOf("."));
                String ext = originName.substring(originName.lastIndexOf(".") + 1);
                String fileId;
                do {
                    fileId = uploadContext.makeFileId(prefix, ext);
                } while (s3service.isInChatFilePath(roomId, fileId));
                return upload(file, roomId, userId, fileId);
            }
    
            private ChatUploadedFile upload(FileRequest file, String roomId, Long userId, String objectName) {
                try {
                    s3service.uploadChatFile(token, roomId, objectName, file.getInputStream(), file.getContentType());
                    String chatFileUrl = uploadContext.getChatFileUrl(roomId, objectName);
    
                    return new ChatUploadedFile(roomId, userId, chatFileUrl, file);
                } catch (Throwable e) {
                    throw new InvalidAccessObjectStorageException(e);
                }
            }
    
            public void deleteChatFile(String fileUrl) {
                s3service.deleteChatFileByDirectUrl(token, fileUrl);
            }
        }
    }

     

     

    파일 다운로드 (RestTemplate 방식)

    ObjectDownloadService.java

    @Service
    @RequiredArgsConstructor
    public class ObjectDownloadService {
    
        private final NHNAuthService nhnAuthService;
    
        public ResponseEntity<byte[]> downloadObject(String fileName, String fileUrl) {
    
            // RestTemplate 생성
            RestTemplate restTemplate = new RestTemplate();
    
            // 헤더 생성
            HttpHeaders headers = new HttpHeaders();
            headers.add("X-Auth-Token", nhnAuthService.requestToken());
            headers.setAccept(List.of(MediaType.APPLICATION_OCTET_STREAM));
            headers.setContentDispositionFormData("attachment", fileName);
    
            HttpEntity<String> requestHttpEntity = new HttpEntity<String>(null, headers);
            
            // API 호출, 데이터를 바이트 배열로 받음
            ResponseEntity<byte[]> response = restTemplate.exchange(fileUrl, HttpMethod.GET, requestHttpEntity, byte[].class);
    
            return response;
        }
        
    }

     

    파일 다운로드 (WebClient 방식)

    ObjectDownloadService.java

    @Service
    @RequiredArgsConstructor
    public class ObjectDownloadService {
    
        private final WebClient webClient;
        private final NHNAuthService nhnAuthService;
    
        public ResponseEntity<byte[]> downloadObject(String fileName, String fileUrl) {
            // 헤더 생성
            HttpHeaders headers = new HttpHeaders();
            headers.add("X-Auth-Token", nhnAuthService.requestToken());
            headers.setAccept(List.of(MediaType.APPLICATION_OCTET_STREAM));
            // 파일 이름을 UTF-8로 인코딩하여 Content-Disposition 헤더에 추가
            String encodedFileName = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
            headers.setContentDispositionFormData("attachment", encodedFileName);
    
            return webClient.get()
                    .uri(fileUrl)
                    .headers(httpHeaders -> httpHeaders.addAll(headers))
                    .exchangeToMono(response -> response.toEntity(byte[].class))
                    .block();
        }
        
    }

     

     

    WebClient 방식의 경우 파일을 다운할 때, in-memory buffer의 크기가 256KB를 초과하면

    아래와 같은 오류가 발생하므로 제한 크기(maxInMemorySize)를 설정해줘야한다.

    org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144

     

     

    WebClientConfig.java

    @Configuration
    public class WebClientConfig {
    
        @Bean
        @Primary
        public WebClient plainWebClient() {
        
            ...
        
            return WebClient.builder()
                    .clientConnector(new ReactorClientHttpConnector(client))
                    .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
                    .build();
        }
    
       	...
    }

     

     

     

     

    이제 위 코드들을 가지고 상황에 맞게 서비스 로직과 컨트롤러를 추가해주면 된다!

     

     

     

    Message Broker에 담기는 Message 객체 수정

    채팅방이기 때문에 Publisher로부터 전달받은 메시지를 Subscriber로 전달해주는 중간 역할인

    Message Broker가 적재하는 Message 객체도 수정해주어야 한다.

    fileName, fileUrl, fileType 3가지를 더 추가해주었다.

     

     

    Message.java

    @Getter
    @NoArgsConstructor
    @ToString
    public class Message {
    
        @NotNull
        private MessageType type;
    
    	@NotNull
        private String roomId;
    
        @NotNull
        private String sender;
    
        @NotNull
        private String message;
    
        @NotNull
        private LocalDateTime messageTime;
    
        private String fileName;
    
        private String fileUrl;
    
        private FileType fileType;
    
        @Builder
        private Message(MessageType type,
                        String roomId,
                        String sender,
                        String message,
                        LocalDateTime messageTime,
                        String fileName,
                        String fileUrl,
                        FileType fileType) {
            this.type = type;
            this.roomId = roomId;
            this.sender = sender;
            this.message = message;
            this.messageTime = messageTime;
            this.fileName = fileName;
            this.fileUrl = fileUrl;
            this.fileType = fileType;
        }
    }

     

     

     

    DynamoDB 테이블에 업로드한 파일과 관련된 필드 추가

    현재 사용자들이 채팅방을 나갔다 다시 들어와도 이전에 나눈 대화들이 소멸되지 않도록

    NoSQL인 DynamoDB를 사용하여 채팅 메시지들을 저장하고 있다.

     

     

     

    여기에도 이미지/파일 업로드 기능이 추가되면서 3가지 정도의 필드를 추가해주었다.

    1. 원본 이미지/파일 이름 (fileName)

    2. 이미지/파일 url (fileUrl)

    3. 파일 타입 (fileType)

     

     

    ChatRoomMessage.java

    @DynamoDBTable(tableName = "ChatRoomMessage")
    @Getter
    @Setter
    @NoArgsConstructor()
    public class ChatRoomMessage {
    
        @Id
        @Getter(AccessLevel.NONE)
        @Setter(AccessLevel.NONE)
        private ChatRoomMessageId chatRoomMessageId;
    
        @DynamoDBHashKey(attributeName = "roomId")
        public String getRoomId() {
            return chatRoomMessageId != null ? chatRoomMessageId.getRoomId() : null;
        }
    
        public void setRoomId(String roomId) {
            if (chatRoomMessageId == null) {
                chatRoomMessageId = new ChatRoomMessageId();
            }
            chatRoomMessageId.setRoomId(roomId);
        }
    
        @DynamoDBRangeKey(attributeName = "createdAt")
        @DynamoDBTypeConverted(converter = DynamoDBConfig.LocalDateTimeConverter.class)
        public LocalDateTime getCreatedAt() {
            return chatRoomMessageId != null ? chatRoomMessageId.getCreatedAt() : null;
        }
    
        @DynamoDBTypeConverted(converter = DynamoDBConfig.LocalDateTimeConverter.class)
        public void setCreatedAt(LocalDateTime createdAt) {
            if (chatRoomMessageId == null) {
                chatRoomMessageId = new ChatRoomMessageId();
            }
            chatRoomMessageId.setCreatedAt(createdAt);
        }
    
        @DynamoDBAttribute
        private String messageType;
    
        @DynamoDBAttribute
        private Long userId;
    
        @DynamoDBAttribute
        private String userNickname;
    
        @DynamoDBAttribute
        private String content;
    
        @DynamoDBAttribute
        private String fileName;
    
        @DynamoDBAttribute
        private String fileUrl;
    
        @DynamoDBAttribute
        private String fileType;
    }

     

    FileType.java

    public enum FileType {
        /**
         * 이미지
         */
        IMAGE,
    
        /**
         * 파일
         */
        FILE,
    
        /**
         * 일반 메시지 형태일 경우
         */
        NONE
    }

     

     

    SpringBoot에서 DynamoDB 연동과 관련된 내용은 아래 글을 참고해주세요!

     

    Spring-Data-DynamoDB를 사용하여 SpringBoot와 AWS DynamoDB 연동하기

    들어가며 현재 진행하고 있는 실시간 채팅방 관련 프로젝트에서 채팅방에서 나눈 이전 대화들을 저장하여 사용자가 다시 채팅방에 접속하면 이전 대화 내용들을 보여주는 기능을 추가적으로

    kjungw1025.tistory.com

     

    굳이 FileType에서 IMAGE와 FILE로 나눈 이유는?? 

     

     

    위 카카오톡 사진과 같이 사용자가 앨범또는 파일 버튼을 눌러 업로드를 했을 때

    같은 이미지더라도 어떤 방식으로 업로드 했느냐에 따라서 

    채팅방 상의 화면에서 다르게 보이는 것을 비슷하게 구현하고 싶었기 때문이다.

     

     

    앨범 or 파일 버튼을 눌러 업로드했을 때 채팅방 화면에서 다르게 보여지는 것을 알 수 있다.

     

     

    위와 같이 구현하기 위해

    백엔드에서 FileType 값을 주면, 프론트에서 해당 작업을 하기 수월하다고 판단했기 때문이였다. 

     

     

     

    결과적으로 DynamoDB 테이블에 아래와 같이 값이 들어가게 된다.

     

    DynamoDB

     

     

     

     

    로컬에서 성공적으로 작동하는 것을 확인하여 개발 서버로 배포를 진행했다.

    그런데, 실행해보니 아래와 같은 오류가 발생했다...

    Failed to load resource: the server responded with a status of 413 (Request Entity Too Large)

     

     

     

    Nginx 설정 (client_max_body_size)

    Failed to load resource: the server responded with a status of 413 (Request Entity Too Large)

     

    위와 같은 오류는 클라이언트가 너무 큰 사이즈의 request를 보내지 못 하도록

    nginx에서 사이즈 제한을 해뒀기 때문에 발생한 오류이다.

     

    정확히 말하면 client_max_body_size 설정 때문이며, 기본값은 1MB이다.

    즉, 내가 NHN Cloud Object Storage에 파일을 업로드하기 위해

    채팅방에서 보낸 파일의 크기가 1MB를 넘었기 때문에 위와 같은 오류가 발생한 것이다.

     

    해결 방법

    1. 우선 nginx 환경 설정을 위해 nginx.conf 파일을 vi 편집기를 통해 열도록 하자

    $ sudo vi /etc/nginx/nginx.conf

     

     

    2. http 부분에 아래와 같이 사이즈 설정하기!

    아래 예시는 10MB를 허용하는 경우이다. 

    http {
        client_max_body_size 10M;
    
        ...
    }

     

    위에서 말했다시피 설정을 해주지 않으면 기본값은 1MB이다.

    또한 제한을 두지 않으려면 0으로 설정하면 되지만 클라이언트가 악의적으로 큰 용량의 파일을 업로드하여

    디스크를 채울 수 있으므로 0으로 설정하는 것은 추천하지 않는다.

     

     

     

    3. Nginx 재시작하기

    $ sudo service nginx restart

     

     

     

     

    성공!

    Nginx 재시작 후,

    개발 서버에 접속하여 테스트 채팅방에서 다시 파일을 업로드하니 성공적으로 업로드가 되었다.

     

     

     

    프론트 화면은 귀엽게 봐주세요.....

     

     

    Object Storage에서 특정 채팅방 id 폴더에 파일이 잘 업로드 된 것을 확인할 수 있다!

     

     

Designed by Tistory.