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

환경: springboot 2.6.2, spring-web

 

아래와 같은 컨트롤러가 있다.

@RequestMapping("/api/member")
@RestController
@RequiredArgsConstructor
public class MemberController {

  private final UserService userService;

 
  @GetMapping("/loss-game-money")
  public BaseResponse<MemberLossGameMoneyResponse> getMemberLossGameMoney(@ModelAttribute MemberLossGameMoneyRequest request) {
    return userService.getMemberLossGameMoney(request);
  }

  
  @GetMapping("/{id}")
  public BaseResponse<MemberInfoResponse> getMember(@PathVariable String id) {
    return new BaseResponse<>(userService.getMemberInfo(id));
  }
}

 

이 상황에서 아래 api를 요청한다면? endpoint가 정의되어 있지 않아 404가 날 것이라 기대했다.

http://localhost:8600/api/member

하지만 200 ok 가 떨어졌다.

 

분명 컨트롤러에 정의되어 있지 않고, 그렇다고 에러 내용이 controller advice에 정의되어 있지도 않은데.. 

그리고 과거에는 404로 떨어졌던 기억도 있던 터라 구글링을 해본다..

 

의심 1. 스프링 버전 이슈?

구글링 하다가 스프링 버전 2.3 이후부터 바뀐 스펙이라고 적힌 것을 봐서.. 스프링 버전의 문제인가 의심했다.(개소리로 판명)

The change in behavior where Spring Boot started returning a 200 OK response with an empty body for unmatched endpoints instead of a 404 response happened around Spring Boot version 2.3.x.
In versions prior to 2.3.x, the default behavior was to return a 404 Not Found response for unmatched endpoints. However, starting from version 2.3.x, the default behavior was changed to return a 200 OK response with an empty body.

공식 문서를 찾다가 실패하여 신규 프로젝트에 버전을 아래와 같이 중간 버전만 하나씩 올려서 테스트해 봤는데

  • 테스트해 본 버전: 2.2.6.RELEASE, 2.3.4.RELEASE, 2.4.5, 2.5.6, 2.6.3, 2.7.3, 2.7.10, 2.7.18, 3.0.0, 3.2.4(현재 최신)

절대 재현되지 않는다.. 버전 문제는 아니고 소스 문제라고 판명..

아래와 같이 모두 동일한 결과를 return 한다. 404 잘만 나는구먼..

없는 주소로 요청할 경우 404

 

의심 2. controllerAdvice...?

혹시 exception handler가 잡아서 200으로 반환하나 싶어 소스를 뒤져봐도 그런 건 없었다.

 

의심 3. 그럼 필터? 인터셉터?

혹시나 싶어 필터나 인터셉터를 하나씩 주석해 보고 실행해 본다.

로그를 살펴보니 필터에 해당하는 로그는 잘 찍히고 있어 필터 이후에서 200을 반환하는 것이라 판단했다.

그리고 인터셉터를 하나씩 보는데.. 잡았다 요놈..

@Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    if (handler instanceof HandlerMethod) {
    	...
      return HandlerInterceptor.super.preHandle(request, response, handler);
    }
    return false; ///
  }

위 함수에서 컨트롤러에 정의된 api는 if (handler instanceof HandlerMethod) 구문에 true를 반환하여 작업을 하고 마지막에 true를 반환하지만 정의되지 않은 api는 false를 타게 된다. false를 타면 상태코드 200에 empty body로 리턴된다..! false를 반환한다는 의미가 controller를 타지 않고 작업을 종료한다는 의미고 그게 잘 되었으니 어쩌면 맞을 수도..

혹시나 싶어서 신규 프로젝트로 기본 세팅만 한 후 재현해 본다.

1. @EnableWebMvc 없고 + return false

@Component
public class TestInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    return false;
  }
}
@Component
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

  private final TestInterceptor interceptor;
  
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
  //TestInterceptor가 이미 빈으로 등록되어 있으므로 주입하여 사용하기위해 아래 주석;
  //TestInterceptor에 @Component가 없으면 빈이 아니므로 아래 주석이 작동함
//    registry.addInterceptor(new TestInterceptor()).addPathPatterns("/**");
    registry.addInterceptor(interceptor);
  }
}
@RequestMapping("/api")
@RestController
public class TestController {

  @GetMapping("/test")
  public String test( ) {
    return "hello";
  }
}
  • interceptor 설정 전
    • http://localhost:8080/api/test 요청 시
      • 200 hello
    • http://localhost:8080/api/test3 요청 시
      • 404 기본 404 not found 메시지
  • interceptor 설정 후
    • http://localhost:8080/api/test 요청 시
      • 200 empty body
    • http://localhost:8080/api/test3 요청 시
      • 200 empty body

 

200이 아닌 다른 상태 값으로 반환하려면 아래와 같이 수정하면 된다.

empty body에 404로 떨어진다.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  if (! (handler instanceof HandlerMethod)) {
    response.setStatus(404); //
    return false;
  }
  return true;
}

 

이를 수정해 본다면? 에러를 던져보자.

2. @EnableWebMvc 없고 + throw exception

아래와 같이 false 반환대신 에러를 던지면 throw 절을 만나게 되고 BasicErrorController를 타고 500으로 떨어진다.

(어떤 종류의 exception이건 500으로 떨어진다. 그 이유는 Exception이 처리가 이뤄지지 않은 상태로 WAS에게 전달되었기 때문이다. WAS 입장에서는 처리되지 않은 Exception을 받으면 이를 예상치 못한 문제로 인해 발생했다고 간주하고 status코드 500에 Internal Server Error를 메시지에 설정하게 된다.)

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  if (! (handler instanceof HandlerMethod)) {
    throw new RuntimeException("nono");
  }
  return true;
}

 

@EnableWebMvc 어노테이션의 유무

1. @EnableWebMvc 있고 + 인터셉터에서 return true로 진행시키면

여기서 @EnableWebMvc 어노테이션을 추가하면 (어떤 에러를 던지건) 404로 에러가 바뀐다....

spring-web dependency가 있으면 @EnableWebMvc를 선언하지 않아도 된다고 알고 있는데 결과가 다르다니..(아래에서 계속)

더 확인해 보니 @EnableWebMvc를 선언하면 아래 부분이 true로 내려와서

handler instanceof HandlerMethod == true

결국 preHandler가 true로 return 되어 에러를 만나지 않고, controller 단에 가서 url을 찾다가 없어서 404 -> BasicErrorController를 타는 플로우였다.

2. @EnableWebMvc + 인터셉터 내부에서 에러 발생 시키면

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  throw new HttpClientErrorException(HttpStatus.MULTI_STATUS);
}
2024-04-18 09:56:29.978  WARN 12950 --- [nio-8080-exec-8] o.s.web.servlet.PageNotFound             : No mapping for GET /api/test3
2024-04-18 09:56:29.980 ERROR 12950 --- [nio-8080-exec-8] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] threw exception

org.springframework.web.client.HttpClientErrorException: 207 MULTI_STATUS
	at com.example.demo.interceptor.TestInterceptor.preHandle(TestInterceptor.java:16) ~[main/:na]
	...

2024-04-18 09:56:29.982 ERROR 12950 --- [nio-8080-exec-8] o.a.c.c.C.[Tomcat].[localhost]           : Exception Processing ErrorPage[errorCode=0, location=/error]

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.springframework.web.client.HttpClientErrorException: 207 MULTI_STATUS
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-5.3.22.jar:5.3.22]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.22.jar:5.3.22]
    ...

 

해당 부분에서도 항상 에러를 뱉게 하면 에러가 발생해도 404로 떨어진다.

그 의미는 에러가 발생해도 controller 쪽으로 요청이 들어온다는 것인데, 그 이유는 WAS의 에러페이지를 위한 요청 때문이다.

https://cs-ssupport.tistory.com/494

 

interceptor에 에러를 반환하고 exception handler를 통해 에러를 반환하려고 하면 어떻게 해야 할까?

interceptor는 dispatcher servlet 이후 스프링 컨텍스트 안에 있기 때문에 controller advice를 통한 처리가 가능하다.

1. @EnableWebMvc + controllerAdvice

@EnableWebMvc를 선언한 상태에서 interceptor에서 에러를 뱉게 아래와 같이 수정하고

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  throw new RuntimeException("yesyes");
}

controllerAdvice를 통해 exception handler를 작성하면 의도한 대로 상태코드 400에 에러 메시지가 나온다!

@RestControllerAdvice
public class InterceptorExceptionHandler {

  @ExceptionHandler({RuntimeException.class})
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public String error(RuntimeException exception){
    return "Exception : " + exception.getMessage();
  }
}

WARN 23846 --- [nio-8080-exec-1] o.s.web.servlet.PageNotFound             : No mapping for GET /api/test3

로그에는 page not found 가 적힌다.

 

2. @EnableWebMvc 제거 + controllerAdvice

여기서 @EnableWebMvc를 선언을 제거한다면?

그래도 api는 동일한 결과가 반환된다.

그런데 로그는 다르게 찍힌다. 반환한 에러에 대한 값이 찍힌다.

ERROR 24122 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: yesyes] with root cause

java.lang.RuntimeException: yesyes
	at com.example.demo.interceptor.TestInterceptor.preHandle(TestInterceptor.java:14) ~[main/:na]
	at org.springframework.web.servlet.HandlerExecutionChain.applyPreHandle(HandlerExecutionChain.java:148) ~[spring-webmvc-5.3.22.jar:5.3.22]
	...

 

@EnableWebMvc 어노테이션 관련..

spring-web dependency가 있으면 @EnableWebMvc를 선언하지 않아도 된다고 알고 있는데 결과가 다르다니..

에 대해 좀 더 알아본다.

우선 맞다. springboot에 spring boot starter web 디펜덴시가 있으면 @SpringBootApplicaion 어노테이션에 의해 @EnableAutoConfiguration 어노테이션이 작동하고 웹의 기능(DispatcherServlet 등)을 사용하기 위한 기본 세팅을 자동으로 해준다.

@EnableWebMvc 어노테이션이 사용되면 스프링은 웹 애플리케이션을 위한 준비를 하게 되는데, 기본 설정값으로 아래 클래스를 읽어온다. 

public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport
The @EnableWebMvc annotation is used in Spring to enable the Spring MVC framework for a web application. When this annotation is used, it imports the Spring MVC configuration from WebMvcConfigurationSupport
 and helps in setting up the necessary components for handling web requests, such as controllers, views, and more. This annotation is often used in conjunction with the @Configuration annotation to enable Spring MVC configuration in a Java-based configuration approach.

 

우리가 기본 세팅을 커스터마이징 하려면 WebMvcConfigurerAdapter라는 클래스를 extend 해야 하는데, 이 클래스는 spring5.0 이래로 deprecated 되었고, WebMvcConfigurer interface를 implement 해야 한다.

그런데 여기서 주의해야 할 점은 커스텀 세팅을 사용하려면 @EnableWebMvc 어노테이션이 없어야 한다는 것이다.

만약 @EnableWebMvc 어노테이션과 커스텀 세팅을 사용하려면 빈으로 등록된 DelegatingWebMvcConfiguration 클래스를 확장하여 재정의해야 한다. 기본 값을 사용하지 않기에 모든 함수를 재정의해야 함에 주의하자.

https://docs.spring.io/spring-boot/docs/current/reference/html/web.html

https://dev.to/xterm/be-careful-when-using-configuration-classes-with-enablewebmvc-in-spring-boot-2n32

 

Be careful when using @Configuration classes with @EnableWebMvc in Spring Boot

Situation Recently, we have been faced with a strange issue after adding a configuration...

dev.to

위에서 @EnableWebMvc 어노테이션 유무에 따라 결과가 다르게 나온 이유는 아마도,

@EnableWebMvc 어노테이션 + 커스텀 세팅(인터셉터)인데 DelegatingWebMvcConfiguration 클래스의 재정의가 없어 기본 세팅값으로 override 되어 다른 결과가 나온 것이 아닌가 추측된다.

 


위 내용을 학습하다 스프링 버전 3부터 루트 매핑(@RequestMapping("")과 @RequestMapping("/"))에 대한 이슈가 있다는 글을 보아서 참고로 링크 남긴다.. 관련 글 때문에 더 헷갈렸다.

https://github.com/spring-projects/spring-boot/issues/33499

 

404 error occurs with RequestMapping(path="") · Issue #33499 · spring-projects/spring-boot

Used version SpringBoot3.0.0 GA Occurrence event When I specify an empty string in Controller's RequestMapping (@RequestMappint(path="")) and access the root path, The mapping doesn't work properly...

github.com


interceptor in spring context: was 흐름까지 정리 https://cs-ssupport.tistory.com/494

 

[Spring] 스프링 인터셉터

[Spring] Servlet "Filter" 현재 Controller에 의해서 매핑되는 Page가 다음 종류가 있다고 하자 1. 로그인 하지 않고 접근 가능 2. 로그인 해야 접근 가능 >> 여기서 과연 "로그인 해야 접근 가능한 페이지"가

cs-ssupport.tistory.com

https://velog.io/@monkeydugi/Spring-Interceptor%EC%97%90%EC%84%9C-%EC%98%88%EC%99%B8%EB%A5%BC-%EC%9D%91%EB%8B%B5-%ED%95%B4%EC%A3%BC%EB%8A%94-%EB%B0%A9%EB%B2%95

 

Spring Interceptor에서 예외를 응답 해주는 방법

상황이 어떤가 살펴보자.소셜 로그인을 시도한다.authorization code가 유효하지 않으면, 예외를 발생 시킨다.500으로 응답한다.위의 코드는 인터셉터에서 예외를 발생 시킨다.하지만 이렇게 끝내면 5

velog.io

 

728x90
반응형
반응형

환경: springboot2.7.6

 

로컬에서 개발할 때, 그리고 운영 환경으로 배포할 때 내용물에 따라 설정파일을 분리할 수 있으며, 환경에 따라 다르게 가져가야 한다.

-- 소스 실행
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=production

-- jar 로 빌드해서 배포
java -Dspring.profiles.active=production service.jar

 

설정파일은 파일명 자체를 여러가지로 둘 수도 있고, 그 안에서 환경(profile)을 줄 수도 있다.

 

자바 소스 안에서 프로파일을 분기할 수도 있다.

728x90
반응형
반응형

환경: springboot2.7.6. java11,  mysql5.7

 

문제 상황

endDate에 23:59:59를 세팅하는 게 귀찮고 반복적이라 느껴져서 LocalTime.MAX를 사용하는 방향으로 구현하고 있었다.

private LocalDateTime endDate;

//
endDate.toLocalDate().atTime(LocalTime.MAX) 
//위와 동일
endDate.withHour(23).withMinute(59).withSecond(59).withNano(999999999)

 

디버그 시 endDate에 23:59:59:99999.. 로 잘 들어가는 것을 확인하고 entity.save를 진행하였는데

디비에 들어가는 값은 그 다음날 00:00:00으로 들어가 있었다.

즉 2024-01-01 23:59:59.9999999.. 로 저장을 해도 디비에는 2024-01-02 00:00:00으로 들어가는 상황

 

원인

java단에서 오류가 아님을 확인하니 mysql 단에서 자체 처리하는 것이라는 생각이 들었다.

LocalTime.MAX는 초 이하 9자리까지 지원한다.

즉, 23:59:59.999999999 로 처리한다.

 

현재 해당 디비 콜롬은 아래처럼 정의되어 있다.

초 단위 미만을 지원하지 않는 datetime은 초 단위 아래 값이 오면 반올림해버린다.

하여 초 단위 보다 더 정밀한 시간 값이 필요하면 MySQL5.7에 추가된 fractional second를 이용한 datetime(6) 등을 사용해야 한다.

하지만 MySQL이 지원하는 Fractional Seconds의 최대 자릿수는 6자리고 이를 초과했기 때문에 LocalTime.MAX를 담으면 자동으로 반올림 처리된다.

 

