티스토리 뷰

프로젝트 내에서 게시글 이미지를 저장하고 불러와야 했다. 아직은 클라우드 서비스를 이용할 생각이 없어서 로컬 저장소를 이용하기로 했다. 아래 글을 많이 참고하였다.

 

 

https://spring.io/guides/gs/uploading-files/

 

Getting Started | Uploading Files

To start a Spring Boot MVC application, you first need a starter. In this sample, spring-boot-starter-thymeleaf and spring-boot-starter-web are already added as dependencies. To upload files with Servlet containers, you need to register a MultipartConfigEl

spring.io

 

먼저 이미지 파일 요청받을 컨트롤러를 작성해보겠다.
Content-Type: multipart/form-data
image라는 키 값으로 MultipartFile을 받는다.

@RestController
@RequiredArgsConstructor
public class FileUploadController {

    private final FileUploadService fileUploadService;

    @PostMapping("/posts/images")
    public ResponseEntity<List<FileSaveResult>> handleFileUpload(
            @RequestParam("image") List<MultipartFile> imageFiles) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(fileUploadService.store(imageFiles));
    }
}

 

이미지를 저장할 때는 무작정 저장을 할 수는 없다.
기본적으로 이미지가 존재해야하며 이미지 이름은 겹칠 수 있으므로 중복되지 않게 해야 한다.

  1. MultipartFile이 유효한지 확인
  2. UUID를 이용해서 중복되지 않는 파일 이름 생성
  3. 로컬 이미지 저장소에 파일을 저장
  4. 파일 저장 후에는 사용자가 이미지에 접근할 수 있는 url을 만들어서 응답

저장소 경로와 이미지에 접근하는 base url 같은 경우에는 환경에 따라 달라질 수 있기 때문에 환경 변수로 설정했다.
ImageConfig 클래스에 정의해두었고 rootLocation이 저장소 경로 baseUrl이 접근할 url 기본 경로이다.

 

@Service
@RequiredArgsConstructor
public class FileUploadService {

    private final ImageConfig imageConfig;
    private final FileGenerator fileGenerator;
    private final FileGeneratorUtil fileGeneratorUtil;

    public List<FileSaveResult> store(List<MultipartFile> files) {
        return files.stream().map(this::store).toList();
    }

    public FileSaveResult store(MultipartFile file) {
        validateFile(file); // 1번
        String fileName = fileGeneratorUtil.generateFileName(file.getOriginalFilename()); // 2번
        Path absolutePath = fileGeneratorUtil.generateAbsolutePath(fileName);

        fileGenerator.save(file, absolutePath); // 3번
        return FileSaveResult.builder()
                .url(fileGeneratorUtil.generateFileUrl(imageConfig.getBaseUrl(), fileName))
                .fileName(fileName)
                .originalFileName(file.getOriginalFilename())
                .build();
    }

    private static void validateFile(MultipartFile file) {
        if (file.isEmpty()) {
            throw new StorageException("빈 파일을 저장할 수 없습니다.");
        }
    }
}

 

FileGeneratorUtil class는 파일 생성할 때 도움을 주는 클래스이다.
위에서는 UUID로 파일 이름을 생성하고 파일에 접근할 수 있는 URL을 만드는 데 사용한다.

@Component
public class FileGeneratorUtil {

    private static final String URL_FORMAT = "%s/%s";
    private static final String STORE_FILE_OUTSIDE_CURRENT_DIRECTORY_MESSAGE = "유효하지 않은 저장 경로입니다.";
    private final FileNameGenerator fileNameGenerator;
    private final Path rootPath;

    public FileGeneratorUtil(FileNameGenerator fileNameGenerator, ImageConfig imageConfig) {
        this.fileNameGenerator = fileNameGenerator;
        rootPath = Paths.get(imageConfig.getRootLocation());
    }

    public String generateFileName(String fileName) {
        return fileNameGenerator.generate(fileName);
    }

    public String generateFileUrl(String baseUrl, String fileName) {
        return String.format(URL_FORMAT, baseUrl, fileName);
    }

    public Path generateAbsolutePath(String fileName) {
        Path absolutePath = rootPath.resolve(Paths.get(fileName))
                .normalize()
                .toAbsolutePath();
        if (!absolutePath.getParent().equals(rootPath.toAbsolutePath())) {
            throw new StorageException(STORE_FILE_OUTSIDE_CURRENT_DIRECTORY_MESSAGE);
        }
        return absolutePath;
    }
}

@Component
@RequiredArgsConstructor
public class FileNameGenerator {

    private static final String DELIMITER = "_";
    private final UuidGenerator uuidGenerator;

    public String generate(String fileName) {
        return uuidGenerator.generate() + DELIMITER + fileName;
    }
}

 

FileGenerator는 파일과 저장할 경로를 받아 저장해주는 역할을 한다.

@Slf4j
@Component
public class FileGenerator {

    public void save(MultipartFile file, Path destinationFile) {
        try (InputStream inputStream = file.getInputStream()) {
            Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            log.error("file save error = {}", e.getMessage());
            throw new StorageException("파일 저장에 실패했습니다.", e);
        }
    }
}

 

아래 코드 블럭에 store 메소드처럼 한 클래스 안에 전부 구현해도 상관없다.
그래도 지금까지 설명한 코드들은 생각이 들어간 코드라서 설명을 해보도록 하겠다.

 

일단 위에 store 메소드와 아래 store 메소드를 보고 어느 게 더 눈에 더 잘 들어오는 지 생각해보자.
아래가 본인이 작성한터라 본인 눈에 눈에 잘 들어오는 것도 사실일테지만, 아래 store 메소드가 메소드들로 쪼개져 있어서 이름만 보고도 대충 무엇을 하는지 한 눈에 파악하기 쉽다. 그에 반해 위에 store 메소드는 검증, 파일 이름을 만들고, Path를 만들고 Path를 검증하고 파일 저장까지 너무 많은 일을 하고 있다. 그래서 나는 하나씩 분리해보기로 했다.

// 한 곳에 몰아넣은 store 메소드
public void store(MultipartFile file) {
    try {
        if (file.isEmpty()) { // 파일 검증
            throw new StorageException("Failed to store empty file.");
        }
        String fileName = uuidGenerator.generate() + DELIMITER + file.getOriginalFilename(); // 파일 이름 생성
        Path destinationFile = rootLocation.resolve(Paths.get(fileName)) // 저장할 경로
                .normalize()
                .toAbsolutePath();
        if (!destinationFile.getParent().equals(this.rootLocation.toAbsolutePath())) { // 경로 유효성 검증
            // This is a security check
            throw new StorageException(
                    "Cannot store file outside current directory.");
        }
        try (InputStream inputStream = file.getInputStream()) { // 파일 저장
            Files.copy(inputStream, destinationFile,
                StandardCopyOption.REPLACE_EXISTING);
        }
    }
    catch (IOException e) {
        throw new StorageException("Failed to store file.", e);
    }
}
// 필자가 작성한 store 메소드
public FileSaveResult store(MultipartFile file) {
    validateFile(file); // 1번
    String fileName = fileGeneratorUtil.generateFileName(file.getOriginalFilename()); // 2번
    Path absolutePath = fileGeneratorUtil.generateAbsolutePath(fileName);

    fileGenerator.save(file, absolutePath); // 3번
    return FileSaveResult.builder()
            .url(fileGeneratorUtil.generateFileUrl(imageConfig.getBaseUrl(), fileName))
            .fileName(fileName)
            .originalFileName(file.getOriginalFilename())
            .build();
}

처음에는 MultipartFile을 멤버 변수로 가진 인스턴스를 이용해서 파일 저장도 하고 url도 리턴하고 해볼까 라는 생각을 했지만 역시나 이것도 너무 많은 일을 하게 되고 이미지를 생성할 때마다 인스턴스를 생성하는 것에서 별로 좋지 못하다고 생각했다. 그래서 빈으로 등록해야겠다고 생각했고 빈으로 만드는 이상 싱글톤처럼 작동하기 때문에 전부 파라미터로 받아서 필요한 일만 처리해주기로 했다.

 

위에 store 메소드에 주석에 달아둔 것처럼 총 5가지 일을 하고 있는데, 이것을 하나하나 분리해서 구현하였다.
FileGeneratorUtil에서는 파일 이름 생성, Path 생성, url 생성을 도와준다.

파일 이름 생성하는 것은 FileNameGenerator 에게 위임해서 그저 호출하기만 한다.

FileGenerator는 이름 그대로 File을 생성하는데 도움을 주고 있다.
다른 사람들이 봤을 때 필자가 구현한 store 메소드도 성에 안찰 수도 있다. 아직 실력이 부족해서 여기까지 밖에 생각을 못했다.

직접 의존하지 않고 최대한 파라미터로 받게 했으며, 하나의 메소드가 여러 일을 하고 있지도 않다. 지금 이 코드들은 정리한 코드인데 지저분했을 때는 ImageConfig 환경 변수를 너도나도 이용하고 있었다. FileUploadService class 즉 최대한 바깥쪽에서 ImageConfig 변수를 넘겨주도록 바꿨다.

 

좋은 코드를 짤 수 있도록 많이 생각하고 있는데, 참 어렵다. 의도적으로 훈련을 지속적으로 해줘야할 것 같고 아직 기본기가 많이 부족한 것을 느낀다. 그래도 이렇게 분리해보니 재미있어서 시간 가는 줄 모르고 한 것 같다.

다음 글에서는 이미지 불러오기에 대해서 설명하도록 하겠다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함