대충 넘어가지 않는 습관을 위한 기록

이미지 업로드 최적화 : 자바 - 코틀린 변환 기록

uhyvn 2025. 1. 23. 22:58

이번에 합류한 서비스에서 Java/Spring 으로 약 1년간 운영한 코드를, 여러 이유로 팀에서 코틀린으로 변환하며 새롭게 레포를 짜기로 했다.

코틀린은 처음이라 앞으로도 헷갈리거나 오래 걸렸던 내용들이 있으면 기록하려고 한다.

 

이번 글은 곧 런칭하는 다른 서비스에 적용했던 최적화 s3 이미지 업로드 기능을 코틀린에 새로 적용하며 알게 된 내용들, 그리고 새로 적용한 기능을 기록하겠다.

 

 

 


 

 

 

 

S3StorageService

@Service
class S3StorageService(
    private val imageUploader: ImageUploader
) : IStorageService {

    companion object {
        const val CATEGORY_PROFILE: String = "profile"
        const val CATEGORY_LETTER: String = "letter"
    }

    override fun uploadProfileImage(file: MultipartFile): String {
        return imageUploader.uploadImage(file, CATEGORY_PROFILE)
    }

    override fun uploadLetterImage(file: MultipartFile): String {
        return imageUploader.uploadImage(file, CATEGORY_LETTER)
    }
}

 

  • 다른 도메인에서도 쉽게 호출할 수 있도록, 이미지 업로드를 담당하는 클래스와 카테고리를 설정해서 이미지 업로드의 메서드를 호출하는 S3StorageService로 역할과 책임을 분리했다.
  • 다음 코드를 보면 알겠지만, 저 카테고리에 따라 버킷에 디렉토리 구조가 다르게 저장된다.
  • 그래서 우리 서비스에 당장 필요한 두 가지 도메인에 대한 카테고리만 우선 설정했다.

 

 

 

 

ImageUploader - 코드 전체

@Service
class ImageUploader(
    private val s3Client: S3Client
) {
    @Value("\${cloud.aws.s3.bucket}")
    lateinit var bucket: String

    @Value("\${cloud.aws.cloudfront.url}")
    lateinit var cloudFrontUrl: String

    private val log = KotlinLogging.logger {}

    companion object {
        const val DATE_FORMAT_YYYYMMDD: String = "yyyy/MM/dd"
    }

    fun uploadImage(file: MultipartFile, category: String): String {
        val filePath = getFilePath(category)
        var convertedFile: File? = null
        var originalFile: File? = null

        try {
            originalFile = convertMultipartToFile(file)
            convertedFile = convertToWebpWithResize(originalFile, filePath)

            FileInputStream(convertedFile).use { fileInputStream ->
                s3Client.putObject(
                    PutObjectRequest.builder()
                        .bucket(bucket)
                        .key(filePath)
                        .acl(ObjectCannedACL.PUBLIC_READ)
                        .contentType("image/webp")
                        .build(),
                    RequestBody.fromInputStream(fileInputStream, convertedFile.length())
                )
            }
        } catch (e: Exception) {
            커스텀 에러 처리
        } finally {
            deleteLocalFile(originalFile)
            deleteLocalFile(convertedFile)
        }
        return getCloudFrontUrl(filePath)
    }

    fun convertToWebpWithResize(originalFile: File, fileName: String): File {
        return try {
            val webpFile = File("$fileName.webp")
            Files.createDirectories(webpFile.parentFile.toPath())

            ImmutableImage.loader()
                .fromFile(originalFile)
                .max(1280, 1280)
                .output(WebpWriter.DEFAULT, webpFile)

            webpFile
        } catch (e: Exception) {
            커스텀 에러 처리
        }
    }

    private fun convertMultipartToFile(file: MultipartFile): File {
        val convFile = File("${System.getProperty("java.io.tmpdir")}/${file.originalFilename}")
        file.transferTo(convFile)
        return convFile
    }

    private fun getFilePath(category: String): String {
        return "$category/${createDatePath()}/${generateRandomFileNamePrefix()}.webp"
    }

    private fun createDatePath(): String {
        return LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT_YYYYMMDD))
    }

    private fun generateRandomFileNamePrefix(): String {
        return UUID.randomUUID().toString().replace("-", "").substring(0, 16)
    }

    private fun getCloudFrontUrl(fileKey: String): String {
        return "$cloudFrontUrl/$fileKey"
    }

    private fun deleteLocalFile(file: File?) {
        if (file?.exists() == true && !file.delete()) {
            log.warn { "로컬 파일 삭제 실패: ${file.absolutePath}" }
        }
    }

}
  • 이건 전체 코드고, 이제부터 하나씩 Java와의 차이점과 함께 공유하겠다.
  • 코틀린 기본 문법에 대해서는 자료들이 많으니 여기서는 언급하지 않겠다.
  • 그리고,, 아직 코틀린이 익숙하지 않아서 코틀린 문법에 대해서는 따로 찾아보길 바란다.

 

 

 

 

 


 

 

 

 