https://lenditkr.github.io/MySQL/fractional-seconds-rouding-problem/

 

아니 시간도 반올림이 된다고?

니가 지정한 내가 아냐~ - Soo

lenditkr.github.io

 

그리고 JPA/mysql-connector 내부적으로도 반올림하는 부분이 있어 시간에 민감한 데이터인 경우 LocalDateTime.now()를 사용하는 것도 문제가 될 수 있다고 한다. 이럴 땐 DB connect 시 설정에 sendFractionalSeconds=false를 추가해줘야 하는 듯.

https://medium.com/naverfinancial/%EC%98%A4%EB%8A%98%EC%9E%90-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EC%A1%B0%ED%9A%8C%ED%96%88%EB%8A%94%EB%8D%B0-%EB%8B%A4%EC%9D%8C%EB%82%A0-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EB%94%B8%EB%A0%A4%EC%99%80%EC%9A%94-mysql-ae9b0f696742

 

오늘자 데이터를 조회했는데 다음날 데이터가 딸려와요 (MySQL)

안녕하세요. 네이버 파이낸셜 내자산&증권개발팀의 이정빈입니다.

medium.com

그래서

  • 날짜 저장 시 mysql의 콜롬 타입을 고려해야 하며
  • LocalTime.MAX 대신 LocalTime.of(23, 59, 59) / LocalTime.of(23, 59, 59, 999_999) 등으로 써야 한다..

 

728x90
반응형
반응형

환경: java11

 

java8이후로 java.util.Date, Calendar가 deprecated되고 LocalDateTime, ZonedDateTime, OffsetDateTime이 등장하였다. 각각 어떤 것인지 살펴보자.

 

LocalDateTime

날짜와 시간에 대한 정보만 있지 zone이나 offset에 대한 정보는 없다. zone에 상관없이 그냥 시간 그 자체를 담는 용도이다.

따라서 LocalDateTime을 아래처럼 포매팅하면 에러가 난다.

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX") 
private LocalDateTime endRegDate;
Unsupported field: OffsetSeconds

 

 

지금 시간 가져오기

LocalDateTime now = LocalDateTime.now();

서울에서, 현재 시각이 2024-02-14 11:00:00 이면 위 결과값도 동일하게 11시이다.

 

특정 시간 가져오기

LocalDateTime now = LocalDateTime.of(2024, 1, 1, 0, 0, 0);

ZonedDateTime

LocalDateTime에 zone이 들어간 형태(UTC/Greenwich)

서머타임(Daylight Saving Time): 시간대 변환 시 서머타임을 고려해야!! ZonedDateTime은 서머타임을 자동으로 처리함

 

지금 시간 가져오기

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX") 
ZonedDateTime now = ZonedDateTime.now();
//"2024-02-14T11:57:23.797+09"

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX") 
ZonedDateTime.now(ZoneId.of("UTC"));
//"2024-02-14T02:57:23.797Z"

그냥 now는 서버 시스템 존(Asia/Seoul)을 기반으로 가져오고, zone id를 주면 변환된다.

포매팅할 때 X를 추가하면(Z아님..) 끝에 존 정보 +09 혹은 UTC면 Z가 들어간다.

 

특정 시간 가져오기

ZonedDateTime zonedDateTime = ZonedDateTime.of(2023, 12, 25, 10, 30, 0, 0, ZoneId.of("America/New_York"));

zone 정보를 줘야 한다.

참고: https://www.baeldung.com/java-format-zoned-datetime-string


OffsetDateTime

ZonedDateTime 처럼 zone을 기반으로 움직이는 데이터이지만 zoneId가 아닌 offset("+02:00", "-08:00")을 기반으로 한다.

특정 zone id를 모를 때 사용하기 좋음

 

현재 시간 가져오기

OffsetDateTime.now();
//"2024-02-14T12:10:05.884+09"
OffsetDateTime.now(ZoneId.of("UTC"));
//"2024-02-14T03:10:05.884Z"

사용법은 ZonedDateTime과 동일하다. 심지어 zone id로도 세팅 가능.

