Skip to content

Conversation

@koosco
Copy link
Member

@koosco koosco commented Jan 8, 2026

summary

  • MediaUseCase 추가

    • GenerateUploadTicketUseCase
    • GetMediasUseCase
    • ConfirmMediaUseCase
    • DeleteMediaUseCase
    • UpdatePhotoUseCase
  • FakeMediaStorage 추가

  • Photo API 추가

    • 이미지 업로드 API
    • 이미지 목록 API
    • 이미지 삭제 API
    • 이미지 벌크 삭제 API
    • 이미지 수정 API
  • PhotoImageE2ETest 추가

details

MediaUseCase 추가

GenerateUploadTicketUseCase

fun execute(command: GenerateUploadTicketCommand): GenerateUploadTicketResult {
        // storageKey 생성
        val storageKey = MediaKey.generate(command.mediaType, command.filename)

        // Media 엔티티 생성 및 저장
        val media = Media(
            storageKey = storageKey,
            ownerId = command.ownerId,
            mediaType = command.mediaType,
            contentType = command.contentType,
        )

        val savedMedia = transactionRunner.run { mediaRepository.save(media) }

        // Upload Ticket 발급
        val uploadTicket = mediaStorage.generateUploadTicket(
            key = storageKey,
            contentType = command.contentType,
        )

        return GenerateUploadTicketResult(
            mediaId = savedMedia.id!!,
            uploadUrl = uploadTicket.url,
            method = uploadTicket.method,
            expiresAt = uploadTicket.expiresAt,
            contentType = command.contentType,
        )
    }
  • 이미지 업로드를 위한 presigned url 발급 usecase
  • 애플리케이션 계층에서 S3에서 사용하는 presignedUrl을 추상화하여 UploadTicket, uploadUrl 워딩을 사용했습니다

GetMediasUseCase

  • cache port 추가
  • 항상 cache miss가 나는 FakeMediaCacheAdapter를 주입받습니다. (추후 redis/local cache adapter 변경)

ConfirmMediaUseCase

  • 이미지 업로드 요청을 하는 다른 서비스 (e.g. 포토이미지, 포토, 프로필 이미지..)에서 엔티티 저장을 하기 전 실제 오브젝트 스토리지에 정상적으로 저장되었는지 확인하기 위한 유스케이스입니다.
  • presigned url을 통해 클라이언트가 이미지를 저장하지 못했을 수도 있기 때문에 이를 검증하기 위한 로직이 필요합니다.
  • 사용자가 이미지를 저장했다고 api를 호출하더라도 실제 오브젝트 스토리지에 저장되어 있지 않다면 이미지 저장 전체 과정이 실패됩니다.
  • 클라이언트 업로드로 진행되기 때문에 조회 시점에 Look Aside 방식으로 캐싱을 할 예정입니다. 캐싱은 storageId를 key로 image binary를 저장하게 됩니다.
  • c.c. 클라이언트가 S3 저장에 대한 응답을 받은 후 서버에 저장 완료 요청을 보낼 것 같은데, 재시도 처리가 필요할지 의견 궁금합니다. @Darren4641

DeleteMediaUseCase

  • 단건 삭제와 배치 삭제를 포함합니다.
    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
        }

        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,
                )
            },
        )
    }
  • cache 무효화는 best effort로 시도하지만 실패할 수 있는 작업입니다. getMediasUseCase에서 DB 조회를 한 후 삭제 상태가 아닌 데이터만을 반환하므로 cache 무효화는 실패가 허용됩니다.
  • object storage 삭제 요청 실패의 경우 현재 재시도 로직을 포함하고 있지 않습니다. object storage 삭제 요청 이전에 DELETE_RERQUESTED로 변경해놓았기 때문에 사용자가 삭제된 리소스에 직접적으로 접근을 불가능합니다. 현재는 실패한 경우 버킷에서 직접 제거하는 과정을 필요로 합니다. (로그 보며 추후 개선)
  • object storage 삭제 요청 이후 디비에서 삭제하는 로직에 대한 재처리가 필요합니다. 삭제를 실패하는 경우 별도의 후처리가 없기 때문에 이에 대한 처리가 필요할 수 있습니다.

FakeMediaStorage 추가

  • test profile용 Media Object Storage 구현체입니다. localstack을 이용한 로컬 테스트를 진해하는 경우 구현체 변경이 필요합니다.

Photo API 추가

이미지 업로드 API

fun execute(command: UploadPhotoCommand): UploadPhotoResult {
        // media가 object storage에 정상적으로 저장되었는지 확인
        val availability = mediaClient.verifyMediaUploaded(
            ownerId = command.userId,
            mediaId = command.mediaId,
        )

        // TODO : 실패한 경우 처리, 재시도 or 실패 간주
        // 현재는 전체 usecase 실패로 간주
        if (availability != AVAILABLE) {
            throw BusinessException(ResultCode.UPLOAD_FAILED)
        }

        val photo = PhotoImage(
            userId = command.userId,
            mediaId = command.mediaId,
            folderId = command.folderId,
        )

        val savedPhoto = transactionRunner.run { photoImageRepository.save(photo) }

        return UploadPhotoResult(savedPhoto.id!!)
    }
  • media service에 요청(현재는 메서드 호출) 이후 실패한 경우 별도 처리 없이 에러를 던집니다. verifyMediaUploaded 실패시 전체 실패, 재시도 등 어떤 방법으로 실패를 처리할지 얘기해보면 좋을 것 같습니다. 추후 포즈나 사용자 프로필 이미지도 비슷한 flow로 진행해도 될 것 같습니다. @Darren4641

