728x90
반응형
728x90
반응형
반응형

OpenFeign은 Spring Cloud에서 제공하는 HTTP 클라이언트로, RESTful 서비스 간의 통신을 간편하게 처리할 수 있게 해준다.

1. OpenFeign의 기본 에러 처리 방식

OpenFeign은 기본적으로 400번대, 500번대의 HTTP 상태 코드를 에러로 간주하며, feign.FeignException을 발생시킨다.

  • 4xx (클라이언트 에러): 잘못된 요청, 인증 실패 등.
  • 5xx (서버 에러): 서버 내부 오류, 서비스 불가 등.

2. 에러 처리(httpCode != 200)의 경우

2-1. Feign Client에 ErrorDecoder 설정

ErrorDecoder의 역할

  • ErrorDecoder는 HTTP 상태 코드가 200번대가 아닐 때 호출됨
  • ErrorDecoder는 주로 4xx(클라이언트 에러)나 5xx(서버 에러)에 대한 예외 처리를 담당
  • OpenFeign은 기본적으로 ErrorDecoder.Default를 사용하여 FeignException을 던지며, 이를 커스터마이징하여 예외를 처리할 수 있음.

클래스 레벨 @FeignClient -> configuration에 커스텀 에러 디코더 달아줄 수 있음

import org.springframework.cloud.openfeign.FeignClient;

@FeignClient(name = "example-client", url = "http://example.com", configuration = FeignConfig.class)
public interface ExampleClient {

    @GetMapping("/resource")
    String getResource();
}
///////////

import org.springframework.context.annotation.Bean;

public class FeignConfig {
    @Bean
    public ErrorDecoder errorDecoder() { //////FeignException이 발생한 경우
        return new CustomErrorDecoder();
    }
}
////////////

import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.http.HttpStatus;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class CustomErrorDecoder implements ErrorDecoder {

	//에러 처리
    @Override
    public Exception decode(String methodKey, Response response) {
        String message = null;
        try {
            if (response.body() != null) {
                message = StreamUtils.copyToString(response.body().asInputStream(), StandardCharsets.UTF_8);
            }
        } catch (IOException e) {
            return new Exception("Failed to process response body", e);
        }

        HttpStatus status = HttpStatus.resolve(response.status());
        switch (status) {
            case BAD_REQUEST:
                return new BadRequestException("Bad Request: " + message);
            case NOT_FOUND:
                return new NotFoundException("Resource Not Found: " + message);
            case INTERNAL_SERVER_ERROR:
                return new InternalServerErrorException("Internal Server Error: " + message);
            default:
                return new Exception("Generic error: " + status + " " + message);
        }
    }
}

위 예제는 http status가 200이 아닐 경우이다. 200이 아닌 400, 500일 경우 Exception으로 처리되고 자동으로 저기에 걸린다.

3. 성공 처리(httpCode == 200)의 경우 

3-1. Decoder 사용해서 처리

Decoder는 OpenFeign에서 HTTP 응답을 처리할 때 사용되는 인터페이스로, 주로 성공적인 HTTP 응답(200번대)을 파싱하여 객체로 변환한다. 기본적으로 Decoder는 예외 처리와는 관계없이 모든 응답을 처리할 수 있다. 그러나 응답이 성공적이지 않은 경우, 즉 HTTP 상태 코드가 200번대가 아닐 경우에는 OpenFeign이 기본적으로 ErrorDecoder를 사용하여 예외를 던지기 때문에 Decoder가 호출되지 않는다.

만약 200인데 body에 있는 값으로 에러를 처리해야한다면 Decoder를 사용할 수 있다.

import feign.Response;
import feign.Util;
import feign.codec.Decoder;

import java.io.IOException;
import java.lang.reflect.Type;

public class CustomDecoder implements Decoder {
    
    private final Decoder defaultDecoder;
    
    public CustomDecoder(Decoder defaultDecoder) {
        this.defaultDecoder = defaultDecoder;
    }
    
    @Override
    public Object decode(Response response, Type type) throws IOException {
        // 상태 코드가 200번대일 때만 호출됨
        if (response.status() == 200) {
            // 응답 바디를 읽고 필요한 처리를 수행
            String body = Util.toString(response.body().asReader());
            System.out.println("Response Body: " + body);
            
            // 기본 디코더를 사용하여 바디를 객체로 변환
            return defaultDecoder.decode(response, type);
        }
        
        // 상태 코드가 200번대가 아닌 경우 예외를 던지거나 기본 처리를 수행
        throw new RuntimeException("Unexpected status code: " + response.status());
    }
}

Decoder와 ErrorDecoder의 비교

  • Decoder: 성공적인 HTTP 응답(200번대)에 대한 처리. 응답을 객체로 변환(파싱).
    • 상태 코드가 200이더라도 응답 바디에 따라 예외 처리가 필요하다면, Decoder에서 바디를 확인하고 사용자 정의 예외를 던질 수 있다.
  • ErrorDecoder: 4xx, 5xx 상태 코드에 대한 예외 처리. 예외를 던짐.

 

