Full output
Antigravity 원문 결과물
이미지 업로드 검증 API 구현 / Google / Gemini 3.5 Flash / 실행일 2026-07-24
08. 이미지 업로드 검증 API 구현 (Antigravity)
추가된 파일 목록
src/main/kotlin/com/samebrief/image/storage/Storage.ktsrc/main/kotlin/com/samebrief/image/dto/ImageDtos.ktsrc/main/kotlin/com/samebrief/image/service/ImageUploadService.ktsrc/main/kotlin/com/samebrief/image/controller/ImageUploadController.ktsrc/main/kotlin/com/samebrief/image/config/ImageConfig.ktsrc/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