Kotlin과 MultiModule을 적용한 Spring Boot Application 작성에 익숙해지기 위해 FileUpload 기능을 각 레이어(Presentation, Application, Domain)로 나눠서 해당 조건을 기반으로 설계를 연습하기 위해 작성한 프로젝트 입니다.
도메인 레이어에서는 비즈니스 도메인과 관련된 주요 로직과 규칙을 포함하는 곳입니다. 비즈니스 영역의 복잡성을 모델링하고 해결하기 위한 핵심 역할을 수행합니다.
FileUpload와 같은 기능은 미디어 파일을 저장하는 기능이 될 수도 있고 엑셀같은 파일을 저장할 수 도 있습니다. 이번에 만들 기능은 미디어 파일을 저장하고 삭제하는 기능을 만들것 이기떄문에 Media라는 도메인에서 File이라는 세부 Domain을 정의하였습니다. 모듈과 패키지는 아래와 같이 구성했습니다.
domain 모듈
├── media 모듈
│ ├── file 패키지
│ │ ├── ...
│ │ └── ...
│ └── ...
├── ...
그러면 FileUpload와 관련된 도메인 모델은 어떤게 필요할까요? 일단 파일은 S3나 Local 같은 저장소에 저장이 될것이고 해당 저장소에 저장된 후 해당 파일에 대한 메타데이터도 어딘가에 저장을 해야합니다. 그러면 일단 FileMetaData 모델과 저장소에 저장하기 위한 정보들을 처리하는 모델들이 필요할 것 같습니다. 핵심 클래스에 대해 알아보도록 하겠습니다.
@Entity
@Table(name = "MEDIA_FILE_META_DATA")
class MediaFileMetaData(
storageType: StorageType,
filePath : String,
originalFileName: String,
fileSize: Long,
fileUrl: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ID", nullable = false)
val id: Long = 0L
@Enumerated(EnumType.STRING)
@Column(name = "STORAGE_TYPE", nullable = false)
var storageType: StorageType = storageType
private set
@Column(name = "FILE_SIZE", nullable = false)
var fileSize: Long = fileSize
private set
@Column(name = "FILE_PATH", nullable = false)
var filePath: String = filePath
private set
@Column(name = "ORIGINAL_FILE_NAME", nullable = false)
var originalFileName: String = originalFileName
private set
@Column(name = "FILE_URL", unique = true, nullable = false)
var fileUrl: String = fileUrl
private set
}
MediaFileMetaData는 File에 대한 메타데이터 클래스입니다.
저장소에 저장하기 위해서는 저장소 구성 데이터가 필요합니다. 가령 S3에 저장한다고 하면 S3Bucket, Local인 경우 StoragePath 이러한 저장소 구성 데이터는 서비스 어플리케이션에 따라 설정이 달라지기 때문에 이러한 데이터를 받기 위해서는 적절한 데이터 모델이 필요합니다. 그래서 저는 아래와 같이 구성했습니다.
sealed class StorageConfiguration(val baseUrl : String) {
data class S3(val s3Bucket : String, val cloudFrontUrl : String) : StorageConfiguration(cloudFrontUrl)
data class Local(val storagePath : String, val localUrl : String) : StorageConfiguration(localUrl)
}
StorageConfiguration는 저장소 구성 데이터 클래스입니다.
저는 해당 도메인을 사용하는 사용자 입장에서 어느 Repository를 통해서 저장해야하지? 라는 생각을 하지 않도록 MediaFileProcessor를 두어 행위를 나타내는 MediaFileAction, MediaFileRepository와 MediaFileConfiguration을 가진 FileStrategy를 통해 어느 Repository를 통해서 저장할지에 대한 로직을 위임했습니다.
sealed class MediaFileAction(open val storageConfiguration: StorageConfiguration) {
abstract fun execute(fileRepository: MediaFileRepository)
data class StoreMediaFile(
val command: StoreFileCommand,
override val storageConfiguration: StorageConfiguration
) : MediaFileAction(storageConfiguration) {
override fun execute(fileRepository: MediaFileRepository) {
fileRepository.save(storageConfiguration, command)
}
}
data class DeleteMediaFile(
val command: DeleteFileCommand,
override val storageConfiguration: StorageConfiguration
) : MediaFileAction(storageConfiguration) {
override fun execute(fileRepository: MediaFileRepository) {
fileRepository.delete(storageConfiguration, command)
}
}
data class StoreFileCommand(
val fileInputStream: InputStream,
val filePath: String,
val fileContentType: String,
val fileSize: Long
)
data class DeleteFileCommand(
val filePath: String
)
}
MediaFileAction은 저장소의 동작을 정의한 클래스입니다.
저장하고 삭제만 할 것이기 때문에 저장과 관련된 액션과 삭제와 관련된 액션을 정의하였습니다. 각 액션마다 필요한 데이터가 다르기 때문에 sealed class를 통해 분리해서 각각 필요한 데이터를 받을 수 있게 정의하였습니다.
interface MediaFileRepository {
val storageConfigClass: Class<out StorageConfiguration>
fun save(
storageConfiguration: StorageConfiguration,
command : FileStrategy.StoreFileCommand
)
fun delete(
storageConfiguration: StorageConfiguration,
command : FileStrategy.DeleteFileCommand
)
fun resolveConfiguration(configuration : StorageConfiguration) : Boolean {
return storageConfigClass.isInstance(configuration)
}
fun <T : StorageConfiguration> castConfiguration(configuration: StorageConfiguration, castConfiguration: Class<T>): T {
if (!castConfiguration.isInstance(configuration)) {
throw IllegalArgumentException("StorageConfiguration type mismatch")
}
return castConfiguration.cast(configuration)
}
}
MediaFileRepository은 저장소에 대한 인터페이스 입니다.
CastConfiguration Method : MediaFileRepository로 저장소들을 추상화했기 떄문에 StorageConfiguration도 구체적인 클래스에 대해 알지 못해 필요한 정보를 가져오지 못하기 때문에 저장소에 맞는 StorageConfiguration Casting해서 반환해주는 로직
class MediaFileProcessor internal constructor(
private val action: MediaFileAction,
private val fileRepository: MediaFileRepository
) {
fun executeAction() {
action.execute(fileRepository)
}
}
MediaFileProcess는 사용자가 실제로 저장소에 대한 동작을 실행시키기 위한 클래스입니다.
MediaFileProccessor를 생성하는 책임을 MediaFileProccessorFactory로 위임 시킬 것이기 때문에 다른 모듈에서 해당 프로세서를 직접 생성하지 못하게 internal 접근 제어자로 선언하였고 executeAction 메서드를 통해 인자로 받은 Action을 실행하게 됩니다.
class MediaFileProcessorFactory(
private val fileRepositoryResolver: MediaFileRepositoryResolver
) {
fun create(
mediaFileAction: MediaFileAction
): MediaFileProcessor {
val storageConfiguration = mediaFileAction.storageConfiguration
val repository = fileRepositoryResolver.resolve(storageConfiguration)
return MediaFileProcessor(
action = mediaFileAction,
fileRepository = repository
)
}
}
MediaFileProcessorFactory는 MediaFileProcessor를 생성하기 위한 클래스입니다.
사용자는 MediaFileProcessorFactory의 create method를 통해 Processor를 생성할 수 있습니다. create method를 보면 먼저 Repository를 찾고 Action과 Repsoitory를 기반으로 MediaFileProcessor를 생성해 반환합니다.
class MediaFileRepositoryResolver(
private val fileRepositories : List<MediaFileRepository>
) {
fun resolve(storageConfiguration : StorageConfiguration): MediaFileRepository {
val resolveFileRepositoryPredicate: (MediaFileRepository) -> Boolean = { fileRepository ->
fileRepository.resolveConfiguration(
configuration = storageConfiguration
)
}
return fileRepositories.find(resolveFileRepositoryPredicate)
?: throw IllegalArgumentException("Can't resolve storageConfiguration for FileRepository")
}
}
MediaFileRepositoryResolver는 MediaFileRepository를 반환하는 클래스입니다.
MediaFileRepository를 어떻게 찾을 수 있는지 로직을 보면 StorageConfiguration을 해당 Repository가 처리할 수 있는지 resolveConfiguration을 통해 확인하고 있으면 그걸 반환해줍니다.
위에서는 도메인을 살펴봤는데요 이번에는 Infrastructure에 대해 살펴보도록 하겠습니다.
저는 아래와 같이 편의상 별도의 모듈로 두진 않고 file 패키지안에 infra 패키지로 구성하였습니다.
domain 모듈
├── media 모듈
│ ├── file 패키지
│ │ ├── infra 패키지
│ │ └── ...
│ └── ...
├── ...
그러면 Infrasturcture에는 무엇이 있어야할까요? MediaFileRepository의 구현체가 있어야할텐데 Local 저장소와 S3 저장소 둘다 다루면 글이 길어지기 때문에 Local 저장소만 다루도록 하겠습니다.
@Repository
class LocalFileRepository : MediaFileRepository {
private val localConfigClass = StorageConfiguration.Local::class.java
override val storageConfigClass: Class<out StorageConfiguration> = localConfigClass
override fun save(storageConfiguration: StorageConfiguration, command: StoreFileCommand) {
validateStoreFile(command)
val config = castConfiguration(storageConfiguration, localConfigClass)
val filePath = Path(config.storagePath).resolve(command.filePath)
.normalize()
try {
Files.createDirectories(filePath)
Files.copy(command.fileInputStream, filePath, StandardCopyOption.REPLACE_EXISTING)
} catch (exception: IOException) {
val deleteCommand = DeleteFileCommand(command.filePath)
delete(storageConfiguration, deleteCommand)
throw IllegalArgumentException(
"Store file for local fail : FileName [%s]".format(command.filePath, exception)
)
}
}
override fun delete(storageConfiguration: StorageConfiguration, command: DeleteFileCommand) {
val config = castConfiguration(storageConfiguration, localConfigClass)
try {
val filePath = Path(config.storagePath).resolve(command.filePath)
.normalize()
Files.deleteIfExists(filePath)
} catch (e: IOException) {
throw IllegalArgumentException(
"Delete file for local fail : FileName [%s]".format(command.filePath)
)
}
}
private fun validateStoreFile(command: StoreFileCommand) {
val filePath: String = command.filePath
if (filePath.contains("..")) {
throw IllegalArgumentException("Invalid store file for Local")
}
}
}
코드가 엄청 긴데 위에서 부터 한번 보겠습니다.
private val localConfigClass = StorageConfiguration.Local::class.java
override val storageConfigClass: Class<out StorageConfiguration> = localConfigClass
위 두 프로퍼티 같은 경우는 StorageConfiguration에 대해 검증하고 저장소에 맞는 구성파일을 가져오기 위해 사용되는 프로퍼티입니다.
override fun save(storageConfiguration: StorageConfiguration, command: StoreFileCommand) {
validateStoreFile(command)
val config = castConfiguration(storageConfiguration, localConfigClass)
val filePath = Path(config.storagePath).resolve(command.filePath)
.normalize()
try {
Files.createDirectories(filePath)
Files.copy(command.fileInputStream, filePath, StandardCopyOption.REPLACE_EXISTING)
} catch (exception: IOException) {
val deleteCommand = DeleteFileCommand(command.filePath)
delete(storageConfiguration, deleteCommand)
throw IllegalArgumentException(
"Store file for local fail : FileName [%s]".format(command.filePath, exception)
)
}
}
private fun validateStoreFile(command: StoreFileCommand) {
val filePath: String = command.filePath
if (filePath.contains("..")) {
throw IllegalArgumentException("Invalid store file for Local")
}
}
override fun delete(storageConfiguration: StorageConfiguration, command: DeleteFileCommand) {
val config = castConfiguration(storageConfiguration, localConfigClass)
try {
val filePath = Path(config.storagePath).resolve(command.filePath)
.normalize()
Files.deleteIfExists(filePath)
} catch (e: IOException) {
throw IllegalArgumentException(
"Delete file for local fail : FileName [%s]".format(command.filePath)
)
}
}
어플리케이션 레이어에서는 특정 어플리케이션 서비스에 관련한 비즈니스 로직과 비즈니스 규칙을 포함하는 곳입니다. Domain은 전체 어플리케이션과 관련된 비즈니스 로직과 비즈니스 규칙을 정의하지만 어플리케이션 레이어에서는 특정 어플리케이션 서비스에 특화된 비즈니스를 관리합니다.
모듈 구조는 아래와 같습니다.
app 모듈
├── file-upload-api 모듈
│ ├── application 패키지
│ │ ├── file 패키지
│ │ └── ...
│ └── ...
├── ...
편의상 프레젠테이션 레이어와 어플리케이션 레이어를 하나의 app 모듈에 정의하고 패키지를 나눴습니다.
class StorageConfigurationResolver(
private val s3MediaStorageProperties: S3MediaStorageProperties,
private val localMediaStorageProperties: LocalMediaStorageProperties
) {
fun resolve(storageType : StorageType) : StorageConfiguration = when (storageType) {
StorageType.S3 -> StorageConfiguration.S3(s3MediaStorageProperties.bucket, s3MediaStorageProperties.baseUrl)
StorageType.LOCAL -> StorageConfiguration.Local(localMediaStorageProperties.path, localMediaStorageProperties.baseUrl)
}
}
StorageConfigurationResolver는 어플리케이션에 특화된 저장소 구성 데이터로 StorageType에 맞게 StorageConfiguration를 생성해주는 역할을 가지고 있습니다.
@Transactional
@Service
class UploadFileUseCase(
private val processorFactory: MediaFileProcessorFactory,
private val storageConfigurationResolver: StorageConfigurationResolver,
private val fileMetaDataRepository: MediaFileMetaDataRepository
) {
operator fun invoke(command: UploadFileCommand): UploadFileModel {
val storageConfiguration = storageConfigurationResolver.resolve(command.storageType)
val storeFileCommand = createStoreFileCommand(command)
val storeAction = StoreMediaFile(storeFileCommand, storageConfiguration)
processorFactory.create(storeAction)
.executeAction()
val fileUrl = constructFileURI(storageConfiguration.baseUrl, command.filePath).toString()
val fileMetaData = MediaFileMetaData(
storageType = command.storageType,
originalFileName = command.originalFileName,
fileSize = command.fileSize,
fileUrl = fileUrl,
filePath = command.filePath
)
fileMetaDataRepository.save(fileMetaData)
return UploadFileModel(
fileId = fileMetaData.id,
fileUrl = fileUrl
)
}
private fun createStoreFileCommand(command: UploadFileCommand) = StoreFileCommand(
fileInputStream = command.fileInputStream,
filePath = command.filePath,
fileContentType = command.fileContentType,
fileSize = command.fileSize
)
private fun constructFileURI(baseUrl: String, filePath: String): URI = try {
URI(baseUrl).resolve(filePath)
} catch (exception: URISyntaxException) {
throw IllegalArgumentException("URI Parsing Error URL : %s".format(baseUrl), exception)
}
}
파일을 저장하기 위한 UseCase로 storageConfigurationResolver를 통해 StorageConfiguration을 가져오고 StoreAction을 생성해 ProcessorFactory를 통해 MediaFileProccessor를 가져옵니다.
Processor에서 executeAction 메서드를 통해 파일 저장 액션을 실행합니다.
미디어 파일의 메타데이터인 MediaFileMetaData를 저장하고 id와 fileUrl을 반환해줍니다.
@Transactional
@Service
class DeleteFileUseCase(
private val processorFactory: MediaFileProcessorFactory,
private val storageConfigurationResolver: StorageConfigurationResolver,
private val fileMetaDataRepository: MediaFileMetaDataRepository
) {
operator fun invoke(command: DeleteFileCommand) {
val fileMetaData = fileMetaDataRepository.findByIdOrNull(command.fileId)
?: throw IllegalArgumentException("FileMetaData not exist for fileUrl")
val storageConfiguration = storageConfigurationResolver.resolve(fileMetaData.storageType)
val deleteCommand = MediaFileAction.DeleteFileCommand(fileMetaData.filePath)
val action = DeleteMediaFile(deleteCommand, storageConfiguration)
processorFactory.create(action)
.executeAction()
fileMetaDataRepository.delete(fileMetaData)
}
}
파일을 삭제하기 위한 UseCase로 파일을 삭제하기 위해 필요한 데이터를 가져오려고 FileMetaDataId로 FileMetadata를 조회하고 그걸 기반으로 StorageConfiguration를 StorageConfigurationResolver통해 가져오고 DeleteAction을 생성해 ProcessorFactory를 통해 MediaFileProccessor를 가져옵니다.
Processor에서 executeAction 메서드를 통해 파일 삭제 액션을 실행합니다.
파일이 삭제되었다면 FileMetaData도 삭제합니다.
프레젠테이션 레이어는 사용자와 애플리케이션의 비즈니스 로직 사이의 인터페이스 역할을 하며, 소프트웨어 설계의 기본 원칙인 관심사 분리를 보장합니다.
모듈 구조는 아래와 같습니다.
app 모듈
├── file-upload-api 모듈
│ ├── presentation 패키지
│ │ ├── file 패키지
│ │ └── ...
│ └── ...
├── ...
class MultipartFilePathResolver(
@Value("\${file.media.base-root}") private val baseRoot: String
) {
fun resolve(file: MultipartFile): Path {
val fileName = file.originalFilename?.let { originalName ->
val uuid = UUID.randomUUID().toString()
val fileExtension = StringUtils.getFilenameExtension(originalName)
if (fileExtension != null) "$uuid.$fileExtension" else uuid
} ?: throw IllegalArgumentException("Original filename must not be null")
return Path.of(baseRoot).run {
val currentDateString = LocalDate.now(Clock.systemUTC()).format(DateTimeFormatter.ISO_DATE)
this.resolve(currentDateString)
.resolve(abs(currentDateString.hashCode()).toString())
.resolve(fileName)
.normalize()
}
}
}
Spring Web에서는 파일 관련해서 MultipartFile이라는 클래스를 제공하는데 멀티파트파일에 대해서 Request를 받을 수 있습니다. Web 의존성이 아무래도 있기 때문에 다른 DTO로 변환해줘야합니다. 그래서 MultiPartFile에서 저장에 필요한 정보를 가져와 새로운 DTO로 변환해줘야합니다.
MultipartFilePathResolver는 MultiPartFile으로 FilePath 즉 File에 대한 이름을 만들어주는 역할을 가진 클래스입니다. 일단 FileName은 겹치지 않게 UUID와 확장자로 만들고 Path도 겹치지 않도록 현재시간의 hash 값과 BaseRoot를 사용해서 path를 만듭니다.
@RestController
class FileController(
private val multipartFilePathResolver: MultipartFilePathResolver,
private val uploadFileUseCase : UploadFileUseCase,
private val deleteFileUseCase : DeleteFileUseCase
) {
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/api/files")
fun uploadFile(file: MultipartFile): UploadFileResponse {
val filePath = multipartFilePathResolver.resolve(file)
val contentType = file.contentType ?: throw IllegalArgumentException("File content type not exist")
val originalFileName = file.originalFilename?: throw IllegalArgumentException("File OriginalName not exist")
val command = UploadFileCommand(
fileInputStream = file.inputStream,
filePath = filePath.toString(),
originalFileName = originalFileName,
fileContentType = contentType,
fileSize = file.size,
storageType = StorageType.LOCAL
)
val model = uploadFileUseCase(command)
return UploadFileResponse(
fileId = model.fileId,
fileUrl = model.fileUrl
)
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/api/files/{fileId}")
fun deleteFile(@PathVariable fileId: Long) {
val command = DeleteFileCommand(
fileId = fileId
)
deleteFileUseCase.invoke(command)
}
}
uploadFile 메서드는 MultipartFilePathResolver를 통해 FilePath를 가져오고 UploadFileCommand를 만들어 파일을 저장하는 유스케이스를 실행시켜 파일을 저장하고 Response로 FileId와 FileUrl을 반환합니다.
deleteFile 메서드는 FileId를 받아 DelteFileCommand를 만들어 파일을 삭제하는 유스케이스를 실행시켜 파일을 삭제합니다.