3-2. feign 의 interceptor사용

@FeignClient -> configuration에 feign의 interceptor를 사용할 수 있다.

이 때 interceptor는 모든 처리를 잡기 때문에 조건을 잘 줘야한다.

import feign.Response;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FeignConfig {

    @Bean
    public ResponseInterceptor responseInterceptor() {
        return new ResponseInterceptor();
    }
}
///////////////

import feign.Request;
import feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class ResponseInterceptor implements feign.ResponseInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(ResponseInterceptor.class);

    @Override
    public void apply(Response response) {
        // HTTP 상태 코드가 200인 경우만 처리
        if (response.status() == 200 && response.body() != null) {
            try {
                // 응답 바디를 읽어온다.
                String body = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
                logger.info("Response Body: {}", body);

                // 응답 바디의 특정 조건을 확인
                if (body.contains("error") || body.contains("INVALID")) {
                    // 응답 바디에 에러 메시지가 포함된 경우 예외를 발생시킴
                    throw new CustomException("Error found in response body");
                }
            } catch (IOException e) {
                logger.error("Failed to read response body", e);
                throw new RuntimeException("Failed to read response body", e);
            }
        }
    }
}

 

3-3. AOP로 처리

@FeignClient(name = "gameserver-client", url = "${external.gameserver.url}")
@ExternalGameServerAdminHeaderValid
public interface ExternalGameServerFeignService {
@Slf4j
@Component
@Aspect
public class ExternalGameServerAdminHeaderValidAspect {

  @Around(
      "@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)")
  public Object validationAdminHeader(ProceedingJoinPoint joinPoint) throws Throwable {
    Object response = joinPoint.proceed(); //메소드 실행

    if (response instanceof ExternalAdminResponseHeader) {
      ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
      String errorMessage = header.getHeader().getErrMsg();
      if (StringUtils.isNotEmpty(errorMessage)) {
        MethodSignature method = (MethodSignature) joinPoint.getSignature();
        String methodName = method.getMethod().getName();
        log.warn("invalid admin header {}, {}", methodName, errorMessage);
        throw new AException(AExceptionCode.ADMIN_SUPPORT_API_ERROR, methodName, errorMessage);
      }
    } else {
      log.warn("response is not ExternalAdminResponseHeader");
    }

    return response;
  }
}

 

클래스/메소드에 사용된 어노테이션 함수 전/후로 실행되도록 짜져 있으나 실질적으로는 후에만 실행하면 되니까 아래처럼 수정해도 될 것 같다.

@AfterReturning(
    pointcut = "@within(com.annotation.ExternalGameServerAdminHeaderValid) || @annotation(com.annotation.ExternalGameServerAdminHeaderValid)",
    returning = "response")
public void validationAdminHeader(JoinPoint joinPoint, Object response) {
    // 메서드가 정상적으로 실행된 후 반환된 response 객체를 이용해 로직 수행
    if (response instanceof ExternalAdminResponseHeader) {
        ExternalAdminResponseHeader header = (ExternalAdminResponseHeader) response;
        String errorMessage = header.getHeader().getErrMsg();
        if (StringUtils.isNotEmpty(errorMessage)) {
            MethodSignature method = (MethodSignature) joinPoint.getSignature();
            String methodName = method.getMethod().getName();
            log.warn("Invalid admin header in method: {}, error message: {}", methodName, errorMessage);
        }
    } else {
        log.warn("Response is not of type ExternalAdminResponseHeader");
    }
}
  • pointcut: 타겟 메서드를 지정하는 포인트컷 표현식을 정의. @within 및 @annotation을 사용하여 특정 어노테이션이 적용된 클래스나 메서드에 대해 어드바이스를 적용
  • returning = "response": 반환 값을 response라는 매개변수로 받아옴. 반환 값이 없거나, void인 경우에는 @AfterReturning 어드바이스가 실행되지 않음

 

1. 포인트컷 표현식의 의미

1.1. @within(com.annotation.ExternalGameServerAdminHeaderValid)

  • 의미: 클래스 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 모든 클래스의 모든 메서드를 포인트컷으로 지정
@ExternalGameServerAdminHeaderValid
public class SomeController {
    public void someMethod() {
        // 이 메서드는 @within 포인트컷에 의해 AOP 적용 대상이 됨
    }

    public void anotherMethod() {
        // 이 메서드도 @within 포인트컷에 의해 AOP 적용 대상이 됨
    }
}

1.2. @annotation(com.annotation.ExternalGameServerAdminHeaderValid)

  • 의미: 메서드 레벨에 @ExternalGameServerAdminHeaderValid 어노테이션이 적용된 특정 메서드를 포인트컷으로 지정
public class AnotherController {
    
    @ExternalGameServerAdminHeaderValid
    public void specificMethod() {
        // 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 됨
    }

    public void otherMethod() {
        // 이 메서드는 @annotation 포인트컷에 의해 AOP 적용 대상이 아님
    }
}
 

@Around와 @After, @AfterReturing, @AfterThrowing의 차이

@Around 어노테이션

  • @Around 어노테이션은 메서드 호출 전후에 특정 로직을 실행할 수 있음
  • ProceedingJoinPoint를 통해 메서드 호출 전후에 실행되는 로직을 정의할 수 있으며, joinPoint.proceed()를 호출함으로써 실제 메서드를 실행
    • joinPoint.proceed()를 호출하지 않으면 실제 메서드가 실행되지 않음
  • 메서드의 실행을 제어하거나, 실행 결과를 변환하거나, 예외 처리를 커스터마이징할 때 유용함
    • 메서드 실행 여부를 조건에 따라 결정 가능
@Around("@annotation(com.example.MyAnnotation)")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
    // 메서드 실행 전 로직
    log.info("Before method execution");

    Object result = joinPoint.proceed(); // 실제 메서드 호출

    // 메서드 실행 후 로직
    log.info("After method execution");

    return result; // 메서드의 결과 반환
}

@After 어노테이션 (= finally)

  • @After는 메서드가 정상적으로 실행되었거나 예외가 발생하더라도 항상 실행됨. 메서드의 반환 값에 접근할 수 없고, 메서드가 실행되었음을 확인하거나 리소스 정리 등의 작업을 할 때 사용.
  • @After 어노테이션은 메서드의 실행이 완료된 후에 AOP 로직을 실행함
  • joinPoint.proceed()와 같은 메서드 호출을 제어할 수 없음!!
  • 메서드의 반환 값이나 예외 처리에는 관여하지 않음. 메서드가 종료된 후 추가적인 작업(예: 리소스 정리, 로그 기록)을 수행하는 데 사용됨
    • 현재 상황에 부적합.. 무조건 실행되나 return되는 객체를 잡을 수 없다.

@AfterReturning 어노테이션

  • 메서드가 성공적으로 완료된 후에만 실행. 예외가 발생하지 않은 경우에만 호출!
  • @AfterReturning은 메서드가 성공적으로 반환된 후에만 실행됨. 반환된 값에 접근할 수 있으며, 이를 기반으로 후처리 로직을 작성할 수 있다.
  • 타겟 메서드의 반환 값에 접근할 수 있음

@AfterThrowing 어노테이션

  • @AfterThrowing: 메서드에서 예외가 발생했을 때만 실
  • 타겟 메서드가 던진 예외에 접근할 수 있음
  • 예외 발생 시, 예외를 기반으로 추가 로직이 필요한 경우
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ComprehensiveLoggingAspect {

    @AfterReturning(pointcut = "execution(* com.example.service.MyService.*(..))", returning = "result")
    public void logAfterReturning(Object result) {
        System.out.println("Method returned successfully with value: " + result);
    }

    @AfterThrowing(pointcut = "execution(* com.example.service.MyService.*(..))", throwing = "ex")
    public void logAfterThrowing(Exception ex) {
        System.out.println("Method threw an exception: " + ex.getMessage());
    }
}
  • pointcut: 어떤 메서드에 대해 어드바이스를 적용할지 지정합니다. 이 예제에서는 MyService 클래스의 모든 메서드에 적용됩니다.
  • returning: 타겟 메서드의 반환 값을 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 타겟 메서드의 반환 값에 접근할 수 있습니다.
  • throwing: 타겟 메서드가 던진 예외를 받아오기 위한 매개변수 이름을 지정합니다. 이 매개변수를 통해 발생한 예외에 접근할 수 있습니다.

참고


위의 AOP 함수는 아래의 Decoder로 변환 가능하다.

@Slf4j
@RequiredArgsConstructor
public class FeignDecoder implements Decoder {

  private final Decoder defaultDecoder;

  @Override
  public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
    String responseBody = Util.toString(response.body().asReader(StandardCharsets.UTF_8));
    ExternalAdminResponseHeader header = JacksonUtils.jsonToObject(responseBody, ExternalAdminResponseHeader.class);
    String errorMessage = header.getHeader().getErrMsg();
    if (StringUtils.isNotEmpty(errorMessage)) {
      log.warn("invalid admin header {}", errorMessage);
      throw new AException(AapokerExceptionCode.ADMIN_SUPPORT_API_ERROR, errorMessage);
    }
    return defaultDecoder.decode(response, type);
  }
}

다만 이 경우 기존에 에러가 발생한 함수를 알 수 없다..

feign은 성공했다고 남기에 에러로그가 없어 어떤 함수에서 호출한 응답인지 확인이 어려워 결국 AOP를 적용하였다.

728x90
반응형
반응형

환경: springboot2.7.6, java17

 

micrometer

  • jvm 기반의 애플리캐이션 metrics 제공
  • springboot2 +
  • premetheus 등 다양한 모니터링 시스템 지원
  • (구) turbine server -> hystrix client

 

timer

  • 짧은 지연 시간, 이벤트의 사용 빈도 측정
  • 시계열로 이벤트의 시간, 호출 빈도 등 제공
  • @Timed 제공

 

서비스 연동

1. dependency 추가

<!-- micrometer -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

<!-- actuator -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator </artifactId>
</dependency>

2. application.yml actuator 정보 추가

management:
  endpoints:
    web:
      exposure:
        include: info, metrics, prometheus #추가

3. @Timed 어노테이션으로 metrics 마킹

    @GetMapping("/welcome")
    @Timed(value = "users.welcome", longTask = true)
    public String welcome(){
//        return environment.getProperty("greeting.message");
        return greeting.getMessage();
    }

4. 추가한 metrics는 사용을 하면 /actuator/metrics 에 표기됨

4. 그리고 실제 호출정보는 /actuator/premetheus 에 남음

728x90
반응형
반응형

환경: springboot2.7.6, spring cloud2021.0.8, java17

 

zipkin

  • 분산 환경의 데이터 수집, 추적 시스템(오픈소스, 트위터 시작, google drapper에서 발전)
  • 분산 환경에서의 시스템 병목 현상 파악
  • collector, query service, database, webui 로 구성
  • span : 하나의 요청에 사용되는 작업 단위; 64bit unique ID(in msa component)
  • trace: 트리 구조로 이뤄진 span set; 하나의 요청에 같은 trace ID 발급(in total flow)
  • spring cloud sleuth; zipkin 서버와 연동, trace/span id를 로그에 추가 가능 

 

다운로드

 

로컬 터미널로 실행을 하고 http://127.0.0.1:9411/zipkin/ 들어가면 웹화면이 나온다.

 

서비스에서 사용하기(양쪽 모두에 세팅)

1. dependency 추가

<!-- zipkin -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-sleuth-zipkin</artifactId>
</dependency>

2. application.yml 추가

spring:
  zipkin:
    base-url: http://localhost:9411
    enabled: true
  sleuth:
    sampler:
      probability: 1.0

3. 재시작하고 연동되어있는 api를 날려보면

첫번째 서버

2024-02-08 15:54:27.101  INFO [user-service,89bf1666da761e42,89bf1666da761e42] 27456 --- [o-auto-1-exec-5] c.e.userservice.service.UserServiceImpl  : before call orders msa
2024-02-08 15:54:27.210 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] ---> GET http://order-service/order-service/fcb59ec4-b2d6-4fd2-aefd-c1004542c801/orders HTTP/1.1
2024-02-08 15:54:27.211 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] ---> END HTTP (0-byte body)
2024-02-08 15:54:28.095 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] <--- HTTP/1.1 200 (883ms)
2024-02-08 15:54:28.095 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] connection: keep-alive
2024-02-08 15:54:28.095 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] content-type: application/json
2024-02-08 15:54:28.096 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] date: Thu, 08 Feb 2024 06:54:28 GMT
2024-02-08 15:54:28.096 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] keep-alive: timeout=60
2024-02-08 15:54:28.096 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] transfer-encoding: chunked
2024-02-08 15:54:28.096 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] 
2024-02-08 15:54:28.097 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] [{"productId":"Catalog3","qty":1,"unitPrice":2000,"totalPrice":2000,"createdAt":"2024-02-08T15:51:49","orderId":"17af8280-b028-42d6-b590-594578c75d4c"},{"productId":"Catalog3","qty":2,"unitPrice":2000,"totalPrice":4000,"createdAt":"2024-02-08T15:52:28","orderId":"765e3a28-c6ae-4e48-a4a3-dd2cc73d3caa"}]
2024-02-08 15:54:28.097 DEBUG [user-service,89bf1666da761e42,cc4d64c75759fa06] 27456 --- [pool-4-thread-1] c.e.u.client.OrderServiceClient          : [OrderServiceClient#getOrders] <--- END HTTP (303-byte body)
2024-02-08 15:54:28.138  INFO [user-service,89bf1666da761e42,89bf1666da761e42] 27456 --- [o-auto-1-exec-5] c.e.userservice.service.UserServiceImpl  : after call orders msa

user-service,89bf1666da761e42,89bf1666da761e42

user-service,89bf1666da761e42,cc4d64c75759fa06

  • traceId: 89bf1666da761e42
  • spanId: cc4d64c75759fa06
  • 처음엔 tId랑 같은걸로 시작했다가 새로운 커낵션이 맺어지면 새로운 spanId를 딴다.

 

두번째 서버

2024-02-08 15:54:27.567  INFO [order-service,89bf1666da761e42,730648c3fe5dad8a] 4968 --- [o-auto-1-exec-4] c.e.o.controller.OrderController         : before get orders
Hibernate: select order0_.id as id1_0_, order0_.created_at as created_2_0_, order0_.order_id as order_id3_0_, order0_.product_id as product_4_0_, order0_.qty as qty5_0_, order0_.total_price as total_pr6_0_, order0_.unit_price as unit_pri7_0_, order0_.user_id as user_id8_0_ from orders order0_ where order0_.user_id=?
2024-02-08 15:54:28.069  INFO [order-service,89bf1666da761e42,730648c3fe5dad8a] 4968 --- [o-auto-1-exec-4] c.e.o.controller.OrderController         : after call orders msa

order-service,89bf1666da761e42,730648c3fe5dad8a

  • traceId: 89bf1666da761e42
  • spanId: 730648c3fe5dad8a

4. 해당 값을 가지로 웹으로 들어가서 검색

http://localhost:9411/zipkin/traces/89bf1666da761e42

msa component, 시간 등등 해당 요청으로 연결된 모든 정보를 알 수 있다.

어떤 서비스에서 에러가 발생하면 아래처럼 에러를 표시해준다.

728x90
반응형
반응형

환경: springboot2.7.6, java11

 

circuit breaker

  • 장애가 발생하는 서비스에 반복적인 호출이 되지 못하게 차단
  • 특정 서비스가 정상적으로 동작하지 않을 경우 다른 기능으로 대체수행하여 장애를 회피함
  • open이 되었을 때 우회수행
  • spring cloud netflix hystrix(19년 deprecated)
  • resilience 4j ; 경량/자바 8 이상 지원

 

1. pom.xml 에 의존성 추가

<!-- resilience4j -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>

2. open feign 을 통해 다른 msa component로 통신하던 부분을 circuit breaker로 변경

우선 기본적으로 제공하는 circuitBreakerFactory을 사용해본다.

//클래스 상단에 주입
    private final CircuitBreakerFactory circuitBreakerFactory;
    
    ...

//함수 안
		//open-feign with error decoder
        //orders = orderServiceClient.getOrders(userId);

        CircuitBreaker circuitBreaker =circuitBreakerFactory.create("circuitbreaker");
        orders = circuitBreaker.run(() -> orderServiceClient.getOrders(userId), throwable -> new ArrayList<>());

3. 이렇게 해두고 해당 msa component를 down 시킨상태에서 api 요청.

로그에는 접속 불가가 뜨지만 결과는 빈 array 반환됨

4. 기본 세팅말고 설정을 상세하게 바꾸고 싶다면 아래처럼 custom하여 빈으로 등록하면 됨

각 설정 의미는 공식 문서 참고

@Configuration
public class Resilience4Config {

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfig(){
        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(4)).build();
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(4)
                .waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) //기본값임
                .slidingWindowSize(2)
                .build();
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                                                            .timeLimiterConfig(timeLimiterConfig)
                                                            .circuitBreakerConfig(circuitBreakerConfig)
                                                            .build());
    }
}
728x90
반응형
반응형

환경: springboot2.7.6, java 17, springcloud 2021.0.8

이전글:

2024.01.29 - [개발/spring] - [cloud] 파일을 동적으로 관리하는 config server

2024.01.29 - [개발/spring] - [cloud] spring cloud bus

spring cloud config service에서 파일로 관리되는 설정파일을 공용으로 쓰도록 설정되어 있고(native방식)

해당 설정파일에는 디비 연결 정보가 들어있어 password와 같은 민감정보가 있다.

파일로 관리되기 때문에 파일이 노출되면 민감정보가 바로 드러나게 되는데, 이를 암호화해서 관리해보자.

 

비대칭키 사용

1. config 서버에 암복호화 추가

config server에 bootstrap.yml을 만들고 암호화에 사용할 대칭키를 입력한다.

encrypt:
  key: abcdefghijklmnopqrstuvwxyz0123456789

암호화 확인

복호화 확인

 

2. 공용으로 관리하는 설정 파일(bootstrap.yml에 명시된 name)에 아래와 같이 내용 추가

password는 plain으로 sa인데 위의 암호화 api로 암호화 한 값을 넣었

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:test
    username: sa
    password: '{cipher}07407f328f8c2ba016949743f42e0a9da120328d9e0f44f38bad351b9c2b7419'

 

참고로 설정 서버에 직접 파일을 확인하면 아래와 같이 복호화된 값이 나온다. 이 값이 cloud bus 로 연결된 사용처들에게 전달되는 값이다.

참고로 프로퍼티에 암호화된 값이 잘못되었을 경우 위의 password 부분에 n/a라고 표시된다.

 

대칭키 사용

RSA 키쌍 생성

keytool -genkeypair -alias apiEncryptionKey -keyalg RSA -dname "CN=Kenneth Lee, OU=API Development, O=joneconsulting.co.kr, L=Seoul, C=KR"  -keypass "1q2w3e4r" -keystore apiEncryptionKey.jks -storepass "1q2w3e4r"

keytool -genkeypair -alias apiEncryptionKey -keyalg RSA -dname "CN=Kenneth Lee, OU=API Development, O=joneconsulting.co.kr, L=Seoul, C=KR"  -keypass "1q2w3e4r" -keystore apiEncryptionKey.jks -storepass "1q2w3e4r"

private key 확인

 keytool -list -keystore apiEncryptionKey.jks -v

인증서 가져오기

 keytool -export -alias apiEncryptionKey -keystore apiEncryptionKey.jks -rfc -file trustServer.cer

공개키 가져오기

keytool -import -alias trustServer -file trustServer.cer -keystore publicKey.jks

확인

테스트: 위와 동일한 방법

위와 동일하게 파일에는 암호화 값이 있고 사용시에는 자동으로 복호화 해준다.

728x90
반응형
반응형

환경: springboot2.7.6, java 17, springcloud 2021.0.8

 

사용 이유:

분산 시스템의 노드(서비스들)를 경량 메시지 브로커(rabbit mq)와 연결하여 상태 및 구성에 대한 변경 사항을 연결된 노드에게 전달(broadcast)한다.

즉 중간에 메세지를 전달하는 미들웨어(AMQP; 메시지 지향 프로토)를 둠으로써 안정적으로 변경사항을 적용하도록 함

 

사용 법:

우선 기존에 spring cloud config 서버가 있어야 하고 거기서 설정파일을 가져오게끔 bootstrap도 추가되어 있어야 한다.

아래 글 참고.

2024.01.29 - [개발/spring] - [cloud] 파일을 동적으로 관리하는 config server

버스로 연결하려는 모든 프로젝트에 아래와 같이 디펜덴시 추가한다. 수정 시 확산을 위해 actuator도 필요하다.

<!-- 설정파일 외부  -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

<!-- actuator -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator </artifactId>
</dependency>
<!-- spring cloud bus -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bus-amqp </artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: busrefresh

actuator 설정에 busrefresh 추가

연결된 서비스 중 아무데서나 위 api를 호출하면 204 성공이 떨어지고 spring cloud config 서버를 통해 받고 있던 데이터들이 전 서비스에 걸쳐 모두 한 번에 refresh 되는 것을 볼 수 있다. (이전에는 각각 /actuator/refresh를 호출했어야 했음)

728x90
반응형
반응형

환경: springboot2.7.6, java 17, springcloud 2021.0.8

 

application.properties/yml과 같은 설정파일을 수정하면 서버를 재시작해야 한다는 부담이 있다.

spring cloud에서 제공하는 config server를 이용하면 설정 파일을 수정해도 재시작하지 않고, 동적으로 값을 읽어 올 수 있다.

 

공용 프로퍼티 설정 서비스 만들기(이하 config service라고 명명)

1. 프로퍼티 파일을 외부로 빼준다. 보통 공통 값(디비, API url, 공통으로 사용하는 값 등)을 뺀 파일을 외부에 생성한다.

여기서는 documents아래에 임의의 폴더를 생성하여 만들었다.

참고로 윈도우로 작업하였다.

 

2. 이 파일을 추적할 서버를 새로 만든다. 아래와 같이 디펜덴시를 추가하고 어노테이션을 단다.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-config-server</artifactId>
</dependency>
@EnableConfigServer
@SpringBootApplication

그리고 이제 추적할 파일에 대한 정보를 프로퍼티 파일에 작성한다.

server:
  port: 8888

spring:
  application:
    name: config-service
  cloud:
    config:
      server:
        git:
          uri: file:///Users/JIHYUN BANG/Documents/source/inflearn/git-local-repo
     	  default-label: main

파일을 바로 볼거라 file:// 로 시작하는데 깃으로 관리한다면 아래 처럼 해당 주소를 기입하면 될 것 같다.

        git:
          uri: https://github.com/haileyjhbang/inflearn-config.git
         # username: bbb //private repository 일 경우 필요
         # password: aaa //private repository 일 경우 필요
     #  uri: file:///Users/JIHYUN BANG/Documents/source/inflearn/git-local-repo

여기서 주의해야하는 것은 파일 경로를 알기 위해 아래와 같이 명령어를 입력할 경우 (윈도우의 경우) 아래와 같이 C드라이브 아래부터 나오는데, /c 를 제외하고 /Users 부터 입력해야 한다는 것.

$ pwd
/c/Users/JIHYUN BANG/Documents/source/inflearn/git-local-repo

 

2-1. 파일 시스템으로 프로퍼티들을 관리할 경우 profiles.active: native

spring:
  application:
    name: config-service
  profiles:
    active: native
  cloud:
    config:
      server:
        native:
          search-locations: file:///${user.home}/Documents/source/inflearn/native-repo

 

3. 서버를 시작하고 아래 주소로 확인해 보면 ecommerce.yml의 내용이 화면에 찍힌다.

http://localhost:8888/ecommerce/default

 

4. 허나 로그를 보면 아래와 같은 에러가 지나가는데

org.springframework.cloud.config.server.environment.NoSuchLabelException: No such label: main
	at org.springframework.cloud.config.server.environment.JGitEnvironmentRepository.refresh(JGitEnvironmentRepository.java:307) ~[spring-cloud-config-server-3.1.8.jar:3.1.8]
	at org.springframework.cloud.config.server.environment.JGitEnvironmentRepository.getLocations(JGitEnvironmentRepository.java:256) ~[spring-cloud-config-server-3.1.8.jar:3.1.8]
	at org.springframework.cloud.config.server.environment.MultipleJGitEnvironmentRepository.getLocations(MultipleJGitEnvironmentRepository.java:139) ~[spring-cloud-config-server-3.1.8.jar:3.1.8]
...

Caused by: org.eclipse.jgit.api.errors.RefNotFoundException: Ref main cannot be resolved
	at org.eclipse.jgit.api.CheckoutCommand.call(CheckoutCommand.java:223) ~[org.eclipse.jgit-5.13.1.202206130422-r.jar:5.13.1.202206130422-r]
	at org.springframework.cloud.config.server.environment.JGitEnvironmentRepository.checkout(JGitEnvironmentRepository.java:461) ~[spring-cloud-config-server-3.1.8.jar:3.1.8]
	at org.springframework.cloud.config.server.environment.JGitEnvironmentRepository.refresh(JGitEnvironmentRepository.java:300) ~[spring-cloud-config-server-3.1.8.jar:3.1.8]
	... 69 common frames omitted

2024-01-29 14:01:10.993  INFO 20748 --- [nio-8888-exec-1] .c.s.e.MultipleJGitEnvironmentRepository : Will try to refresh master label instead.
2024-01-29 14:01:11.218  WARN 20748 --- [nio-8888-exec-1] .c.s.e.MultipleJGitEnvironmentRepository : Could not merge remote for master remote: null

내용을 살펴보면 git-local-repo\ecommerce.yml 가 깃으로 관리되는데 main 브랜치가 없다는 것이다.

다시 보니 기본 브랜치가 master로 되어 있었고

설정파일을 아래처럼 수정하면 에러가 사라진다.

spring.cloud.config.server.git.default-label: master

 

5. 신기한 점은 git uri에  file://로 설정할 경우 해당 파일 경로가 remote 깃과 연결되지 않았을 때는 파일 저장만으로도 바로 반영이 되었는데, 

remote 깃과 연결한 후에는 파일을 수정하여 저장하더라도 자동으로 git에서 파일을 가져와서 override 하기 때문에 파일이 수정되지 않는다. 그 후에는 깃에 커밋하고 푸시해야만 반영됨.

즉, 파일시스템으로 프로퍼티를 관리하기위한 제대로된 방법은 native를 사용하는 것이다.

file://인 경우 ecommerce.yml이 반영되려면 깃에 커밋을 하지 않고도 저장만 하면 바로 반영이 된다. 즉, 저장을 하고 http://localhost:8888/ecommerce/default 를 호출하면 서버 재시작 없이도 변경된 내용을 확인할 수 있음. 물론 이는 지금 깃이 아닌 파일을 바라보도록 했기 때문.. 깃으로 수정했다면 remote push까지 해야한다. <<-깃에 연결 전

 

 

공용 설정 가져오는 서버 설정 추가(사용처; 이하 user service라고 명명)

 

1. 사용처에 아래 디펜덴시 추가

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

2. 공용으로 관리하기로 한 정보는 설정파일에서 없애고 resources/ 아래에 bootstrap.yml 추가(이름 꼭 확인)

해당 파일은 프로젝트에서 별도로 가지고 있는 application.yml 보다 먼저 로딩된다.

로딩이 그렇다는 것이고 적용 우선 순위는 아래와 같다.

 application.yml   application-`name`.yml   application-`name`-<profile>.yml

즉, bootstrap.yml로 로딩된 ecommerce.yml 은 위 순서에서 application-ecommerce.yml으로 인식되어 두번째 우선순위를 갖는다.

# application.yml에서 일부를 띠어서 공용으로 관리하기로 했기 때문에
# application.yml 이전에 불러올 정보를 가져올 프로퍼티임
spring:
  cloud:
    config:
      uri: http://127.0.0.1:8888
      name: ecommerce   #파일명.yml 일 때 파일명 작성

3. 설정 후 서버 시작 시 아래 로그 지나감 확인

