Resilience4j란?
MSA환경에서는 각각의 서비스를 호출하여 통신을 이루는 시스템이다.
통신 과정에서 통신이 원활히 진행되지 않아 늦게 데이터를 반환받는 지연
의 상태 또는 장애가 발생하여 통신이 실패
하는 상황이 발생한다.
이에 대한 대처를 하기 위해 이 Resilience4j를 사용하여 CircuitBreaker , fallover, retry
등의 설정을 통해 장애를 직접 대처할 수 있도록 예방한다.
위 작업을 수행하기 위해 윈도우-슬라이드
알고리즘을 사용해서 작업 단위를 한칸씩 이동하며 상태를 체크하는 방식이다.
Resilience4j는 언제 사용해야 할까?
위에서 말했듯이 주로 다른 서버에 호출을 하여 원하는 데이터를 받아오거나 어떤 이벤트를 날릴때 사용한다. 내 애플리케이션의 서비스 레이어에 이벤트를 처내고 발생하는 장애는 ExceptionHandler
를 사용하여 처리할 수 있기 때문이다. 하지만 내 애플리케이션에서도 Resilience4j를 사용할 수 있다.
Resilience4j를 사용하는 상황의 특성은 다음과 같이 키워드로 설명 할 수 있다. 비정상적인 상황 처리
와 반복적인 요청
이다.
한 번의 요청만 날려서 처리할 수 있는 상황이라면 간단히 끝나겠지만, 어떤 상황에 의해 정상적인 응답을 받아 오지 못할 경우 우리는 재요청을 날리며 성공적인 응답을 받아오려고 한다.
이때 Resilience4j를 사용하며, 그 외의 상황에서는 ExceptionHandler를 통해 예외에 대한 로직을 발생시켜주면 될 것 이다.
다양한 상황에서 어떻게 사용할 수 있는지 다시 적어보겠다.
1. 외부 API ghcnf
- 타사 서비스, OpenFeign, RestTemplate, WebClient 등으로 외부 시스템과 통신할 때
2. 내부 마이크로서비스 간 호출 - REST API, gRPC, MessageQue방식의 호출 시 장애 대응
3. 데이터베이스 호출 보호 - JPA, MyBatis 등의 DB 접근 로직에서 특정 쿼리가 계속해서 실패할 경우 과부하 방지
4. Cache 서버 장애 대응 - 캐시 서버가 다운되었을 때, DB에 직접 조회하여 가져오도록 설정
5. 비동기 작업 - 배치나 스케쥴러 작업시 작업을 차단하여 전체 서비스의 안정성을 유지할 수 있다.
Resilience4j의 특징
- 3가지 상태
- Open, Half-Open, Closed
- Open은 서킷 브레이커가 모든 요청을 즉시 실패로 처리하는 상태이다. 완전히 문을 닫은것이다. 이 상태를 만듬으로써 다른 서비스에 장애가 전파되는 것을 방지 할 수 있다. 설정한 일정 시간이 지난 후에는 하프-오픈 상태로 전환된다.
- Half-Open은 오픈 상태에서 대기 시간이 지나면 바뀌는 상태로 제한된 수의 요청을 허용하여 정상적으로 응답을 반환할 수 있는지 확인하는 상태이다. 요청이 성공하면 Closed상태로 전환된다.
- 요청이 만약 다시 실패하면 서킷 브레이커는 다시 오픈 상태로 전환된다.
- Fallback
- 호출 실패 시 fallback 메서드를 실행시켜 장애가 발생할 수 있는 상황에 대해 대처할 수 있다.
- 아래에서 예시로 코드를 공유하겠지만 예를 들어 토큰을 전달받는 요청에서 제대로 토큰이 전달되지 않는 경우 fallback 메서드에서 레디스 캐시로 저장된 데이터를 가져와 반환하거나, 임의의 토큰을 새로 발급하여 전달하는 등 사용자는 모르지만 정상적으로 수행되는 것처럼 흐름을 제어할 수 있다.
- Monitoring : 서킷 브레이커의 상태를 actuator와 promethous를 사용하여 확인할 수 있다.
Resilience4j 구현하기
아래 프로젝트는 기존의 User Server에 Resilience4j를 추가하는 과정이다.
의존성 설정
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-validation'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
implementation 'org.springframework.boot:spring-boot-starter-aop'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.github.openfeign:feign-micrometer'
외부 서버와 통신을 위해 Openfeign client도 함께 추가 하였다. Resilience4j를 사용하기 위해서는 가장 아래의 두 개의 의존성을 추가하면 된다.
aop를 함께 추가해야 하는 것을 보니 아마 프록시 객체를 통해 처리하는 것으로 보인다.
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
implementation 'org.springframework.boot:spring-boot-starter-aop'
yml파일 작성
spring:
application:
name: user-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/movieapp
username: root
password: Group0000-
jpa:
hibernate:
ddl-auto: update
show-sql: true
data:
redis:
host: localhost
port: 6379
cache:
type: redis
eureka:
instance:
prefer-ip-address: true
client:
service-url:
defaultZone: http://localhost:19090/eureka/
register-with-eureka: true
fetch-registry: true
server:
port: 19091
resilience4j:
circuitbreaker:
configs:
default: # 기본 구성 이름
registerHealthIndicator: true # 애플리케이션의 헬스 체크에 서킷 브레이커 상태를 추가하여 모니터링 가능
# 서킷 브레이커가 동작할 때 사용할 슬라이딩 윈도우의 타입을 설정
# COUNT_BASED: 마지막 N번의 호출 결과를 기반으로 상태를 결정
# TIME_BASED: 마지막 N초 동안의 호출 결과를 기반으로 상태를 결정
slidingWindowType: COUNT_BASED # 슬라이딩 윈도우의 타입을 호출 수 기반(COUNT_BASED)으로 설정
# 슬라이딩 윈도우의 크기를 설정
# COUNT_BASED일 경우: 최근 N번의 호출을 저장
# TIME_BASED일 경우: 최근 N초 동안의 호출을 저장
slidingWindowSize: 5 # 슬라이딩 윈도우의 크기를 5번의 호출로 설정
minimumNumberOfCalls: 5 # 서킷 브레이커가 동작하기 위해 필요한 최소한의 호출 수를 5로 설정
slowCallRateThreshold: 100 # 느린 호출의 비율이 이 임계값(100%)을 초과하면 서킷 브레이커가 동작
slowCallDurationThreshold: 60000 # 느린 호출의 기준 시간(밀리초)으로, 60초 이상 걸리면 느린 호출로 간주
failureRateThreshold: 50 # 실패율이 이 임계값(50%)을 초과하면 서킷 브레이커가 동작
permittedNumberOfCallsInHalfOpenState: 3 # 서킷 브레이커가 Half-open 상태에서 허용하는 최대 호출 수를 3으로 설정
# 서킷 브레이커가 Open 상태에서 Half-open 상태로 전환되기 전에 기다리는 시간
waitDurationInOpenState: 20s # Open 상태에서 Half-open 상태로 전환되기 전에 대기하는 시간을 20초로 설정
각 설명은 주석으로 함께 달았다.
현재는 설정이름이 default이지만 resilience4j.retry.instances.특정할이름.base-config=설정셋
이 양식으로 설정을 해주면된다. sucketBreaker어노테이션에서 설정한 이름이 특정할 이름에 들어가고, 설정셋은 resilience4j.retry.configs.설정셋.메소드들=값
이렇게 설정셋에 이름을 붙여 만들면 하나의 셋트로 적용시킬 수 있다. 굳이 설정을 여러개로 분리하지 않아도 된다면 default로 일괄적인 적용을 하면 된다.
위 설정은 서킷브레이커에 대한 설정이고 아래는 Retry를 사용하면 설정할 수 있는 설정값들이다.
resilience4j.retry.configs.설정셋.메소드들=값
#예시
#재요청 시도 횟수
resilience4j.retry.configs.default.max-attempts=3 //
#재요청 간격
resilience4j.retry.configs.default.wait-duration=3000ms
#예외 처리(retry에 포함) 만약 ignore에도 포함되어 있다면 ignore이 우선된다.
resilience4j.retry.configs.default.retry-exceptions[0]=java.io.IOException
#예외 처리(retry에 포함 안한다.)
resilience4j.retry.configs.default.ignore-exceptions[0]=java.io.IOException
application 설정
이전과 변동사항은 없다!
package com.sparta.cloud.movie_reservation_user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
@EnableFeignClients
@EnableDiscoveryClient
public class MovieReservationUserApplication {
public static void main(String[] args) {
SpringApplication.run(MovieReservationUserApplication.class, args);
}
}
Redis Config
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 캐시 유효시간 30분
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())))
.build();
}
}
redis에 데이터를 저장하기 위한 redisTemplate
과 캐시를 사용하기 위한 cacheManager
설정 예시이다. 만약 redisTemplate을 통해 직접 저장한다면 cacheManager는 설정하지 않아도 된다.
User Service
@CircuitBreaker(name = "authService" , fallbackMethod ="getTokenFallback" )
@Transactional
public TokenResponse signIn(UserSignInRequest userSignInRequest) {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
TokenResponse token;
User user = getUserByEmail(userSignInRequest);
token = validateUserPasswordAndGetToken(userSignInRequest, passwordEncoder, user);
user.updateRefreshToken(token.getRefreshToken());
redisTemplate.opsForValue().set(ACCESSTOKEN_PREFIX+user.getId(),token.getAccessToken(),60*60, TimeUnit.SECONDS);
redisTemplate.opsForValue().set(REFRESHTOKEN_PREFIX+user.getId(),token.getAccessToken(),60*60, TimeUnit.SECONDS);
return token;
}
public TokenResponse getTokenFallback(UserSignInRequest userSignInRequest, Throwable ex) {
System.out.println("🔴 인증 서버 장애! Redis에서 캐싱된 토큰 반환: " + ex.getMessage());
User user = getUserByEmail(userSignInRequest);
String accessToken = redisTemplate.opsForValue().get(ACCESSTOKEN_PREFIX + user.getId());
String refreshToken = redisTemplate.opsForValue().get(REFRESHTOKEN_PREFIX + user.getId());
if(accessToken != null && refreshToken != null) {
return TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
throw new IllegalArgumentException("🔴 토큰 발급 불가! 다시 로그인해주세요.");
}
이곳이 설정파일 다음으로 중요한 내용이다.
@CircuitBreaker(name = "authService" , fallbackMethod ="getTokenFallback" )
이 어노테이션을 통해 yml파일에 authService로 설정한 내용이 존재한다면 적용하고 그게 아니라면 default로 적용된다.
또한 fallbackMethod를 사용함으로써 getTokenFallback 메서드를 장애 발생시 작동시킬 수 있다.
장애가 발생하는 구간은
token = validateUserPasswordAndGetToken(userSignInRequest, passwordEncoder, user);
이 구간이다.
내부 로직에서는 토큰의 정보를 Auth Server에서 생산하고 가져오는 요청을 하는 FeignClient 통신 메서드가 들어있다.
이 장애에 대처하기 위해, 그리고 서비스의 성능을 개선하기 위해 Redis를 사용해서
redisTemplate.opsForValue().set(REFRESHTOKEN_PREFIX+user.getId(),token.getAccessToken(),60*60, TimeUnit.SECONDS);
토큰을 캐싱해두었다.
이 로직에서 장애가 발생하면 getTokenFallback메서드가 수행된다.
fallback 메서드는 문제 발생지점의 매개변수와 동일해야 하며, Throwable 클래스가 매개변수에 함께 추가되어야 한다.
fallback메서드에서는 장애 발생시 레디스에서 캐싱해둔 유저의 토큰값을 가져와 반환한다. 만약 캐시 미스로 데이터가 반환되지 않았다면 다시 로그인을 요청하는 로직을 수행시킨다.
System.out.println("🔴 인증 서버 장애! Redis에서 캐싱된 토큰 반환: " + ex.getMessage());
User user = getUserByEmail(userSignInRequest);
String accessToken = redisTemplate.opsForValue().get(ACCESSTOKEN_PREFIX + user.getId());
String refreshToken = redisTemplate.opsForValue().get(REFRESHTOKEN_PREFIX + user.getId());
if(accessToken != null && refreshToken != null) {
return TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
throw new IllegalArgumentException("🔴 토큰 발급 불가! 다시 로그인해주세요.");
작동 확인하기
Auth Server를 Eureka Server와 Gateway에서 인식하지 못하도록 서버를 종료시키고 약 1분정도 기다려준다. 서버의 상태 검사및 네트워크 연결이 그 이전까지는 정상적으로 작동될 수 있다.
그리고 User Server에서 API를 호출하면
다음과 같이 200번대로 정상적인 응답을 받아옴을 알 수 있다.
토큰 또한 정상적으로 생성된 것을 알 수 있다. 이 토큰을 통해 다른 API를 호출해봤더니 정상적으로 데이터가 등록되고 조회되었다.
그럼 어떻게 작동되었는지 fallback메서드의 로그를 확인해보자.
2025-02-11T16:04:08.705+09:00 WARN 17646 --- [user-service] [io-19091-exec-5] .s.c.o.l.FeignBlockingLoadBalancerClient : Load balancer does not contain an instance for the service auth-service
🔴 인증 서버 장애! Redis에서 캐싱된 토큰 반환: [503] during [POST] to [http://auth-service/auth/token] [AuthClient#getToken(UserResponse)]: [Load balancer does not contain an instance for the service auth-service]
Hibernate: select u1_0.user_id,u1_0.birthday,u1_0.created_at,u1_0.email,u1_0.gender,u1_0.is_deleted,u1_0.password,u1_0.phone_number,u1_0.refresh_token,u1_0.role,u1_0.updated_at,u1_0.user_name from user u1_0 where (u1_0.is_deleted = 0) and u1_0.email=?
다음과 같이 서버 장애가 발생하였다는 로그와 함께 기존의 캐싱해두었던 토큰 값을 반환하면서 정상적으로 작동된것처럼 플로우가 흘러간 것을 확인할 수 있다.
이처럼 서킷브레이커를 사용해서 장애가 발생한것도 모르게 처리할 수 있다.
다음에는 Config서버를 Github의 경로로 설정하고 중앙소에서 설정 파일을 관리할 수 있는 방법을 소개하겠다!
'Spring > Spring Cloud' 카테고리의 다른 글
[MSA] MSA에서 JPA Entity 연관관계를 어떻게 풀어낼까? (0) | 2025.02.11 |
---|---|
[MSA - Spring Cloud] Spring Cloud Gateway 개발하기 (1) | 2025.02.08 |
[MSA] 멀티 모듈에서 중복되는 코드를 서브모듈끼리 공유하는 방법 (0) | 2025.02.08 |
[MSA - Spring Cloud] Eureka Client 개발하기(feat. FeignClient) (0) | 2025.02.07 |
[MSA - Spring Cloud] Eureka Server 개발하기(feat. Service Discovery란? 서버/클라이언트 사이드 디스커버리 전략) (0) | 2025.02.07 |