비교 요약으로 돌아가기

Full output

Antigravity 원문 결과물

이미지 업로드 검증 API 구현 / Google / Gemini 3.5 Flash / 실행일 2026-07-24

08. 이미지 업로드 검증 API 구현 (Antigravity)

추가된 파일 목록

  1. src/main/kotlin/com/samebrief/image/storage/Storage.kt
  2. src/main/kotlin/com/samebrief/image/dto/ImageDtos.kt
  3. src/main/kotlin/com/samebrief/image/service/ImageUploadService.kt
  4. src/main/kotlin/com/samebrief/image/controller/ImageUploadController.kt
  5. src/main/kotlin/com/samebrief/image/config/ImageConfig.kt
  6. src/test/kotlin/com/samebrief/image/controller/ImageUploadControllerTest.kt

src/main/kotlin/com/samebrief/image/storage/Storage.kt

package com.samebrief.image.storage

interface Storage {
    fun save(fileName: String, bytes: ByteArray, contentType: String)
}

class FakeStorage : Storage {
    data class SavedFile(val fileName: String, val bytes: ByteArray, val contentType: String)
    
    val savedFiles = mutableListOf<SavedFile>()
    
    override fun save(fileName: String, bytes: ByteArray, contentType: String) {
        savedFiles.add(SavedFile(fileName, bytes, contentType))
    }
    
    fun clear() {
        savedFiles.clear()
    }
}

src/main/kotlin/com/samebrief/image/dto/ImageDtos.kt

package com.samebrief.image.dto

data class ImageUploadResponse(
    val fileName: String,
    val contentType: String,
    val size: Long
)

data class ErrorResponse(
    val code: String = "INVALID_IMAGE_UPLOAD",
    val message: String = "이미지 파일을 확인해 주세요."
)

src/main/kotlin/com/samebrief/image/service/ImageUploadService.kt

package com.samebrief.image.service

import com.samebrief.image.dto.ImageUploadResponse
import com.samebrief.image.storage.Storage
import org.springframework.web.multipart.MultipartFile
import java.util.UUID

class InvalidImageUploadException : RuntimeException()

class ImageUploadService(private val storage: Storage) {

    fun upload(file: MultipartFile): ImageUploadResponse {
        val originalFilename = file.originalFilename
        if (originalFilename.isNullOrBlank()) {
            throw InvalidImageUploadException()
        }

        val dotIndex = originalFilename.lastIndexOf('.')
        if (dotIndex == -1 || dotIndex == originalFilename.length - 1) {
            throw InvalidImageUploadException()
        }

        val extension = originalFilename.substring(dotIndex + 1).lowercase()
        val contentType = file.contentType
        if (contentType.isNullOrBlank()) {
            throw InvalidImageUploadException()
        }

        val expectedContentType = when (extension) {
            "jpg", "jpeg" -> "image/jpeg"
            "png" -> "image/png"
            "webp" -> "image/webp"
            else -> throw InvalidImageUploadException()
        }

        if (contentType != expectedContentType) {
            throw InvalidImageUploadException()
        }

        val size = file.size
        if (size < 1 || size > 5 * 1024 * 1024) {
            throw InvalidImageUploadException()
        }

        val uuid = UUID.randomUUID().toString()
        val newFileName = "$uuid.$extension"
        val bytes = file.bytes

        storage.save(newFileName, bytes, contentType)

        return ImageUploadResponse(
            fileName = newFileName,
            contentType = contentType,
            size = size
        )
    }
}

src/main/kotlin/com/samebrief/image/controller/ImageUploadController.kt

package com.samebrief.image.controller

import com.samebrief.image.dto.ErrorResponse
import com.samebrief.image.dto.ImageUploadResponse
import com.samebrief.image.service.ImageUploadService
import com.samebrief.image.service.InvalidImageUploadException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

@RestController
@RequestMapping("/api/v1/images")
class ImageUploadController(private val imageUploadService: ImageUploadService) {

    @PostMapping
    fun uploadImage(@RequestParam("file") file: MultipartFile): ResponseEntity<ImageUploadResponse> {
        val response = imageUploadService.upload(file)
        return ResponseEntity.status(HttpStatus.CREATED).body(response)
    }