UploadImage()

    fun uploadImage(file: MultipartFile, category: String): String {
        val filePath = getFilePath(category)
        var convertedFile: File? = null
        var originalFile: File? = null

        try {
            originalFile = convertMultipartToFile(file)
            convertedFile = convertToWebpWithResize(originalFile, filePath)

            FileInputStream(convertedFile).use { fileInputStream ->
                s3Client.putObject(
                    PutObjectRequest.builder()
                        .bucket(bucket)
                        .key(filePath)
                        .acl(ObjectCannedACL.PUBLIC_READ)
                        .contentType("image/webp")
                        .build(),
                    RequestBody.fromInputStream(fileInputStream, convertedFile.length())
                )
            }
        } catch (e: Exception) {
            커스텀 에러 처리
        } finally {
            deleteLocalFile(originalFile)
            deleteLocalFile(convertedFile)
        }
        return getCloudFrontUrl(filePath)
    }

 

핵심 메서드다. 순서부터 설명하자면,

  1. getFilePath() 로 파일을 저장할 경로를 생성한다.
  2. convertMultipartToFile() 로 멀티파트 파일을 임시 디렉토리 파일로 변환하여 반환한다.
  3. convertToWebpWithResize() 로 파일을 WebP 포맷으로 변환하고, 리사이즈한 후 반환한다.
  4. S3에 파일을 업로드한다.
  5. 로컬 디스크에 있는 파일들을 삭제한다.
  6. 이미지 URL을 반환한다.

 

간단히 설명하면 이렇다. 이제 하나씩 Java 코드와 비교하며 살펴보겠다.

 

 


 

 

 

 

Java

private String getFilePath(String category) {
    return category + "/" + createDatePath() + "/" + generateRandomFilePrefix() + ".webp";
}

private String createDatePath() {
    return LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT_YYYYMMDD));
}

private String generateRandomFilePrefix() {
    return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}

public File convertMultipartToFile(MultipartFile file) throws IOException {
    File convFile = new File(System.getProperty("java.io.tmpdir") + "/" + file.getOriginalFilename());
    file.transferTo(convFile);
    return convFile;
}

public File convertToWebpWithResize(File originalFile, String fileName) {
    try {
        File webpFile = new File(fileName + ".webp");
        Files.createDirectories(webpFile.getParentFile().toPath());

        ImmutableImage.loader()
            .fromFile(originalFile)
            .max(1280, 1280)
            .output(WebpWriter.DEFAULT, webpFile);
        return webpFile;

    } catch (Exception e) {
        throw new BaseException(ErrorCode.S3_UPLOADER_ERROR);
    }
}

 

Kotlin

private fun getFilePath(category: String): String {
    return "$category/${createDatePath()}/${generateRandomFileNamePrefix()}.webp"
}

private fun createDatePath(): String {
    return LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT_YYYYMMDD))
}

private fun generateRandomFileNamePrefix(): String {
    return UUID.randomUUID().toString().replace("-", "").substring(0, 16)
}

private fun convertMultipartToFile(file: MultipartFile): File {
    val convFile = File("${System.getProperty("java.io.tmpdir")}/${file.originalFilename}")
    file.transferTo(convFile)
    return convFile
}

fun convertToWebpWithResize(originalFile: File, fileName: String): File {
    return try {
        val webpFile = File("$fileName.webp")
        Files.createDirectories(webpFile.parentFile.toPath())

        ImmutableImage.loader()
            .fromFile(originalFile)
            .max(1280, 1280)
            .output(WebpWriter.DEFAULT, webpFile)

        webpFile
    } catch (e: Exception) {
        log.error("Webp 변환 실패", e)
        throw RuntimeException(e.message, e)
    }
}

 

사실 3번까지는 위처럼 크게 별 다른 점은 없다. 그냥 문법의 차이만 다를 뿐..

 

 

 

 

 

 

Java - uploadImage()

    public UploadImageInfo uploadMultipartFileToBucket(String category, MultipartFile file) {
        
        ...
        try {
            originalFile = convertMultipartToFile(file); // 원본 파일 변환
            convertedFile = convertToWebpWithResize(originalFile, filePath); // WebP 변환

            ObjectMetadata metadata = createMetadataFromFile(convertedFile);

            try (FileInputStream fileInputStream = new FileInputStream(convertedFile)) {
                amazonS3.putObject(new PutObjectRequest(bucket, filePath, fileInputStream, metadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead)
                );
            }
        }
        ...
        
    }
    
    private ObjectMetadata createMetadataFromFile(File file) {
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentType("image/webp");
        metadata.setContentLength(file.length());
        return metadata;
    }

 

Kotlin - uploadImage()

    fun uploadImage(file: MultipartFile, category: String): String {
        
        ...
        try {
            originalFile = convertMultipartToFile(file)
            convertedFile = convertToWebpWithResize(originalFile, filePath)

            FileInputStream(convertedFile).use { fileInputStream ->
                s3Client.putObject(
                    PutObjectRequest.builder()
                        .bucket(bucket)
                        .key(filePath)
                        .acl(ObjectCannedACL.PUBLIC_READ)
                        .contentType("image/webp")
                        .build(),
                    RequestBody.fromInputStream(fileInputStream, convertedFile.length())
                )
            }
        }
        ...
        
    }

 

 

