프로젝트

[프로젝트] File 서비스 확장성 있는 구조 만들기 (feat.Bridge pattern)

HEY__ 2024. 9. 15. 21:26
728x90

이 글은 공부를 하면서 알게 된 내용들을 기록하는 글 입니다. 오류나 고쳐야 할 사항들이 있다면 지적 부탁드립니다!

✅ 디자인 패턴 도입의 필요성

GitGet 서비스에는 `사용자의 프로필`, `토픽의 이미지`, `인스턴스(챌린지)의 이미지`를 저장/수정/삭제하는 기능을 지원합니다.

이에 따라서 파일 시스템을 구현했습니다.

 

초기 개발 단계에서는 개발진들이 자신의 컴퓨터에서 각자 서버를 돌렸기 때문에

`java.io.File` 패키지에서 제공하는 메서드들을 통해  로컬 저장소(각자의 컴퓨터 하드)에 파일을 저장/수정/삭제하는 기능(이하 파일 시스템)을 개발했습니다.

 

어느정도 개발이 완료되고, AWS에 서비스를 배포하면서 문제가 발생했습니다. 

배포 이전에는 각자의 로컬 저장소에 파일을 저장하고 사용해도 문제가 없었지만, 배포가 이루어진다면 배포 중인 서비스에 대한 파일 시스템을 담당할 스토리지(Storage)가 필요했습니다.

그에 따라 파일을 로컬 저장소가 아닌 AWS S3 버킷에 저장하고 활용하는 방법을 채택했습니다.

 

그런데! 여기서 문제가 또 발생했습니다.

1️⃣ 이전의 코드에서는 로컬 스토리지에 종속된 코드였기 때문에, 이전의 코드 구조를 그대로 따라가면 AWS S3에 종속된 클래스가 하나 더 만들어진다는 점

2️⃣ 개발 버전에서는 로컬 스토리지, 배포 버전에서는 AWS S3 버킷을 사용해야 하는데, 이러한 전환이 유연하지 않다는 점

3️⃣ 그에 따라 중복되는 코드가 발생할 가능성이 매우 높으며, 공통으로 구현해야 하는 로직을 놓칠 수 있다는 점

4️⃣ 혹시나 이후 다른 버전이 발생한다면 위의 문제점이 또 발생할 가능성이 높은 점

 

 

이러한 문제점을 해결하기 위해 확장성/유연성 있는 구조를 도입하기로 했습니다.

플랫폼(Local 버전, S3 버전)이 변경되어도 파일이 관리되는 위치만 변경되며, 프로덕션 코드에는 영향이 가지 않도록 하고 싶었습니다.

이를 위해 interface와 상속을 활용하여 변경과 확장에 유연한 구조로 변경했고, 그 결과 GoF의 디자인 패턴 중 구조 패턴에 해당하는 `Bridge pattern`와 유사했습니다.

 

`Bridge pattern`에 대해 알아보고, 파일 시스템에 어떻게 적용이 되었는지 알아보도록 하겠습니다.


✅ Bridge Pattern

🔥 정의

Bridge is a structural design pattern that lets you split a large class or a set of closely related classes into two separate hierarchies—abstraction and implementation—which can be developed independently of each other.
출처: https://refactoring.guru/design-patterns/bridge

`Bridge pattern`(이하 브릿지 패턴)은 큰 클래스(large class) 혹은 밀접하게 관련된 클래스들을 두 개의 개별적인 계층 구조(추상화 & 구현)로 나누는 구조 디자인 패턴입니다. 이를 통해 각각의 계층을 독립적으로(independently) 개발할 수 있습니다.

 

🔥 구조

밑의 이미지는 `Bridge pattern`의 구조를 도식화 한 것 입니다.

 

우선 `Abstraction`는 기능 계층의 상위 클래스이며, 실질적으로 `Client`는 이 클래스(Abstraction)의 매서드를 통해 원하고자하는 기능을 호출할 수 있습니다.

또한 `Implementation`을 의존하며, Implementation 내의 메서드들을 호출하여 원하는 작업을 처리합니다.

 

`Implementation`은 인터페이스로서, 구현 클래스들이 모두 공통으로 가져야하는 메서드들을 미리 선언합니다. 

`Abstraction` 클래스(상위 클래스)는 여기에서 선언한 메서드들만을 호출할 수 있으므로, 이를 고려해야 합니다.

 

`Concrete Implementation`은 `Implementation` 인터페이스를 상속받아 해당 인터페이스 내의 메서드들을 모두 구현해야 합니다.

