Skip to content

feat: s3 파일 업로드 구현 및 aws lambda를 활용한 이미지 프로세싱 구조로 변경#55

Merged
Bumnote merged 5 commits intodevfrom
feat/image-s3-uploads
Mar 26, 2026
Merged

feat: s3 파일 업로드 구현 및 aws lambda를 활용한 이미지 프로세싱 구조로 변경#55
Bumnote merged 5 commits intodevfrom
feat/image-s3-uploads

Conversation

@Bumnote
Copy link
Copy Markdown
Member

@Bumnote Bumnote commented Mar 26, 2026

#️⃣ 연관된 이슈

#54

📝 작업 내용

Feat

  • S3를 활용하기 위해서 의존성 추가 및 Config 파일을 구현했습니다.
  • 사용자마다 UUID를 활용한 고유의 폴더를 만들어 이미지를 업로드하고, 마지막에 _complete 빈 파일을 통해 람다 트리거를 발동시킵니다.
  • S3 업로드와 관련된 prefix, suffix를 별도의 상수 유틸 함수로 분리했습니다.
  • 이미지 업로드 시, 결과 확인을 받기까지 클라이언트와 SSE 통신을 유지합니다.
  • 프론트에서 주기적으로 status를 확인하는 요청을 보내고, 응답하는 로직을 구현했습니다.
  • aws lambda에서 성공 및 실패 응답에 대한 controller-service 로직을 구현했습니다.
  • Redis를 사용하기 위한 Config 파일을 작성했습니다.

Remove

  • 사용하지 않는 Async 관련 파일을 제거합니다.
  • 세션 필터와 관련된 파일을 제거합니다.
  • 이메일 전송 기능과 관련된 파일을 제거합니다.
  • OpenAiClient와 관련된 파일을 제거합니다.
  • RestClient 동기 통신과 관련된 파일을 제거합니다.
  • 사용하지 않는 불필요한 의존성과 함수들은 제거했습니다.

Refactor

  • 회원 로직이 없어져 기존에 존재하던 SecurityConfig 내용을 수정했습니다.
  • WebConfig 내용 중 WebMvcConfigurer 인터페이스에서 WebFluxConfigurer 인터페이스로 변경했습니다.

💬 리뷰 요구사항

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요


- 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
@Bumnote Bumnote requested a review from Copilot March 26, 2026 17:16
@Bumnote Bumnote self-assigned this Mar 26, 2026
@Bumnote Bumnote merged commit 25cf01c into dev Mar 26, 2026
2 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +67 to +70
return s3Service
.uploadCompletionMarker(jobId)
.then(createJob(jobId));
})
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

업로드 완료 마커(uploadCompletionMarker)를 먼저 올리고 그 다음 createJob(jobId)를 실행하고 있습니다. 마커 업로드 직후 Lambda가 트리거되거나 클라이언트가 즉시 status 조회를 하면, 아직 Job 레코드가 없어 404/상태 유실이 발생할 수 있습니다. Job 생성(및 필요하면 Redis 초기화)을 먼저 수행한 뒤 마커를 업로드하는 순서로 바꾸거나, 둘 다 성공해야 마커가 올라가도록 보장하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +46
.subscribeOn(Schedulers.boundedElastic())
.doOnError(err -> {
});
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

saveRetripReactive.doOnError(err -> { })가 비어 있어 의미가 없고(오히려 누락처럼 보임), 에러 로깅/메트릭/추적이 필요하다면 여기서 처리하거나 해당 블록을 제거하는 편이 좋습니다.

Suggested change
.subscribeOn(Schedulers.boundedElastic())
.doOnError(err -> {
});
.subscribeOn(Schedulers.boundedElastic());