다른 점은 여기서 나타난다.

 

 

메타데이터 생성

자바에서는 분명히 ObjectMetadata를 생성해서 컨텐츠 타입을 넣어줘야 요청 생성이 가능했다.

그러나 코틀린에서는 맨 처음엔 아래와 같은 방법을 제시해 주는 글이 많았다.

    val metadataMap = mapOf(
        "Content-Type" to "image/webp"
    )

 

이렇게 해서 metadate를 맵 형식으로 넣어야만 가능했다.

그래서 시도해 봤지만, 실제 객체의 컨텐츠 타입에는 application/octet-stream 같은 엉뚱한 값이 들어 있었고,

이렇게 만들어진 객체 URL을 입력하면 바로 창이 띄워지는 게 아닌 자동 파일 다운로드가 되는 걸 확인할 수 있었다.

 

 

그래서 또 방법을 찾아 헤매다가, 그냥 PutObjectRequest를 빌드하는 과정에 .contentType으로 타입을 지정하는 메서드를 발견했다!

바로 적용해 봤고 올바르게 작동했다.

 

 

 

 

File.separator

아 그리고 가끔 "/" 말고 File.separator를 사용하는 플젝들을 봤는데, 그렇게 파일 경로로 지정하면 애플리케이션을 실행하는 환경의 운영체제에 따라 \ 혹은 / 로 다르게 들어가기 때문에 또 다른 디렉토리가 생성되거나 이상한 파일 키값이 생성되어서 나중에 클라우드 프론트를 적용할 때 NoSuchKey를 만날 수 있다... 

 

 

 

 

 

AWS SDK 버전 차이 (1.x 와 2.x)

1.x 에서는 AmazonS3 혹은 AmazonS3Client를 사용했었다.

import com.amazonaws.services.s3.AmazonS3;
---
import com.amazonaws.services.s3.AmazonS3Client;

 

그러나 2.x 에서는 S3Client를 사용하게 되면서 PutObjectRequest 설정 방식이 바뀌었다.

import software.amazon.awssdk.services.s3.S3Client

 

 

1.x에서는 new PutObjectRequest(bucket, key, file) 같은 방식으로 객체 업로드를 처리했으나,

2.x에서는 PutObjectRequest.builder()로 세부 필드를 하나씩 지정해줘야 한다.

 

그리고 위에서도 얘기했지만

  • 메타데이터 설정 방식
    • 1.x에서는 메타데이터를 ObjectMetadata로 명확히 설정했으나,
    • 2.x에서는 메타데이터를 PutObjectRequest 내부에서 키-값 형식으로 처리해야 해서 다소 다른 로직이 필요하다.
  • CloudFront와의 연동
    • 1.x에서는 설정이 단순했지만,
    • 2.x에서는 contentType과 같은 설정이 명확히 반영되지 않으면 클라이언트에서 예상치 못한 동작 발생.

 

 

 

 

 

File.delete()

그리고 로컬 디스크에서 파일 삭제는 이번에 적용한 내용이다.

내 코드로 이미지 한 개를 업로드하면, 업로드된 후에도 디스크에는 두 개의 이미지가 저장된다.

 

어느 시점에 저장되냐면,

 

첫 번째로 file.transferTo(File convFile) 를 실행할 때 실제로 파일의 내용을 convFile 경로에 복사하거나 이동해서 저장하게 되는데, 이 과정에서 파일이 디스크에 실제로 생성된다.

결과적으로 File 객체는 파일 경로를 나타내는 역할만 하지만, transferTo() 메서드가 디스크에 파일을 작성하는 동작을 수행하는 것이다.

 

그리고 두 번째는 convertToWebpWithResize 메서드에서 저장된다.

File webpFile = new File(fileName + ".webp");
Files.createDirectories(webpFile.getParentFile().toPath());
ImmutableImage.loader()
    .fromFile(originalFile)
    .max(1280, 1280)
    .output(WebpWriter.DEFAULT, webpFile);
return webpFile;

 

이 메서드에서는 Webp 포맷으로 변환된 파일(webpFile)이 생성된다.

생성된 파일은 이후 uploadImage 메서드에서 S3에 업로드된다. 그러나 업로드 후 더 이상 사용되지 않으므로, 삭제되어야 한다.

 

 

혹시나 S3 업로드 혹은 다른 메서드 실행 중에 에러가 발생하여 도중에 멈춘다 해도,

해당 메서드 실행 전까지 만약 내가 말한 두 메서드가 이미 실행됐다면 로컬에 그대로 저장되어 있으니,

try-finally로 감싸서 도중에 멈추게 되더라도 파일은 삭제하게끔 로직을 작성해야 한다.

 

 

 

 

 

 

 

 


 

 

 

 

다 해결된 문제들을 정리하고 나니 별로 많지 않았는데, 직접 부딪친 과정에서는 시간이 꽤 걸렸다...

 

다음 글들에서는 더 깊은 내용을 다루고 싶다.