환경(e.g. 플랫폼)에 맞춰 메서드들을 구현합니다. 

출처: https://refactoring.guru/ko/design-patterns/bridge

 


Bridge pattern 적용하기

🔥 왜 Bridge pattern 이었는가?

파일 시스템에 `Local` 환경과 `AWS S3` 환경 둘 다 구현해놓고, 필요에 따라 원하는 환경으로 사용할 수 있어야 한다는 요구 사항이 발생했습니다.

 

밑은 `Bridge pattern`을 적용하기 이전의 파일 시스템 구조입니다.

`FilesManager`에 모든 구현을 했기 때문에 플랫폼에 종속적일 수 밖에 없고, 다른 환경으로 사용하고 싶으면 FilesManager 내의 메서드들을 직접 수정해야 하는 상황이었습니다.

이는 당연하게도 비효율적이며 휴먼 에러가 발생할 수 있는 여지가 너무나 컸습니다.

패턴 적용 이전의 구조

 

 

여기에서 문제가 되는 부분은 실질적으로 파일을 저장하고, 불러오고, 삭제하는 특정 기능에서만 코드가 달라진다는 점을 확인했습니다.

그렇다면 이 부분만 따로 빼서 구현하고, 원하는 환경에 따라 각자 다른 메서드를 호출하면 되지 않을까요?

 

원하는 환경에 맞는 메서드를 호출하려면 환경에 따라 각자 다른 클래스를 의존해야할텐데 우리는 이를 원하지 않습니다.

인터페이스와 상속을 활용하여 같은 클래스를 의존하여도, 원하는 환경에 따라 다른 메서드를 호출할 수 있도록 변경해봅시다.

 

플랫폼에 따라 다른 메서드를 호출하는 부분들을 모아 `interface`로 만들고, 구체 클래스들은 `interface`의 메서드를 직접 구현하여 플랫폼에 맞는 코드를 작성합니다.

그리고 상위 클래스에서 interface를 의존하며, Bean 주입 과정에서 사용하고자하는 Bean을 주입받아 사용하면 될 것 같습니다.

 

이러한 패턴을 가지고 있는 디자인 패턴이 있을 것이라 생각해 자료 조사를 해보니,

디자인 패턴 중 구조 패턴에 해당하는 `Bridge pattern`이 딱 이러한 모양을 가지고 있는 것을 확인할 수 있었고, 이를 적용하기로 했습니다.

 

 

 

🔥 Bridge pattern을 적용한 이후의 구조

파일 객체에 대한 메타 데이터는 데이터베이스의 `Files` 테이블을 통해 관리됩니다.

 

`로컬 스토리지` 또는 `AWS S3`에 파일(이미지)를 실제로 저장하고, 불러오고, 삭제하는 작업. 즉, 플랫폼에 따라 구체적인 코드가 잘라지는 부분과

파일(이미지)의 조작 이후 데이터베이스의 `Files` 테이블의 레코드에 변경이 이루어지는 부분을 서로 나눌 수 있을 것이라 판단했습니다.

 

파일(이미지)를 플랫폼 환경에 맞춰 처리를 한 후 Files 테이블에 변경에 대한 정보를 전달하면, (하위 기능)

상위 클래스에서는 이 정보를 통해 `Files` 테이블에 변경을 가하고 클라이언트에게 필요한 정보를 전달(상위 기능)해주도록 합시다. 

 

 

우선 플랫폼의 종류와 상관없이 파일(이미지)를 저장/조회/삭제하는 공통 메서드들을 interface(그림에서의 `Implementation`)을 `FileService`로 명명했습니다. 

`LocalFileService`, `S3FileService` 클래스는 각 플랫폼에 맞는 코드를 구현하는 `Concrete Implementation`으로,

`FileService` 인터페이스를 상속받아 메서드를 구현합니다.

 

처리된 파일의 데이터를 바탕으로 `Files` 테이블에 변경을 가하고, 클라이언트가 필요로 하는 기능을 제공하는 `Abstraction`는 `FileManager`로 명명했습니다.

해당 클래스에서는 `FileService`를 의존하며, `FileService`를 통해 상위 수준의 기능을 구현합니다.

출처: https://refactoring.guru/ko/design-patterns/bridge
GitGet 서비스의 파일 시스템 구조

 

 

🔥 실제 코드

FileService 인터페이스는 각 플랫폼에 따라 공통적으로 구현해야하는 메서드들을 정의합니다.

같이 작업하는 다른 백엔드 팀원분이 나중에 이 코드를 봤을 때 요구사항을 파악할 수 있도록 주석을 통해 어떤 일을 하고, 어떤 주의사항이 있는지 작성했습니다.

 

LocalFileService, S3FileService의 코드를 보면 FileService의 메서드를 Override하여 각자의 플랫폼에 맞게 구현한 모습을 확인할 수 있습니다.

@Service
public interface FileService {

    /**
     * Files에 저장된 파일의 접근 URI 반환
     *
     * @param files 얻기 원하는 파일의 정보를 담고 있는 Files 객체
     * @return
     */
    String getFileAccessURI(Files files);

    /**
     * 전달한 파일 저장 후, Files 객체 형성에 필요한 정보를 담은 객체 반환
     *
     * @param multipartFile 저장하고자 전달한 파일
     * @param fileType      저장하고자하는 파일의 종류 (Topic, Instance, Profile 중 1)
     * @return Files 객체 생성에 필요한 정보(UploadDTO) 반환
     */
    FileDTO upload(MultipartFile multipartFile, FileType fileType);

    /**
     * 기존에 저장소에 저장되어 있던 파일을 특정 타입에 복사 후, Files 객체 생성에 필요한 정보들을 반환
     * NOTE!! 복사 이전에 원본이 되는 파일이 저장소에 존재하는지 `validateFileExist()`를 통해 확인 필요
     *
     * @param files    복사하고자하는 파일의 정보를 담고 있는 Files 객체
     * @param fileType 복사해서 적용하고 싶은 대상의 파일 타입(TOPIC/INSTANCE/PROFILE 중 택 1)
     * @return Files 객체 생성에 필요한 정보(UploadDTO) 반환
     * @throws BusinessException 원본이 되는 파일이 저장소에 존재하지 않는 경우 FILE_NOT_EXIST 발생
     */
    FileDTO copy(Files files, FileType fileType);

    /**
     * Files에 해당하는 이미지를 찾아서 삭제 및 새로운 이미지 저장 후, Files 내용 갱신에 필요한 정보들을 반환
     *
     * @param files         대체 하고자하는 대상 객체
     * @param multipartFile 저장하고자하는 파일
     * @return Files 내용 갱신에 필요한 정보(UpdateDTO) 반환
     */
    UpdateDTO update(Files files, MultipartFile multipartFile);

    /**
     * Files 객체 내의 정보를 활용하여 저장소(Local/S3)에서 해당 파일 삭제.
     *
     * @param files 삭제하고자하는 Files 객체
     * @throws BusinessException 삭제에 실패했을 때 발생
     */
    void deleteInStorage(Files files);

    /**
     * Files 객체 내의 정보를 활용하여 저장소에 파일이 저장이 되어 있는지 확인 후 boolean 반환
     * 각 저장소의 특성에 맞춰 Files 내의 메타데이터를 통해 저장소 내에 파일이 제대로 저장되어 있는지 확인
     * 파일이 존재하지 않는 경우 FILE_NOT_EXIST 예외 발생 시킬 것
     *
     * @param files 저장소에 저장되어 있는지 확인하고자하는 Files 객체
     * @throws FILE_NOT_EXIST 저장소에서 파일(이미지)을 찾을 수 없을 때 발생
     */
    void validateFileExist(Files files);
}
public class LocalFileService implements FileService {
    private final String UPLOAD_PATH;
    private final FileUtil fileUtil;


    public LocalFileService(FileUtil fileUtil, @Value("${file.upload.path}") String UPLOAD_PATH) {
        this.fileUtil = fileUtil;
        this.UPLOAD_PATH = UPLOAD_PATH;
    }

    @Override
    public FileDTO upload(MultipartFile multipartFile, FileType fileType) {
        fileUtil.validateFile(multipartFile);
        FileDTO fileDTO = fileUtil.getFileDTO(multipartFile, fileType, UPLOAD_PATH);

        try {
            File file = new File(fileDTO.fileURI());
            createPath(fileDTO.fileURI());
            multipartFile.transferTo(file);
        } catch (IOException e) {
            throw new BusinessException(FILE_NOT_SAVED);
        }

        return fileDTO;
    }

    @Override
    public String getFileAccessURI(Files files) {
        return files.getFileURI();
    }

    @Override
    public FileDTO copy(Files files, FileType fileType) {
        validateFileExist(files);

        CopyDTO copyDTO = fileUtil.getCopyInfo(files, fileType, UPLOAD_PATH);
        createPath(copyDTO.folderURI());

        File originFile = new File(files.getFileURI());
        File copyFile = new File(copyDTO.fileURI());

        try {
            java.nio.file.Files.copy(originFile.toPath(), copyFile.toPath(),
                    StandardCopyOption.COPY_ATTRIBUTES);
        } catch (IOException e) {
            throw new BusinessException(FILE_NOT_COPIED);
        }
        return FileDTO.builder()
                .fileType(fileType)
                .originalFilename(copyDTO.originalFilename())
                .savedFilename(copyDTO.savedFilename())
                .fileURI(copyDTO.fileURI())
                .build();
    }

    @Override
    public UpdateDTO update(Files files, MultipartFile multipartFile) {
        deleteInStorage(files);
        FileDTO fileDTO = upload(multipartFile, files.getFileType());

        return UpdateDTO.of(fileDTO);
    }

    @Override
    public void deleteInStorage(Files files) {
        String fileURI = files.getFileURI();
        File targetFile = new File(fileURI);
        if (!targetFile.delete()) {
            throw new BusinessException(FILE_NOT_DELETED);
        }
    }

    @Override
    public void validateFileExist(Files files) {
        String fileURI = files.getFileURI();
        File file = new File(fileURI);
        if (!file.exists()) {
            throw new BusinessException(FILE_NOT_EXIST);
        }
    }

    private void createPath(String uri) {
        File file = new File(uri);
        if (!file.exists()) {
            file.mkdirs();
        }
    }
}
public class S3FileService implements FileService {
    private final AmazonS3 amazonS3;
    private final FileUtil fileUtil;
    private final String bucket;
    private final String cloudFrontDomain;
    
    public S3FileService(AmazonS3 amazonS3, FileUtil fileUtil, String bucket, String cloudFrontDomain) {
        this.amazonS3 = amazonS3;
        this.fileUtil = fileUtil;
        this.bucket = bucket;
        this.cloudFrontDomain = cloudFrontDomain;
    }

    @Override
    public String getFileAccessURI(Files files) {
        return cloudFrontDomain + files.getFileURI();
    }

    @Override
    public FileDTO upload(MultipartFile multipartFile, FileType fileType) {
        try {
            fileUtil.validateFile(multipartFile);
            FileDTO fileDTO = fileUtil.getFileDTO(multipartFile, fileType, "");

            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(multipartFile.getSize());
            objectMetadata.setContentType(multipartFile.getContentType());

            amazonS3.putObject(bucket, fileDTO.fileURI(), multipartFile.getInputStream(), objectMetadata);
            return fileDTO;
        } catch (IOException e) {
            throw new BusinessException(e);
        }
    }

    @Override
    public FileDTO copy(Files files, FileType fileType) {
        validateFileExist(files);

        CopyDTO copyDTO = fileUtil.getCopyInfo(files, fileType, "");

        CopyObjectRequest copyObjectRequest = new CopyObjectRequest(
                bucket, files.getFileURI(),
                bucket, copyDTO.fileURI()
        );
        amazonS3.copyObject(copyObjectRequest);
        return FileDTO.builder()
                .fileType(fileType)
                .originalFilename(copyDTO.originalFilename())
                .savedFilename(copyDTO.savedFilename())
                .fileURI(copyDTO.fileURI())
                .build();
    }

    @Override
    public UpdateDTO update(Files files, MultipartFile multipartFile) {
        deleteInStorage(files);
        FileDTO fileDTO = upload(multipartFile, files.getFileType());

        return UpdateDTO.of(fileDTO);
    }

    @Override
    public void deleteInStorage(Files files) {
        try {
            amazonS3.deleteObject(bucket, files.getFileURI());
        } catch (SdkClientException e) {
            throw new BusinessException(e);
        }
    }

    @Override
    public void validateFileExist(Files files) {
        if (!amazonS3.doesObjectExist(bucket, files.getFileURI())) {
            throw new BusinessException(FILE_NOT_EXIST);
        }
    }
}

`FileManager`는 상위 클래스로서, `FileService`(Implementation)을 의존하여 필요한 기능을 구현합니다.