Copilot uses AI. Check for mistakes.
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(CsrfSpec::disable)
.authorizeExchange(exchange -> exchange
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

anyExchange().permitAll()로 설정되어 있어 /api/internal/**(Lambda 콜백)까지 누구나 호출할 수 있습니다. 최소한 내부 콜백 엔드포인트는 HMAC/공유 시크릿 헤더 검증, mTLS, IP allowlist, 또는 Spring Security 인증 규칙(예: /api/internal/**는 denyAll + 별도 인증)로 보호해야 합니다.

Suggested change
.authorizeExchange(exchange -> exchange
.authorizeExchange(exchange -> exchange
.pathMatchers("/api/internal/**").denyAll()

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +84
@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());
});
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

/internal/retrips/{jobId}/complete, /internal/retrips/{jobId}/fail가 외부에서 직접 호출 가능하며, 현재 Security 설정도 전체 permitAll이라 콜백 위조가 가능합니다. Lambda가 보낸 요청임을 검증할 수 있도록 (예: 공유 시크릿 헤더/X-Signature, IAM authorizer를 거친 API Gateway, IP allowlist 등) 최소 1개의 서버측 검증을 추가하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +38
private Flux<ServerSentEvent<String>> createSseStream(String jobId) {
Sinks.One<String> sink = Sinks.one();
sinkMap.put(jobId, sink);

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

sinkMap.put(jobId, sink)로 jobId당 Sink를 1개만 유지하면서, 동일 jobId로 SSE를 2번 이상 연결하면 기존 sink가 덮어써집니다. 이 경우 먼저 연결된 클라이언트는 결과를 영원히 못 받고(푸시는 새 sink로만 감) 결과 유실이 발생합니다. computeIfAbsent로 기존 sink를 재사용하거나, 여러 구독자를 지원하려면 Sinks.Many(multicast/replay)로 변경하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +126
String filename = filePart.filename();
String contentType = resolveContentType(filePart);
String key = BUCKET_PREFIX + jobId + "/" + filename;

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

S3 완료 마커가 uploads/{jobId}/_complete인데, 현재 업로드 key가 uploads/{jobId}/ + 원본 filename 이라서 사용자가 파일명을 _complete로 올리면 완료 마커와 동일한 key가 생성되어 Lambda가 조기 트리거될 수 있습니다. 업로드 파일명을 서버에서 강제 rename(예: UUID/순번)하거나 _complete(및 예약 prefix/suffix)를 금지하는 검증을 추가하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +46
aws:
s3:
region: ap-northeast-2
bucket: ${aws.s3.bucket}
access-key: ${aws.s3.access-key}
secret-key: ${aws.s3.secret-key}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

bucket/access-key/secret-key 값이 동일한 프로퍼티를 다시 참조하고 있어(예: bucket: ${aws.s3.bucket}) 순환 참조로 인해 애플리케이션 기동 시 placeholder 해석이 실패할 가능성이 큽니다. 실제 값은 환경변수/secret 파일의 다른 키로 매핑하거나(예: ${AWS_S3_BUCKET}) 여기서는 값을 직접 두고, secret 쪽에서 override 하도록 수정하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +52
server:
netty:
max-initial-line-length: 8192
max-header-size: 32768
validate-headers: false
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

server.netty.validate-headers: false는 잘못된/비정상 헤더를 허용해 요청 스머글링 등 보안 리스크를 키울 수 있습니다. 특별한 호환성 이슈가 아니라면 기본값(검증 활성화)로 두고, 필요 시에만 특정 환경(profile)에서 제한적으로 비활성화하는 방향을 권장합니다.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +28
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.build();
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

현재 StaticCredentialsProvider로 access/secret 키를 애플리케이션 설정에서 주입받도록 되어 있어 키 유출/회전/운영환경 분리 측면에서 위험합니다. 가능하면 DefaultCredentialsProvider(환경변수, 프로파일, EC2/ECS/EKS IAM Role 등)로 전환하고, 로컬에서만 필요 시 별도 profile로 static credential을 사용하도록 분리하세요.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +53
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))
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

resultStream.mergeWith(heartbeat).timeout(Duration.ofMinutes(5))는 heartbeat가 15초마다 이벤트를 발생시키기 때문에 사실상 timeout이 절대 발생하지 않습니다(항상 신호가 들어옴). 전체 연결을 5분 후 종료하려는 목적이라면 take(Duration)/timeout을 resultStream에만 적용하거나, 별도의 overall 타이머로 종료 조건을 분리하세요.

Copilot uses AI. Check for mistakes.
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.

2 participants