Skip to content

deploy: AWS Lambda + RDS 구조로 변경한 ReTrip 아키텍처 배포#57

Merged
Bumnote merged 11 commits intomainfrom
dev
Mar 26, 2026
Merged

deploy: AWS Lambda + RDS 구조로 변경한 ReTrip 아키텍처 배포#57
Bumnote merged 11 commits intomainfrom
dev

Conversation

@Bumnote
Copy link
Copy Markdown
Member

@Bumnote Bumnote commented Mar 26, 2026

#️⃣ 연관된 이슈

#54

📝 작업 내용

Feat

  • 이미지 업로드 기능을 S3에 업로드할 수 있도록 구조를 변경했습니다.
  • 이미지 S3 업로드 putObejct를 트리거로 하여 이미지 프로세싱하는 Lambda 함수가 실행됩니다.
  • 프론트는 업로드 요청과 동시에 서버와 SSE 통신을 맺고, 서버의 응답을 기다립니다.
  • 서버는 람다의 응답을 기다리며, 응답 완료 시 데이터를 DB에 저장하고, 클라이언트로 반환합니다.
  • 반환과 동시에 SSE 연결은 종료됩니다. 또한, 데이터 유실을 막기 위해 중간 데이터를 Redis에 임시로 저장해둡니다. (TTL: 5분)

💬 리뷰 요구사항

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

  • 람다를 활용함으로써 하나의 서버에서 여러 요청에 대한 이미지 프로세싱으로 CPU 사용률이 99.8%까지 치솟는 문제를 해결했습니다.

Bumnote and others added 11 commits September 13, 2025 15:25
- Async 비동기 방식 전환을 위한 Config 설정했습니다.
- 코어 풀 사이즈를 20으로 설정했습니다.
- 맥스 풀 사이즈를 50으로 설정했습니다.
- 대기 큐 용량을 100으로 설정했습니다.
issue #52
- Async 어노테이션을 활용하여 비동기 통신으로 전환했습니다.
- CompletableFuture 반환형을 모든 메서드에 적용했습니다.
- 트랜잭션을 분리하기 위해서 Persistence 작업을 위한 서비스를 따로 분리했습니다.
issue #52
- 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: 배포 환경 변수 수정
@github-actions
Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit b47f603. ± Comparison against base commit 6786f8a.

@Bumnote Bumnote merged commit 3d08387 into main Mar 26, 2026
7 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

이미지 프로세싱 부하를 서버에서 분리하기 위해, 업로드를 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.

Comment on lines +44 to +48
.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 블록이 남아 있어 의미 없이 동작을 가립니다. 에러 로깅/메트릭 등 실제 처리가 없다면 제거하고, 필요하다면 최소한 로그 또는 상위로 전파되는 에러 처리 전략(onErrorMap 등)을 명확히 해주세요.

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

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +23
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "retrip_jobs")
public class RetripJob extends BaseEntity {
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.

새로 추가된 retrip_jobs 테이블이 필요하지만, 현재 레포 내 초기화 SQL/마이그레이션에서 해당 테이블 생성이 확인되지 않습니다. 특히 prod 프로필에서 ddl-auto: validate라면 테이블이 없을 때 애플리케이션 기동이 실패하므로, 운영 DB에 대한 스키마 반영(마이그레이션 스크립트/DDL 적용)을 PR 범위에서 함께 보장해주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +19
REDIS_HOST: ${SPRING_REDIS_HOST}
REDIS_PORT: ${SPRING_REDIS_PORT}
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.

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와 설정을 정합하게 맞춰주세요.

Suggested change
REDIS_HOST: ${SPRING_REDIS_HOST}
REDIS_PORT: ${SPRING_REDIS_PORT}
SPRING_DATA_REDIS_HOST: ${SPRING_REDIS_HOST}
SPRING_DATA_REDIS_PORT: ${SPRING_REDIS_PORT}

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.

S3Client를 StaticCredentialsProvider(accessKey/secretKey)로 고정하면 장기 키가 애플리케이션 설정에 상주하게 되어 키 유출/회전/권한 관리 측면에서 리스크가 큽니다. 가능하면 ECS/Lambda/EC2 역할 기반의 DefaultCredentialsProvider(또는 IRSA)로 전환하고, 최소한 prod에서는 정적 키 사용을 피하도록 구성 분리를 고려해주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +45
log.error("이미지 업로드 중 오류 발생", ex);
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null));
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.

업로드 API에서 IllegalArgumentException(예: 업로드된 이미지 없음/사이즈 초과 등)까지 전부 500으로 변환하고, 응답 body도 null로 내려주고 있습니다. 클라이언트 오류는 400대로 구분해 반환하고(가능하면 메시지 포함), 서버 오류만 500으로 처리하도록 예외 매핑을 분리해주세요.

Suggested change
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)
);

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +57
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);
});
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.

createSseStream()sinkMap.put(jobId, sink)로 기존 sink를 무조건 덮어쓰는 구조라서, 동일 jobId로 재연결/다중 탭 연결이 동시에 존재하면 먼저 생성된 스트림의 doFinally가 나중 sink를 remove(jobId)로 지워버려 결과 푸시가 유실될 수 있습니다. jobId당 단일 연결만 허용한다면 기존 sink가 있을 때는 재사용/에러 반환을 하고, 다중 구독을 허용하려면 Sinks.many().multicast() 등으로 구조를 변경해주세요.

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.

aws.s3.bucket/access-key/secret-key가 같은 키를 다시 참조하는 형태(${aws.s3.bucket} 등)라서 스프링 프로퍼티 해석 시 순환 참조가 발생해 애플리케이션이 기동 실패할 가능성이 큽니다. 실제 값은 application-secret.yml이나 환경변수에서 주입되도록 이 항목들은 제거하거나, ${AWS_S3_BUCKET}처럼 다른 키(환경변수)로 참조하도록 수정해주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +119
.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()));
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.

getJobStatus()switchIfEmpty 분기에서 jobId를 찾지 못하면 orElse(null)null을 방출할 수 있는데, Reactor에서는 null emission이 허용되지 않아 런타임 NPE가 발생합니다. 미존재 케이스는 Mono.empty()를 반환하거나 Mono.justOrEmpty(...)로 감싸도록 수정해주세요.

Suggested change
.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()));

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.

현재 Security 설정이 anyExchange().permitAll()이라서(특히 새로 추가된 /api/internal/** 포함) 모든 엔드포인트가 인증 없이 접근 가능합니다. 콜백/관리용 경로는 별도 매처로 보호하거나 최소한 내부 호출에 필요한 인증 메커니즘을 추가해주세요.

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

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +37
- name: application-secret.yml 파일 생성
run: echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application-secret.yml

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.

CI에서 application-secret.ymlsrc/main/resources에 생성한 뒤 빌드/도커 이미지 빌드를 진행하고 있어, 산출물(JAR/이미지)에 시크릿이 포함될 위험이 큽니다. 빌드 시에는 작업 디렉터리 외부(예: $RUNNER_TEMP)에 생성하고 SPRING_CONFIG_IMPORT/SPRING_CONFIG_ADDITIONAL_LOCATION으로 참조하거나, 테스트에 필요한 값만 env로 주입하는 방식으로 변경해주세요.

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