이미지 목록 API

  • querydsl 적용
  • paging 처리 (page, size) -> order의 경우 즐겨찾기 API에서 추가했습니다.. 작업하다 보니 좀 꼬였습니다 죄송합니다..ㅜㅜ

이미지 삭제 API / 벌크 삭제 API

  • 트랜잭션 내에서 삭제 후 mediaClient로 삭제 요청. media service에서 삭제에 실패하더라도 사용자는 더 이상 리소스에 접근 불가합니다.

이미지 수정 API

  • 한 명의 사용자만 로그인할 수 있고, 개인 리소스에만 접근 가능하여 동시성을 고려하지 않았습니다.

PhotoImageE2ETest 추가

  • 각 항목별 E2ETest를 추가

c.c.

  • 리소스(mediaId)는 userId 대신 ownerId를 사용했습니다. 요구사항이 변경될 가능성을 고려하여 user가 아니라 그룹이나 커플 등의 소유로 변경될 가능성을 고려하였습니다. 현재는 개인 사용자 아카이빙만을 고려하므로 ownerId = userId로 이해하면 됩니다.
  • squash merge 부탁드립니다.

@github-actions
Copy link

github-actions bot commented Jan 8, 2026

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

github-actions bot commented Jan 8, 2026

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

github-actions bot commented Jan 8, 2026

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@koosco koosco marked this pull request as ready for review January 8, 2026 17:07
@github-actions
Copy link

github-actions bot commented Jan 9, 2026

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

github-actions bot commented Jan 9, 2026

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

Copy link
Contributor

@Darren4641 Darren4641 left a comment

Choose a reason for hiding this comment

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

코멘트 남겼습니다~! @koosco

// 오브젝트 스토리지에 해당 키가 존재하는지 확인
val exists = mediaStorage.exists(media.storageKey)

return transactionRunner.runNew {
Copy link
Contributor

Choose a reason for hiding this comment

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

execute내에서는 트랜잭션이 없는데 새로운 트랜잭션으로 작업하는 이유가 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

Media가 다른 도메인에서 호출되는 경우가 많고, 모놀리식 아키텍처 특성상 실수로 다른 곳에서 트랜잭션이 시작된 후 호출될 수 있다고 생각했습니다. 기존 트랜잭션과 별개로 Media 도메인에서 트랜잭션이 독립적으로 실행되는 것을 보장하기 위해 REQUIRES_NEW로 두었습니다. 기존 트랜잭션이 없다면 동일하게 동작하기 때문에 runNew를 사용했습니다.

@field:Nullable
val folderId: Long?,

val memo: String?,
Copy link
Contributor

Choose a reason for hiding this comment

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

memo Length PM과 논의

val photoIds: List<Long>,
)

data class UpdatePhotoRequest(val memo: String?)
Copy link
Contributor

Choose a reason for hiding this comment

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

기획이슈? 인건지 궁금합니다! memo에대해 NotNull 어노테이션이 더 적절해보이긴합니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

NotNull인 경우 기존에 사진에 있던 메모를 지울 수 없다 생각하여 Null 허용을 두었습니다. 개인이 보기 위한 메모이고, 메모를 없앨 수 있도록 하는 것이 UX상 좋을 것 같다고 판단했습니다. 기획자분과 다시 한 번 얘기 나눠보겠습니다.

// TODO : 실패한 경우 처리, 재시도 or 실패 간주
// 현재는 전체 usecase 실패로 간주
if (availability != AVAILABLE) {
throw BusinessException(ResultCode.UPLOAD_FAILED)
Copy link
Contributor

Choose a reason for hiding this comment

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

프론트와 이야기해서 프론트에서 재처리를 진행해야할 것 같은데, (서버측에서 재시도를 못할 것 같다는 생각이,,,) 프론트에서 응답코드로 분기태워야할 것 같긴하네요

Copy link
Member Author

Choose a reason for hiding this comment

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

아마 클라이언트가 S3 이미지 업로드에 성공한 후에 동기적으로 현재 API를 호출할 것 같습니다. 여기서 실패는 네트워크 장애 등 일시적인 장애를 고려한 내용인데 이거는 회의하며 다시 얘기해보면 좋을 것 같습니다.

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request


fun execute(command: ConfirmMediaUploadedCommand): ConfirmMediaUploadedResult {
val media = mediaRepository.getActiveMedia(command.ownerId, command.mediaId)
?: throw BusinessException(ResultCode.NOT_FOUND)
Copy link
Contributor

Choose a reason for hiding this comment

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

presignedUrl 발급받은 후 [POST] /api/photos 사진등록 API를 호출하는데, 이부분에서 계속 예외가 발생합니다!

.markAsUploaded() 처리하는 부분이 아래 return 부분밖에없는데, mediaRepository.getActiveMedia() 의 조건에 MediaStatus.UPLOADED이 있어서 그런 것 같은데 한번 확인 해주세요!

추가로 제가 스테이징 환경으로 한번 로킬에서 실행해보고 presignedUrl 로 업로드를 해본 결과 사진은 잘 저장됐습니다!

image 근데 사진 파일명에 확장자가 빠져서 들어가더라구요

GenerateUploadTicketUseCase 클래스에서 command.contentType이 image/png로 들어가는거 확인했는데도 실제 S3에서는 확장자가 빠져있습니다! 이것도 한번 확인해주세요!

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

@github-actions
Copy link

Code Format Check ✅ PASSED

Spotless Check: success

✨ All code formatting checks passed!


Pushed by: @koosco, Action: pull_request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants