feat: s3 파일 업로드 구현 및 aws lambda를 활용한 이미지 프로세싱 구조로 변경#55
Conversation
- WebClient를 활용하기 위해서 WebFlux 의존성을 추가했습니다. - Apple Silicon 오류를 없애기 위해서 dns-native 의존성을 추가했습니다. - WebClient Config 설정 파일을 구현했습니다. issue #53
- S3를 활용하기 위해서 의존성 추가 및 Config 파일을 구현했습니다. - 사용하지 않는 불필요한 의존성과 함수들은 제거했습니다. - 사용자마다 UUID를 활용한 고유의 폴더를 만들어 이미지를 업로드하고, 마지막에 _complete 빈 파일을 통해 람다 트리거를 발동시킵니다. - S3 업로드와 관련된 prefix, suffix를 별도의 상수 유틸 함수로 분리했습니다. issue #54
- 사용하지 않는 Async 관련 파일을 제거합니다. - 세션 필터와 관련된 파일을 제거합니다. - 이메일 전송 기능과 관련된 파일을 제거합니다. - OpenAiClient와 관련된 파일을 제거합니다. - RestClient 동기 통신과 관련된 파일을 제거합니다. issue #54
- 이미지 업로드 시, 결과 확인을 받기까지 클라이언트와 SSE 통신을 유지합니다. - 프론트에서 주기적으로 status를 확인하는 요청을 보내고, 응답하는 로직을 구현했습니다. - aws lambda에서 성공 및 실패 응답에 대한 controller-service 로직을 구현했습니다. issue #54
- Redis를 사용하기 위한 Config 파일을 작성했습니다. - 회원 로직이 없어져 기존에 존재하던 SecurityConfig 내용을 수정했습니다. - WebConfig 내용 중 WebMvcConfigurer 인터페이스에서 WebFluxConfigurer 인터페이스로 변경했습니다. issue #54
There was a problem hiding this comment.
Pull request overview
AWS S3 업로드를 기반으로 이미지 처리 작업을 Lambda로 분리하고, 결과 전달을 SSE/폴링 + Redis 캐시 구조로 전환하는 PR입니다.
Changes:
- Spring MVC → WebFlux 전환 및 업로드/응답 플로우를 jobId 기반 비동기 처리로 변경
- S3 업로드 +
_complete마커로 Lambda 트리거, Lambda 콜백 수신 API 및 Job 상태 조회 API 추가 - Redis 결과 캐싱 및 SSE 스트림 서비스 도입, 기존 OpenAI/Async/Email/Session 관련 코드 제거
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/resources/application.yml | 테스트 프로파일 설정에서 OAuth/AWS 관련 설정 제거 |
| src/main/resources/application.yml | WebFlux multipart/codec 설정 추가, S3/Netty 설정 및 retrip 설정 재정비 |
| src/main/java/ssafy/retrip/utils/ImageMetaDataUtil.java | 기존 EXIF 메타데이터 추출 유틸 제거 |
| src/main/java/ssafy/retrip/utils/ConstantUtil.java | S3 key prefix/suffix 및 기본 content-type 상수화 |
| src/main/java/ssafy/retrip/handler/CustomRejectedExecutionHandler.java | Async 스레드풀 관련 핸들러 제거 |
| src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java | 세션 인증 필터 제거 |
| src/main/java/ssafy/retrip/domain/job/RetripJobRepository.java | Job 상태 조회를 위한 JPA Repository 추가 |
| src/main/java/ssafy/retrip/domain/job/RetripJob.java | Job 엔티티 및 상태 전이(complete/fail) 추가 |
| src/main/java/ssafy/retrip/domain/job/JobStatus.java | JobStatus enum 추가 |
| src/main/java/ssafy/retrip/config/WebConfig.java | WebMvcConfigurer → WebFluxConfigurer로 전환 및 CORS 유지 |
| src/main/java/ssafy/retrip/config/WebClientConfig.java | WebClient 타임아웃 포함 기본 Bean 추가 |
| src/main/java/ssafy/retrip/config/SecurityConfig.java | Servlet Security → WebFlux Security로 전환(현재 전체 permitAll) |
| src/main/java/ssafy/retrip/config/S3Config.java | AWS SDK v2 S3Client Bean 구성 추가 |
| src/main/java/ssafy/retrip/config/RestClientConfig.java | RestClient 설정 제거 |
| src/main/java/ssafy/retrip/config/RedisConfig.java | RedisTemplate hash serializer 설정 추가 |
| src/main/java/ssafy/retrip/config/OpenAiConfig.java | OpenAI Client 설정 제거 |
| src/main/java/ssafy/retrip/config/EmailConfig.java | 이메일 발송 설정 제거 |
| src/main/java/ssafy/retrip/config/AsyncConfig.java | Async executor 설정 제거 |
| src/main/java/ssafy/retrip/api/service/sse/SseService.java | SSE 연결/결과 푸시 및 heartbeat 스트림 추가 |
| src/main/java/ssafy/retrip/api/service/s3/S3Service.java | S3 putObject 업로드 및 완료 마커 업로드 로직 추가 |
| src/main/java/ssafy/retrip/api/service/retrip/response/ImageUrlResponse.java | 기존 이미지 URL 응답 DTO 제거 |
| src/main/java/ssafy/retrip/api/service/retrip/request/ImageAnalysisRequest.java | 기존 분석 요청 DTO 제거 |
| src/main/java/ssafy/retrip/api/service/retrip/RetripService.java | 이미지 업로드→S3, Lambda 결과 처리, Job 상태 조회 로직으로 전면 개편 |
| src/main/java/ssafy/retrip/api/service/retrip/RetripPersistenceService.java | 블로킹 JPA 저장을 TransactionTemplate + reactive wrapper로 제공 |
| src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java | 서버 내 이미지 변환/리사이즈 로직 제거 |
| src/main/java/ssafy/retrip/api/service/openai/response/Recommendation.java | OpenAI 응답용 DTO 제거(중복/구조 변경에 따른 정리) |
| src/main/java/ssafy/retrip/api/service/openai/OpenAiClient.java | OpenAI 직접 호출 클라이언트 제거 |
| src/main/java/ssafy/retrip/api/service/openai/GptImageAnalysisService.java | GPT 이미지 분석 서비스 제거 |
| src/main/java/ssafy/retrip/api/service/cache/RetripResultCacheService.java | Redis 기반 결과 캐시(10분 TTL) 서비스 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/response/JobUploadResponse.java | 업로드 응답(jobId, status, message) DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/response/JobStatusResponse.java | status 조회 응답 DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/request/LambdaFailureRequest.java | Lambda 실패 콜백 요청 DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/request/LambdaCallbackRequest.java | Lambda 성공 콜백 요청 DTO 및 메타데이터 변환 로직 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java | 업로드(WebFlux), SSE 스트림, internal 콜백, status 조회 API로 전환 |
| build.gradle | starter-web → starter-webflux 전환, AWS SDK v2 추가, 일부 의존성 제거 |
Comments suppressed due to low confidence (1)
src/main/java/ssafy/retrip/api/service/retrip/RetripPersistenceService.java:108
updateRetripDetailsFromMetadata에서sortableList를 생성/정렬하지만 이후 로직은 계속metadataList를 사용하고 있어 정렬 결과가 사용되지 않습니다. 입력 리스트를 변형하지 않으려는 의도였다면 이후 계산에sortableList를 사용하거나, 필요 없다면 해당 복사/정렬 코드를 제거해 불필요한 비용과 혼선을 줄이세요.
List<ImageMetaData> sortableList = new ArrayList<>(metadataList);
sortableList.sort(Comparator.comparing(ImageMetaData::getTakenDate, Comparator.nullsLast(Comparator.naturalOrder())));
LocalDateTime startDate = findEarliestTakenDate(metadataList);
LocalDateTime endDate = findLatestTakenDate(metadataList);
Map<String, Object> mainLocationInfo = analyzeMainLocation(metadataList);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return s3Service | ||
| .uploadCompletionMarker(jobId) | ||
| .then(createJob(jobId)); | ||
| }) |
There was a problem hiding this comment.
업로드 완료 마커(uploadCompletionMarker)를 먼저 올리고 그 다음 createJob(jobId)를 실행하고 있습니다. 마커 업로드 직후 Lambda가 트리거되거나 클라이언트가 즉시 status 조회를 하면, 아직 Job 레코드가 없어 404/상태 유실이 발생할 수 있습니다. Job 생성(및 필요하면 Redis 초기화)을 먼저 수행한 뒤 마커를 업로드하는 순서로 바꾸거나, 둘 다 성공해야 마커가 올라가도록 보장하세요.
| .subscribeOn(Schedulers.boundedElastic()) | ||
| .doOnError(err -> { | ||
| }); |
There was a problem hiding this comment.
saveRetripReactive의 .doOnError(err -> { })가 비어 있어 의미가 없고(오히려 누락처럼 보임), 에러 로깅/메트릭/추적이 필요하다면 여기서 처리하거나 해당 블록을 제거하는 편이 좋습니다.
| .subscribeOn(Schedulers.boundedElastic()) | |
| .doOnError(err -> { | |
| }); | |
| .subscribeOn(Schedulers.boundedElastic()); |
| public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { | ||
| return http | ||
| .csrf(CsrfSpec::disable) | ||
| .authorizeExchange(exchange -> exchange |
There was a problem hiding this comment.
anyExchange().permitAll()로 설정되어 있어 /api/internal/**(Lambda 콜백)까지 누구나 호출할 수 있습니다. 최소한 내부 콜백 엔드포인트는 HMAC/공유 시크릿 헤더 검증, mTLS, IP allowlist, 또는 Spring Security 인증 규칙(예: /api/internal/**는 denyAll + 별도 인증)로 보호해야 합니다.
| .authorizeExchange(exchange -> exchange | |
| .authorizeExchange(exchange -> exchange | |
| .pathMatchers("/api/internal/**").denyAll() |
| @PostMapping("/internal/retrips/{jobId}/complete") | ||
| public Mono<ResponseEntity<Void>> handleLambdaCallback( | ||
| @PathVariable String jobId, | ||
| @RequestBody LambdaCallbackRequest request | ||
| ) { | ||
|
|
||
| log.info("Lambda 콜백 수신: jobId={}", jobId); | ||
|
|
||
| return retripService.handleAnalysisResult(jobId, request) | ||
| .then(Mono.just(ResponseEntity.ok().<Void>build())) | ||
| .onErrorResume(ex -> { | ||
| log.error("Lambda 콜백 처리 실패: jobId={}", jobId, ex); | ||
| return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); | ||
| }); | ||
| } | ||
|
|
||
| @PostMapping("/internal/retrips/{jobId}/fail") | ||
| public Mono<ResponseEntity<Void>> handleLambdaFailure( | ||
| @PathVariable String jobId, | ||
| @RequestBody LambdaFailureRequest request | ||
| ) { | ||
|
|
||
| log.error("Lambda 실패 콜백 수신: jobId={}, error={}", jobId, request.getErrorMessage()); | ||
|
|
||
| return retripService.handleFailure(jobId, request.getErrorMessage()) | ||
| .then(Mono.just(ResponseEntity.ok().<Void>build())) | ||
| .onErrorResume(ex -> { | ||
| log.error("Lambda 실패 콜백 처리 중 오류: jobId={}", jobId, ex); | ||
| return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); | ||
| }); |
There was a problem hiding this comment.
/internal/retrips/{jobId}/complete, /internal/retrips/{jobId}/fail가 외부에서 직접 호출 가능하며, 현재 Security 설정도 전체 permitAll이라 콜백 위조가 가능합니다. Lambda가 보낸 요청임을 검증할 수 있도록 (예: 공유 시크릿 헤더/X-Signature, IAM authorizer를 거친 API Gateway, IP allowlist 등) 최소 1개의 서버측 검증을 추가하세요.
| private Flux<ServerSentEvent<String>> createSseStream(String jobId) { | ||
| Sinks.One<String> sink = Sinks.one(); | ||
| sinkMap.put(jobId, sink); | ||
|
|
There was a problem hiding this comment.
sinkMap.put(jobId, sink)로 jobId당 Sink를 1개만 유지하면서, 동일 jobId로 SSE를 2번 이상 연결하면 기존 sink가 덮어써집니다. 이 경우 먼저 연결된 클라이언트는 결과를 영원히 못 받고(푸시는 새 sink로만 감) 결과 유실이 발생합니다. computeIfAbsent로 기존 sink를 재사용하거나, 여러 구독자를 지원하려면 Sinks.Many(multicast/replay)로 변경하세요.
| String filename = filePart.filename(); | ||
| String contentType = resolveContentType(filePart); | ||
| String key = BUCKET_PREFIX + jobId + "/" + filename; | ||
|
|
There was a problem hiding this comment.
S3 완료 마커가 uploads/{jobId}/_complete인데, 현재 업로드 key가 uploads/{jobId}/ + 원본 filename 이라서 사용자가 파일명을 _complete로 올리면 완료 마커와 동일한 key가 생성되어 Lambda가 조기 트리거될 수 있습니다. 업로드 파일명을 서버에서 강제 rename(예: UUID/순번)하거나 _complete(및 예약 prefix/suffix)를 금지하는 검증을 추가하세요.
| aws: | ||
| s3: | ||
| region: ap-northeast-2 | ||
| bucket: ${aws.s3.bucket} | ||
| access-key: ${aws.s3.access-key} | ||
| secret-key: ${aws.s3.secret-key} |
There was a problem hiding this comment.
bucket/access-key/secret-key 값이 동일한 프로퍼티를 다시 참조하고 있어(예: bucket: ${aws.s3.bucket}) 순환 참조로 인해 애플리케이션 기동 시 placeholder 해석이 실패할 가능성이 큽니다. 실제 값은 환경변수/secret 파일의 다른 키로 매핑하거나(예: ${AWS_S3_BUCKET}) 여기서는 값을 직접 두고, secret 쪽에서 override 하도록 수정하세요.
| server: | ||
| netty: | ||
| max-initial-line-length: 8192 | ||
| max-header-size: 32768 | ||
| validate-headers: false |
There was a problem hiding this comment.
server.netty.validate-headers: false는 잘못된/비정상 헤더를 허용해 요청 스머글링 등 보안 리스크를 키울 수 있습니다. 특별한 호환성 이슈가 아니라면 기본값(검증 활성화)로 두고, 필요 시에만 특정 환경(profile)에서 제한적으로 비활성화하는 방향을 권장합니다.
| @Bean | ||
| public S3Client s3Client() { | ||
| return S3Client.builder() | ||
| .region(Region.of(region)) | ||
| .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) | ||
| .build(); |
There was a problem hiding this comment.
현재 StaticCredentialsProvider로 access/secret 키를 애플리케이션 설정에서 주입받도록 되어 있어 키 유출/회전/운영환경 분리 측면에서 위험합니다. 가능하면 DefaultCredentialsProvider(환경변수, 프로파일, EC2/ECS/EKS IAM Role 등)로 전환하고, 로컬에서만 필요 시 별도 profile로 static credential을 사용하도록 분리하세요.
| Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(15)) | ||
| .map(i -> ServerSentEvent.<String>builder() | ||
| .comment("heartbeat") | ||
| .build()); | ||
|
|
||
| return resultStream.mergeWith(heartbeat) | ||
| .timeout(Duration.ofMinutes(5)) | ||
| .doOnSubscribe(sub -> log.info("SSE 연결 수립: jobId={}", jobId)) |
There was a problem hiding this comment.
resultStream.mergeWith(heartbeat).timeout(Duration.ofMinutes(5))는 heartbeat가 15초마다 이벤트를 발생시키기 때문에 사실상 timeout이 절대 발생하지 않습니다(항상 신호가 들어옴). 전체 연결을 5분 후 종료하려는 목적이라면 take(Duration)/timeout을 resultStream에만 적용하거나, 별도의 overall 타이머로 종료 조건을 분리하세요.
#️⃣ 연관된 이슈
#54
📝 작업 내용
Feat
Remove
Refactor
💬 리뷰 요구사항