비교 요약으로 돌아가기

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에 저장된 값을 확인할 수 있습니다.