INFO 24156 --- [  restartedMain] c.c.c.ConfigServicePropertySourceLocator : Fetching config from server at : http://127.0.0.1:8888
INFO 24156 --- [  restartedMain] c.c.c.ConfigServicePropertySourceLocator : Located environment: name=ecommerce, profiles=[default], label=null, version=4c8198ea3c95ce84f573a78f41b637a4da81ad49, state=null
INFO 24156 --- [  restartedMain] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-configClient'}, BootstrapPropertySource {name='bootstrapProperties-file:///Users/JIHYUN BANG/Documents/source/inflearn/git-local-repo/file:/C:/Users/JIHYUN BANG/Documents/source/inflearn/git-local-repo/ecommerce.yml'}]

 

4. 혹시 실행 시 아래 에러를 만나면, (그래도 실행은 잘 되지만) 프로퍼티 수정이 필요하다.

2024-01-29 15:29:08.527 ERROR 20628 --- [  restartedMain] o.a.catalina.session.StandardManager     : Exception loading sessions from persistent storage

java.io.EOFException: null
	at java.base/java.io.ObjectInputStream$PeekInputStream.readFully(ObjectInputStream.java:2926) ~[na:na]
...
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1303) ~[spring-boot-2.7.6.jar:2.7.6]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1292) ~[spring-boot-2.7.6.jar:2.7.6]
	at com.example.userservice.UserServiceApplication.main(UserServiceApplication.java:18) ~[classes/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:49) ~[spring-boot-devtools-2.7.6.jar:2.7.6]

해당 에러는 Tomcat의 SESSIONS.ser 파일이 없을 때 나는 에러로 추정되며, 해당 파일은 Tomcat이 애플리케이션 세션 상태를 저장하는 데 사용된다. (참고로 현 프로젝트는 spring-boot-starter-web 안의 tomcat을 사용 중)

세션 지속성(persistent session)은 웹 서버가 다운되거나 재시작될 때 사용자 세션 정보를 유지하는 기능인데 이를 통해 서버가 재시작된 후에도 사용자가 이전의 세션을 계속 사용할 수 있음.

세션 지속성을 '끈다'는 것은 이러한 세션 정보의 유지를 비활성화하는 것을 의미한다. 즉, 서버가 다운되거나 재시작되면 모든 사용자 세션 정보가 손실되는 것이다. 이 기능은 개발 과정에서 테스트나 디버깅을 쉽게 하기 위해 사용되곤 하는데, 서비스의 안정성이나 사용자 경험을 위해서는 일반적으로 세션 지속성이 활성화되어 있는 것이 좋다.

spring boot의 경우 내장 tomcat을 사용하기 때문에 직접적인 tomcat 설정 파일을 수정할 수 없음

따라서 에러를 없애기 위해서 application.yml에 아래 설정을 추가(기본적으로 true로 세팅됨)

server:
  servlet:
    session:
      persistent: false

 

5. 공용 설정파일인 ecommerce.yml 파일을 동적으로 가져오려면

위의 config service에서는 파일 커밋 시 바로 반영된 것을 확인할 수 있었으나, 사용처에서는 바로 확인이 안 된다는 것을 알 수 있다. 동적으로 가져오려면 아래와 같은 방법이 있다.

  1. 사용처를 재기동
  2. actuator refresh api 이용
  3. spring cloud bus 사용

1번 방법은.. 매우 비효율적

 

5-1. actuator refresh 사용

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator </artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: refresh, health, beans

재시작 후 ecommerce.yml을 수정하고 저장하면

config service에 반영된 것 확인되고 아래처럼 POST 요청하면 서버가 리로드 되면서 수정된 사항이 반영된다.

다만 actuator refrest를 사용할 경우 해당 프로퍼티를 바라보는 모든 서비스들에 대해 실행해줘야 하기 때문에.. 만약 관련 서비스들이 많다면 여간 번거로운 작업이 아닐 수 없다. 하여 보통은 spring cloud bus를 사용한다.

 

5-1-1. 여러 profile 사용

위와 같이 여러 환경 프로퍼티 파일이 있을 때 사용처에서 아래와 같이 설정하면 해당 환경으로 가져온다.

spring:
  cloud:
    config:
      uri: http://127.0.0.1:8888
      name: ecommerce
  profiles:
    active: prod

-> ecommerce-prod.yml

spring:
  cloud:
    config:
      uri: http://127.0.0.1:8888
      name: ecommerce
  profiles:
    active: dev

-> ecommerce-dev.yml

참고로 해당 값은 아래의 active profile에 dev로 적어도 같은 효과이다.

혹은 VM options에 아래와 같이 작성한다.

-Dspring.profiles.active=dev

 

환경별 프로퍼티 파일을 추가하고 config server를 다시 불러오면 아래와 같이 해당 환경의 프로퍼티 파일과 default 파일을 모두 불러오는 것을 볼 수 있다.

728x90
반응형

+ Recent posts