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가 되고, jpeg는 jpg로 바꾸지 않고 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-data의 file 필드를 받아 서비스에 위임하고, 성공은 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는 @TestConfiguration의 mockk(relaxUnitFun = true)로 주입합니다. relaxUnitFun이라 save는 아무 동작 없이 호출만 기록됩니다. 성공 케이스는 응답 fileName이 하이픈 포함 UUID + .jpg 패턴인지 확인하고, slot으로 잡은 저장 파일명이 같은 패턴인지, MIME이 image/jpeg로 넘어갔는지, 정확히 한 번 저장됐는지 검증합니다. 실패 세 케이스는 400과 에러 바디를 확인한 뒤 verify(exactly = 0)으로 저장이 전혀 호출되지 않았음을 보장합니다. 초과 크기 테스트는 5 MiB + 1바이트 배열을 만들어 경계를 넘깁니다.
시작 프로젝트에 없다면 필요한 최소 설정
기존 스타터에 MockK가 없다면 build.gradle.kts의 dependencies에 다음 한 줄만 추가하면 됩니다(테스트는 스프링 기본 제공 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
예상 결과: 네 건 모두 통과.
photo.JPG·image/jpeg·1바이트 → 201,<uuid>.jpg로 1회 저장.photo.gif·image/gif→ 400INVALID_IMAGE_UPLOAD, 저장 0회.photo.png·image/jpeg(MIME 불일치) → 400, 저장 0회.photo.webp·image/webp·5 MiB 초과 → 400, 저장 0회.