비교 요약으로 돌아가기

Full output

Claude 원문 결과물

이미지 업로드 검증 API 구현 / Anthropic / Opus 4.8 / 실행일 2026-07-24

이미지 업로드 검증 API 구현

검증은 순수 로직으로 모으고 저장은 Storage 인터페이스 뒤로 감췄습니다. 컨트롤러는 MultipartFile을 받아 서비스에 넘기고, 서비스가 파일명·확장자·MIME·크기를 순서대로 검사한 뒤 통과한 경우에만 Storage.save를 호출합니다. 크기 검사를 file.bytes를 읽기 전에 두어 5 MiB를 넘는 파일은 바이트를 메모리에 올리지 않고 거부합니다. 응답에는 생성한 UUID 파일명·MIME·바이트 수만 담고, 원본 파일명이나 저장 경로는 노출하지 않습니다. 로컬 fake는 메모리 리스트에 저장 내역을 쌓아 두어 앱 구동과 수동 확인에 쓰고, 테스트는 MockK로 Storage를 대체해 호출 여부만 검증합니다. 총 5개 파일(구현 4 + 테스트 1)입니다.

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

package com.samebrief.image

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

과업이 준 계약 그대로입니다. 서비스는 이 인터페이스에만 의존하므로 S3든 파일 시스템이든 구현을 갈아 끼워도 검증 로직은 바뀌지 않습니다.

src/main/kotlin/com/samebrief/image/InMemoryStorage.kt

package com.samebrief.image

import org.springframework.stereotype.Component

@Component
class InMemoryStorage : Storage {

    data class Saved(val fileName: String, val bytes: ByteArray, val contentType: String)

    private val store = mutableListOf<Saved>()

    val saved: List<Saved> get() = store.toList()

    override fun save(fileName: String, bytes: ByteArray, contentType: String) {
        store += Saved(fileName, bytes, contentType)
    }
}

실제 저장소 대신 쓰는 로컬 fake입니다. @Component로 등록해 앱이 그대로 뜨고, saved 프로퍼티로 저장 호출 여부와 저장된 값(파일명·바이트·MIME)을 밖에서 확인할 수 있습니다. 테스트에서는 이 fake 대신 MockK 목을 주입하지만, 저장된 값 자체를 확인하고 싶을 때는 이 구현을 그대로 넘겨 쓸 수 있습니다.

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

package com.samebrief.image

import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.util.UUID

class InvalidImageUploadException : RuntimeException()

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

@Service
class ImageUploadService(private val storage: Storage) {

    fun store(file: MultipartFile): StoredImage {
        val originalName = file.originalFilename
        if (originalName.isNullOrBlank()) throw InvalidImageUploadException()

        val extension = extensionOf(originalName) ?: throw InvalidImageUploadException()
        val expectedContentType = ALLOWED[extension] ?: throw InvalidImageUploadException()

        val contentType = file.contentType
        if (contentType.isNullOrBlank() || contentType != expectedContentType) {
            throw InvalidImageUploadException()
        }

        val size = file.size
        if (size < MIN_SIZE || size > MAX_SIZE) throw InvalidImageUploadException()

        val fileName = "${UUID.randomUUID()}.$extension"
        storage.save(fileName, file.bytes, contentType)
        return StoredImage(fileName, contentType, size)
    }

    /** 마지막 점 뒤 확장자를 소문자로 반환. 점이 없거나 점 뒤가 비면 null. */
    private fun extensionOf(name: String): String? {
        val dot = name.lastIndexOf('.')
        if (dot < 0 || dot == name.length - 1) return null
        return name.substring(dot + 1).lowercase()
    }

    companion object {
        private const val MIN_SIZE = 1L
        private const val MAX_SIZE = 5L * 1024 * 1024
        private val ALLOWED = mapOf(
            "jpg" to "image/jpeg",
            "jpeg" to "image/jpeg",
            "png" to "image/png",
            "webp" to "image/webp",
        )
    }
}

검증 순서가 핵심입니다. ① 파일명 존재 → ② 확장자 추출 → ③ 확장자가 허용 집합에 있는지(gif는 여기서 걸립니다) → ④ 확장자가 기대하는 MIME과 업로드 MIME이 정확히 일치하는지(!= 문자열 비교라 대소문자 차이도 불일치로 처리) → ⑤ 크기 범위. 어느 하나라도 실패하면 InvalidImageUploadException을 던지고 storage.save는 호출되지 않습니다. 확장자는 .lowercase()로 정규화하므로 photo.JPG.jpg가 되고, jpegjpg로 바꾸지 않고 jpeg 그대로 둡니다. file.bytes는 크기 검사를 통과한 뒤에만 읽어 거대 파일을 불필요하게 적재하지 않습니다.

src/main/kotlin/com/samebrief/image/ImageController.kt

package com.samebrief.image

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
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 ImageController(private val service: ImageUploadService) {

    data class ErrorResponse(val code: String, val message: String)

    @PostMapping
    fun upload(@RequestParam("file") file: MultipartFile): ResponseEntity<StoredImage> =
        ResponseEntity.status(HttpStatus.CREATED).body(service.store(file))

    @ExceptionHandler(InvalidImageUploadException::class)
    fun handleInvalidUpload(e: InvalidImageUploadException): ResponseEntity<ErrorResponse> =
        ResponseEntity.badRequest()
            .body(ErrorResponse("INVALID_IMAGE_UPLOAD", "이미지 파일을 확인해 주세요."))
}

컨트롤러는 얇게 유지했습니다. multipart/form-datafile 필드를 받아 서비스에 위임하고, 성공은 201 + StoredImage, 검증 실패는 핸들러가 400 + 명세 그대로의 에러 바디로 변환합니다. 예외 타입이 하나뿐이라 상태 코드 분기나 추가 에러 형식 없이 이 엔드포인트 안에서 닫힙니다.

src/test/kotlin/com/samebrief/image/ImageControllerTest.kt

package com.samebrief.image

import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
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

@WebMvcTest(ImageController::class)
@Import(ImageUploadService::class, ImageControllerTest.MockStorageConfig::class)
class ImageControllerTest {

    private val uuidWithExtension = Regex(
        "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.jpg"
    )

    @TestConfiguration
    class MockStorageConfig {
        @Bean
        fun storage(): Storage = mockk(relaxUnitFun = true)
    }

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var storage: Storage

    @Test
    @DisplayName("photo.JPG · image/jpeg 유효 파일은 201과 UUID 기반 .jpg 파일명으로 한 번 저장된다")
    fun uploadValidJpeg() {
        val file = MockMultipartFile("file", "photo.JPG", "image/jpeg", byteArrayOf(0x1))

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.fileName").value(org.hamcrest.Matchers.matchesPattern(uuidWithExtension.pattern)))
            .andExpect(jsonPath("$.contentType").value("image/jpeg"))
            .andExpect(jsonPath("$.size").value(1))

        val nameSlot = slot<String>()
        verify(exactly = 1) { storage.save(capture(nameSlot), any(), "image/jpeg") }
        assertThat(nameSlot.captured).matches(uuidWithExtension.pattern)
    }

    @Test
    @DisplayName("photo.gif · image/gif는 400 INVALID_IMAGE_UPLOAD이며 저장하지 않는다")
    fun rejectDisallowedExtension() {
        val file = MockMultipartFile("file", "photo.gif", "image/gif", byteArrayOf(0x1))

        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()) }
    }

    @Test
    @DisplayName("photo.png · image/jpeg는 MIME 불일치로 400이며 저장하지 않는다")
    fun rejectMimeMismatch() {
        val file = MockMultipartFile("file", "photo.png", "image/jpeg", byteArrayOf(0x1))

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("INVALID_IMAGE_UPLOAD"))

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

    @Test
    @DisplayName("5 MiB를 초과한 photo.webp · image/webp는 400이며 저장하지 않는다")
    fun rejectOversized() {
        val oversized = ByteArray(5 * 1024 * 1024 + 1)
        val file = MockMultipartFile("file", "photo.webp", "image/webp", oversized)

        mockMvc.perform(multipart("/api/v1/images").file(file))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("INVALID_IMAGE_UPLOAD"))

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

@WebMvcTest로 웹 계층만 띄우고 실제 ImageUploadService@Import로, Storage@TestConfigurationmockk(relaxUnitFun = true)로 주입합니다. relaxUnitFun이라 save는 아무 동작 없이 호출만 기록됩니다. 성공 케이스는 응답 fileName이 하이픈 포함 UUID + .jpg 패턴인지 확인하고, slot으로 잡은 저장 파일명이 같은 패턴인지, MIME이 image/jpeg로 넘어갔는지, 정확히 한 번 저장됐는지 검증합니다. 실패 세 케이스는 400과 에러 바디를 확인한 뒤 verify(exactly = 0)으로 저장이 전혀 호출되지 않았음을 보장합니다. 초과 크기 테스트는 5 MiB + 1바이트 배열을 만들어 경계를 넘깁니다.

시작 프로젝트에 없다면 필요한 최소 설정

기존 스타터에 MockK가 없다면 build.gradle.ktsdependencies에 다음 한 줄만 추가하면 됩니다(테스트는 스프링 기본 제공 MockMvc·JUnit 5·AssertJ를 사용).

testImplementation("io.mockk:mockk:1.13.11")

멀티파트 처리는 Spring Boot 기본값으로 활성화돼 있으며, 5 MiB 파일을 받으려면 프레임워크 기본 업로드 한도(기본 1 MB)를 넉넉히 열어 둡니다. application.yml이 없다면 아래 최소 설정만 둡니다.

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

실행

./gradlew test --tests com.samebrief.image.ImageControllerTest

예상 결과: 네 건 모두 통과.

  1. photo.JPG·image/jpeg·1바이트 → 201, <uuid>.jpg로 1회 저장.
  2. photo.gif·image/gif → 400 INVALID_IMAGE_UPLOAD, 저장 0회.
  3. photo.png·image/jpeg(MIME 불일치) → 400, 저장 0회.
  4. photo.webp·image/webp·5 MiB 초과 → 400, 저장 0회.