    @ExceptionHandler(InvalidImageUploadException::class)
    fun handleInvalidImageUpload(ex: InvalidImageUploadException): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse())
    }
}

src/main/kotlin/com/samebrief/image/config/ImageConfig.kt

package com.samebrief.image.config

import com.samebrief.image.service.ImageUploadService
import com.samebrief.image.storage.FakeStorage
import com.samebrief.image.storage.Storage
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class ImageConfig {

    @Bean
    fun storage(): Storage = FakeStorage()

    @Bean
    fun imageUploadService(storage: Storage): ImageUploadService =
        ImageUploadService(storage)
}

src/test/kotlin/com/samebrief/image/controller/ImageUploadControllerTest.kt

package com.samebrief.image.controller

import com.samebrief.image.storage.FakeStorage
import io.mockk.clearMocks
import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@SpringBootTest
@AutoConfigureMockMvc
class ImageUploadControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var fakeStorage: FakeStorage

    @TestConfiguration
    class TestConfig {
        @Bean
        @Primary
        fun testStorage(): FakeStorage = spyk(FakeStorage())
    }

    @BeforeEach
    fun setUp() {
        fakeStorage.clear()
        clearMocks(fakeStorage)
    }

    @Test
    fun `1 photo_JPG, image_jpeg, 1byte file returns 201 and stored once`() {
        val file = MockMultipartFile(
            "file",
            "photo.JPG",
            "image/jpeg",
            byteArrayOf(1)
        )

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.fileName").value(org.hamcrest.Matchers.matchesPattern("^[a-f0-9\\-]{36}\\.jpg$")))
            .andExpect(jsonPath("$.contentType").value("image/jpeg"))
            .andExpect(jsonPath("$.size").value(1))

        verify(exactly = 1) {
            fakeStorage.save(
                fileName = match { it.matches(Regex("^[a-f0-9\\-]{36}\\.jpg$")) },
                bytes = any(),
                contentType = "image/jpeg"
            )
        }
    }

    @Test
    fun `2 photo_gif, image_gif is invalid and not stored`() {
        val file = MockMultipartFile(
            "file",
            "photo.gif",
            "image/gif",
            byteArrayOf(1)
        )

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("INVALID_IMAGE_UPLOAD"))
            .andExpect(jsonPath("$.message").value("이미지 파일을 확인해 주세요."))

        verify(exactly = 0) {
            fakeStorage.save(any(), any(), any())
        }
    }

    @Test
    fun `3 photo_png, image_jpeg is invalid and not stored`() {
        val file = MockMultipartFile(
            "file",
            "photo.png",
            "image/jpeg",
            byteArrayOf(1)
        )

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("INVALID_IMAGE_UPLOAD"))
            .andExpect(jsonPath("$.message").value("이미지 파일을 확인해 주세요."))

        verify(exactly = 0) {
            fakeStorage.save(any(), any(), any())
        }
    }

    @Test
    fun `4 over 5MiB photo_webp, image_webp is invalid and not stored`() {
        val over5MiB = ByteArray(5 * 1024 * 1024 + 1)
        val file = MockMultipartFile(
            "file",
            "photo.webp",
            "image/webp",
            over5MiB
        )

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("INVALID_IMAGE_UPLOAD"))
            .andExpect(jsonPath("$.message").value("이미지 파일을 확인해 주세요."))

        verify(exactly = 0) {
            fakeStorage.save(any(), any(), any())
        }
    }
}

실행 및 테스트 방법

테스트 실행 명령

./gradlew test --tests com.samebrief.image.controller.ImageUploadControllerTest

예상 테스트 결과

ImageUploadControllerTest > 1 photo_JPG, image_jpeg, 1byte file returns 201 and stored once PASSED
ImageUploadControllerTest > 2 photo_gif, image_gif is invalid and not stored PASSED
ImageUploadControllerTest > 3 photo_png, image_jpeg is invalid and not stored PASSED
ImageUploadControllerTest > 4 over 5MiB photo_webp, image_webp is invalid and not stored PASSED

BUILD SUCCESSFUL