feat: 이미지 업로드용 GCS Signed URL 발급 API - #8
Merged
Conversation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
파일명 기반 signed URL 발급/클라이언트 보고 방식의 업로드 완료 콜백을 분석(analysis) 중심 API로 전면 교체하기 위해 선행 제거.
images 테이블을 photos로 리네이밍하고 analysis_id/content_type/uploaded_at/ taken_at 컬럼으로 재구성, 분석 세션을 표현하는 analysis 테이블을 신규 추가. 아직 어떤 PR에도 머지되지 않은 브랜치라 새 버전 파일을 얹지 않고 기존 마이그레이션 파일을 직접 수정했다.
GCS 오브젝트 키 생성 규칙(세그먼트+id+확장자 조합)을 도메인 무관한 순수 유틸리티로 분리. photo뿐 아니라 향후 sticker 등 다른 도메인의 업로드에도 재사용할 수 있도록 global 하위 공용 위치에 둔다.
- POST /api/v1/analysis: 분석 세션 + photos row 일괄 생성, signed URL 배치 발급
(사진마다 개별 호출하지 않고 saveAll/issueUploadUrls로 한 번에 처리)
- POST /api/v1/analysis/{analysisId}/start: GCS 오브젝트 존재 여부를 배치로
확인해 업로드 상태만 전이(PENDING -> COMPLETED/FAILED). 분석 파이프라인
시작은 이번 스코프에 포함하지 않음
- contentType은 요청에서 String으로 받고 AnalysisService에서
PhotoContentType.fromMimeType으로 검증(잘못된 값은 InvalidInputException)
- 인증이 아직 없어 분석 소유자(userId)는 board.userId에서 파생
- 관련 AGENTS.md 문서 갱신
Postgres TIMESTAMPTZ는 마이크로초까지만 저장하는데 Instant.now()는 나노초 정밀도를 가질 수 있어, 저장 후 왕복한 값과 정확히 같은지 비교하면 시스템 클럭 해상도에 따라(Linux CI 등) 실패할 수 있었다.
son-daehyeon
requested changes
Jul 28, 2026
/api/v1 경로 프리픽스 대신 Spring Framework 7 네이티브 API 버저닝을 사용하도록 변경. API 명세 기준 URL에는 버전이 없고 X-API-Version 헤더로 버전을 구분함.
analysis.status가 UPLOADING이 아니면 409(ANALYSIS-003)를 반환한다. GCS에 없는 PENDING 사진을 더 이상 FAILED로 확정하지 않고 PENDING으로 유지해, 클라이언트가 업로드를 마친 뒤 재호출하면 복구되도록 한다.
startUpload에서 @transactional을 제거해 느린 GCS 호출 동안 커넥션 풀이 고갈되지 않도록 한다. 남은 갱신은 CAS 조건의 단일 UPDATE라 트랜잭션 없이도 원자성이 보장된다. Storage 클라이언트에 connect/read 타임아웃(GCS_TIMEOUT_MILLIS)을 추가해 GCS 응답 지연이 무한정 대기로 이어지지 않도록 한다.
동시에 여러 /start 요청이 겹치면 GCS 조회 시점 기준 partition 결과와 실제 DB 갱신 결과가 어긋날 수 있었다. analysis 행을 SELECT ... FOR UPDATE로 잠가 같은 analysisId에 대한 요청을 직렬화하고, 잠금 이후 photos를 다시 조회해 그 최종 상태를 응답으로 반환한다. 잠금 구간은 TransactionTemplate으로 좁게 유지해 GCS 호출은 여전히 트랜잭션 밖에서 실행된다.
PhotoStorage.issueUploadUrls는 입력과 동일한 개수를 반환한다는 계약을 타입으로 강제하지 않는다. 개수가 어긋나면 zip()이 조용히 뒤쪽 사진을 잘라 업로드 URL 없이 누락시키므로, check()로 즉시 예외가 나도록 한다.
명세는 /analysis/{analysisId}/start의 성공 응답을 202로 정의하는데, @ResponseStatus 지정이 없어 Spring 기본값인 200이 나가고 있었다.
x-goog-content-length-range를 서명하지 않아 signed URL로 크기 제한 없이 업로드가 가능했던 문제 수정. GCS 버킷 CORS 허용 헤더 추가 및 클라이언트 반영은 별도 작업으로 진행.
GCS list 결과에서 이름만 남기고 size/createTime을 버려 uploaded_at이 check 시각으로 저장되고, 0바이트 객체도 COMPLETED로 통과하던 문제 수정. - PhotoStorage.existingObjectKeys(Set<String>) -> existingObjects(Map<String, BlobMeta>) 으로 변경해 size/createdAt을 서비스 레이어까지 전달. - uploaded_at은 이제 GCS 객체의 실제 생성 시각(BlobMeta.createdAt)을 저장. - 핵심 의사결정: 0바이트 객체는 FAILED로 확정하지 않고 PENDING 유지. 2번 코멘트에서 세운 원칙(누락 사진을 FAILED로 확정하지 않고 재시도로 복구 가능하게 둔다)과 동일하게 취급 - 클라이언트가 같은 signed URL로 다시 PUT하면 복구되므로 별도 FAILED 분기를 만들지 않음. - PhotoRepository.updateStatusBatch는 사진마다 uploadedAt이 달라져 Map<UUID, Instant> 기반 단건 CAS 반복으로 변경.
domain 레이어인데 @component를 쓰고 있어 의존 방향 규칙 위반이었음. 상태 없는 문자열 조합 로직이라 DI가 필요 없으므로, infrastructure로 옮기고 @component 제거 후 object(싱글턴)로 전환. AnalysisService의 생성자 주입도 제거하고 object를 직접 참조하도록 변경.
toDomain()에서 content_type 문자열을 PhotoContentType으로 변환할 때, DB에서 읽은 값(서버 데이터 문제)에 대해 요청 검증용 fromMimeType을 재사용했다. 이렇게 되면 400/WARN으로 처리되어 "클라이언트 입력 오류"로 잘못 분류된다. DB 값이 예상 밖인 것은 클라이언트 탓이 아닌 서버/데이터 문제이므로, uploadStatus와 동일하게 500/ERROR로 처리해야 한다. PhotoContentType.entries.find + error() 방식으로 변경하여 IllegalStateException을 던지고, GlobalExceptionHandler에서 일반 500 에러로 처리되도록 통일. fromMimeType은 여전히 AnalysisService의 요청 검증(AnalysisService.kt:43)에서만 쓰이며, 그곳은 변경 없음. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@field:Size(min=90, max=100) 애노테이션 추가로 클라이언트 요청 사진 개수를 API 명세(minItems: 90, maxItems: 100) 기준으로 검증. 테스트도 90장 범위로 조정하고 89장 하한 미만 케이스 테스트 추가. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
presentation/dto/ 설명에 @field:Size(min=90, max=100) 애노테이션이 API 명세 기준 사진 개수(90~100)를 검증함을 명시. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
GCS_SIGNED_URL_EXPIRATION_MINUTES(60분)를 GCS_UPLOAD_SIGNED_URL_EXPIRATION_MINUTES(15분)로 변경. 명세상 업로드 URL 만료(900초=15분)와 조회 URL 만료(3600초=1시간)가 서로 달라, 현재 업로드 전용으로만 쓰이는 프로퍼티임을 이름에 명시해 향후 조회 URL 발급 기능 추가 시 혼동을 방지. GcsProperties, gcs.yml, application-test.yml, .env.template 동기화. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
signedUrlExpirationMinutes → uploadSignedUrlExpirationMinutes 개명 및 15분(명세 기준) 값 반영. 향후 조회용 signed URL 추가 시 별도 프로퍼티가 필요하다는 배경도 함께 명시. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
ERD 기준 조회 패턴에 맞춰 idx_analysis_user_id 단일 인덱스를 idx_analysis_user_created(user_id, created_at) 복합 인덱스로 교체. 부분 유니크(uk_analysis_active)는 ANALYSIS-002(유저당 활성 분석 1개 제한, 409 처리) 구현 시 함께 추가 예정 — 지금 추가하면 사전 체크 없이 unique violation이 그대로 500으로 노출됨.
…on-null 강제 명세/ERD는 takenAt을 required/NOT NULL로 정의하지만, 현재 DB 컬럼과 삽입용 DTO가 nullable로 되어 있음. 요청 DTO는 non-null이라 현재 웹 요청 경로에서는 문제 없지만, 향후 배치/파이프라인 같은 다른 진입 경로 추가 시 null이 DB에 유입될 수 있음. 정렬 기준 (taken_at, id)도 보장.
"요청 순서와 동일하게" 블록이 실제로 순서를 검증하도록 수정: - 2개 사진 → 90개 사진 (각각 다른 takenAt)으로 증가해 순서 검증의 현실성 확보 - toSet() 비교 → result.uploads[i]의 photoId가 가리키는 사진의 takenAt이 요청 photos[i]의 takenAt과 일치하는지 인덱스별로 비교 - findAllByAnalysisId() 사용 (findPendingByAnalysisId는 ORDER BY 없음) 이제 순서가 바뀌면 테스트가 실패하므로 실제 계약 검증 가능.
This was referenced Jul 29, 2026
지원하지 않는 contentType 검증 테스트는 photo 배열이 1장뿐이라 @SiZe(min=90, max=100) 제약에서 먼저 400이 발생해 실제 contentType 검증(PhotoContentType.fromMimeType)까지 도달하지 못함. 동일 검증은 이미: - AnalysisServiceTest에서 fromMimeType("image/gif") → InvalidInputException 단위 테스트 - AnalysisControllerTest의 NotFoundException 404 경로에서 GlobalExceptionHandler.handleBusinessException 매핑 검증 따라서 이름과 다른 동작을 하는 중복 테스트를 제거하는 것이 더 명확함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
90~100장 수량 검증은 @field:Size(min=90, max=100)로 이미 구현되어 있으므로, 이를 명확히 구분하여 문서화한다. 변경 내용: 1. 상단 개요: 90~100 photo count check는 구현됨을 명시, lifecycle business rules만 미구현 2. Rules 섹션: Implemented/Not implemented 목록을 분리해서 ANALYSIS-001 상태 명확화 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
코드 자체로 충분히 명확한 주석(인덱스별 순서 검증)을 제거.
forEachIndexed { i, upload } → first { it.id == upload.photoId } → shouldBe photos[i].takenAt
의 흐름만으로 이미 명확함.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
son-daehyeon
approved these changes
Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
작업 내용
images테이블을photos로 리네이밍하고analysis_id/content_type/uploaded_at/taken_at컬럼으로 재구성, 분석 세션을 표현하는analysis테이블 신규 추가 (기존 마이그레이션 파일을 직접 수정 — 아직 머지 전 브랜치라 새 버전 파일을 얹지 않음)ObjectKeyGenerator(global/storage) 추가 — GCS 오브젝트 키 생성 규칙을 도메인 무관하게 분리, 향후 스티커 등 다른 업로드에도 재사용 가능analysis도메인 신규 구현 (기존image도메인 전량 대체)POST /api/v1/analysis: 분석 세션 + 사진(photo) 여러 장을 한 번에 생성하고 signed URL을 배치로 발급 (사진마다 개별 호출하지 않고saveAll/issueUploadUrls로 한 번에 처리)POST /api/v1/analysis/{analysisId}/start: GCS 오브젝트 존재 여부를 배치로 확인해 사진별 업로드 상태만 전이(PENDING → COMPLETED/FAILED). 분석 파이프라인 시작(AI 분석)은 이번 스코프에 포함하지 않음contentType은 요청에서String으로 받고AnalysisService에서PhotoContentType.fromMimeType으로 검증(잘못된 값은InvalidInputException→ 400)userId)는board.userId에서 파생closes #7
변경 유형
확인 사항
./gradlew build통과./gradlew flywayMigrate jooqCodegen실행 후 생성물 커밋리뷰 포인트
POST /api/v1/boards/{boardId}/images/signed-urls,PATCH .../images/{imageId}/upload-status)으로 시작했으나, 클라이언트와 합의된 실제 OpenAPI 명세에 맞춰 분석(analysis) 중심 API로 전면 교체했습니다.ANALYSIS-001), 진행 중 분석 1개 제한(ANALYSIS-002), 일일 횟수 제한(ANALYSIS-006) 등 비즈니스 규칙과GET /analysis/active,GET /analysis/{id},DELETE /analysis/{id},POST /analysis/{id}/reissue엔드포인트, 실제 분석 파이프라인 시작은 포함하지 않았습니다.analysis.user_id는board.userId에서 파생합니다 — 인증이 붙으면 소유권 검증 로직을 추가해야 합니다.