FileService 인터페이스를 의존하기 때문에 어떤 플랫폼을 사용하는지, 어떤 방식으로 파일을 관리하는지 FileManager를 신경쓰지 않아도 됩니다.

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FilesManager {
    private final FileService fileService;
    private final FilesRepository filesRepository;

    @Transactional
    public Files uploadFile(FileHolder fileHolder, MultipartFile multipartFile, FileType fileType) {
        if (multipartFile == null) {
            throw new BusinessException(MULTIPART_FILE_NOT_EXIST);
        }
        FileDTO fileDTO = fileService.upload(multipartFile, fileType);

        Files file = Files.builder()
                .originalFilename(fileDTO.originalFilename())
                .savedFilename(fileDTO.savedFilename())
                .fileType(fileDTO.fileType())
                .fileURI(fileDTO.fileURI())
                .build();
        fileHolder.setFiles(file);
        return filesRepository.save(file);
    }

    @Transactional
    public Files copyFile(Files files, FileType fileType) {
        FileDTO fileDTO = fileService.copy(files, fileType);

        Files copyFiles = Files.create(fileDTO);
        return filesRepository.save(copyFiles);
    }

    @Transactional
    public Files updateFile(Optional<Files> optionalFiles, MultipartFile multipartFile) {
        Files files = optionalFiles.orElseThrow(() -> new BusinessException(FILE_NOT_EXIST));
        if (multipartFile == null) {
            return files;
        }

        UpdateDTO updateDTO = fileService.update(files, multipartFile);
        files.updateFiles(updateDTO);
        return files;
    }

    /**
     * NOTE: 삭제하고자하는 Files 엔티티와 연관관계에 있는 엔티티에서 연관관계를 끊어줘야 합니다.
     */
    @Transactional
    public void deleteFile(Optional<Files> optionalFiles) {
        if (optionalFiles.isEmpty()) {
            return;
        }
        Files files = optionalFiles.get();

        fileService.deleteInStorage(files);
        filesRepository.delete(files);
    }

    public Files findById(Long fileId) {
        return filesRepository.findById(fileId)
                .orElseThrow(() -> new BusinessException(FILE_NOT_EXIST));
    }

    public FileResponse convertToFileResponse(Optional<Files> optionalFiles) {
        return optionalFiles
                .map(files -> {
                    String fileAccessURI = fileService.getFileAccessURI(files);
                    return FileResponse.createExistFile(files.getId(), fileAccessURI);
                })
                .orElseGet(FileResponse::createNotExistFile);
    }
}

 

 

🔥 ++ 원하는 플랫폼으로 변경하기 (작성 중)

위에서 플랫폼에 상관없이 정상동작하는 구조를 만들었는데, 그러면 플랫폼은 어떤 방식으로 변경할 수 있을까요?

`application.yml` 파일과 Config 클래스(@Configuration 어노테이션)을 통해 원하는 Bean을 주입할 수 있습니다.

 

application.yml 파일에 다음와 같이 값을 커스텀하게 설정해줍니다. `file.mode`는 제가 임시로 설정한 값이며, 원하는 대로 값을 설정해주면 됩니다.

file:
  mode: prod

 

Config 클래스에서 `Environment`를 통해 yml 파일에 지정한 값을 불러온 후, 그 값에 따라 LocalFileService 또는 S3FileService 빈을 주입하도록 했습니다.

@Configuration
@RequiredArgsConstructor
public class AppConfig {
    private final S3Config s3Config;
    private final Environment env;


    @Bean
    public FileUtil fileUtil() {
        return new FileUtil();
    }

    @Bean
    public FileService fileManager() {
        final String fileMode = env.getProperty("file.mode");
        assert fileMode != null;

        if (fileMode.equals("local")) {
            final String UPLOAD_PATH = env.getProperty("file.upload.path");
            return new LocalFileService(fileUtil(), UPLOAD_PATH);
        }

        final String bucket = env.getProperty("cloud.aws.s3.bucket");
        final String cloudFrontDomain = env.getProperty("cloud.aws.cloud-front.domain");
        return new S3FileService(s3Config.amazonS3Client(), fileUtil(), bucket, cloudFrontDomain);
    }
    ...
}

 

 

 

 

 

전체 코드는 밑의 repository에서 확인하실 수 있습니다.

https://github.com/TeamTheGenius/TeamTheGenius_Server

 

GitHub - TeamTheGenius/TeamTheGenius_Server: IT 종사자를 위한 규칙적인 공부 습관 형성 서비스✍️

IT 종사자를 위한 규칙적인 공부 습관 형성 서비스✍️. Contribute to TeamTheGenius/TeamTheGenius_Server development by creating an account on GitHub.

github.com

 

 


✅  참고 자료 & 링크

- Refactoring Guru. 브리지 패턴

- 얄팍한 코딩 사전 - 07.브릿지(Bridge) 패턴

 

728x90