특정 시간 가져오기

OffsetDateTime offsetDateTime = OffsetDateTime.of(2023, 12, 25, 10, 30, 0, 0, ZoneOffset.ofHours(-5));

zone offset으로 시간을 줘야 한다.

 

Instant:

  • Instant는 시간대와 상관없이 UTC 시간을 기준으로 시간을 관리할 때 유용
  • 타임존 정보를 포함하지 않는 가장 기본적인 시간 표현 방식
  • 즉, 1970-01-01T00:00:00Z(Epoch Time) 이후의 시간을 초 또는 나노초 단위로 계산한 시간
  • 타임존과 무관하게 시간의 순간을 표현할 때 사용
  • 네트워크 요청, 로그 등 글로벌 시스템에서 시간대를 고려하지 않고 순수 시간을 기록할 때 사용
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class TimeZoneConversionExample {
    public static void main(String[] args) {
        // 서버에서 UTC 시간으로 받은 Instant
        Instant serverTime = Instant.now();
        System.out.println("Server Time (UTC): " + serverTime);

        // 사용자 시간대 (예: 아시아/서울 시간대)
        ZonedDateTime userTime = serverTime.atZone(ZoneId.of("Asia/Seoul"));
        System.out.println("User Time (Asia/Seoul): " + userTime);
    }
}
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
반응형
반응형

이전 작업:

2024.02.04 - [서버 세팅 & tool/kafka] - [windows] kakfa connect 연동

2024.02.08 - [개발/kafka] - [spring-kafka] producer, consumer 기초

환경: springboot 2.7.6, spring-kafka, java11

목표: 인스턴스 별로 하나씩 있던 디비를 공용 maria 디비로 전환, kafka connect 이용하여 source 쐈을 때 sink로 받아서 db에 저장

 

1. maria db 접속, 필요한 테이블 생성

2. pom.xml, application.yml 수정

<dependency>
    <groupId>org.mariadb.jdbc</groupId>
    <artifactId>mariadb-java-client</artifactId>
    <version>3.1.4</version>
</dependency>

3. kafka producer 추가

  • orders 이라는 sink connect로 전송
  • db에 데이터를 저장하는 것이라 kafka가 원하는 db 포맷으로 만들어야 함
@Service
@Slf4j
@RequiredArgsConstructor
public class OrderProducer {
    private final KafkaTemplate kafkaTemplate;

    private final List<Field> fields = Arrays.asList(new Field("string", true, "order_id"),
            new Field("string", true, "user_id"),
            new Field("string", true, "product_id"),
            new Field("int32", true, "qty"),
            new Field("int32", true, "unit_price"),
            new Field("int32", true, "total_price"));

    private final  Schema schema = Schema.builder().type("struct").fields(fields).optional(false).name("orders").build();

    public KafkaOrderDto send(String topic, OrderDto orderDto){

        Payload payload = Payload.builder().orderId(orderDto.getOrderId()).userId(orderDto.getUserId()).productId(orderDto.getProductId()).qty(orderDto.getQty()).unitPrice(orderDto.getUnitPrice()).totalPrice(orderDto.getTotalPrice()).build();
        KafkaOrderDto kafkaOrderDto = new KafkaOrderDto(schema, payload);

        ObjectMapper mapper = new ObjectMapper();
        String jsonString = "";
        try{
            jsonString = mapper.writeValueAsString(kafkaOrderDto);
        }catch (JsonProcessingException e){
            e.printStackTrace();
        }
        kafkaTemplate.send(topic, jsonString);
        log.info("kafka producer sent: {}" , kafkaOrderDto);
        return kafkaOrderDto;
    }
}

 

호출부, 여기서 앞부분이 sink topic이름

orderProducer.send("orders", orderDto);

4. kafka sink connector 추가

마리아 디비랑 연결되는 토픽이 orders 라고 정의하고 등록하는 것임

잘 생성되었나 확인

 

5. 이렇게 되면 해당 인스턴스가 여러가 떠 있어도 어떤 인스턴스가 디비 수정 건을 받았건 상관없이 모두 마리아 디비로 들어가서 단일로 관리가 가능

 

