Skip to content

Commit 3673975

Browse files
committed
[Feat]: ai 서버 서킷 브레이커 추가
1 parent 4d7cca8 commit 3673975

6 files changed

Lines changed: 180 additions & 100 deletions

File tree

Lines changed: 72 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,72 @@
1-
## 워크플로우 이름 설정
2-
#name: Java CI with Gradle
3-
#
4-
## 워크플로우 트리거 설정
5-
##on:
6-
## push:
7-
## branches:
8-
## - "develop"
9-
#
10-
## github action vm의 권한 read로 설정
11-
#permissions:
12-
# contents: read
13-
#
14-
## 작업 정의
15-
#jobs:
16-
# # Docker 이미지 빌드 및 푸시 작업
17-
# build-docker-image:
18-
# runs-on: ubuntu-latest # 최신 Ubuntu 러너에서 실행
19-
# steps:
20-
# # 1. 코드 체크아웃
21-
# - uses: actions/checkout@v3
22-
#
23-
# # 2. JDK 21 설정
24-
# - name: Set up JDK 21
25-
# uses: actions/setup-java@v3
26-
# with:
27-
# java-version: '21'
28-
# distribution: 'temurin'
29-
#
30-
# # 3. Gradle을 사용하여 프로젝트 빌드
31-
# - name: Build with Gradle
32-
# run: |
33-
# chmod +x ./gradlew
34-
# ./gradlew clean build -x test
35-
#
36-
# # 4. Docker 이미지 빌드
37-
# - name: Build Docker image
38-
# run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}:latest .
39-
#
40-
# # 5. Docker Hub 로그인
41-
# - name: Log in to Docker Hub
42-
# uses: docker/login-action@v2
43-
# with:
44-
# username: ${{ secrets.DOCKERHUB_USERNAME }}
45-
# password: ${{ secrets.DOCKERHUB_PASSWORD }}
46-
#
47-
# # 6. Docker 이미지를 Docker Hub에 푸시
48-
# - name: Push Docker image
49-
# run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}:latest
50-
#
51-
# # EC2에서 Docker 이미지 실행 작업
52-
# deploy-to-ec2:
53-
# needs: build-docker-image # build-docker-image 작업이 완료된 후 실행
54-
# runs-on: ubuntu-latest
55-
# steps:
56-
# - name: Deploy to EC2
57-
# uses: appleboy/ssh-action@v1.0.3
58-
# with:
59-
# host: ${{ secrets.DEV_WAS_HOST }} #서버의 public 주소
60-
# username: ${{ secrets.DEV_WAS_USERNAME }} #접속할 사용자
61-
# key: ${{ secrets.DEV_WAS_KEY }} #SSH 키
62-
# port: ${{ secrets.DEV_WAS_SSH_PORT }}
63-
# script: |
64-
# cat ~/my_password.txt | docker login --username ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
65-
# if [ "$(docker ps -qa)" ]; then
66-
# docker rm -f $(docker ps -qa)
67-
# docker rmi $(docker images -q)
68-
# else
69-
# echo "No containers to remove."
70-
# fi
71-
# docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}
72-
# docker run -d -p 8080:8080 --env-file /home/ubuntu/src/config/development.env --name app ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}
1+
# 워크플로우 이름 설정
2+
name: Java CI with Gradle
3+
4+
# 워크플로우 트리거 설정
5+
#on:
6+
# push:
7+
# branches:
8+
# - "develop"
9+
10+
# github action vm의 권한 read로 설정
11+
permissions:
12+
contents: read
13+
14+
# 작업 정의
15+
jobs:
16+
# Docker 이미지 빌드 및 푸시 작업
17+
build-docker-image:
18+
runs-on: ubuntu-latest # 최신 Ubuntu 러너에서 실행
19+
steps:
20+
# 1. 코드 체크아웃
21+
- uses: actions/checkout@v3
22+
23+
# 2. JDK 21 설정
24+
- name: Set up JDK 21
25+
uses: actions/setup-java@v3
26+
with:
27+
java-version: '21'
28+
distribution: 'temurin'
29+
30+
# 3. Gradle을 사용하여 프로젝트 빌드
31+
- name: Build with Gradle
32+
run: |
33+
chmod +x ./gradlew
34+
./gradlew clean build -x test
35+
36+
# 4. Docker 이미지 빌드
37+
- name: Build Docker image
38+
run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}:latest .
39+
40+
# 5. Docker Hub 로그인
41+
- name: Log in to Docker Hub
42+
uses: docker/login-action@v2
43+
with:
44+
username: ${{ secrets.DOCKERHUB_USERNAME }}
45+
password: ${{ secrets.DOCKERHUB_PASSWORD }}
46+
47+
# 6. Docker 이미지를 Docker Hub에 푸시
48+
- name: Push Docker image
49+
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}:latest
50+
51+
# EC2에서 Docker 이미지 실행 작업
52+
deploy-to-ec2:
53+
needs: build-docker-image # build-docker-image 작업이 완료된 후 실행
54+
runs-on: ubuntu-latest
55+
steps:
56+
- name: Deploy to EC2
57+
uses: appleboy/ssh-action@v1.0.3
58+
with:
59+
host: ${{ secrets.DEV_WAS_HOST }} #서버의 public 주소
60+
username: ${{ secrets.DEV_WAS_USERNAME }} #접속할 사용자
61+
key: ${{ secrets.DEV_WAS_KEY }} #SSH 키
62+
port: ${{ secrets.DEV_WAS_SSH_PORT }}
63+
script: |
64+
cat ~/my_password.txt | docker login --username ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
65+
if [ "$(docker ps -qa)" ]; then
66+
docker rm -f $(docker ps -qa)
67+
docker rmi $(docker images -q)
68+
else
69+
echo "No containers to remove."
70+
fi
71+
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}
72+
docker run -d -p 8080:8080 --env-file /home/ubuntu/src/config/development.env --name app ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_DEV_REPO }}

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ dependencies {
6161
replacedBy("org.springframework.boot:spring-boot-starter-log4j2", "Use Log4j2 instead of Logback")
6262
}
6363
}
64+
65+
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
66+
implementation 'org.springframework.boot:spring-boot-starter-aop'
67+
6468
}
6569

