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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface MediaRepositoryPort {
fun getMediaForUploadConfirmation(ownerId: Long, ids: List<Long>): List<Media>

fun save(media: Media): Media
fun saveAll(medias: List<Media>): List<Media>

fun delete(id: Long)
fun deleteAll(ids: List<Long>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import com.neki.media.application.command.DeleteMediaCommand
import com.neki.media.application.command.DeleteMediasCommand
import com.neki.media.application.port.MediaBinaryCachePort
import com.neki.media.application.port.MediaRepositoryPort
import com.neki.media.application.port.MediaStoragePort
import org.slf4j.LoggerFactory

/**
Expand All @@ -22,7 +21,6 @@ import org.slf4j.LoggerFactory
@UseCase
class DeleteMediaUseCase(
private val mediaRepository: MediaRepositoryPort,
private val mediaStorage: MediaStoragePort,
private val cache: MediaBinaryCachePort,
private val transactionRunner: TransactionRunner,
) {
Expand All @@ -33,83 +31,29 @@ class DeleteMediaUseCase(
* media 단건 삭제 usecase
*/
fun execute(command: DeleteMediaCommand) {
// DB media metadata 삭제
val media = transactionRunner.run {
val foundMedia = mediaRepository.getActiveMedia(command.ownerId, command.mediaId)
?: throw BusinessException(ResultCode.NOT_FOUND)

foundMedia.markAsDeleteRequested()
foundMedia.markAsDeleted()
mediaRepository.save(foundMedia)
foundMedia
}

cache.evict(media.storageKey) // cache 무효화 시도

return runCatching {
// object storage 삭제 요청
mediaStorage.deleteByKey(media.storageKey)
}.fold(
onSuccess = {
transactionRunner.runNew {
// 추후 scheduling 검토
mediaRepository.delete(media.id!!)
}
},
onFailure = { e ->
log.warn(
"Media delete failed. Will retry later. fileId={}, key={}",
media.id,
media.storageKey,
e,
)
},
)

// TODO : object storage 삭제 실패한 media에 대해 삭제 필요
cache.evict(media.storageKey)
}

/**
* media bulk 삭제 usecase
*/
fun execute(command: DeleteMediasCommand) {
// 삭제할 media 조회
val medias = transactionRunner.run {
val foundMedias =
mediaRepository.getActiveMedias(command.ownerId, command.mediaIds)

foundMedias.forEach {
it.markAsDeleteRequested()
cache.evict(it.storageKey) // cache 무효화 시도
}
val foundMedias = mediaRepository.getActiveMedias(command.ownerId, command.mediaIds)
foundMedias.forEach { it.markAsDeleted() }
mediaRepository.saveAll(foundMedias)
foundMedias
}

val deletedMediaIds = mutableListOf<Long>()
val failedMedias = mutableListOf<Long>()

// object storage 삭제
medias.forEach { media ->
runCatching {
mediaStorage.deleteByKey(media.storageKey)
}.onSuccess {
deletedMediaIds.add(media.id!!)
}.onFailure { e ->
failedMedias.add(media.id!!)
log.warn(
"Media delete failed. Will retry later. mediaId={}, key={}",
media.id,
media.storageKey,
e,
)
}
}

// object storage 삭제 성공한 오브젝트에 대해 DB soft delete
if (deletedMediaIds.isNotEmpty()) {
transactionRunner.runNew {
mediaRepository.deleteAll(deletedMediaIds)
}
}

// TODO : object storage 삭제 실패한 media에 대해 삭제 필요
medias.forEach { cache.evict(it.storageKey) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class MediaRepositoryAdapter(private val jpaRepository: JpaMediaRepository) : Me

override fun save(media: Media): Media = jpaRepository.save(media)

override fun saveAll(medias: List<Media>): List<Media> = jpaRepository.saveAll(medias)

override fun delete(id: Long) {
jpaRepository.deleteById(id)
}
Expand Down
14 changes: 13 additions & 1 deletion src/main/kotlin/com/neki/photo/domain/entity/PhotoImage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.annotations.DynamicUpdate
import org.hibernate.annotations.SQLRestriction
import java.time.LocalDateTime

/**
* fileName : PhotoImage
Expand All @@ -17,6 +19,7 @@ import org.hibernate.annotations.DynamicUpdate
*/
@Entity
@DynamicUpdate
@SQLRestriction("deleted_at IS NULL")
@Table(name = "TB_PHOTO_IMAGE")
class PhotoImage(
@Id
Expand All @@ -34,4 +37,13 @@ class PhotoImage(

@Column(name = "memo", nullable = true)
var memo: String? = null,
) : BaseTimeEntity()

@Column(name = "deleted_at", nullable = true)
var deletedAt: LocalDateTime? = null,
) : BaseTimeEntity() {

fun softDelete() {
folderId = null
deletedAt = LocalDateTime.now()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ class PhotoImageRepositoryAdapter(
return emptyList()
}

jpaRepository.deleteAll(photos)
photos.forEach { it.softDelete() }
jpaRepository.saveAll(photos)
jpaRepository.flush()

return photos
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class PhotoImageQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory
.update(photoImage)
.setNull(photoImage.folderId)
.where(photoImage.userId.eq(userId), photoImage.folderId.`in`(folderIds))
.where(photoImage.userId.eq(userId), photoImage.folderId.`in`(folderIds), photoImage.deletedAt.isNull)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

QueryDSL의 update 쿼리는 Hibernate의 @SQLRestriction 필터를 자동으로 적용하지 않으므로, 여기서 deletedAt.isNull 조건을 명시적으로 추가하신 것은 매우 적절합니다. 다만, folderIdnull로 변경되는 Soft Delete 특성상 photoImage.folderId.in(folderIds) 조건만으로도 이미 삭제된 데이터는 제외될 것으로 보입니다. 하지만 안전성을 위해 유지하는 것도 나쁘지 않습니다.

.execute().toInt()
}

Expand Down Expand Up @@ -169,6 +169,7 @@ class PhotoImageQueryRepository(private val queryFactory: JPAQueryFactory) {
photoImage.userId.eq(userId),
photoImage.folderId.eq(folderId),
photoImage.id.`in`(photoIds),
photoImage.deletedAt.isNull,
)
.execute().toInt()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- deleted_at 컬럼 추가 (soft delete용)
ALTER TABLE TB_PHOTO_IMAGE ADD COLUMN deleted_at TIMESTAMP NULL;

-- 기존 unique constraint 제거
-- soft-deleted 레코드까지 포함하면 삭제 후 같은 미디어 재등록 시 unique violation 발생
ALTER TABLE TB_PHOTO_IMAGE DROP CONSTRAINT uk_photo_image_media_id;

-- Partial Unique Index로 교체 (deleted_at IS NULL인 레코드에만 unique 보장)
CREATE UNIQUE INDEX uk_photo_image_media_id
ON TB_PHOTO_IMAGE (media_id)
WHERE deleted_at IS NULL;