Conversation
- Async 비동기 방식 전환을 위한 Config 설정했습니다. - 코어 풀 사이즈를 20으로 설정했습니다. - 맥스 풀 사이즈를 50으로 설정했습니다. - 대기 큐 용량을 100으로 설정했습니다. issue #52
- Async 어노테이션을 활용하여 비동기 통신으로 전환했습니다. - CompletableFuture 반환형을 모든 메서드에 적용했습니다. - 트랜잭션을 분리하기 위해서 Persistence 작업을 위한 서비스를 따로 분리했습니다. issue #52
Feat/apply async
- 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
feat: s3 파일 업로드 구현 및 aws lambda를 활용한 이미지 프로세싱 구조로 변경
- 추가된 redis 환경에 대한 환경변수들을 추가했습니다. - CI 과정에서도 application-secret.yml 파일을 생성할 수 있도록 구조를 변경했습니다. - 로컬 환경과 운영 환경을 분리하기 위한 profile 설정을 추가했습니다. issue #54
refactor: 배포 환경 변수 수정
There was a problem hiding this comment.
Pull request overview
이미지 프로세싱 부하를 서버에서 분리하기 위해, 업로드를 S3로 전환하고 S3 putObject 트리거 기반 Lambda 처리 결과를 SSE/폴링으로 전달하는 구조로 ReTrip 백엔드 아키텍처를 변경합니다(WebFlux 기반).
Changes:
- 이미지 업로드를 S3로 전환하고, jobId 기반 처리 상태 조회/결과 전달(SSE + Redis 캐시) 플로우 추가
- WebMVC → WebFlux 전환 및 관련 보안/웹 설정 재구성
- 기존 서버 내 이미지 처리/메타데이터 추출/OpenAI 호출/메일 설정 등 제거 및 영속화 로직 분리
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/resources/application.yml | 테스트 설정에서 OAuth/AWS 설정 제거 등 단순화 |
| src/main/resources/application.yml | WebFlux multipart/Redis/S3 및 profile별 설정 구성 변경 |
| src/main/java/ssafy/retrip/utils/ImageMetaDataUtil.java | 서버 내 메타데이터 추출 유틸 제거(람다로 이관 가정) |
| src/main/java/ssafy/retrip/utils/ConstantUtil.java | S3 키 규칙/기본 content-type 상수 추가 |
| src/main/java/ssafy/retrip/filter/SessionAuthenticationFilter.java | 세션 인증 필터 제거(WebFlux 전환) |
| src/main/java/ssafy/retrip/domain/job/RetripJobRepository.java | job 상태 조회용 JPA Repository 추가 |
| src/main/java/ssafy/retrip/domain/job/RetripJob.java | job 상태 저장 엔티티 추가 |
| src/main/java/ssafy/retrip/domain/job/JobStatus.java | job 상태 enum 추가 |
| src/main/java/ssafy/retrip/config/WebConfig.java | WebMvcConfigurer → WebFluxConfigurer로 전환 및 CORS 설정 유지 |
| src/main/java/ssafy/retrip/config/WebClientConfig.java | Lambda/외부 호출 대비 WebClient Bean 추가 |
| src/main/java/ssafy/retrip/config/SecurityConfig.java | WebFlux Security로 전환(현재 전체 permitAll) |
| src/main/java/ssafy/retrip/config/S3Config.java | AWS SDK v2 S3Client 구성 추가 |
| src/main/java/ssafy/retrip/config/RestClientConfig.java | RestClient Bean 제거(WebFlux 전환) |
| src/main/java/ssafy/retrip/config/RedisConfig.java | RedisTemplate hash serializer 설정 추가 |
| src/main/java/ssafy/retrip/config/OpenAiConfig.java | OpenAI SDK 설정 제거(람다 처리로 이관 가정) |
| src/main/java/ssafy/retrip/config/EmailConfig.java | 메일 설정 제거 |
| src/main/java/ssafy/retrip/api/service/sse/SseService.java | jobId 기반 SSE 연결/하트비트/결과 푸시 서비스 추가 |
| src/main/java/ssafy/retrip/api/service/s3/S3Service.java | S3 업로드 및 완료 마커 업로드 서비스 추가 |
| src/main/java/ssafy/retrip/api/service/retrip/response/ImageUrlResponse.java | 기존 응답 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 | 분석 결과 기반 Retrip 저장/응답 생성 로직 분리 추가 |
| src/main/java/ssafy/retrip/api/service/retrip/ImageConverter.java | 서버 내 이미지 리사이징/변환 로직 제거 |
| src/main/java/ssafy/retrip/api/service/openai/response/Recommendation.java | 중복/미사용 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 기반 결과 캐시(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 | job 상태/결과/에러 응답 DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/request/LambdaFailureRequest.java | Lambda 실패 콜백 request DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/request/LambdaCallbackRequest.java | Lambda 성공 콜백(분석결과+메타데이터) DTO 추가 |
| src/main/java/ssafy/retrip/api/controller/retrip/RetripController.java | 업로드/SSE/콜백/상태조회 API를 WebFlux 기반으로 재구성 |
| docker-compose.yml | DB 제거 및 Redis 의존성/환경변수 구성 변경 |
| build.gradle | WebFlux/AWS SDK v2 구성으로 의존성 재정리 |
| .github/workflows/CI.yml | CI에서 application-secret.yml 생성 단계 추가 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .subscribeOn(Schedulers.boundedElastic()) | ||
| .doOnError(err -> { | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
saveRetripReactive()에 빈 doOnError 블록이 남아 있어 의미 없이 동작을 가립니다. 에러 로깅/메트릭 등 실제 처리가 없다면 제거하고, 필요하다면 최소한 로그 또는 상위로 전파되는 에러 처리 전략(onErrorMap 등)을 명확히 해주세요.
| .subscribeOn(Schedulers.boundedElastic()) | |
| .doOnError(err -> { | |
| }); | |
| } | |
| .subscribeOn(Schedulers.boundedElastic()); | |
| } |
| @Entity | ||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| @Table(name = "retrip_jobs") | ||
| public class RetripJob extends BaseEntity { |
There was a problem hiding this comment.
새로 추가된 retrip_jobs 테이블이 필요하지만, 현재 레포 내 초기화 SQL/마이그레이션에서 해당 테이블 생성이 확인되지 않습니다. 특히 prod 프로필에서 ddl-auto: validate라면 테이블이 없을 때 애플리케이션 기동이 실패하므로, 운영 DB에 대한 스키마 반영(마이그레이션 스크립트/DDL 적용)을 PR 범위에서 함께 보장해주세요.
| REDIS_HOST: ${SPRING_REDIS_HOST} | ||
| REDIS_PORT: ${SPRING_REDIS_PORT} |
There was a problem hiding this comment.
docker-compose에서 Redis 설정을 REDIS_HOST/REDIS_PORT로 주입하고 있는데, 현재 application.yml은 ${redis.prod.host}/${redis.prod.port}를 요구합니다(또는 Spring Boot 표준이면 SPRING_DATA_REDIS_HOST/PORT). 별도 매핑이 없다면 prod 컨테이너에서 Redis 연결 정보가 해석되지 않아 기동 실패/연결 실패가 날 수 있으니, 환경변수 이름을 실제 프로퍼티 키와 일치시키거나(application-secret.yml 포함) compose와 설정을 정합하게 맞춰주세요.
| REDIS_HOST: ${SPRING_REDIS_HOST} | |
| REDIS_PORT: ${SPRING_REDIS_PORT} | |
| SPRING_DATA_REDIS_HOST: ${SPRING_REDIS_HOST} | |
| SPRING_DATA_REDIS_PORT: ${SPRING_REDIS_PORT} |
| @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.
S3Client를 StaticCredentialsProvider(accessKey/secretKey)로 고정하면 장기 키가 애플리케이션 설정에 상주하게 되어 키 유출/회전/권한 관리 측면에서 리스크가 큽니다. 가능하면 ECS/Lambda/EC2 역할 기반의 DefaultCredentialsProvider(또는 IRSA)로 전환하고, 최소한 prod에서는 정적 키 사용을 피하도록 구성 분리를 고려해주세요.
| log.error("이미지 업로드 중 오류 발생", ex); | ||
| return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null)); |
There was a problem hiding this comment.
업로드 API에서 IllegalArgumentException(예: 업로드된 이미지 없음/사이즈 초과 등)까지 전부 500으로 변환하고, 응답 body도 null로 내려주고 있습니다. 클라이언트 오류는 400대로 구분해 반환하고(가능하면 메시지 포함), 서버 오류만 500으로 처리하도록 예외 매핑을 분리해주세요.
| log.error("이미지 업로드 중 오류 발생", ex); | |
| return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null)); | |
| if (ex instanceof IllegalArgumentException) { | |
| log.warn("이미지 업로드 중 클라이언트 오류 발생: {}", ex.getMessage()); | |
| return Mono.just( | |
| ResponseEntity.status(HttpStatus.BAD_REQUEST) | |
| .header("X-Error-Message", ex.getMessage() != null ? ex.getMessage() : "Invalid upload request") | |
| .body(null) | |
| ); | |
| } | |
| log.error("이미지 업로드 중 서버 오류 발생", ex); | |
| return Mono.just( | |
| ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) | |
| .header("X-Error-Message", "Internal server error") | |
| .body(null) | |
| ); |
| private Flux<ServerSentEvent<String>> createSseStream(String jobId) { | ||
| Sinks.One<String> sink = Sinks.one(); | ||
| sinkMap.put(jobId, sink); | ||
|
|
||
| Flux<ServerSentEvent<String>> resultStream = sink.asMono() | ||
| .map(data -> ServerSentEvent.<String>builder() | ||
| .event("result") | ||
| .data(data) | ||
| .build()) | ||
| .flux(); | ||
|
|
||
| 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)) | ||
| .doFinally(signal -> { | ||
| sinkMap.remove(jobId); | ||
| log.info("SSE 연결 종료: jobId={}, signal={}", jobId, signal); | ||
| }); |
There was a problem hiding this comment.
createSseStream()이 sinkMap.put(jobId, sink)로 기존 sink를 무조건 덮어쓰는 구조라서, 동일 jobId로 재연결/다중 탭 연결이 동시에 존재하면 먼저 생성된 스트림의 doFinally가 나중 sink를 remove(jobId)로 지워버려 결과 푸시가 유실될 수 있습니다. jobId당 단일 연결만 허용한다면 기존 sink가 있을 때는 재사용/에러 반환을 하고, 다중 구독을 허용하려면 Sinks.many().multicast() 등으로 구조를 변경해주세요.
| 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.
aws.s3.bucket/access-key/secret-key가 같은 키를 다시 참조하는 형태(${aws.s3.bucket} 등)라서 스프링 프로퍼티 해석 시 순환 참조가 발생해 애플리케이션이 기동 실패할 가능성이 큽니다. 실제 값은 application-secret.yml이나 환경변수에서 주입되도록 이 항목들은 제거하거나, ${AWS_S3_BUCKET}처럼 다른 키(환경변수)로 참조하도록 수정해주세요.
| .switchIfEmpty(Mono.fromCallable(() -> | ||
| retripJobRepository.findByJobId(jobId) | ||
| .map(job -> { | ||
| if (job.getStatus() == COMPLETED) { | ||
| return JobStatusResponse.completed(jobId, null); | ||
| } else if (job.getStatus() == FAILED) { | ||
| return JobStatusResponse.failed(jobId, job.getErrorMessage()); | ||
| } | ||
| return JobStatusResponse.processing(jobId); | ||
| }) | ||
| .orElse(null)) | ||
| .subscribeOn(Schedulers.boundedElastic())); |
There was a problem hiding this comment.
getJobStatus()의 switchIfEmpty 분기에서 jobId를 찾지 못하면 orElse(null)로 null을 방출할 수 있는데, Reactor에서는 null emission이 허용되지 않아 런타임 NPE가 발생합니다. 미존재 케이스는 Mono.empty()를 반환하거나 Mono.justOrEmpty(...)로 감싸도록 수정해주세요.
| .switchIfEmpty(Mono.fromCallable(() -> | |
| retripJobRepository.findByJobId(jobId) | |
| .map(job -> { | |
| if (job.getStatus() == COMPLETED) { | |
| return JobStatusResponse.completed(jobId, null); | |
| } else if (job.getStatus() == FAILED) { | |
| return JobStatusResponse.failed(jobId, job.getErrorMessage()); | |
| } | |
| return JobStatusResponse.processing(jobId); | |
| }) | |
| .orElse(null)) | |
| .subscribeOn(Schedulers.boundedElastic())); | |
| .switchIfEmpty( | |
| Mono.defer(() -> | |
| Mono.justOrEmpty(retripJobRepository.findByJobId(jobId)) | |
| .map(job -> { | |
| if (job.getStatus() == COMPLETED) { | |
| return JobStatusResponse.completed(jobId, null); | |
| } else if (job.getStatus() == FAILED) { | |
| return JobStatusResponse.failed(jobId, job.getErrorMessage()); | |
| } | |
| return JobStatusResponse.processing(jobId); | |
| })) | |
| .subscribeOn(Schedulers.boundedElastic())); |
| public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { | ||
| return http | ||
| .csrf(CsrfSpec::disable) | ||
| .authorizeExchange(exchange -> exchange |
There was a problem hiding this comment.
현재 Security 설정이 anyExchange().permitAll()이라서(특히 새로 추가된 /api/internal/** 포함) 모든 엔드포인트가 인증 없이 접근 가능합니다. 콜백/관리용 경로는 별도 매처로 보호하거나 최소한 내부 호출에 필요한 인증 메커니즘을 추가해주세요.
| .authorizeExchange(exchange -> exchange | |
| .authorizeExchange(exchange -> exchange | |
| .pathMatchers("/api/internal/**").authenticated() |
| - name: application-secret.yml 파일 생성 | ||
| run: echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application-secret.yml | ||
|
|
There was a problem hiding this comment.
CI에서 application-secret.yml을 src/main/resources에 생성한 뒤 빌드/도커 이미지 빌드를 진행하고 있어, 산출물(JAR/이미지)에 시크릿이 포함될 위험이 큽니다. 빌드 시에는 작업 디렉터리 외부(예: $RUNNER_TEMP)에 생성하고 SPRING_CONFIG_IMPORT/SPRING_CONFIG_ADDITIONAL_LOCATION으로 참조하거나, 테스트에 필요한 값만 env로 주입하는 방식으로 변경해주세요.
#️⃣ 연관된 이슈
#54
📝 작업 내용
Feat
💬 리뷰 요구사항