6670
tasks.named('test') {

src/main/java/com/potato/balbambalbam/card/cardFeedback/service/AiCardFeedbackService.java

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,69 @@
33
import com.potato.balbambalbam.card.cardFeedback.dto.AiFeedbackRequestDto;
44
import com.potato.balbambalbam.card.cardFeedback.dto.AiFeedbackResponseDto;
55
import com.potato.balbambalbam.exception.AiGenerationFailException;
6+
import com.potato.balbambalbam.exception.AiServerException;
67
import com.potato.balbambalbam.exception.InvalidParameterException;
8+
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
9+
import lombok.RequiredArgsConstructor;
710
import lombok.extern.slf4j.Slf4j;
8-
import org.springframework.beans.factory.annotation.Value;
9-
import org.springframework.http.HttpStatus;
10-
import org.springframework.http.MediaType;
1111
import org.springframework.stereotype.Service;
12+
import org.springframework.web.reactive.function.client.ClientResponse;
1213
import org.springframework.web.reactive.function.client.WebClient;
14+
import org.springframework.web.reactive.function.client.WebClientRequestException;
1315
import reactor.core.publisher.Mono;
16+
import reactor.util.retry.Retry;
1417

1518
import java.time.Duration;
19+
import java.util.concurrent.TimeoutException;
1620

17-
@Service
1821
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
1924
public class AiCardFeedbackService {
20-
WebClient webClient = WebClient.builder()
21-
.codecs(configurer -> configurer
22-
.defaultCodecs()
23-
.maxInMemorySize(5 * 1024 * 1024)) // 5MB
24-
.build();
25-
@Value("${ai.service.url}")
26-
private String AI_URL;
2725

28-
public AiFeedbackResponseDto postAiFeedback(AiFeedbackRequestDto aiFeedbackRequestDto) {
26+
private final WebClient aiWebClient;
2927

30-
AiFeedbackResponseDto aiFeedbackResponseDto = webClient.post()
31-
.uri(AI_URL + "/ai/feedback")
32-
.contentType(MediaType.APPLICATION_JSON)
33-
.body(Mono.just(aiFeedbackRequestDto), AiFeedbackRequestDto.class)
28+
private final Retry retryPolicy = Retry.backoff(2, Duration.ofMillis(200))
29+
.maxBackoff(Duration.ofSeconds(1))
30+
.jitter(0.3)
31+
.filter(this::isRetryable);
32+
33+
@CircuitBreaker(name = "aiFeedback", fallbackMethod = "fallback")
34+
public AiFeedbackResponseDto postAiFeedback(AiFeedbackRequestDto aiFeedbackRequestDto) {
35+
return aiWebClient.post()
36+
.uri("/ai/feedback")
37+
.bodyValue(aiFeedbackRequestDto)
3438
.retrieve()//요청
35-
//에러 처리 : 요청이 잘못갔을 경우
36-
.onStatus(HttpStatus.BAD_REQUEST::equals,
37-
response -> response.bodyToMono(String.class).map(InvalidParameterException::new))
38-
//에러 처리 : 사용자 텍스트 추출 실패
39-
.onStatus(HttpStatus.UNPROCESSABLE_ENTITY::equals,
40-
response -> response.bodyToMono(String.class).map(AiGenerationFailException::new))
41-
//에러 처리 : 텍스트 분리 실패, 정확도 계산, 그래프 추출 실패
42-
.onStatus(HttpStatus.INTERNAL_SERVER_ERROR::equals,
43-
response -> response.bodyToMono(String.class).map(AiGenerationFailException::new))
39+
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
40+
this::mapError)
4441
.bodyToMono(AiFeedbackResponseDto.class)
45-
.timeout(Duration.ofSeconds(60)) //10초 안에 응답 오지 않으면 TimeoutException 발생
42+
.retryWhen(retryPolicy)
43+
.timeout(Duration.ofSeconds(15)) // 전체 시도(Retry 포함)에 대한 마지노선
4644
.block();
45+
}
4746

48-
return aiFeedbackResponseDto;
47+
private Mono<? extends Throwable> mapError(ClientResponse response) {
48+
return response.bodyToMono(String.class)
49+
.defaultIfEmpty("")
50+
.map(body -> {
51+
int statusCode = response.statusCode().value();
52+
if (statusCode == 400) return new InvalidParameterException(body);
53+
if (statusCode == 422) return new AiGenerationFailException(body);
54+
if (statusCode >= 500) return new AiServerException(body);
55+
return new RuntimeException("AI error: " + statusCode + " body=" + body);
56+
});
4957
}
58+
59+
private boolean isRetryable(Throwable ex) {
60+
return ex instanceof TimeoutException
61+
|| ex instanceof WebClientRequestException
62+
|| ex instanceof AiServerException
63+
|| ex.getCause() instanceof io.netty.handler.timeout.ReadTimeoutException;
64+
}
65+
66+
private AiFeedbackResponseDto fallback(Throwable t) {
67+
log.warn("AI circuit breaker OPEN - fallback executed", t);
68+
throw new AiServerException("AI service temporarily unavailable");
69+
}
70+
5071
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.potato.balbambalbam.config;
2+
3+
import io.netty.channel.ChannelOption;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.http.HttpHeaders;
8+
import org.springframework.http.MediaType;
9+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
10+
import org.springframework.web.reactive.function.client.WebClient;
11+
import reactor.netty.http.client.HttpClient;
12+
13+
import java.time.Duration;
14+
15+
16+
@Configuration
17+
public class AiWebClientConfig {
18+
19+
private final String baseUrl;
20+
21+
public AiWebClientConfig(@Value("${ai.service.url}") String baseUrl) {
22+
this.baseUrl = baseUrl;
23+
}
24+
25+
@Bean
26+
public WebClient aiWebClient() {
27+
HttpClient httpClient = HttpClient.create()
28+
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
29+
.responseTimeout(Duration.ofSeconds(5));
30+
31+
return WebClient.builder()
32+
.baseUrl(baseUrl)
33+
.clientConnector(new ReactorClientHttpConnector(httpClient))
34+
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
35+
.codecs(c -> c.defaultCodecs().maxInMemorySize(5 * 1024 * 1024))
36+
.build();
37+
}
38+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.potato.balbambalbam.exception;
2+
3+
public class AiServerException extends RuntimeException {
4+
public AiServerException(String message) {
5+
super(message);
6+
}
7+
}

src/main/resources/application.properties

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,14 @@ logging.level.org.hibernate.dialect.Dialect=ERROR
3333
logging.level.org.hibernate.orm.deprecation=ERROR
3434

3535
# Open Session In View
36-
spring.jpa.open-in-view=false
36+
spring.jpa.open-in-view=false
37+
38+
# circuit breaker
39+
resilience4j.circuitbreaker.instances.aiFeedback.sliding-window-size=20
40+
resilience4j.circuitbreaker.instances.aiFeedback.failure-rate-threshold=50
41+
resilience4j.circuitbreaker.instances.aiFeedback.minimumNumberOfCalls=10
42+
resilience4j.circuitbreaker.instances.aiFeedback.waitDurationInOpenState=10s
43+
resilience4j.circuitbreaker.instances.aiFeedback.record-exceptions[0]=com.potato.balbambalbam.exception.AiServerException
44+
resilience4j.circuitbreaker.instances.aiFeedback.record-exceptions[1]=java.util.concurrent.TimeoutException
45+
resilience4j.circuitbreaker.instances.aiFeedback.record-exceptions[2]=org.springframework.web.reactive.function.client.WebClientRequestException
46+
resilience4j.circuitbreaker.instances.aiFeedback.record-exceptions[3]=io.netty.handler.timeout.ReadTimeoutException

0 commit comments

Comments
 (0)