Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ out/
### VS Code ###
.vscode/


/src/main/resources/storage/
/docs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.ritier.springr2dbcsample

import com.ritier.springr2dbcsample.common.config.properties.StorageProperties
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.ritier.springr2dbcsample.application.factory

import com.ritier.springr2dbcsample.common.exception.AppException
import com.ritier.springr2dbcsample.common.exception.ErrorCode
import com.ritier.springr2dbcsample.domain.model.Image
import com.ritier.springr2dbcsample.domain.vo.image.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirst
import kotlinx.coroutines.withContext
import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Component
import reactor.core.publisher.Flux
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import javax.imageio.ImageIO

@Component
class ImageFactory {
companion object {
suspend fun fromFilePart(filePart: FilePart): Pair<Image, ImagePayload> {
val name = filePart.filename()
?: throw AppException(ErrorCode.INVALID_FILENAME)

val contentType = filePart.headers().contentType?.toString()
?: throw AppException(ErrorCode.UNSUPPORTED_FILE_TYPE)

val bytes = filePart.content()
.flatMap { Flux.just(it.asByteBuffer().array()) }
.collectList()
.awaitFirst()
.let { chunks ->
ByteArrayOutputStream().apply {
chunks.forEach(::write)
}.toByteArray()
}

val payload = ImagePayload(bytes)
val dimensions = extractDimensions(bytes)

val image = Image(
fileName = ImageFileName(name),
dimensions = dimensions,
mimeType = MimeType(contentType),
fileSize = FileSize(payload.size()),
uploadedAt = LocalDateTime.now()
)

val validationResult = image.validate()
if (validationResult is ValidationResult.Invalid) {
throw AppException(ErrorCode.INVALID_IMAGE_FORMAT)
}

return image to payload
}

private suspend fun extractDimensions(bytes: ByteArray): ImageDimensions {
return try {
val bufferedImage = withContext(Dispatchers.IO) {
ImageIO.read(ByteArrayInputStream(bytes))
} ?: throw AppException(ErrorCode.INVALID_IMAGE_FORMAT)

ImageDimensions(bufferedImage.width, bufferedImage.height)
} catch (e: Exception) {
throw AppException(ErrorCode.IMAGE_PROCESSING_FAILED, e)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.common.exception.AppException
import com.ritier.springr2dbcsample.common.exception.ErrorCode
import com.ritier.springr2dbcsample.domain.helper.PasswordHasher
import com.ritier.springr2dbcsample.domain.model.User
import com.ritier.springr2dbcsample.domain.repository.UserCredentialRepository
import com.ritier.springr2dbcsample.domain.repository.UserRepository
import com.ritier.springr2dbcsample.domain.vo.user.Email
import com.ritier.springr2dbcsample.domain.vo.auth.LoginResult
import com.ritier.springr2dbcsample.infrastructure.security.JwtTokenProvider
import com.ritier.springr2dbcsample.presentation.dto.auth.TokenResponse
import com.ritier.springr2dbcsample.presentation.dto.auth.SignInRequest
import com.ritier.springr2dbcsample.presentation.dto.auth.SignUpRequest
import com.ritier.springr2dbcsample.presentation.dto.auth.SignUpResponse
import mu.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class AuthService(
private val userCredentialRepository: UserCredentialRepository,
private val userRepository: UserRepository,
private val passwordHasher: PasswordHasher,
private val tokenProvider: JwtTokenProvider,
) {
private val logger = KotlinLogging.logger {}

suspend fun signUp(request: SignUpRequest): SignUpResponse {
if (userRepository.findByEmail(request.email) != null) {
throw AppException(ErrorCode.USER_ALREADY_EXISTS)
}

val user = User.create(request.username, Email(request.email), request.age)
val savedUser = userRepository.save(user)

val credential = savedUser.createCredential(request.password, passwordHasher)
userCredentialRepository.save(credential)

logger.info { "회원가입 성공: ${savedUser.email.value}" }

return SignUpResponse(user.id.value, user.email.value, user.username);
}

suspend fun signIn(request: SignInRequest): TokenResponse {
val credential = userCredentialRepository.findByEmail(request.email)
?: throw AppException(ErrorCode.USER_NOT_FOUND)


val updatedCredential = when (val loginResult = credential.attemptLogin(request.password, passwordHasher)) {
is LoginResult.Success -> {
userCredentialRepository.updateLastLogin(credential.userId)
loginResult.credential
}
LoginResult.AccountInactive -> throw AppException(ErrorCode.USER_INACTIVE)
LoginResult.WrongPassword -> throw AppException(ErrorCode.INVALID_PASSWORD)
}

val token = updatedCredential.issueToken(tokenProvider);

logger.info { "로그인 성공: ${request.email}" }
return TokenResponse(token)

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.domain.repository.CommentRepository
import com.ritier.springr2dbcsample.domain.vo.posting.PostingId
import com.ritier.springr2dbcsample.presentation.dto.posting.CommentDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.springframework.stereotype.Service

@Service
class CommentService(
private val commentRepository: CommentRepository
) {

suspend fun findByPostingId(postingId: Long): Flow<CommentDto> {
return commentRepository.findByPostingId(PostingId(postingId))
.map { CommentDto.from(it) }
}
// TODO: comment에서 user 연관관계 즉시 가져오기
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.domain.vo.image.MultiImageUploadResults
import com.ritier.springr2dbcsample.domain.vo.image.SingleImageUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.springframework.stereotype.Component

@Component
class ImageUploadExecutor {
companion object {
private const val MAX_CONCURRENT_UPLOADS = 5
}

suspend fun <T, R> executeParallel(
items: List<T>,
transform: suspend (T) -> R
): MultiImageUploadResults {
val semaphore = Semaphore(MAX_CONCURRENT_UPLOADS)

val results = coroutineScope {
items.map { item ->
async(Dispatchers.IO) {
semaphore.withPermit {
transform(item)
}
}
}
}.awaitAll()

return MultiImageUploadResults(results as List<SingleImageUploadResult>)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.application.factory.ImageFactory
import com.ritier.springr2dbcsample.common.exception.AppException
import com.ritier.springr2dbcsample.domain.model.Image
import com.ritier.springr2dbcsample.domain.repository.ImageRepository
import com.ritier.springr2dbcsample.domain.vo.image.ImageUploadResult
import com.ritier.springr2dbcsample.domain.vo.image.StorageResult
import com.ritier.springr2dbcsample.infrastructure.storage.port.ImageStoragePort
import mu.KotlinLogging
import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service


@Service
class ImageUploadService(
private val imageStoragePort: ImageStoragePort,
private val imageRepository: ImageRepository
) {
private val logger = KotlinLogging.logger {}

suspend fun uploadImage(filePart: FilePart): ImageUploadResult {
return try {
val (image, payload) = ImageFactory.fromFilePart(filePart)

logImageInfo(image)

val storageResult = imageStoragePort.upload(image, payload)

when (storageResult) {
is StorageResult.Success -> {
val imageWithUrl = image.copy(url = storageResult.url)
val savedImage = imageRepository.save(imageWithUrl)

logger.info { "이미지 업로드 성공: ${savedImage.fileName.value}" }
ImageUploadResult.Success(savedImage.toDto())
}
is StorageResult.Failure -> {
logger.error { "스토리지 업로드 실패: ${storageResult.error}" }
ImageUploadResult.Failure(storageResult.error)
}
}
} catch (e: AppException) {
logger.error(e) { "이미지 업로드 실패: ${e.message}" }
ImageUploadResult.Failure(e.message ?: "알 수 없는 오류")
}
}

private fun logImageInfo(image: Image) {
if (!image.isWebOptimized()) {
logger.warn { "웹 최적화되지 않은 이미지: ${image.fileName.value}" }
}
if (image.needsOptimization()) {
logger.info { "최적화 권장 이미지: ${image.fileName.value}" }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.common.exception.ErrorCode
import com.ritier.springr2dbcsample.domain.vo.image.ImageUploadResult
import com.ritier.springr2dbcsample.domain.vo.image.MultiImageUploadResults
import com.ritier.springr2dbcsample.domain.vo.image.SingleImageUploadResult
import com.ritier.springr2dbcsample.presentation.dto.image.MultiImageUploadRequest
import mu.KotlinLogging
import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service

@Service
class MultiImageUploadService(
private val imageUploadService: ImageUploadService,
private val uploadExecutor: ImageUploadExecutor
) {
private val logger = KotlinLogging.logger {}

suspend fun uploadMultipleImages(request: MultiImageUploadRequest): MultiImageUploadResults {
logger.info { "다중 이미지 업로드 시작: ${request.files.size}개 파일" }

return uploadExecutor.executeParallel(request.files) { filePart ->
uploadSingleImage(filePart)
}
}

private suspend fun uploadSingleImage(filePart: FilePart): SingleImageUploadResult {
return try {
val uploadResult = imageUploadService.uploadImage(filePart)
when (uploadResult) {
is ImageUploadResult.Success ->
SingleImageUploadResult.Success(
fileName = filePart.filename() ?: "unknown",
url = uploadResult.image.url ?: "",
imageDto = uploadResult.image
)
is ImageUploadResult.Failure ->
SingleImageUploadResult.Failure(
fileName = filePart.filename() ?: "unknown",
error = uploadResult.error
)
}
} catch (e: Exception) {
logger.warn(e) { "개별 이미지 업로드 실패: ${filePart.filename()}" }
SingleImageUploadResult.Failure(
fileName = filePart.filename() ?: "unknown",
error = e.message ?: ErrorCode.INTERNAL_SERVER_ERROR.message,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.ritier.springr2dbcsample.application.service

import com.ritier.springr2dbcsample.common.exception.AppException
import com.ritier.springr2dbcsample.common.exception.ErrorCode
import com.ritier.springr2dbcsample.domain.repository.PostingRepository
import com.ritier.springr2dbcsample.domain.vo.posting.PostingId
import com.ritier.springr2dbcsample.domain.vo.user.UserId
import com.ritier.springr2dbcsample.presentation.dto.posting.PostingDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mu.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class PostingService(private val postingRepository: PostingRepository) {

private val logger = KotlinLogging.logger {}

suspend fun findPostingById(id: Long): PostingDto {
val posting = postingRepository.findById(PostingId(id))
?: throw AppException(ErrorCode.POSTING_NOT_FOUND)

logger.info("게시물 조회 성공 - $id")
return PostingDto.from(posting)
}

// 성능 최적화: bufferUntilChanged 패턴으로 N+1 해결
suspend fun findAllPostings(): Flow<PostingDto> {
return postingRepository.findAll()
.map { PostingDto.from(it) } // 연관관계 데이터 포함
}

suspend fun findPostingsByUserId(userId: Long): Flow<PostingDto> {
return postingRepository.findByUserId(UserId(userId))
.map { PostingDto.from(it) }
}


// FIXME: > suspend fun findAll(): Flow<PostingDto> = postingRepository.findAll().asFlow().map(this::loadRelations)
// Took average 300ms~500ms to take response from client.(too slow)
// !!! non-blocking 과 관리용이성을 보장하면서 성능을 개선하는 방법을 보완할 필요 !!!
// @Autowired
// private lateinit var postingRepository: PostingRepository
// @Autowired
// private lateinit var postingImageRepository: PostingImageRepository
// @Autowired
// private lateinit var userRepository: UserRepository
// suspend fun loadRelations(posting: Posting): PostingDto = withContext(Dispatchers.IO) {
// val deferredUser = async { userRepository.findById(posting.userId) }
// val deferredImages = async { postingImageRepository.findPostingImagesByPostingId(posting.id) }
// posting.user = deferredUser.await()
// posting.images = deferredImages.await().map { it.image!! }.toList()
// PostingDto.from(posting)
// }
}
Loading