일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 코테
- 연습문제
- 이것이 자바다
- 포트폴리오
- CoffiesVol.02
- SQL
- Java
- LV.02
- JPA
- mysql
- Redis
- 일정관리프로젝트
- docker
- CI/CD
- Lv.0
- LV1
- 프로그래머스
- LV0
- Kafka
- LV02
- GIT
- 데이터 베이스
- S3
- LV03
- 알고리즘
- Join
- LV01
- 일정관리 프로젝트
- 디자인 패턴
- spring boot
- Today
- Total
코드 저장소.
일정추천 기능 고도화- OpenFeign에서 WebClient 전환기 본문
목차
1.OpenFeign 에서 WebClient로 바꾸게 된 이유
2. 일정추천의 고도화 작업
3. 적용 결과
4.후기
1.OpenFeign 에서 WebClient로 바꾸게 된 이유
처음 일정 추천 기능을 OpenAI API와 연동할 때는 OpenFeign을 사용했다. 단순하게 동작했고 구현 난이도도 낮았지만, 실제 운영 환경에서는 여러 제약이 드러났다.
1-1. GraalVM을 도입을 하기 위해서
현재 배포된 프로젝트의 서버의 용량이 2GB인 점에서 서버를 띄우게 되면 서버의 메모리가 500MB밖에 남지 않았다는 점으로 graalvm을 적용을 하게 되면 서버 메모리의 양을 줄일 수 있다는 장점이 있기 때문인데. 하지만 GraalVM 적용 시 OpenFeign은 다음과 같은 기술적 문제점이 있습니다.
OpenFeign은 기본적으로 리플렉션(Reflection)을 광범위하게 사용합니다. GraalVM은 애플리케이션을 네이티브 이미지로 빌드할 때, 실행에 필요한 모든 코드를 컴파일 시점에 정적으로 분석합니다. 하지만 리플렉션은 런타임에 동적으로 호출되는 특성 때문에 GraalVM이 이를 미리 예측하고 최적화하는 데 어려움이 있습니다.
이것에 대한 근거는 아래의 링크와 같습니다.
https://github.com/spring-cloud/spring-cloud-openfeign/issues/837
Spring Native Build Runtime Error · Issue #837 · spring-cloud/spring-cloud-openfeign
Describe the bug Hey there, So I've been playing around with native-build and I decided to give it a shoot for one of our existing projects. Everything is working fine for the default JVM build. Ho...
github.com
즉, GraalVM 환경에서는 OpenFeign을 안정적으로 사용하기가 어려웠다
1-2. 논블로킹(Non-Blocking) I/O와 세밀한 제어
AI 일정 추천과 같이 외부 API를 호출하는 기능은 네트워크 지연이 발생할 수 있습니다. OpenFeign은 기본적으로 동기(Blocking) 방식으로 동작하여 요청이 많아지면 스레드 풀이 고갈될 위험이 있습니다. 반면, WebClient는 논블로킹 방식으로 작동하여 적은 수의 스레드로도 높은 동시성을 처리할 수 있습니다.
또한, WebClient는 Fluent API를 제공하여 HTTP 요청에 대한 세부적인 제어가 용이합니다. AI API 호출 시 복잡한 헤더를 추가하거나, 응답 결과에 따라 유연하게 후속 처리를 하는 등 OpenFeign보다 확장성 있는 코드를 작성할 수 있다는 장점도 WebClient로 전환을 결정한 중요한 이유입니다.
이러한 이유로, 저는 GraalVM 도입의 효율성을 극대화하고 서비스의 성능 및 안정성을 확보하기 위해 OpenFeign을 WebClient로 전환했습니다.
1-3. 기존 구조에서의 변경
기존에 OpenFeign를 사용을 했을 때의 그림은 아래와 같습니다.
기존 구조는 단순했고 프롬프트의 경우에도 정적인 프롬프트를 사용을 했고 Blocking 방식이라 성능에 제약이 있었습니다. 이러한 단점을 보완하기 위해서 아래와 같이 구조를 변경을 했습니다.
다음과 같이 변경을 했을때의 내용은 아래와 같습니다.
(1) 통신 방식과 아키텍처 변화
- 기존: FeignClient 기반 동기 호출 → OpenAiResponse를 직접 받고, JSON 파싱도 동기 처리
- 신규: WebClient + Reactor 기반 → Mono<OpenAiResponse> 체인으로 논블로킹 처리
- 핵심: 동기 → 리액티브 전환으로 GraalVM 호환성과 고성능 확보
(2) 데이터 리턴 타입 변화
- 기존: JPA Entity List<Schedules> 동기 반환
- 신규: DTO 기반 Mono<List<SchedulesModel>> 반환 + 캐싱 포함
- 핵심: 서비스 계층까지 리액티브 타입을 도입해 유연성과 확장성 강화
(3) 캐싱 로직 추가
- 기존: 캐싱 없음 → 매번 DB + OpenAI 호출
- 신규: ScheduleRecommendCacheService 도입 → Redis 캐시 활용
- 효과: 동일 요청 시 캐시 히트 → API 호출 스킵, 성능 개선
(4) 프롬프트 설계 고도화
- 기존: 단순히 “빈 시간 추천” 수준
- 신규: 일정 패턴 분석(topContent), 아침/저녁 여부, 과부하 상태 반영, ISO-8601 포맷 강제
- 효과: 단순 일정 채우기 → 사용자 패턴/컨디션 고려한 지능형 추천
(5) JSON 처리 안정성 강화
- 기존: 정규식으로 JSON 블록만 추출, 단순 Jackson 파싱
- 신규: [ ~ ] 범위만 추출 + TypeReference 파싱 + contents 타입 방어 로직 추가
- 효과: 응답 파싱 실패 → 서비스 터짐 방지
(6) Fallback 처리 개선
- 기존: List<Schedules> 엔티티 직접 반환, “AI 추천 불가” 고정 일정만 제공
- 신규: Mono<List<SchedulesModel>>로 반환, 리액티브 체인 유지 + 모델 기반 일정 생성
- 효과: 일관성 있는 리액티브 플로우 유지
(7) 로깅 체계 고도화
- 기존: 단순 에러 로그만 출력
- 신규: memberId, 일정 수, 캐시 히트 여부, 프롬프트, JSON 추출 결과, contents 타입 변환까지 세밀하게 기록
- 효과: 운영 관점에서 장애/성공 흐름 추적 용이
2. 일정추천의 고도화 작업
2-1.webClient를 사용을 하기 위해서는 의존성을 주입을 합니다.
dependecies {
//webclient
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
2-2. 일정추천의 서비스 코드는 아래와 같습니다.
@CircuitBreaker(name = "openAiClient", fallbackMethod = "fallbackRecommendSchedules")
public Mono<List<SchedulesModel>> recommendSchedules(String userId, Pageable pageable) {
Long memberId = memberRepository.findByUserId(userId)
.orElseThrow(() -> new MemberCustomException(MemberErrorCode.NOT_USER))
.getId();
String cacheKey = "schedule:recommend:" + userId + ":" + LocalDate.now();
// 1. 캐시 확인
Optional<List<SchedulesModel>> cached = recommendCacheService.get(cacheKey, new TypeReference<>() {});
if (cached.isPresent()) {
log.info("캐시 히트: {}", cacheKey);
return Mono.just(cached.get());
}
// 2. DB 조회 → 프롬프트 생성 → OpenAI 호출
return Mono.just(schedulesRepository.findAllByUserId(userId, pageable).getContent())
.map(this::mapToSchedules)
.map(this::buildPromptFromSchedules)
.map(this::buildOpenAiRequest)
.flatMap(request -> aiClient.getChatCompletion(request))
.map(response -> {
String content = response.getChoices().get(0).getMessage().getContent();
String extractedJson = extractJsonFromContent(content);
List<ScheduleRecommendationDto> dtoList = parseJson(extractedJson);
List<SchedulesModel> recommended = mapToRecommendedSchedules(dtoList, memberId);
recommendCacheService.set(cacheKey, recommended, Duration.ofHours(6)); // 캐싱
return recommended;
});
}
핵심 동작 흐름
- 캐시 확인: 오늘자 추천 일정이 Redis에 있으면 즉시 반환 (API 호출 스킵).
- DB 조회: 사용자의 일정 목록을 조회.
- 프롬프트 생성: 빈 시간대, 일정 패턴 등을 반영한 지능형 프롬프트 구성.
- OpenAI 호출: WebClient를 통해 비동기 호출.
- 결과 처리 및 캐싱: 응답을 DTO로 변환 후 Redis에 6시간 캐싱.
Fallback 처리
public Mono<List<SchedulesModel>> fallbackRecommendSchedules(String userId, Pageable pageable, Throwable t) {
log.error("[OpenAI fallback 작동] userId={}, 이유: {}", userId, t.getMessage(), t);
Long memberId = memberRepository.findByUserId(userId)
.orElseThrow(() -> new MemberCustomException(MemberErrorCode.NOT_USER))
.getId();
SchedulesModel fallbackSchedule = SchedulesModel.builder()
.contents("AI 추천 일정 제공 불가 - 기본 일정")
.scheduleMonth(LocalDateTime.now().getMonthValue())
.scheduleDays(LocalDateTime.now().getDayOfMonth())
.startTime(LocalDateTime.now())
.endTime(LocalDateTime.now().plusHours(1))
.memberId(memberId)
.build();
return Mono.just(List.of(fallbackSchedule));
}
OpenAI API가 실패하면 CircuitBreaker가 자동으로 Fallback을 호출.
“AI 추천 일정 제공 불가”라는 기본 일정을 반환하여 서비스 중단 방지.
캐싱 서비스
@Component
@RequiredArgsConstructor
public class ScheduleRecommendCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
// 캐시 저장
public <T> void set(String key, T value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
// 캐시 조회
public <T> Optional<T> get(String key, TypeReference<T> typeRef) {
Object result = redisTemplate.opsForValue().get(key);
if (result == null) return Optional.empty();
try {
return Optional.of(objectMapper.convertValue(result, typeRef));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
- 캐시 키: schedule:recommend:{userId}:{date}
- 캐시 Hit 시: OpenAI 호출 스킵 → 응답 속도 개선
- 캐시 Miss 시: OpenAI 호출 후 결과를 저장
3. 적용 결과
기능을 만들어 봤으니 이제 일정추천이 잘 구현이 되는지 그리고 캐싱이 되는지를 확인을 해보고 Jmeter를 활용해서 현재 배포된 환경(LightSail 2GB)에서 얼마나 버틸수 있는지를 측정을 해볼것입니다. 우선은 측정 도구와 서버환경은 아래와 같습니다.
측정 도구
- Jmeter : 요청 부하 발생 및 응답 시간 측정
- Grafana (Prometheus 연동): JVM Heap, GC, DB 커넥션 풀 등 서버 내부 지표 모니터링
서버 환경
- 서버: 2GB VM (JVM 힙 512m~1g, HikariCP max=20)
- API: 일정 추천 API (/api/chat/recommend)
- 인증: JWT 헤더 포함
테스트 시나리오
- 정상 호출: 단일 사용자, 10회 반복 호출
- 부하 테스트: 동시 사용자 50명100명 , Ramp-up 30초, Duration 3분
3-1. Jmeter로 단일 10회 호출 결과
위의 지표를 아래와 같이 정리를 할 수 있습니다.
- 평균 응답시간: 54ms
- 최소/최대: 31ms ~ 200ms
- 표준편차: ~49ms
- 에러율: 0%
- 처리량(Throughput): ~18 req/sec
- Heap 사용량: ~118MiB (Max 221MiB)
- GC Pause: 짧게 돌려서 거의 없음
- CPU 사용률: 큰 변화 없음 (ops/s 그래프도 미약한 수준)
Throughput 수치는 큰 의미는 없지만, 응답시간이 안정적이고 에러도 없으니 정상 동작은 문제가 없다는 것을 알 수 있습니다.
3-2. 부하 테스트 (50VU)
다음은 50VU로 부하 테스트를 해봤을때의 결과입니다.
조건은 동시 사용자 50명, Ramp-up 30초, Duration 3분 으로 측정을 한 결과입니다.
- 평균 응답: 346ms
- P95: 556ms
- P99: 709ms
- 최대 응답: 2.1s
- Throughput: 132 req/sec
- 에러율 지표: 0%
- Heap 사용량: 평균 118MiB (안정적)
- GC Pause: Full GC 없음
- DB 풀: 최대 active=20 근접, 부하 구간에서 pending 증가 확인
50명 동시 요청까지 안정적으로 처리했으며, 일부 tail latency(P99 이상) 구간에서 응답이 2초를 넘어가는 현상을 확인을 할 수 있습니다.
3-3. 부하 테스트(100VU)
다음은 100VU의 결과입니다. 조건은 동시 사용자 100명, Ramp-up 30초, Duration 3분 으로 측정을 한 결과입니다.
- 평균 응답: 945ms
- P95: 1.78s
- P99: 2.1s
- 최대 응답: 6.3s
- 처리량: ~96 req/sec
- 에러율: 0%
- Heap/GC: JVM Heap 안정적 유지, Minor GC pause ~100ms 발생
- DB 풀: 동시성 구간에서 대기 증가
LightSail 2GB VM 환경 기준 최대 100명까지는 서버가 정상 동작했으며, P95 < 2초 수준으로 안정적으로 버텼음을 확인하였다.
다만 GC Pause 및 DB 풀 대기 시간이 증가해 리소스 한계가 서서히 드러남을 알 수 있었다.
4.후기
이번 성능 검증을 통해 단순 기능 구현이 아니라, 운영 환경에서의 안정성까지 고려한 아키텍처적 개선을 할 수 있었습니다.
특히 얻은 인사이트는 다음과 같습니다.
- 비동기 전환은 코드 스타일 문제가 아니라 서버 자원 효율성과 직결됨.
- 캐싱 + Fallback 없이는 외부 API 연동을 실서비스에서 안정적으로 운영하기 어렵다는 점.
- 평균 응답시간만 볼 게 아니라 P95/P99, tail latency를 함께 봐야 병목을 찾을 수 있음.
- 성능 문제는 단순히 API 코드가 아니라 DB 풀, JVM Heap, GC 등 내부 리소스 지표와 연관된다는 점.
- 작은 서버(2GB VM)에서도 적절한 아키텍처와 튜닝을 적용하면 100명 동시 요청까지 버틸 수 있음을 검증했다는 점.
이번 개선을 통해 단순 API 호출 수준을 넘어서, 실제 운영 가능한 서비스 아키텍처로 한 단계 올라섰다고 생각합니다.
'포폴 > 일정관리앱' 카테고리의 다른 글
Kafka Exactly-Once 보장하기: Outbox + EventId 기반 멱등 처리 (0) | 2025.09.01 |
---|---|
프로젝트 배포3- CI부분에 캐싱 적용 (1) | 2025.07.06 |
첨부파일업로드3-Presignedurl기능 고도화 (1) | 2025.07.06 |
Spring 서비스의 운영 모니터링 환경 구축기2: Prometheus, Grafana, Loki, Kafka/Redis Exporter (0) | 2025.06.20 |
Spring 서비스의 운영 모니터링 환경 구축기1: Prometheus, Grafana, Loki, Kafka/Redis Exporter (0) | 2025.06.17 |