728x90
반응형

'개발 > kafka' 카테고리의 다른 글

[spring-kafka] producer, consumer 기초  (0) 2024.02.08
반응형

환경: springboot2.7.6, java11, h2 연결 

 

producer 정보 보내는 쪽

1. pom.xml 추가

<!-- kafka -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

 

2. producer kafka 연결 설정

@EnableKafka
@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory<String, String> producerFactory(){
        Map<String, Object> properties = new HashMap<>();

        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092");
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);

        return new DefaultKafkaProducerFactory<>(properties);
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate(){
        return new KafkaTemplate<>(producerFactory());
    }
}

 

3. 카프카를 사용하여 전송하려는 메세지 함수 설정

@Service
@Slf4j
@RequiredArgsConstructor
public class KafkaProducer {
    private final KafkaTemplate<String, String> kafkaTemplate;

    //tojson
    public OrderDto send(String kafkaTopic, OrderDto orderDto){
        ObjectMapper mapper = new ObjectMapper();
        String jsonString = "";
        try{
            jsonString = mapper.writeValueAsString(orderDto);
        }catch (JsonProcessingException e){
            e.printStackTrace();
        }
        kafkaTemplate.send(kafkaTopic, jsonString);
        log.info("kafka producer sent: {}" , orderDto);
        return orderDto;
    }
}

 

 

consumer 정보 받는 

1. 동일

2. consumer kafka 연결 설정

@EnableKafka
@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, String> consumerFactory(){
        Map<String, Object> properties = new HashMap<>();

        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "consumerGroupId");//consumer grouping
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        return new DefaultKafkaConsumerFactory<>(properties);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(){
        ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory = new ConcurrentKafkaListenerContainerFactory<>();
        kafkaListenerContainerFactory.setConsumerFactory(consumerFactory());
        return kafkaListenerContainerFactory;
    }
}

 

3. 받는 listener 설정

@Service
@Slf4j
@RequiredArgsConstructor
public class KafkaConsumer {
    private final CatalogRepository catalogRepository;

    @KafkaListener(topics = "example-catalog-topic") //데이터가 전달되면 가져와서 실행
    public void updateQty(String kafkaMessage){
        log.info("kafka message: {}", kafkaMessage);
        Map<String, Object> map = new HashMap<>();
        ObjectMapper mapper = new ObjectMapper();
        try {
            map = mapper.readValue(kafkaMessage, new TypeReference<Map<String, Object>>() {});
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        Catalog catalog = catalogRepository.findByProductId((String)map.get("productId"));
        if(catalog != null){
            catalog.setStock(catalog.getStock() - (Integer)map.get("qty"));
            catalogRepository.save(catalog);
        }
    }
}
728x90
반응형
반응형

환경: springboot2.7.6(hibernate core 5.6.14), spring-jpa, java 11, mysql5.7

 

spring jpa를 이용하여 아래 쿼리를 질의하려고 할 때

select (@rank := @rank +1) as ranking,
hutw.* 
from user_stat_weekly hutw, (select @rank := 0) as ranking
where hutw.start_date ='2024-02-05'
order by hutw.prize_total desc

 

@를 사용한 사용자 변수는 native query를 사용해야 한다.

 

허나 그냥 사용하면 아래와 같은 에러가 발생한다.

Caused by: org.hibernate.QueryException: Space is not allowed after parameter prefix ':'

:= @rank 이 부분에서 발생하는 것인데, 하이버네이트 버전에 따라 역슬래시를 하나나 두 개 넣어야 한다고 한다.

내가 사용하는 버전에서는 아래처럼 두 개 넣었더니 성공한다.

SELECT (@rank \\:= @rank +1) ...

 

잘 지나가나 싶어서 실행해 보면 아래 에러가 발생하는데, 해당 에러는 native query 결과를 object에 세팅할 때 class를 매핑해서 나는 것으로 projection interface로 바꾸면 해결된다.

No converter found capable of converting from type [org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap] to type
728x90
반응형

+ Recent posts