feat: 프로젝트 파일 S3 API 구현 - #67
Conversation
…le-s3-api # Conflicts: # .env.example
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthrough프로젝트 파일의 S3 업로드·다운로드·삭제 저장소와 프로젝트 파일 관리 API를 추가했습니다. 파일 메타데이터, 검증, 권한, 커서 페이징, 핀·최종 상태 변경을 구현했으며 프로젝트의 Changes프로젝트 계약 정리
S3 저장소 추상화
파일 도메인과 서비스
파일 REST API
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ProjectFileController
participant ProjectFileService
participant StorageService
Client->>ProjectFileController: 프로젝트 파일 업로드 또는 다운로드 요청
ProjectFileController->>ProjectFileService: 요청 데이터와 사용자 ID 전달
ProjectFileService->>StorageService: 저장 키 기준 S3 업로드 또는 다운로드
StorageService-->>ProjectFileService: 저장 완료 또는 파일 스트림 반환
ProjectFileService-->>ProjectFileController: 파일 응답 데이터 반환
ProjectFileController-->>Client: JSON 응답 또는 다운로드 스트림 반환
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
src/main/java/com/slatto/global/storage/StorageService.java (1)
7-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift
StorageService계약에서 Spring/AWS 타입을 분리하세요.
Line 9-11의MultipartFile과ResponseInputStream<GetObjectResponse>가 추상화 계층에 그대로 노출됩니다.ProjectFileDownloadResponse는 이미InputStream을 쓰고 있으니, 계약은InputStream이나 메타데이터를 담는 중립 DTO로 맞춰 구현체와 호출부의 결합을 줄여 주세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/slatto/global/storage/StorageService.java` around lines 7 - 13, Update the StorageService contract to remove Spring and AWS-specific types: replace MultipartFile in upload and ResponseInputStream<GetObjectResponse> in download with neutral abstractions such as InputStream and, where needed, a metadata DTO. Adjust implementations and callers to adapt at the boundary while preserving existing upload, download, and delete behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java`:
- Around line 14-28: 정렬 키와 cursor 기준이 불일치해 파일이 누락되므로, ProjectFileRepository의 조회
조건을 pinned 상태·pinnedAt 시각·id를 모두 반영하는 keyset 조건으로 변경하고 ProjectFileService의
페이지네이션 계약을 저장소 정렬 키를 담은 opaque 복합 cursor를 생성·반환하도록 갱신하세요.
ProjectFileRepository.java 14-28과 ProjectFileService.java 61-83을 모두 반영하고, 기존
pinned 우선 정렬 순서는 유지하세요.
In `@src/main/java/com/slatto/domain/project/service/ProjectFileService.java`:
- Around line 98-117: Update the upload flow in ProjectFileService around
storageService.upload() and projectFileRepository.save() so a database save or
transaction commit failure triggers guaranteed compensating deletion of the
uploaded storageKey, or persist an upload state that supports retry and cleanup.
Ensure failures cannot leave an inaccessible S3 object without a retryable or
cleanup path.
- Around line 35-45: Align the active Spring multipart configuration with
ProjectFileService.MAX_FILE_SIZE by setting
spring.servlet.multipart.max-file-size and max-request-size to at least 100MB,
ensuring multipart requests accepted by the service are not rejected earlier by
the framework.
---
Nitpick comments:
In `@src/main/java/com/slatto/global/storage/StorageService.java`:
- Around line 7-13: Update the StorageService contract to remove Spring and
AWS-specific types: replace MultipartFile in upload and
ResponseInputStream<GetObjectResponse> in download with neutral abstractions
such as InputStream and, where needed, a metadata DTO. Adjust implementations
and callers to adapt at the boundary while preserving existing upload, download,
and delete behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 904b68f9-824f-4158-b5be-6d7f33a86f8c
📒 Files selected for processing (23)
.env.examplebuild.gradlesrc/main/java/com/slatto/domain/project/controller/ProjectFileController.javasrc/main/java/com/slatto/domain/project/converter/ProjectConverter.javasrc/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.javasrc/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.javasrc/main/java/com/slatto/domain/project/dto/ProjectFileDownloadResponse.javasrc/main/java/com/slatto/domain/project/dto/ProjectFileListResponse.javasrc/main/java/com/slatto/domain/project/dto/ProjectFileResponse.javasrc/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.javasrc/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.javasrc/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.javasrc/main/java/com/slatto/domain/project/entity/Project.javasrc/main/java/com/slatto/domain/project/entity/ProjectFile.javasrc/main/java/com/slatto/domain/project/exception/ProjectErrorCode.javasrc/main/java/com/slatto/domain/project/repository/ProjectFileRepository.javasrc/main/java/com/slatto/domain/project/service/ProjectFileService.javasrc/main/java/com/slatto/domain/project/service/ProjectService.javasrc/main/java/com/slatto/global/config/S3Config.javasrc/main/java/com/slatto/global/s3/S3Controller.javasrc/main/java/com/slatto/global/s3/S3Service.javasrc/main/java/com/slatto/global/storage/S3StorageService.javasrc/main/java/com/slatto/global/storage/StorageService.java
💤 Files with no reviewable changes (5)
- src/main/java/com/slatto/global/s3/S3Controller.java
- src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java
- src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java
- src/main/java/com/slatto/global/s3/S3Service.java
- src/main/java/com/slatto/domain/project/entity/Project.java
| @Query(""" | ||
| select pf | ||
| from ProjectFile pf | ||
| join fetch pf.uploader u | ||
| where pf.project.id = :projectId | ||
| and pf.deletedAt is null | ||
| and (:keyword is null | ||
| or :keyword = '' | ||
| or lower(pf.fileName) like lower(concat('%', :keyword, '%'))) | ||
| and (:cursor is null or pf.id < :cursor) | ||
| order by | ||
| case when pf.pinnedAt is null then 1 else 0 end asc, | ||
| pf.pinnedAt desc, | ||
| pf.id desc | ||
| """) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
정렬 기준과 cursor 기준을 동일한 복합 키로 맞춰 주세요. pinned 파일을 먼저 정렬하면서 id만 cursor로 사용하면 항목이 누락됩니다. 예를 들어 pinned 된 낮은 id를 첫 페이지에서 반환하면, 그보다 큰 id의 unpinned 파일은 다음 조회의 pf.id < :cursor 조건에서 영구히 제외됩니다.
src/main/java/com/slatto/domain/project/repository/ProjectFileRepository#L14-L28:pinnedAt상태·시각·id를 모두 반영하는 keyset 조건으로 변경하거나, id 정렬만 사용하도록 정렬 정책을 변경하세요.src/main/java/com/slatto/domain/project/service/ProjectFileService#L61-L83: 저장소 정렬 키를 표현할 수 있는 opaque 복합 cursor를 생성·반환하도록 API 계약을 변경하세요.
📍 Affects 2 files
src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java#L14-L28(this comment)src/main/java/com/slatto/domain/project/service/ProjectFileService.java#L61-L83
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java`
around lines 14 - 28, 정렬 키와 cursor 기준이 불일치해 파일이 누락되므로, ProjectFileRepository의 조회
조건을 pinned 상태·pinnedAt 시각·id를 모두 반영하는 keyset 조건으로 변경하고 ProjectFileService의
페이지네이션 계약을 저장소 정렬 키를 담은 opaque 복합 cursor를 생성·반환하도록 갱신하세요.
ProjectFileRepository.java 14-28과 ProjectFileService.java 61-83을 모두 반영하고, 기존
pinned 우선 정렬 순서는 유지하세요.
| private static final long MAX_FILE_SIZE = 100L * 1024 * 1024; | ||
| private static final int DEFAULT_PAGE_SIZE = 20; | ||
| private static final int MAX_PAGE_SIZE = 50; | ||
| private static final String STORAGE_KEY_FORMAT = "projects/%d/files/%s.%s"; | ||
| private static final Map<String, Set<String>> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( | ||
| "application/pdf", Set.of("pdf"), | ||
| "image/jpeg", Set.of("jpg", "jpeg"), | ||
| "image/png", Set.of("png"), | ||
| "application/msword", Set.of("doc"), | ||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Set.of("docx") | ||
| ); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -HI '^application.*\.(yml|yaml|properties)$' . -x sh -c '
echo "==> $1"
rg -n "spring\.servlet\.multipart|multipart:|max-file-size|max-request-size" "$1" || true
' sh {}Repository: SLAT-TO/SLAT-TO-BE
Length of output: 329
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in src/main/resources/application.yml src/main/resources/application-dev.yml src/main/resources/application-prod.yml src/test/resources/application.yml src/main/java/com/slatto/domain/project/service/ProjectFileService.java; do
echo "===== $f ====="
sed -n '1,220p' "$f" | nl -ba | sed -n '1,220p'
echo
doneRepository: SLAT-TO/SLAT-TO-BE
Length of output: 243
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path in [
Path("src/main/resources/application.yml"),
Path("src/main/resources/application-dev.yml"),
Path("src/main/resources/application-prod.yml"),
Path("src/test/resources/application.yml"),
Path("src/main/java/com/slatto/domain/project/service/ProjectFileService.java"),
]:
print(f"===== {path} =====")
text = path.read_text()
for i, line in enumerate(text.splitlines(), 1):
if "multipart" in line or "MAX_FILE_SIZE" in line or "upload" in line or "storage" in line:
print(f"{i}: {line}")
print()
PYRepository: SLAT-TO/SLAT-TO-BE
Length of output: 1261
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== multipart-related references =="
rg -n --hidden --glob '!target/**' --glob '!build/**' --glob '!dist/**' \
'spring\.servlet\.multipart|max-file-size|max-request-size|MultipartConfigElement|MultipartResolver|CommonsMultipartResolver' .
echo
echo "== ProjectFileService relevant lines =="
sed -n '1,220p' src/main/java/com/slatto/domain/project/service/ProjectFileService.java | nl -ba | sed -n '1,220p'Repository: SLAT-TO/SLAT-TO-BE
Length of output: 191
multipart 제한을 100MB 이상으로 맞춰 주세요.
MAX_FILE_SIZE는 서비스 내부 검증일 뿐이라, 활성 프로필의 spring.servlet.multipart.max-file-size와 max-request-size가 100MB 이상이어야 정상 요청이 먼저 거절되지 않습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/slatto/domain/project/service/ProjectFileService.java`
around lines 35 - 45, Align the active Spring multipart configuration with
ProjectFileService.MAX_FILE_SIZE by setting
spring.servlet.multipart.max-file-size and max-request-size to at least 100MB,
ensuring multipart requests accepted by the service are not rejected earlier by
the framework.
| String contentType = file.getContentType(); | ||
| String storageKey = createStorageKey(projectId, request.getFileName()); | ||
| storageService.upload(file, storageKey); | ||
|
|
||
| ProjectFile projectFile = ProjectFile.create( | ||
| project, | ||
| currentMember.getUser(), | ||
| request.getFileName(), | ||
| contentType, | ||
| file.getSize(), | ||
| request.getDescription(), | ||
| request.getIsFinal(), | ||
| storageKey | ||
| ); | ||
|
|
||
| if (Boolean.TRUE.equals(request.getIsPinned())) { | ||
| projectFile.pin(); | ||
| } | ||
|
|
||
| ProjectFile savedFile = projectFileRepository.save(projectFile); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
S3 업로드 성공 후 DB 실패 시 고아 객체가 남습니다.
storageService.upload()는 DB 트랜잭션 밖의 쓰기입니다. save() 또는 커밋이 실패하면 접근할 수 없는 S3 객체가 누적됩니다. 롤백 후 보상 삭제를 보장하거나, 업로드 상태를 영속화해 재시도·정리 가능한 흐름으로 바꿔 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/slatto/domain/project/service/ProjectFileService.java`
around lines 98 - 117, Update the upload flow in ProjectFileService around
storageService.upload() and projectFileRepository.save() so a database save or
transaction commit failure triggers guaranteed compensating deletion of the
uploaded storageKey, or persist an upload state that supports retry and cleanup.
Ensure failures cannot leave an inaccessible S3 object without a retryable or
cleanup path.
🔗 관련 이슈 (Related Issue)
📝 작업 내용
주요 검토 파일:
src/main/java/com/slatto/domain/project/controller/ProjectFileController.javasrc/main/java/com/slatto/domain/project/service/ProjectFileService.javasrc/main/java/com/slatto/domain/project/entity/ProjectFile.javasrc/main/java/com/slatto/domain/project/repository/ProjectFileRepository.javasrc/main/java/com/slatto/global/storage/S3StorageService.javasrc/main/java/com/slatto/global/storage/StorageService.javasrc/main/java/com/slatto/global/config/S3Config.java1. 프로젝트 파일 API 구현
프로젝트 상세의 파일 탭에서 사용할 파일 API를 추가했습니다.
GET /api/v1/projects/{projectId}/filesPOST /api/v1/projects/{projectId}/filesPATCH /api/v1/projects/{projectId}/files/{fileId}DELETE /api/v1/projects/{projectId}/files/{fileId}GET /api/v1/projects/{projectId}/files/{fileId}/download목록 조회는
keyword파일명 검색과 cursor 기반 페이지네이션을 지원.2. S3 저장 구조 추가
프로젝트 파일 업로드/다운로드를 S3와 연동했습니다.
FE -> BE -> S3FE -> BE -> S3binary streamingstorageKey저장기존
global/s3초안 구조는 프로젝트 파일 도메인에서 사용할 수 있도록global/storage추상화와 프로젝트 파일 서비스 쪽으로 정리했습니다.3. 프로젝트 파일 메타데이터 정리
프로젝트 파일 탭에서 필요한 메타데이터를 저장하도록
ProjectFile구조를 정리했습니다.storageKey파일 삭제는 실제 row 삭제가 아니라
deletedAt기준 soft delete로 처리합니다.4. 파일 업로드 검증 및 권한 검증
파일 업로드 시 기본 검증을 추가했습니다.
pdf,jpg,jpeg,png,doc,docxmp4는 프로젝트 파일 탭이 아니라 영상 도메인에서 처리하는 방향으로 제외5gb는 너무 큰 것 같다고 판단하여 100mb로 고정하였습니다.
권한은 다음 기준으로 검증합니다.
ADMIN권한 필요5. S3 설정 보정
팀 공통 S3 설정에 맞춰 로컬/배포 환경에서 사용할 수 있도록 설정을 보정했습니다.
cloud.aws.s3.bucket✅ 검증
./gradlew compileJava./gradlew test💬 To Reviewers
프로젝트 파일 업로드/다운로드는 Presigned URL 방식이 아니라 백엔드 중계 방식으로 구현했습니다.
✅ PR 체크리스트
Summary by CodeRabbit
새로운 기능
변경 사항
roleNames로 변경되었습니다.