기록

이미지 처리 및 저장 방법 : 로컬 저장소 업로드/조회/삭제 본문

Web/Spring

이미지 처리 및 저장 방법 : 로컬 저장소 업로드/조회/삭제

youngyin 2024. 2. 5. 12:00

들어가며

토이 프로젝트를 진행하면서 이미지를 효과적으로 관리하기 위해 로컬 저장소를 활용하는 방법에 대해 고민하게 되었다. 이에 따라 이미지의 안전한 업로드, 조회, 삭제 방법을 찾아보고 적용한 내용을 공유하고자 한다.

(1) 이전글

2024.01.29 - [Web/Spring] - API/이미지 처리 및 저장 방법

(2) 프로젝트 구성
  • Java Version: 17
  • Build Tool: Gradle
  • Kotlin
  • Spring Boot
(3) 패키지 구성

아래에서는 ImgLocalService (로컬영역에 이미지 저장)하는 부분을 다루고자 한다. 

본문

1. 로컬 저장소에 이미지 업로드하기

(1) POST /api/image 엔드포인트

클라이언트에서 서버로 이미지를 업로드

@RequestMapping("/api")
@RestController
class ImageController(private val service : ImageService) {

    @PostMapping(value = ["/image"], consumes = ["multipart/form-data"])
    fun save(
        @RequestParam(name = "file") file: MultipartFile,
        @RequestParam(name = "type") type: ImageType
    ): ResponseEntity<BaseResp> {
        return service.save(ImageReq(type = type, file = file))
    }
}

(2) ImageService.uploadImage

실제 이미지를 로컬 저장소에 저장하는 프로세스

@Service
class ImgLocalServiceImpl(
    @Value("\${file.upload-dir}")
    private val uploadDir: String
) {

    fun uploadImage(fileName: String, imageReq: ImageReq) {
        try {
            val file = imageReq.file
            val directoryPath = Paths.get(uploadDir)

            // 디렉토리가 존재하지 않으면 생성
            if (!Files.exists(directoryPath)) {
                try {
                    Files.createDirectories(directoryPath)
                } catch (e: IOException) {
                    e.printStackTrace()
                    throw e
                }
            }

            // 파일 복사
            val filePath: Path = directoryPath.resolve("${fileName}.jpeg")
            Files.copy(file.inputStream, filePath)
        } catch (e: IOException) {
            // 처리 중 발생한 예외 처리
            e.printStackTrace()
            throw e
        }
    }

2. 로컬 저장소에서 이미지 조회하기

(1) GET /api/image/{imageId} 엔드포인트

클라이언트에서 특정 이미지를 조회

@RequestMapping("/api")
@RestController
class ImageController(private val service : ImageService) {

    @GetMapping(value = ["/image/{imageId}"])
    fun downloadImage(@PathVariable imageId: String): ResponseEntity<ByteArray> {
        try {
            val imageData = service.downloadImage(imageId)

            // Content-Type 설정
            val contentType: MediaType = MediaType.IMAGE_JPEG
            val headers = HttpHeaders()
            headers.contentType = contentType

            // Content-Disposition 설정 (파일 다운로드를 위한 설정)
            headers.contentDisposition = ContentDisposition
                .builder("inline")
                .filename("$imageId.${contentType.subtype}")
                .build()

            return ResponseEntity(imageData, headers, HttpStatus.OK)
        } catch (e: FileNotFoundException) {
            // 이미지를 찾을 수 없는 경우에 대한 예외 처리
            return ResponseEntity(HttpStatus.NOT_FOUND)
        }
    }
}

(2) ImageService.downloadImage

이미지 아이디를 활용하여 로컬 저장소에서 이미지를 가져오는 프로세스

@Service
class ImgLocalServiceImpl(
    @Value("\${file.upload-dir}")
    private val uploadDir: String
) {
    fun downloadImage(fileName: String): ByteArray {
        val directoryPath: Path = Paths.get(uploadDir)
        val fileName = "$fileName"

        // 디렉토리 내의 파일들 중 해당 파일 이름으로 시작하는 첫 번째 파일을 찾음
        val files: List<Path> = Files.list(directoryPath)
            .use { stream ->
                stream.filter { path ->
                    path.fileName.toString().startsWith(fileName)
                }.toList()
            }

        if (files.isNotEmpty()) {
            // 가장 처음으로 찾은 파일을 다운로드
            return Files.readAllBytes(files[0])
        } else {
            // 파일이 없는 경우에 대한 예외 처리 또는 기본값 설정
            throw FileNotFoundException("File not found: $fileName")
        }
    }
}

3. 로컬 저장소에서 이미지 삭제하기

(1) DELETE /api/image/{imageId} 엔드포인트

클라이언트에서 불필요한 이미지를 안전하게 삭제하는 방법
이미지 아이디를 기반으로 데이터베이스와 로컬 저장소에서 이미지를 삭제하는 프로세스

@RequestMapping("/api")
@RestController
class ImageController(private val service : ImageService) {

    @DeleteMapping(value = ["/image/{imageId}"])
    fun deleteById(@PathVariable imageId: String): ResponseEntity<BaseResp> {
        return service.save(ImageReq(type = type, file = file))
    }
}

(2) ImageService.deleteImage

@Service
class ImgLocalServiceImpl(
    @Value("\${file.upload-dir}")
    private val uploadDir: String
) {
    fun deleteImage(fileName: String) {
        val directoryPath: Path = Paths.get(uploadDir)
        val fileName = "$fileName"

        // 디렉토리 내의 파일들 중 해당 파일 이름으로 시작하는 파일들을 모두 삭제
        Files.newDirectoryStream(directoryPath, "$fileName*").use { stream: DirectoryStream<Path> ->
            stream.forEach { filePath: Path ->
                Files.deleteIfExists(filePath)
            }
        }
    }
}

마무리하며

확장성 고려

현재는 사용자가 적고 프로젝트 초기 단계이기 때문에 서버의 이중화를 고려하지 않았다. 그러나 서버를 이중화하여 두 개의 인스턴스를 둔다면 문제가 발생할 수 있다. 첫 번째 인스턴스에서 저장한 이미지는 두 번째 인스턴스에서 접근하기 어렵다. 그러므로 향후 확장성을 고려하여 외부의 이미지 저장소를 만들어야 한다.
데이터베이스에 이미지 리소스를 저장하는 방법도 있고, S3와 같은 클라우드를 활용하는 방법도 있다. 전자의 경우 이미지의 크기나 부하가 갈 가능성이 있어서 S3에 저장하는 방법을 고려하였다. 이후 포스팅에서는 S3를 이용하여 이미지를 처리하는 방법을 정리하려고 한다.

이미지 저장 트랜젝션

@Service
class ImageService(
    private val repository : ImageRepository,
    private val converter : ImageConverter,
) {

    @Transactional(rollbackFor = [Exception::class])
    fun save(request: ImageReq, authReq: AuthReq): ImageResp {
        // 이미지 파일명 채번
        val fileName = generateImageFileName(request)

        // 이미지 정보 저장
        val model = converter.requestToModel(request)
        val result = repository.save(model.apply {
            fileId = fileName
        })

        // 이미지 파일 저장
        storageService.uploadImage(fileName = fileName, imageReq = request)

        return converter.modelToResponse(result)
    }

    // 이미지 파일명 채번
    private fun generateImageFileName(request: ImageReq) : String{
        val timestamp = dateTimeConverter.convertToString(LocalDateTime.now(), DateTimePattern.COMPACT)
        val random = String.format("%02d", Random.nextInt(0, 100))
        return "IM${request.type.prefix}${timestamp}${random}"
    }
}

 

A. 메타데이터 저장 후 리소스 저장
이미지를 저장하기 위해 아래 프로세스를 거친다.

  1. 이미지 메타정보(이미지-아이디, 이미지-크기, 이미지-파일명)을 저장
  2. 이미지 리소스를 저장

(1)메타정보를 저장은 문제없이 처리되었고, (2)이미지 리소스를 저장에서 문제가 생긴다면 비교적 쉽게 롤백할 수 있다.

 

B. 리소스 저장 후 메타데이터 저장

반대로 아래의 순서로 이미지 처리가 시작된다고 해보자.

  1. 이미지 리소스를 저장
  2. 이미지 메타정보(이미지-아이디, 이미지-크기, 이미지-파일명)을 저장

(1)이미지 리소스를 저장은 문제없이 처리되었고, (2)메타정보를 저장에서 문제가 생긴다면, 정합성을 위해 저장된 이미지 리소스를 삭제해야 한다. 이는 데이터베이스 저장내역을 롤백하는 것보다 까다로운 작업이라고 생각해서, 트랜잭션을 A처럼 구성하였다.

Comments