Toast Editor 사진 저장로직 최적화(AWS S3 Object Tag 활용)

jkky98·2025년 1월 21일
0

ProjectSpring

목록 보기
6/20
post-thumbnail

기존 로직

위는 기존 로직이다. 사진을 첨부하면 마크다운 이미지 링크가 생성되며 오른쪽 마크다운 뷰어에 사진이 보이게 된다.

const editor = new toastui.Editor({
        el: document.querySelector('#editor'), // 에디터를 적용할 요소 (컨테이너)
        height: 'auto',                        // 에디터 영역의 높이 값 (OOOpx || auto)
        initialEditType: 'markdown',            // 최초로 보여줄 에디터 타입 (markdown || wysiwyg)
        initialValue: writeFormContent || '',   // 내용의 초기 값으로, 반드시 마크다운 문자열 형태여야 함
        previewStyle: 'vertical',               // 마크다운 프리뷰 스타일 (tab || vertical)
        placeholder: '내용을 입력해 주세요.',
        hooks: {
            async addImageBlobHook(blob, callback) { // 이미지 업로드 로직 커스텀
                try {
                    /*
                     * 1. 에디터에 업로드한 이미지를 FormData 객체에 저장
                     *    (이때, 컨트롤러 uploadEditorImage 메서드의 파라미터인 'image'와 formData에 append 하는 key('image')값은 동일해야 함)
                     */
                    const formData = new FormData();
                    formData.append('image', blob);

                    // 2. FileApiController - uploadEditorImage 메서드 호출
                    const response = await fetch('/editor/image-upload', {
                        method : 'POST',
                        body : formData,
                    });

                    // 3. 컨트롤러에서 전달받은 디스크에 저장된 파일명
                    const filename = await response.text();
                    console.log('서버에 저장된 파일명 : ', filename);

                    // 4. addImageBlobHook의 callback 함수를 통해, 디스크에 저장된 이미지를 에디터에 렌더링
                    const imageUrl = `/editor/image-print?filename=${filename}`;
                    callback(imageUrl, 'image alt attribute');

                } catch (error) {
                    console.error('업로드 실패 : ', error);
                }
            }
        }

    });

toastUi Editor 객체에 훅을 만들어 비동기로 하여금 이미지를 저장시킨다. 배포서버에서는 이를 s3에 저장하고 개발환경에서는 로컬에 저장한다. 그러므로 환경마다 동작하는 controller가 다르다.

저장이 완료되자마자 마크다운 뷰어에 랜더링 하기위해download 요청을 보내어 사진을 띄우게 된다.

재빠른 뷰잉을 위해 우리는 게시글 제출 시점에 사진을 저장하지않고 작성 도중 저장하게 된다.

문제

만약 악성 사용자가 파일을 첨부하고 지우기를 반복한다면 저장소에는 사진이 계속해서 쌓이게 될 것이다.

해결방안 모색

현재 사용하는 기술 스택 안에서 해결하기 위해 다음과 같은 방법을 떠올렸다.

S3의 객체 태그 기능(영구상태, 임시상태)을 활용하기로 결정했다.

기존 로직과 동일하되 작성도중 사진 저장에 대해서는 임시 태그로 하여금 저장한다. 제출 시 content에서 마크다운 이미지 url을 파싱한 후 해당 url들에 대해 저장소의 객체들을 영구화 시킨다.

임사상태의 객체들은 정책에 따라 24시간이 지나면 사라진다. 그리고 해당 파일들은 수정에 따라 혹은 게시글 삭제에 따라 영구화 필요성이 사라질 수 있으므로 이 상태를 관리하기 위해 Post와 1:N인 PostFile 엔티티를 만들어 관리하도록 할 것이다.

S3 수명주기 정책 추가

태그 status = delete가 추가되면 객체 생성 24시간 후 삭제되도록 했다.

저장 로직 변경(prod만)

