Full output
Codex 원문 결과물
이미지 업로드 검증 API 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-24
src/main/kotlin/com/samebrief/images/ImageUploadController.kt
package com.samebrief.images
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/v1/images")
class ImageUploadController(
private val imageUploadService: ImageUploadService,
) {
@PostMapping
fun upload(@RequestParam("file") file: MultipartFile): ResponseEntity<ImageUploadResponse> =
ResponseEntity.status(HttpStatus.CREATED).body(imageUploadService.upload(file))
}
data class ImageUploadResponse(
val fileName: String,
val contentType: String,
val size: Long,
)
src/main/kotlin/com/samebrief/images/ImageUploadService.kt
package com.samebrief.images
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.util.Locale
import java.util.UUID
interface Storage {
fun save(fileName: String, bytes: ByteArray, contentType: String)
}
@Service
class ImageUploadService(
private val storage: Storage,
) {
fun upload(file: MultipartFile): ImageUploadResponse {
val originalFileName = file.originalFilename ?: throw InvalidImageUploadException()
val extension = extensionOf(originalFileName) ?: throw InvalidImageUploadException()
val contentType = file.contentType ?: throw InvalidImageUploadException()
val expectedContentType = CONTENT_TYPES[extension] ?: throw InvalidImageUploadException()
if (contentType != expectedContentType || file.size !in 1..MAX_FILE_SIZE) {
throw InvalidImageUploadException()
}
val fileName = "${UUID.randomUUID()}.$extension"
val bytes = file.bytes
storage.save(fileName, bytes, contentType)
return ImageUploadResponse(fileName, contentType, file.size)
}
private fun extensionOf(fileName: String): String? {
val lastDot = fileName.lastIndexOf('.')
if (fileName.isBlank() || lastDot == -1 || lastDot == fileName.lastIndex) return null
return fileName.substring(lastDot + 1).lowercase(Locale.ROOT)
}
private companion object {
const val MAX_FILE_SIZE = 5L * 1024 * 1024
val CONTENT_TYPES = mapOf(
"jpg" to "image/jpeg",
"jpeg" to "image/jpeg",
"png" to "image/png",
"webp" to "image/webp",
)
}
}
src/main/kotlin/com/samebrief/images/InMemoryStorage.kt
package com.samebrief.images
import org.springframework.stereotype.Component
@Component
class InMemoryStorage : Storage {
private val files = mutableListOf<StoredFile>()
@Synchronized
override fun save(fileName: String, bytes: ByteArray, contentType: String) {
files += StoredFile(fileName, bytes.copyOf(), contentType)
}
@Synchronized
fun savedFiles(): List<StoredFile> = files.map {
it.copy(bytes = it.bytes.copyOf())
}
data class StoredFile(
val fileName: String,
val bytes: ByteArray,
val contentType: String,
)
}
src/main/kotlin/com/samebrief/images/ImageUploadExceptionHandler.kt
package com.samebrief.images
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ImageUploadExceptionHandler {
@ExceptionHandler(InvalidImageUploadException::class)
fun handleInvalidImageUpload(): ResponseEntity<ImageUploadError> =
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ImageUploadError("INVALID_IMAGE_UPLOAD", "이미지 파일을 확인해 주세요."))
}
data class ImageUploadError(
val code: String,
val message: String,
)
class InvalidImageUploadException : RuntimeException()
src/test/kotlin/com/samebrief/images/ImageUploadControllerTest.kt
package com.samebrief.images
import io.mockk.any
import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.http.MediaType
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
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ImageUploadControllerTest {
private lateinit var storage: InMemoryStorage
private lateinit var mockMvc: MockMvc
@BeforeEach
fun setUp() {
storage = spyk(InMemoryStorage())
mockMvc = MockMvcBuilders
.standaloneSetup(ImageUploadController(ImageUploadService(storage)))
.setControllerAdvice(ImageUploadExceptionHandler())
.build()
}
@Test
fun `JPG image is saved once and returns a UUID jpg file name`() {
val bytes = byteArrayOf(1)
val file = MockMultipartFile("file", "photo.JPG", "image/jpeg", bytes)
mockMvc.perform(multipart("/api/v1/images").file(file))
.andExpect(status().isCreated)
.andExpect(jsonPath("$.fileName").value(org.hamcrest.Matchers.matchesPattern(
"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.jpg$",
)))
.andExpect(jsonPath("$.contentType").value("image/jpeg"))
.andExpect(jsonPath("$.size").value(1))
verify(exactly = 1) { storage.save(any(), any(), "image/jpeg") }
val saved = storage.savedFiles().single()
assertTrue(saved.fileName.matches(Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.jpg$")))
assertEquals("image/jpeg", saved.contentType)
assertContentEquals(bytes, saved.bytes)
}
@Test
fun `GIF image is rejected without saving`() {
assertInvalidAndNotSaved(MockMultipartFile("file", "photo.gif", "image/gif", byteArrayOf(1)))
}
@Test
fun `PNG with JPEG MIME type is rejected without saving`() {
assertInvalidAndNotSaved(MockMultipartFile("file", "photo.png", "image/jpeg", byteArrayOf(1)))
}
@Test
fun `WEBP larger than 5 MiB is rejected without saving`() {
assertInvalidAndNotSaved(
MockMultipartFile("file", "photo.webp", "image/webp", ByteArray(5 * 1024 * 1024 + 1)),
)
}
private fun assertInvalidAndNotSaved(file: MockMultipartFile) {
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) { storage.save(any(), any(), any()) }
assertTrue(storage.savedFiles().isEmpty())
}
}
기본 Spring Boot Kotlin 프로젝트에 spring-boot-starter-web, spring-boot-starter-test, mockk가 설정되어 있다는 전제입니다. ./gradlew test --tests com.samebrief.images.ImageUploadControllerTest로 실행하면 MockMvc 요청 네 건에서 각각 201/400 응답과 MockK 저장 호출 횟수, fake에 저장된 값을 확인할 수 있습니다.