FeignClient의 정보를 확인하는 로그
application.yml에 다음의 설정을 추가하도록 하겠습니다.
logging:
level:
com.microservices.authservice.client: DEBUG
FeignClient 클래스들이 있는 패키지의 logger수준을 DEBUG 수준으로 맞춘 것입니다. 그리고 AuthServiceApplication에 다음의 코드를 작성하겠습니다.
package com.microservices.authservice;
import feign.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class AuthServiceApplication {
...
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}
Logger의 전체 정보를 확인하겠다는 메서드입니다.
그러면 PostClient의 getPosts의 url를 다음과 같이 잘못된 경로로 설정해두겠습니다.
package com.microservices.authservice.clients;
import com.microservices.authservice.vo.ResponsePost;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
@FeignClient(name="post-service")
public interface PostClient {
@GetMapping("/{userId}/posts_wrong")
public List<ResponsePost> getPosts(@PathVariable("userId") String userId);
}
그러면 실행을 해보고 테스트를 진행해보겠습니다. 테스트화면 결과입니다.
에러 코드를 자세히 보면 500 에러 코드도 보이고, 다음의 에러 코드도 보이네요.
feign.FeignException$NotFound: [404] during [GET] to [http://post-service/a4defb5d-0ab8-4a25-9cf2-1ee2e2888035/posts_wrong] [PostClient#getPosts(String)]: [{"timestamp":"2021-09-02T00:34:36.239+00:00","status":404,"error":"Not Found","message":"No message available","path":"/a4defb5d-0ab8-4a25-9cf2-1ee2e2888035/posts_wrong"}]
우선 404에러는 해당 페이지를 찾지못한다는 에러입니다. 앞서 /posts_wrong경로로 만들어 두었으니 당연히 페이지를 찾지 못하겠죠. 그래서 FeignClient에서 에러가 났으니 최종적으로 auth-service서버에서는 내부 오류가 발생했기 때문에 500에러가 발생하였습니다. 그러면 이 예외에 대한 처리를 해보겠습니다.
postman에서 보이는 에러는 FeignException이라는 에러입니다. 그러면 이 에러에 대한 예외처리를 AuthServiceImpl에서 처리해보도록 하겠습니다.
package com.microservices.authservice.service;
import com.microservices.authservice.clients.PostClient;
import com.microservices.authservice.clients.RentalClient;
import com.microservices.authservice.dto.UserDto;
import com.microservices.authservice.entity.UserEntity;
import com.microservices.authservice.repository.AuthRepository;
import com.microservices.authservice.util.DateUtil;
import com.microservices.authservice.vo.ResponsePost;
import feign.FeignException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
...
@Transactional
@Override
public UserDto getUser(String userId) {
log.info("Auth Service's Service Layer :: Call getUser Method!");
UserEntity userEntity = authRepository.findByUserId(userId);
if(userEntity == null) throw new UsernameNotFoundException(userId);
List<ResponsePost> postList = null;
try {
postList = postClient.getPosts(userId);
} catch(FeignException e) {
log.error(e.getMessage());
}
return UserDto.builder()
.email(userEntity.getEmail())
.nickname(userEntity.getNickname())
.phoneNumber(userEntity.getPhoneNumber())
.userId(userEntity.getUserId())
.encryptedPwd(userEntity.getEncryptedPwd())
.posts(postList)
.build();
}
...
}
간단하게 예외처리에 대한 코드를 작성했으니 다시 한번 테스트를 해보도록 하겠습니다.
2021-09-02 09:44:49.744 ERROR 26354 --- [nio-7000-exec-1] c.m.authservice.service.AuthServiceImpl : [404] during [GET] to [http://post-service/a4defb5d-0ab8-4a25-9cf2-1ee2e2888035/posts_wrong] [PostClient#getPosts(String)]: [{"timestamp":"2021-09-02T00:44:49.737+00:00","status":404,"error":"Not Found","message":"No message available","path":"/a4defb5d-0ab8-4a25-9cf2-1ee2e2888035/posts_wrong"}]
postman에서 잘못된 endpoint로 요청을 보내도 feignclient로 얻어오는 정보를 제외하고는 나머지 정보를 잘 받아오는 모습을 보실 수 있습니다. 하지만 여전히 endpoint가 잘못되었으니 콘솔창에는 위의 에러메시지가 발생하는 것을 알 수가 있습니다.
그러면 세세하게 에러코드를 다루는 클래스를 작성해보도록 하겠습니다.
feignclient를 이용하면서 만났던 에러는 404에러였습니다. 잘못된 endpoint로 요청을 한 결과이죠. 그러면 404에러를 만났을 때의 케이스를 다루기 위해 다음의 클래스를 작성하도록 하겠습니다.
package com.microservices.authservice.error;
import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class FeignErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
switch (response.status()) {
case 400 :
break;
case 404 :
if(methodKey.contains("getPosts")) {
return new ResponseStatusException(
HttpStatus.valueOf(response.status()),
"User's posts is empty."
);
}
break;
default :
return new Exception(response.reason());
}
return null;
}
}
switch구문을 이용하여 case를 나누어 404 case에 관한 에러코드를 작성했습니다. 그러면 이를 사용하기 위해 AuthServiceApplication에 Bean등록을 해주도록 하겠습니다.
package com.microservices.authservice;
import com.microservices.authservice.error.FeignErrorDecoder;
import feign.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
public class AuthServiceApplication {
...
@Bean
public FeignErrorDecoder getFeignErrorDecoder() {
return new FeignErrorDecoder();
}
}
이제 앞서 작성했던 getUser메서드의 try ~ catch 구문을 지우고 다시 원래대로 돌린 후 재실행을 하고나서 결과 화면을 보도록 하겠습니다.
맨 처음 에러를 만났을 때와 달리 404에러를 반환받는 모습을 보실 수 있습니다. 이 이유는 FeignErrorDecoder클래스에서 404 케이스 때문인데요. 404 케이스를 다루면서 HttpStatus.valueOf(response.status())의 값이 반환되었기 때문에 404 에러가 나타나게 됩니다. 그리고 콘솔창에서 2021-09-02 10:01:54.465 WARN 28856 --- [nio-7000-exec-1] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [org.springframework.web.server.ResponseStatusException: 404 NOT_FOUND "User's posts is empty."]
값도 확인하실 수 있습니다.
CircuitBreaker는 장애가 발생하는 서비스에 반복적으로 호출이 되지 못하게 차단하는 기능입니다. 예를 들어 타임아웃이라던지, 페이지를 못찾는다던지 이러한 경우의 에러를 만나 서비스가 정상적으로 작동하지 않을 경우 이를 대체할 수 있는 다른 기능을 수행해주는 장치입니다.
다음의 디펜던시를 추가하도록 하겠습니다.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
그리고 AuthServiceImpl에 circuitbreaker코드를 넣어주도록 하겠습니다.
package com.microservices.authservice.service;
import com.microservices.authservice.clients.PostClient;
import com.microservices.authservice.clients.RentalClient;
import com.microservices.authservice.dto.UserDto;
import com.microservices.authservice.entity.UserEntity;
import com.microservices.authservice.repository.AuthRepository;
import com.microservices.authservice.util.DateUtil;
import com.microservices.authservice.vo.ResponsePost;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.circuitbreaker.CircuitBreaker;
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
private AuthRepository authRepository;
private BCryptPasswordEncoder passwordEncoder;
private PostClient postClient;
private RentalClient rentalClient;
private CircuitBreakerFactory circuitBreakerFactory;
@Autowired
public AuthServiceImpl(
AuthRepository authRepository,
BCryptPasswordEncoder passwordEncoder,
PostClient postClient,
RentalClient rentalClient,
CircuitBreakerFactory circuitBreakerFactory
) {
this.authRepository = authRepository;
this.passwordEncoder = passwordEncoder;
this.postClient = postClient;
this.rentalClient = rentalClient;
this.circuitBreakerFactory = circuitBreakerFactory;
}
...
@Transactional
@Override
public UserDto getUser(String userId) {
log.info("Auth Service's Service Layer :: Call getUser Method!");
UserEntity userEntity = authRepository.findByUserId(userId);
if(userEntity == null) throw new UsernameNotFoundException(userId);
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
List<ResponsePost> postList = circuitBreaker.run(
() -> postClient.getPosts(userId),
throwable -> new ArrayList<>()
);
return UserDto.builder()
.email(userEntity.getEmail())
.nickname(userEntity.getNickname())
.phoneNumber(userEntity.getPhoneNumber())
.userId(userEntity.getUserId())
.encryptedPwd(userEntity.getEncryptedPwd())
.posts(postList)
.build();
}
...
}
실행중인 post-service를 정지하고 테스트를 진행해보도록 하겠습니다.
콘솔창에서는 post-service에 관한 에러 로그를 살펴 볼 수 있습니다.
2021-09-02 10:47:18.212 WARN 34131 --- [oundedElastic-1] o.s.c.l.core.RoundRobinLoadBalancer : No servers available for service: post-service
2021-09-02 10:47:18.212 WARN 34131 --- [pool-1-thread-1] .s.c.o.l.FeignBlockingLoadBalancerClient : Service instance was not resolved, executing the original request
post-service를 정지했으니 당연히 feignclient부분에서 에러 메시지를 받아야 함에도 불구하고 circuitbreaker를 사용하니 post값을 제외한 나머지 값들이 정상 출력되는 모습을 볼 수 있습니다.
이 circuitbreaker를 custom화 시키는 클래스를 작성해보도록 하겠습니다.
package com.microservices.authservice.config;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
public class Resilience4JConfig {
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration() {
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(4)
.waitDurationInOpenState(Duration.ofMillis(1000))
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(2)
.build();
TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(4))
.build();
return factory -> factory.configureDefault(
id -> new Resilience4JConfigBuilder(id).timeLimiterConfig(timeLimiterConfig)
.circuitBreakerConfig(circuitBreakerConfig)
.build()
);
}
}
코드를 잠깐 보도록 하겠습니다.
failureRateThreshold은 circuitbreaker를 on 시키는 확률입니다. 예를들면 failureRateThreshold(5)일 경우 5번의 장애가 발생할 경우 circuitbreaker 가동이 됩니다.
waitDurationnOpenState(Duration.ofMillis(1000))은 circuitbreaker 가 작동 지속시간입니다.
slidingWindowType은 circuitbreakr가 닫힐 때 결과를 기록하는데 사용되는 슬라이딩 창의 유형(카운트, 시간)입니다.
slidingWindowSize은 circuitbreaker가 닫힐 때 결과를 기록하는데 사용되는 슬라이딩의 창크기입니다.
TimeLimterConfig.timeoutDuration은 timeLimit를 정하는 api이고, 예를 들어 post-service에서 4초동안 응답이 없다면 circuitbreaker 작동합니다.
마찬가지로 post-service를 끄고, 켜고의 테스트 결과를 확인해보도록 하겠습니다.
잘 작동하는 모습을 볼 수 있습니다.
zipkin이라는 서버를 이용하여 분산추적을 해보도록 하겠습니다. zipkin은 분산 환경의 시간 흐름에 대한 데이터 수집, 추적 시스템을 사용할 수 있게 합니다. 즉, 분산 환경에서의 시스템에서 병목 현상을 파악하는데 도움을 줍니다. 집킨은 span, trace를 이용하는데 span은 하나의 요청에서 사용되는 작업의 단위이며 unique한 id를 가지고 있습니다. trace는 트리 구조로 이루어진 span set이며 하나의 요청에 대한 같은 id를 가집니다. 즉 다음과 같은 구조를 가지게 됩니다.
zipkin 서버를 설치하도록 하겠습니다.
원하는 디렉토리로 이동 후 다음의 명령어를 입력하겠습니다.
curl -sSL https://zipkin.io/quickstart.sh | bash -s
java -jar zipkin.jar
auth-service에 zipkin, sleuth 디펜던시를 추가하도록 하겠습니다.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
<version>2.2.8.RELEASE</version>
</dependency>
...
spring:
application:
name: auth-service
zipkin:
base-url: http://127.0.0.1:9411
enabled: true
sleuth:
sampler:
probability: 1.0
...
package com.microservices.authservice.service;
import com.microservices.authservice.clients.PostClient;
import com.microservices.authservice.clients.RentalClient;
import com.microservices.authservice.dto.UserDto;
import com.microservices.authservice.entity.UserEntity;
import com.microservices.authservice.repository.AuthRepository;
import com.microservices.authservice.util.DateUtil;
import com.microservices.authservice.vo.ResponsePost;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.circuitbreaker.CircuitBreaker;
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
...
@Transactional
@Override
public UserDto getUser(String userId) {
log.info("Auth Service's Service Layer :: Call getUser Method!");
UserEntity userEntity = authRepository.findByUserId(userId);
if(userEntity == null) throw new UsernameNotFoundException(userId);
log.info("Before call post-service");
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
List<ResponsePost> postList = circuitBreaker.run(
() -> postClient.getPosts(userId),
throwable -> new ArrayList<>()
);
log.info("After called post-service");
return UserDto.builder()
.email(userEntity.getEmail())
.nickname(userEntity.getNickname())
.phoneNumber(userEntity.getPhoneNumber())
.userId(userEntity.getUserId())
.encryptedPwd(userEntity.getEncryptedPwd())
.posts(postList)
.build();
}
...
}
와 같이 코드를 만들어주고 post-service에도 동일하게 설정을 진행한 후 결과를 보도록 하겠습니다.
auth-service의 콘솔창입니다.
2021-09-02 11:44:58.455 INFO [auth-service,21f1ccd8a4a5a285,21f1ccd8a4a5a285] 43077 --- [nio-7000-exec-1] c.m.authservice.service.AuthServiceImpl : Before call post-service
2021-09-02 11:44:59.169 INFO [auth-service,21f1ccd8a4a5a285,21f1ccd8a4a5a285] 43077 --- [nio-7000-exec-1] c.m.authservice.service.AuthServiceImpl : After called post-service
[auth-service,21f1ccd8a4a5a285,21f1ccd8a4a5a285]
이 부분에서 2번째 값이 traceId, 3번째 값이 spanId입니다. 그러면 zipkin 페이지로 가서 traceId로 분산 추적 결과를 보도록 하겠습니다.
결과가 잘 나오는 모습을 볼 수 있습니다. 이상으로 circuitbreaker ~ zipkin까지 예제를 살펴 보았고 다음 포스트에서는 이 예제들을 rental-service와 관련해서 최종적으로 적용하고 테스트해보도록 하겠습니다.
인프런: Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) - 이도원