try (InputStream inputStream = image.getInputStream()) {
            // S3에 파일 업로드
            // 태그 추가
            List<Tag> tags = new ArrayList<>();
            tags.add(new Tag("Status", "delete")); // "Status=delete" 태그 추가
            
            PutObjectRequest putObjectRequest = new PutObjectRequest(
                    s3BucketName,
                    saveFilename,
                    inputStream,
                    null).withTagging(new ObjectTagging(tags));

            amazonS3.putObject(putObjectRequest);

            return saveFilename;

저장시 태그를 추가하여 1일 생명주기를 주도록 했다. FileController의 임무는 이것이 끝이고 이제는 제출시 write Post 로직을 수정해야한다.

저장, 수정 Service 메서드

저장시

	@Transactional
    public void saveWrite(WriteForm form, Long sessionUserId) throws IOException {

        User userById = userService.findUserById(sessionUserId);

        String storedFileName = handleThumnail(form);
        encodingUrlAndContent(form);

        Post post = Post.of(form, userById, seriesService.getSeries(form.getSeries()), storedFileName);
        Post postSaved = postRepository.save(post);
        setTagsForPost(tagProvider(form.getTags()), postSaved);

        // content에서 영구화 시킬 url List 추출
        List<String> imageFilenames = extractImageFilenames(form.getContent());

        // S3 파일 태그 제거상태 -> 영구상태
        imageFilenames.forEach(filename -> updateTagToPermanent(filename, s3BucketName));

        // PostFile 추가
        List<PostFile> postFiles = imageFilenames.stream()
                .map(urlImage -> PostFile.of(postSaved, urlImage))
                .toList();

        postFileRepository.saveAll(postFiles);

    }
    
    private static List<String> extractImageFilenames(String content) {
        // 정규식 패턴 (앞에 ![image alt attribute] 포함, 경로 수정)
        String regex = "!\\[image alt attribute\\]\\(/editor/editor-image-print\\?filename=([a-zA-Z0-9._-]+)\\)";

        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(content);

        // 결과를 저장할 리스트
        List<String> imageFilenames = new ArrayList<>();

        // 정규식 매칭
        while (matcher.find()) {
            // 첫 번째 그룹에서 파일명 추출
            String filename = matcher.group(1);
            imageFilenames.add(filename);
        }

        return imageFilenames;
    }

정규식을 활용하여 content에서 image파일의 이름만 뽑아낸다. s3에 해당 파일이름으로 검색하여 해당 태그들을 수정한다.(Status: delete -> permanent)

수정시

	@Transactional
    public void saveEditWrite(WriteForm form, String postUrl) throws IOException {

        Post post = postService.findByPostUrl(postUrl);
        post.updateSeries(seriesService.getSeries(form.getSeries()));

        // url, content 인코딩
        encodingUrlAndContent(form);

        // thumnail, series 빼고 업데이트
        post.updatePostWithoutThumbnailAndSeries(form);

        // 기존 Post-PostTag 관계 제거
        post.getPostTags().forEach(postTag -> postTag.getTag().getPostTags().remove(postTag));
        post.getPostTags().clear(); // Post와의 관계 끊기

        editThumnail(form, post);
        setTagsForPost(tagProvider(form.getTags()), post);

        updateTagAndRepository(form, post);
    }
    
    private void updateTagAndRepository(WriteForm form, Post post) {
        List<String> imageFilenames = extractImageFilenames(form.getContent());
        List<String> postFilesFromPost = post.getPostFiles().stream()
                .map(postFile -> postFile.getUrl())
                .toList();

        updateTagWriteEdit(imageFilenames, postFilesFromPost);
        updateRepositoryWriteEdit(post, imageFilenames);
    }
    
    private void updateTagWriteEdit(List<String> imageFilenames, List<String> postFiles) {
        // 추가된 사진파일 s3 태그로 영속화
        imageFilenames.stream()
                        .filter(filename -> !postFiles.contains(filename))
                        .forEach(filename -> updateTagToPermanent(filename, s3BucketName));
        // 사라진 사진파일 s3 제거 태그로 변환
        postFiles.stream()
                        .filter(postFile -> !imageFilenames.contains(postFile))
                        .forEach(postFile -> updateTagToDelete(postFile, s3BucketName));
    }
    
    private void updateRepositoryWriteEdit(Post post, List<String> imageFilenames) {
        postFileRepository.deleteByPostId(post.getId());
        postFileRepository.saveAll(imageFilenames.stream()
                .map(urlImage -> PostFile.of(post, urlImage))
                .toList());
    }

수정시에는 updateTagAndRepository()로 하여금 Tag 변화를 적용해주고 DB는 해당 post의 모든 File정보를 지우고 다시 생성하도록 했다.

DB의 경우는 하나의 post에 해당하는 filename이 그리 많지 않을 것으로 판단하여 모두 지우고 다시 생성하도록 했다.

write, edit로직 모두에서 객체태그 설정 관련 코드가 엔티티를 다루는 코드 뒤에 오게 되는데, 트랜잭션의 경우에 DB작업만 관리가 가능하므로 DB작업이 성공적으로 모두 끝났을때 이를 처리하도록 뒤에 두었다.

이로써 Editor에서 사진 파일을 즉각적으로 업로드 하는 로직에 대해 S3태그로 하여금 수명주기를 주고 이를 관리하여 S3에 필요없는 사진이 쌓이는 것을 방지하도록 했다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보