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

이전 글: 2022.02.11 - [개발/spring] - [axon] query handler - clone coding

 

[axon] query handler - clone coding

이전 글: 2022.02.04 - [개발/spring] - [axon] event upcasting - clone coding [axon] event upcasting - clone coding 이전 글: 2022.01.25 - [개발/spring] - [axon] query/replay 성능개선 - clone coding [..

bangpurin.tistory.com

클론 코딩 참고 블로그는 다음과 같다: https://cla9.tistory.com/21?category=814447 

 

17. Query 어플리케이션 구현(Query) - 3

1. 서론 이번 포스팅에서는 Scatter-Gather Query를 구현하겠습니다. Scatter-Gather Query는 동일한 Query를 수행하는 Query Handler가 여러 App에 존재할 경우 모든 App에 Query를 요청하여 결과를 취합받아 최..

cla9.tistory.com

 

지난 시간에 이어 쿼리 핸들링에 대한 이야기이다.

총 세 가지 방법이 있는데 두 가지는 지난 시간에 코딩하였고 오늘은 마지막 방법인 Scatter-Gather Query를 구현한다.

위 블로그를 따라 구현하다보면 마지막 서버 실행 부분에 아래와 같이 circular reference 관련 에러가 난다.

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'org.axonframework.config.Configurer': Requested bean is currently in creation: Is there an unresolvable circular reference?

예전 글에서도 본 적 있는 에런데 아래와 같은 설정을 추가하면 된다. springboot2.6부터 circular reference 가 기본 설정에서 제외되었기 때문이다.

spring: 
  main:
    allow-circular-references: true

 

앱을 모두 실행하고 테스트 해봤을 때 로그를 확인해본다.

1. 쿼리 서비스 흐름

HolderAccountController.getAccountInfoScatterGather -> QueryServiceImpl.getAccountInfoScatterGather -> holderID / 잔액을 dto로 만들어서 제주/서울에게 넘긴 후 결과를 LoanLimitResult.class로 받아오라고 명령 -> 서울/제주의 QueryHandler 가 받아서 처리 -> Query Service에서는 두 곳에서의 응답이 다 오기를 기다리고 결과를 조합하여 리스트로 내려줌

Hibernate: select holderacco0_.holder_id as holder_i1_1_, holderacco0_.account_cnt as account_2_1_, holderacco0_.address as address3_1_, holderacco0_.name as name4_1_, holderacco0_.tel as tel5_1_, holderacco0_.total_balance as total_ba6_1_ from mv_account holderacco0_ where holderacco0_.holder_id=?
///////서울 제주로 요청 보냄

//////응답1 서울
15:57:34.506 DEBUG 8092 --- [ault-executor-1] o.a.a.c.query.AxonServerQueryBus         : Received query response [message_identifier: "204f7d62-8f12-4df7-881d-339387ab3fa2"
payload {
  type: "com.cqrs.loan.LoanLimitResult"
  data: "<com.cqrs.loan.LoanLimitResult><holderID>e5775054-4265-46ef-8116-297ac22f480d</holderID><bankName>SeoulBank</bankName><balance>7980</balance><loanLimit>11970</loanLimit></com.cqrs.loan.LoanLimitResult>"
}
meta_data {
  key: "traceId"
  value {
    text_value: "b540735f-c8a3-48da-96a7-9e90aa752966"
  }
}
meta_data {
  key: "correlationId"
  value {
    text_value: "b540735f-c8a3-48da-96a7-9e90aa752966"
  }
}
request_identifier: "b540735f-c8a3-48da-96a7-9e90aa752966"
]
//////응답2 제주
15:57:34.507 DEBUG 8092 --- [ault-executor-1] o.a.a.c.query.AxonServerQueryBus         : Received query response [message_identifier: "16efaa5a-e221-4740-85b2-ff5478d8ed4e"
payload {
  type: "com.cqrs.loan.LoanLimitResult"
  data: "<com.cqrs.loan.LoanLimitResult><holderID>e5775054-4265-46ef-8116-297ac22f480d</holderID><bankName>JejuBank</bankName><balance>7980</balance><loanLimit>9576</loanLimit></com.cqrs.loan.LoanLimitResult>"
}
meta_data {
  key: "traceId"
  value {
    text_value: "b540735f-c8a3-48da-96a7-9e90aa752966"
  }
}
meta_data {
  key: "correlationId"
  value {
    text_value: "b540735f-c8a3-48da-96a7-9e90aa752966"
  }
}
request_identifier: "b540735f-c8a3-48da-96a7-9e90aa752966"
]

2. 제주

15:57:34.464 DEBUG 8162 --- [ueryProcessor-0] c.c.jeju.component.AccountLoanComponent  : >>> handling LoanLimitQuery(holderID=e5775054-4265-46ef-8116-297ac22f480d, balance=7980)

3. 서울

15:57:34.464 DEBUG 8163 --- [ueryProcessor-0] c.c.s.component.AccountLoanComponent     : >>> handling LoanLimitQuery(holderID=e5775054-4265-46ef-8116-297ac22f480d, balance=7980)

 

결과 화면

 

 

728x90
반응형
반응형

이전 글: 2022.02.04 - [개발/spring] - [axon] event upcasting - clone coding

 

[axon] event upcasting - clone coding

이전 글: 2022.01.25 - [개발/spring] - [axon] query/replay 성능개선 - clone coding [axon] query/replay 성능개선 - clone coding 이전 글: 2022.01.24 - [개발/spring] - [axon] query/replay - clone coding..

bangpurin.tistory.com

클론 코딩 참고 블로그는 다음과 같다: https://cla9.tistory.com/20?category=814447 

 

16. Query 어플리케이션 구현(Query) - 2

1. 서론 이번 시간에는 Query 기능 중 Point to Point, Subscription 기능을 구현합니다. 또한, Query 결과를 보기 위하여 Client 화면을 간략하게 만들겠습니다. Client 화면은 크게 Point to Point Query와 Subs..

cla9.tistory.com

 

이번 글은 쿼리 핸들링에 관한 내용이다.

위 과정 중, 어노테이션 validation 부분이 스프링 버전업으로 빠져있어서 추가해주었다.

2022.02.11 - [개발/spring] - [annotation] NotNull NotBlank NonNull NotEmpty...

 

[annotation] NotNull NotBlank NonNull NotEmpty...

스프링에서 어노테이션 검증을 사용하면 별도의 로직없이 setter가 작용할 때(컨트롤러에 들어오기에도 전) 바로 변수 검증을 진행한다. 인입 로그 없이 에러가 나서 당혹스러울 수는 있지만 잘

bangpurin.tistory.com

 

1. Point to Point Query

api를 실행했을 때의 흐름을 살펴보면 HolderAccountController.getAccountInfo -> QueryServiceImpl.getAccountInfo -> queryGateway -> queryHandler로 가는 것을 알 수 있다.
여기를 어떻게 찾아가는지 확인하기 위해 아래와 같이 같은 request/response를 가지는 핸들러를 하나 더 만들어서 테스트를 해봤는데.. 

////on3 작동    
@QueryHandler
public HolderAccountSummary on3(AccountQuery query){
    log.debug(">>> handling fake {}", query);
    HolderAccountSummary res = new HolderAccountSummary();
    res.setName("test");
    return res;
}

@QueryHandler
public HolderAccountSummary one(AccountQuery query){
    log.debug(">>> handling queryHandler {}", query);
    return repository.findByHolderId(query.getHolderId()).orElse(null);
}
-----------------------------------
////on2 작동
@QueryHandler
public HolderAccountSummary on3(AccountQuery query){
    log.debug(">>> handling fake {}", query);
    HolderAccountSummary res = new HolderAccountSummary();
    res.setName("test");
    return res;
}

@QueryHandler
public HolderAccountSummary on2(AccountQuery query){
    log.debug(">>> handling queryHandler {}", query);
    return repository.findByHolderId(query.getHolderId()).orElse(null);
}

여러 번 이름을 바꿔서 실행해봤는데, 어째 이름의 alphabetical order.. 가 낮은 순(a-> b-> c..)으로 작동되는 것 같다. 

찾아보니 axon에서도 query handler 의 순서에 대해 명시해놓긴 했으나 명확한 기준이라고 하긴 애매하다.

1. On the actual instance level of the class hierarchy (as returned by this.getClass()), all annotated methods are evaluated
2.If one or more methods are found of which all parameters can be resolved to a value, the method with the most specific type is chosen and invoked
3.If no methods are found on this level of the class hierarchy, the super type is evaluated the same way
4.When the top level of the hierarchy is reached, and no suitable query handler is found, this query handling instance is ignored.

내가 이해한 바로는 1. request/response 타입에 맞는 핸들러인지를 먼저 확인하고, 2. 해당 핸들러가 복수개이면 더 하위 레벨/자세한(상속을 받았다거나) 쪽을 따르는 듯하다. 내가 짠 위 코드는 같은 형태의 핸들러가 복수개이지만 뎁스가 같아서 다른 기준으로 순서를 정했을 터인데... 알파벳순이 왠지 맞는 것 같다..

 

2. Subscription Query

api를 실행했을 때의 흐름을 살펴보면 HolderAccountController.getAccountInfoSubscription -> QueryServiceImpl.getAccountInfoSubscription -> flux를 이용하여 subscribe 하고 있다는 것을 알 수 있다.

event handler 중 바로 노티 받을 곳에서 emit을 하면 subscribe에서 받는 구조.

화면은 SSE(Server Sent Event) 방식으로 구현되어 있으며 EventSource 객체를 사용하였다.

최초에 ui의 조회 버튼으로 커낵션을 연결하면

queryResult.initialResult().subscribe(emitter::next);

위 로직으로 인해 화면에 현 상태가 화면에 먼저 뿌려지며, 그 후에는 버튼을 누르지 않아도 emit 된 값이 listen 중이던 flux 쪽으로 와서 doOnNext를 타고 ui로 간다. ui에서도 비동기를 받을 수 있는 EventSource객체로 url을 호출한지라 eventSource.onmessage 함수에서 자동으로 데이터를 가져와 화면에 뿌려준다. 조회는 한 번만 눌렀을 뿐인데 화면에 변한 값이 자동으로 보인다.(신기하다..)

이후 종료를 누르면 그 후 일어난 변화에 대해서는 자동으로 표시해주지 않는다.

//현 상태 확인
14:07:44.689 DEBUG 6007 --- [nio-9090-exec-1] com.cqrs.query.service.QueryServiceImpl  : >>> queryServiceImpl getAccountInfoSubscription handling AccountQuery(holderId=e5775054-4265-46ef-8116-297ac22f480d)
14:07:44.693 DEBUG 6007 --- [nio-9090-exec-1] o.a.a.c.query.AxonServerQueryBus         : Subscription Query requested with subscription Id [340e3830-c7f5-44b9-8f03-98ddb1722cca]
14:07:44.790 DEBUG 6007 --- [ault-executor-1] c.c.q.p.HolderAccountProjection          : >>> handling queryHandler AccountQuery(holderId=e5775054-4265-46ef-8116-297ac22f480d)
//10원 인출
14:08:04.926 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> projecting WithdrawMoneyEvent(holderID=e5775054-4265-46ef-8116-297ac22f480d, accountID=fffb8fa2-94dd-4e84-b7fd-072d09d01d33, amount=10) , timestamp : 2022-02-11T05:08:04.842Z
14:08:04.932 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> getHolder : e5775054-4265-46ef-8116-297ac22f480d 
14:08:04.978 DEBUG 6007 --- [ault-executor-0] com.cqrs.query.service.QueryServiceImpl  : doOnNext : com.cqrs.query.entity.HolderAccountSummary@7d175f21, isCanceled false
//10원 인출
14:08:13.930 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> projecting WithdrawMoneyEvent(holderID=e5775054-4265-46ef-8116-297ac22f480d, accountID=fffb8fa2-94dd-4e84-b7fd-072d09d01d33, amount=10) , timestamp : 2022-02-11T05:08:13.907Z
14:08:13.930 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> getHolder : e5775054-4265-46ef-8116-297ac22f480d 
14:08:13.943 DEBUG 6007 --- [ault-executor-1] com.cqrs.query.service.QueryServiceImpl  : doOnNext : com.cqrs.query.entity.HolderAccountSummary@358555d0, isCanceled false
//4000원 추가
14:08:34.520 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> projecting DepositMoneyEvent(holderID=e5775054-4265-46ef-8116-297ac22f480d, accountID=37ddb7c8-0d3b-443d-a6d5-c83d873f0521, amount=4000) , timestamp : 2022-02-11T05:08:34.499Z
14:08:34.521 DEBUG 6007 --- [sor[accounts]-1] c.c.q.p.HolderAccountProjection          : >>> getHolder : e5775054-4265-46ef-8116-297ac22f480d 
14:08:34.536 DEBUG 6007 --- [ault-executor-0] com.cqrs.query.service.QueryServiceImpl  : doOnNext : com.cqrs.query.entity.HolderAccountSummary@3cf7a219, isCanceled false

Subscription 방식은 한번 커넥션을 맺은 상태에서는 별다른 호출이 없어도 자동으로 변화를 감지하기 때문에 사용성이 좋을 것 같긴 하지만 webflux와 EventSource를 사용하면서 개발의 진입장벽이 있을 것으로 보인다. 또한 서버와 클라이언트의 Connection을 유지해야하기 때문에 Subscription Query가 증가할수록 그에 상응하는 Thread 수가 증가한다는 단점이 있다는 것을 확인해야 한다.

ui에서 별다른 유저 액션 없이도 어떻게 계속 동기화된 데이터를 받을 수 있을까를 고민했던 과거의 나에게 약간의 해답을 준 오늘의 실습이었다. 아직 모든 게 완벽히 와닿지는 않지만 여러 번 반복해서 보다 보면 조금씩 이해할 수 있지 않을까...

728x90
반응형
반응형

스프링에서 어노테이션 검증을 사용하면 별도의 로직없이 setter가 작용할 때(컨트롤러에 들어오기에도 전) 바로 변수 검증을 진행한다. 인입 로그 없이 에러가 나서 당혹스러울 수는 있지만 잘못된 변수가 들어와서 문제를 일으킬 우려를 아애 차단하는 방법이다.

가장 유명한 어노테이션 모듈은 javax.validation.constraints 가 제공하는 어노테이션인데 요즘에는 jetbrain이나 롬복에서도 비슷한 기능의 어노테이션이 있는 듯하다(비슷한 이름도 많아서 import 할 때 잘 봐야 한다).

springboot2.3 이후부터는 javax 모듈을 포함하던 어노테이션 검증자가 'org.springframework.boot:spring-boot-starter-web' 에서 제외되어 별도의 dependency가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

javax.validation.constraints 에서 제공하는 문자열 검증에 많이 쓰이는 세 가지 어노테이션(@NotNull, @NotEmpty, @NotBlank)과 롬복에서 제공하는 @NonNull 어노테이션을 같이 비교해본다. 

  null "" (empty string) "    " 허용 타입
@NotNull invalid valid valid all
@NotEmpty invalid invalid valid CharSequence
Collection
Map
Array
@NotBlank invalid invalid invalid(trimmed length > 0) CharSequence
(String)
@NonNull invalid valid valid  

위 표에서 간단하게 명시하긴 했지만, @NotNull, @NotEmpty, @NotBlank 와 @NonNull은 사실 쓰임이 다르다.

@NotNull, @NotEmpty, @NotBlank가 허용된 타입의 변수에 대한 확인이라면, @NonNull은 null-check 로직을 자동으로 생성해주는 애노테이션이다. 

생성자, 메소드 특정 파라미터에 @NonNull이 달려있으면 롬복은 자동적으로 해당 파라미터에 대한 null check 코드를 생성한다. 이때 null check 코드는 메서드나 생성자의 최상단에 위치한다. 또, 필드에 @NonNull이 달려있으면 해당 필드에 값을 설정하는 메서드들에도 null check 코드를 생성한다.

아래는 구현 예시이다. 

public ResponseEntity<Flux<HolderAccountSummary>> getAccountInfoSubscription(@PathVariable(value = "id") @NonNull @NotBlank String holderId){
    return ResponseEntity.ok()
            .body(queryService.getAccountInfoSubscription(holderId));
}

이렇게 짜놓으면 빌드 클래스에는 아래와 같이 null check 코드가 추가되어 있다.

public ResponseEntity<Flux<HolderAccountSummary>> getAccountInfoSubscription(@PathVariable("id") @NotBlank @NonNull String holderId) {
    if (holderId == null) {
        throw new NullPointerException("holderId is marked non-null but is null");
    } else {
        return ResponseEntity.ok().body(this.queryService.getAccountInfoSubscription(holderId));
    }
}

null check 코드가 추가되었는데도 변수 앞에 @NonNull은 왜 안 지워져 있고 그대로인지는 의문이다..

 

참고로 javax.validation.constraints 라이브러리에 null 체크 말고도 어노테이션으로 검증할 수 있는 것들이 많은데(size, positive, negative, future, email, digits, assertTrue, etc.) 그때그때 찾아보면서 사용해야겠다.


참고: https://jyami.tistory.com/55

 

@Valid 를 이용해 @RequestBody 객체 검증하기

Springboot를 이용해서 어노테이션을 이용한 validation을 하는 방법을 적으려 한다. RestController를 이용하여 @RequestBody 객체를 사용자로부터 가져올 때, 들어오는 값들을 검증할 수 있는 방법을 소개한

jyami.tistory.com

롬복 어노테이션: daleseo.com/lombok-useful-annotations

728x90
반응형
반응형

매번 보고 또 봐도 까먹고 또 까먹고 헷갈리고 당황하는 것이 이 쪽 친구들인 것 같다.

interceptor, filter, aop, servlet, servlet container, dispatcher servlet...

반복학습을 하면 할수록 점점 마스터할 수 있을 것이라 믿으며..! 

필요한 내용에 대해 간략히 정리해 본다.

 

Servlet container 란?

  • 서블렛의 생성부터 소멸까지의 life cycle을 관리
  • 요청이 올 때마다 새로운 자바 스레드 생성하고 HttpServletRequest, HttpServletResponse 두 객체를 생성
  • 톰캣/제티 등

was와 servlet container의 차이가 궁금해서 찾아봤는데 정확히는 모르겠고 was가 더 큰 개념 같다.

application server > servlet container

 

interceptor vs filter

request flow

필터란(서블릿 필터)

  • 필터는 Web Application에 등록되며, 요청 스레드가 Servlet Container에 도착하기 전에 수행(필터가 호출되고 서블릿이 호출)
  • 체인으로 구성
  • 유일하게 ServletRequest, ServletResponse의 객체를 변환할 수 있다. 주로 전역적으로 처리해야 하는 일에 사용됨
  • Springboot에서 특정 urlPattern 에만 필터를 적용하거나 세부적인 설정(우선순위, 필터 이름 등) 시에는 FilterRegistrationBean 빈을 사용하면 되고 아니면 Component로 전역 사용 가능

제공 함수

  • init() 메서드: 필터 초기화 메서드이며 서블릿 컨테이너가 생성될 때 호출
  • doFilter() 메서드: 고객의 요청이 올 때마다 해당 메서드가 호출되며 메인 로직을 이 메서드에 구현하면 됨
  • destory() 메서드: 필터 종료 메서드이며 서블릿 컨테이너가 소멸될 때 호출

사용 예

  • 오류 처리
  • 인코딩 처리
  • 보안 관련 기능
  • 데이터 압축이나 변환 기능
  • 요청이나 응답에 대한 로그
  • 권한 검사

인터셉터란

  • 인터셉터는 스프링 콘텍스트에 등록되며, 서블릿 컨테이너를 통과한 후 dispatcher servlet과 컨트롤러 호출 직전/후에 수행됨
  • 필터는 서블릿이 제공하는 기술이지만 스프링 인터셉터는 스프링 MVC가 제공하는 기술
  • 스프링 컨텍스트 내에 존재하기 때문에 모든 Bean 객체에 접근 가능
  • 체인으로 구성
  • 필터와 비슷하지만 호출 시점이 다르고 인터셉터가 더 많은 기능을 제공함

제공 함수

  • preHandle() 메서드: 컨트롤러 호출 전(HandlerAdapter 호출 전)에 호출. preHandle의 반환 값이 true일 경우 다음 체인으로 진행이 되고, false일 경우 더 이상 진행이 되지 않으며 핸들러 어댑터 또한 호출이 되지 않음
  • postHandle() 메서드: 컨트롤러 호출 후(HandlerAdapter 호출 후)에 호출. 컨트롤러의 예외가 발생하면 호출되지 않음
  • afterCompletion() 메서드: 뷰가 렌더링 된 이후에 호출되며 컨트롤러의 성공/실패와 무관하게 무조건 호출됨

사용 예

  • 보안(spring security)

ContentCachingRequestWrapper

사실 이 모든 것은 애플리케이션의 incoming/outgoing 로깅을 개발하려다가 어디에다 할지, 요즘 추세나 더 좋은 건 없는지 찾아보다가 시작하였다.
필터에서 body까지 확인하기 위해서는 request body를 읽을 수 있어야 하지만 기본적으로 사용되는 HttpServletRequest 같은 경우에는 InputStream은 한 번만 읽어올 수 있고, 두 번째 읽기 시도를 하는 순간 IOException이 발생한다. 그래서 보통 wrapper를 사용하여 구현하는데, 이번에는 아래 두 wrapper를 이용해서 구현해 보았다.

ContentCachingRequestWrapper extends HttpServletRequestWrapper
ContentCachingResponseWrapper extends HttpServletResponseWrapper

주요 로직은 생략하고 request body 가져오는 부분만 보자면 아래와 같다.

//래핑 해제
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
byte[] buf = wrapper.getContentAsByteArray();  //byte로 본문 가져오기
String body = new String(buf, 0, buf.length, "UTF-8"); //byte to string

response body도 동일한 로직으로 사용하면 된다.

우선 필터를 통해 HttpServletResponse, HttpServletRequest 클래스로 들어온 request와 response를 ContentCachingRequestWrapper와 ContentCachingResponseWrapper로 래핑 해주어야 한다.

왜냐하면 HttpServletRequest 그대로 request.getReader 함수를 호출하거나 안에 있는 데이터를 읽으려고 하면, 단 한 번만 읽을 수 있도록 톰캣에서 만들어두었기 때문에 이걸 다시 읽을 수 있는 클래스로 래핑해주어야 하기 때문이다.

Response도 동일하게, 안에 있는 Body값을 한번만 읽을 수 있게 해 두었기 때문에 필터로 다시 읽을 수 있는 클래스로 래핑 하지 않으면 사용자가 response값을 받지 못하는 참사가 일어날 수 있다.

wrappingResponse.copyBodyToResponse(); 이 부분이 핵심이다. 이걸 통해 body값을 copy 해서 캐시로 저장해 두기 때문에 다시 읽을 수 있다.

https://hirlawldo.tistory.com/44


++ 기존에 web-flux(reactor 기반)만 사용하다가 spring-web을 추가하면서 servlet 기반으로 변경 시

implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-webflux"

아래와 같이 두 종류의 api가 있다.

/// mono리턴 -> controller 밖에 단에서 block이 걸려 body 내용물 추출 가능
@GetMapping
public Mono<BaseResponse> getConfig() {
...
    return Mono.just(new BaseResponse(result));
}

/// 이미 block되어 내용물이 있는 상태에서 return
@GetMapping("/configuration")
public BaseResponse<MoneyExchangeConfigurationResponse> configuration() {
...
    return new BaseResponse<>(moneyExchangeService.getConfiguration());
}

아래와 같이 필터를 걸다간 mono 쪽에서 아무 응답을 받지 못하는 경우가 생긴다.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
	...
    ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
    ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response);

    chain.doFilter(requestWrapper, responseWrapper); ///
    ...
}

mono 쪽에서 되려면 reponseWrapper -> reponse로 바꾸면 되긴 하는데..

이러면 body log가 안 남는 문제가 있다.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
	...
    ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
    ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response);

    chain.doFilter(requestWrapper, response); ///
    ...
}

 

이리저리 구글링 해봐도 mono에 대한 body logging은 webFilter를 사용하라고 밖에 안 나오고

여러 방식을 잘 모르고 혼용하는 것은 후에 관리하기 힘들 듯하여 우선은 mono를 들어내고 block 방식으로 할까 한다..


참고: https://www.sollabs.tech/ContentCachingRequestWrapper

 

ContentCachingRequestWrapper

Sollabs Main Page

www.sollabs.tech

 

차이 설명 with 코드: https://velog.io/@ansalstmd/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EB%8B%A4%EC%96%91%ED%95%9C-%EA%B8%B0%EB%8A%A5-5.-Spring-Boot-Filter%EC%99%80-Interceptor

 

스프링부트 다양한 기능 5. Spring Boot Filter와 Interceptor

Filter란 Web Application에서 관리되는 영역으로써 Spring Boot Framework에서 Client로 부터 오는 요청/응답에 대해서 최초/최종 단계의 위치에 존재하며, 이를 통해서 요청/응답의 정보를 변경하거나, Spring

velog.io

 

로깅 예시 https://vkuzel.com/log-requests-and-responses-including-body-in-spring-boot

728x90
반응형
반응형

이전 글: 2022.02.08 - [서버 세팅, tool 사용법/postman] - [test] environment variable setting & snippets

 

[test] environment variable setting & snippets

이전 글: 2022.02.08 - [서버 세팅, tool 사용법/postman] - [test] before you dive into api testing [test] before you dive into api testing 포스트맨이란 API 개발을 보다 빠르고 쉽게 구현 할 수 있도록..

bangpurin.tistory.com

환경: 포스트맨 Version 9.12.1

목표: collection, pre-request script, tests 를 활용하여 chaining test를 진행해본다.

콜렉션에 GET/POST api를 저장해놓은 후 GET api로 정보를 불러와서 불러온 값을 POST api 실어 보내본다.

 

0. 환경변수 세팅(필요 시)

테스트로 name = purin 값을 넣어놨다.

 

1. 콜랙션에 api 세팅

 

2. GET api의 test script 작성

pm.test("정상 요청일 때 변수 저장", function () {
    if(pm.response.to.have.status(200)){ //정상 처리 되었을 경우
        var jsonData = pm.response.json(); //결과를 json으로 만들고
        var username = jsonData[0].username; //결과json의 첫번째 덩어리의 username을 변수화
        var phone = jsonData[1].phone;      //결과json의 두번째 덩어리의 phone을 변수화
        console.log("check username: " + username) //혹시 몰라 로깅 

        pm.collectionVariables.set("username", username); //콜랙션 변수로 지정(이름, 값)
        pm.collectionVariables.set("phone", phone);       //콜랙션 변수로 지정(이름, 값)
    }else{
        console.log("this got error?")  //정상 처리 되지 않았을 경우 로그 남김
    }
});

username과 phone이란 값을 결과 값에서 가져와 저장하는 것을 알 수 있다.

실행시켰을 때 정상적으로 되는 것 확인.

참고로 console.log는 어디에 남냐면 포스트맨의 메뉴표시줄에 view > show postman console에서 확인 가능하다.

클릭하면 새로운 창이 뜨며 로그가 쌓이는 것을 볼 수 있다.

 

3. POST api의 스크립트 작성

우선 body 부분에 저장하고하는 json을 작성한다. 이 때 변수는 {{중괄호}} 처리한다.

[
    {
        "id": 11,
        "name": "{{name}}",
        "username": "{{username}}",
        "email": "test",
        "address": {
            "street": "seoul",
            "suite": "Apt. 556",
            "city": "Gwenborough",
            "zipcode": "92998",
            "geo": {
                "lat": "-37.3159",
                "lng": "81.1496"
            }
        },
        "phone": "{{phone}}",
        "website": "hildegard.org",
        "company": {
            "name": "Romaguera-Crona",
            "catchPhrase": "Multi-layered client-server neural-net",
            "bs": "harness real-time e-markets"
        }
    }
]

테스트 코드를 작성해보자. 간략하게 세 가지 테스트 코드를 작성한다.

pm.test("request 변수 확인", function () {
    var req = JSON.parse(pm.request.body.raw); //request body(보내는 요청) 값을 json으로 변환
    console.log(req)       //로깅
    pm.expect(req[0].username).to.eql("Bret"); //보내는 요청의 첫번째 덩어리의 username이 Bret이랑 완전히 같은지 확인
});
pm.test("Successful POST request", function () { //상태 코드 확인
    pm.expect(pm.response.code).to.be.oneOf([201, 202]);
});
pm.test("Status code name has string", function () { //상태 코드 문자열 확인
    pm.response.to.have.status("Created");
});

위와 같이 작성하고 send를 누르면 name = purin(환경 변수) / username = Bret(콜렉션 변수) / phone (콜렉션 변수) 가 자동으로 들어가며 테스트가 성공한다. 왜 일까? 

중괄호된 변수는 포스트맨이 알아서 변수를 찾아서 넣어주기 때문에 따로 관여할 필요가 없다.

하지만 만약 변수에 약간의 수정을 가미해야 한다면?

pre-request script에서 진행하면 된다(오른쪽에 snippet을 활용하여 코드를 작성하면 더 쉽다).

pm.environment.set("username", pm.collectionVariables.get("username") + " 방지");

콜렉션 변수에서 username을 꺼내와서 뒤에 스트링을 붙이고 환경 변수의 username으로 세팅하였다. 즉, username이라는 이름의 변수가 환경 변수에도, 콜렉션 변수에도 있게 된다. 이렇게 되면 {{username}} 은 어떤 값을 가져가게 될까?

이전 글에 변수와 그 범위가 중요하다고 언급한 적이 있는데, 여기서 그 진가가 발휘된다.

아래 그림처럼 global < collection < env < data < local 순으로 힘이 세기 때문에, 환경 변수의 값을 가져가게 된다.

postman variable scope

 

이 상태로 테스트 코드를 돌리면 실패한다. 왜냐면 username이 Bret인지 확인하도록 짰는데 환경변수의 username이 그게 아니기 때문이다.

 

다시 성공시키려면 환경변수의 username을 지우면 된다. clear 함수가 이럴 때 사용되는 것이다.

 

4. collection 을 활용한 순차적인 테스팅

collection에서 run collection을 클릭하면 collection에 속하는 api들을 여러 설정에 맞게 테스트 할 수 있다.

  • api 왼쪽의 석삼자를 클릭하여 순서를 맞춰주고,
  • 오른쪽 섹션에서 전체 테스트를 몇 번 진행할지(iterations), api 사이 간격은 얼마로 할 것인지(delay) 등을 설정할 수 있으며,
  • 테스트 결과를 파일로 내려 받거나 변수 설정을 유지할 것인지 등도 선택할 수 있다.

설정 후 run을 누르면 테스트 결과가 나온다.

 

이를 활용하면 다수의 api 테스트를 한 번에 진행할 수 있다!

728x90
반응형
반응형

이전 글: 2022.02.08 - [서버 세팅 & tool/postman] - [test] environment variable setting & snippets

 

[test] environment variable setting & snippets

이전 글: 2022.02.08 - [서버 세팅, tool 사용법/postman] - [test] before you dive into api testing [test] before you dive into api testing 포스트맨이란 API 개발을 보다 빠르고 쉽게 구현 할 수 있도록..

bangpurin.tistory.com

환경: 포스트맨 Version 9.12.1

포스트맨으로 response의 json format이 맞는지 검사할 수 있는데, 아래와 같이 진행하면 된다.

 

1. 포스트맨으로 api 요청을 하고 결과 json을 복사해 둔다.

 

2. 다음 사이트에 접속한다: https://techbrij.com/brijpad/#json

 

BrijPad 2.0: Online Tool for Web Development & Data Analysis

 

techbrij.com

Json탭을 선택한 후 왼쪽 칸에 1번에서 복사해 둔 json을 붙여 넣기 하고 json to schema버튼을 누른다(min/full 아무거나 무관).

해당 사이트는 json을 분석하여 기본적인 json의 구조를 분석해준다. 타입이 무엇인지(array, object 등), 각 항목이 필수 값인지(우선 json의 모든 값이 필수라고 지정되지만 손으로 수정하면 된다) 어떤 타입인지(string, number 등)를 기본적으로 알려주며 maxItem, maxLength, pattern 등 다양하게 지정 가능하지만 위 사이트에서는 거기까지는 해주지 않고 필요할 경우 직접 수정하면 된다.

참고 json schema 세부 설정 값: https://json-schema.org/understanding-json-schema/index.html

 

Understanding JSON Schema — Understanding JSON Schema 2020-12 documentation

Understanding JSON Schema JSON Schema is a powerful tool for validating the structure of JSON data. However, learning to use it by reading its specification is like learning to drive a car by looking at its blueprints. You don’t need to know how an elect

json-schema.org

우선 기본 테스트이기에 오른쪽의 스키마를 복사하여 진행해보도록 한다.

 

3. 포스트맨 테스트에 스키마 검증 로직 적용

스키마 검사를 위해서는 ajv라는 라이브러리(링크: 공식사이트)가 필요한데, 포스트맨에서는 별도의 설치 없이 사용 가능하다.

검증하고자 하는 api의 테스트 탭에 다음과 같이 작성한다.

var Ajv = require('ajv');  //lib 불러오고
var ajv = new Ajv({logger: console}); //ajv 객체 생성하고(옵션없이 생성 가능하며 옵션은 json포맷이어야 함)
var schema = 
     {
        "items": {
            "required": [
                "id",
                "name",
                "username",
                "email",
                "address",
                "phone",
                "website",
                "company"
            ],
            "properties": {
                "id": {
                    "$id": "#/items/properties/id",
                    "type": "integer"
                },
                "name": {
                    "$id": "#/items/properties/name",
                    "type": "string"
                },
                "username": {
                    "$id": "#/items/properties/username",
                    "type": "string"
                },
                "email": {
                    "$id": "#/items/properties/email",
                    "type": "string"
                },
                "address": {
                    "required": [
                        "street",
                        "suite",
                        "city",
                        "zipcode",
                        "geo"
                    ],
                    "properties": {
                        "street": {
                            "$id": "#/items/properties/address/properties/street",
                            "type": "string"
                        },
                        "suite": {
                            "$id": "#/items/properties/address/properties/suite",
                            "type": "string"
                        },
                        "city": {
                            "$id": "#/items/properties/address/properties/city",
                            "type": "string"
                        },
                        "zipcode": {
                            "$id": "#/items/properties/address/properties/zipcode",
                            "type": "string"
                        },
                        "geo": {
                            "required": [
                                "lat",
                                "lng"
                            ],
                            "properties": {
                                "lat": {
                                    "$id": "#/items/properties/address/properties/geo/properties/lat",
                                    "type": "string"
                                },
                                "lng": {
                                    "$id": "#/items/properties/address/properties/geo/properties/lng",
                                    "type": "string"
                                }
                            },
                            "$id": "#/items/properties/address/properties/geo",
                            "type": "object"
                        }
                    },
                    "$id": "#/items/properties/address",
                    "type": "object"
                },
                "phone": {
                    "$id": "#/items/properties/phone",
                    "type": "string"
                },
                "website": {
                    "$id": "#/items/properties/website",
                    "type": "string"
                },
                "company": {
                    "required": [
                        "name",
                        "catchPhrase",
                        "bs"
                    ],
                    "properties": {
                        "name": {
                            "$id": "#/items/properties/company/properties/name",
                            "type": "string"
                        },
                        "catchPhrase": {
                            "$id": "#/items/properties/company/properties/catchPhrase",
                            "type": "string"
                        },
                        "bs": {
                            "$id": "#/items/properties/company/properties/bs",
                            "type": "string"
                        }
                    },
                    "$id": "#/items/properties/company",
                    "type": "object"
                }
            },
            "$id": "#/items",
            "type": "object"
        },
        "$id": "http://example.org/root.json#",
        "type": "array",
        "definitions": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
    };

변수들이 준비되었으니 테스트 코드를 짜 보자면 아래와 같다.

pm.test('Schema is valid', function() {
    var data = pm.response.json();   //결과 값을 json으로 변환하여 data에 담고
    pm.expect(ajv.validate(schema, data)).to.be.true; //검증 값이 true(참)인지 확인
    //ajv.validate(schema, data) : ajv라이브러리의 validate함수를 이용하여 schema가 data랑 맞는지 검증
});

 

test results 섹션에 테스트 이름과 테스트 통과 여부가 잘 나오는 것을 알 수 있다.

형태가 바뀌어서는 안되는 api를 검증할 경우(특히 외부에 공개해야 하는 api 일 경우 api doc과 일치하는지 확인할 때) 유용하게 쓰일 테스트이다.

728x90
반응형
반응형

이전 글: 2022.02.08 - [서버 세팅, tool 사용법/postman] - [test] before you dive into api testing

 

[test] before you dive into api testing

포스트맨이란 API 개발을 보다 빠르고 쉽게 구현 할 수 있도록 도와주며, 개발된 API를 테스트하여 문서화 또는 공유 할 수 있도록 도와주는 플랫폼이다. 포스트맨으로는 api 요청을 하고 관련 스

bangpurin.tistory.com

환경: 포스트맨 Version 9.12.1

사전지식: 본문의 내용을 따라하기 전, 아래 사이트를 확인하여 각 변수에 대한 범위와 활용성에 대한 이해를 해야한다.

https://learning.postman.com/docs/sending-requests/variables/

 

Home

Postman Network Browse APIs, workspaces, and collections inside Postman. Explore Postman →

learning.postman.com

 

이제 api testing을 위한 세팅을 진행한다.

collection에 api-testing 이라는 콜랙션을 생성하고 api를 저장하였다.

이번 테스트에서는 free fake api 제공하는 jsonplaceholder의 샘플 api를 사용한다.

//GET or POST
https://jsonplaceholder.typicode.com/users

 

1. 환경변수 생성

url이 반복되니 url을 환경변수로 저장한다.

url을 환경변수로 생성하면 서버별로 콜랙션을 따로 만들 필요 없이 환경변수의 값만 바꿔주면 된다(ex. 개발서버/qa서버별로 콜랙션을 나눌 필요가 없고 환경변수에 값만 변경하여 돌릴 수 있다).

오른쪽 상단에 있는 눈 마크에서 설정할 수 있다. environment 와 global이 있는데 environment는 환경에 따라 전환하여 쓰는 값이고 global은 환경에 상관없이 공통으로 쓸 값이다. url은 environment와 성격이 더 맞기 때문에 environment의 add를 눌러 저장한다.

아래와 같이 환경에 맞게 url/기타 변수 등을 입력하고 환경변수의 이름을 지정한 후 save를 누른다.

그리고 다시 api를 쏘는 공간으로 돌아와서 주소 값을 방금 설정한 변수로 세팅해준다. 중괄호 두 개를 사용하면 변수를 불러올 수 있다.

여기서 처음에 잘 안될 수 있는데, 이는 환경 지정하지 않았기 때문이다. 오른쪽 상단에서 올바른 환경변수(real-server)를 선택해주자.

 

2. snippets 스크립스 설명

snippet을 활용한 기본 테스트를 해본다.

처음 보이는 부분은 변수 가져오기/저장하기/비우기에 관한 부분이다. 직접적인 테스팅은 아니고 테스트 스크립트에서 필요할 때 불러다가 사용하면 된다.

맨 위의 sene a request와 response body: convert xml body to a json을 제외한 나머지는 모두 테스팅 코드이다. 눌러보면 아래와 같은 형태를 하고 있음을 알 수 있다.

pm.test("테스트 이름/설명", function () {
  //어떻게 테스트 할지 로직
  pm.expect(); //테스트 기대값 확인하는 함수
});

 pm이라는 라이브러리에서 test라는 함수를 사용하고 있다. 첫 번째 변수는 테스트 이름이고 두 번째 매개변수인 함수가 실제 로직이 들어갈 곳이다. 보통 expect 함수를 써서 결과 값을 확인하게 되어 있다.

snippet에서 볼 수 있는 함수들은 아래와 같다.

pm.test("Status code is 200", function () {
    pm.response.to.have.status(200); //status 가 200을 가지고 있는지
});
pm.test("Body matches string", function () {
    pm.expect(pm.response.text()).to.include("Bret"); //결과 어딘가에 Bret 이라는 단어를 포함하고(include) 있는지
    //pm.response.text() : 결과를 string화 함
});
pm.test("Your test name", function () {
    var jsonData = pm.response.json();   //결과 값을 json으로 만들고
    pm.expect(jsonData[0].id).to.eql(1); //array의 첫 번째 덩어리의 id 값이 1인지 확인
    //jsonData[0].id : 코드에서 대부분의 것은 0부터 시작이라 0이 첫 번째이고 결과 값이 object가 아닌 array여서 index([n])를 넣어줘야함
});
pm.test("Body is correct", function () {
    pm.response.to.have.body('[{"id":1}]'); //결과가 이것과 완전히 동일한지 확인
});
pm.test("Content-Type is present", function () {
    pm.response.to.have.header("Content-Type"); //특정 헤더가 있는지 확인
});
pm.test("Response time is less than 200ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(200); //응답시간이 200ms 이하인지 확인
});
pm.test("Successful POST request", function () {
    pm.expect(pm.response.code).to.be.oneOf([201, 200]); //결과 http status code가 둘 중 하나인지 확인
});
pm.test("Status code name has string", function () {
    pm.response.to.have.status("OK"); //결과 http status code가 숫자가 아니라 문자로 올 때(대소문자 구분함에 주의)
});

/////

var schema = {
    "items": {
        "type": "boolean"
    }
};

var data1 = [true, false];
var data2 = [true, 123];

pm.test('Schema is valid', function () {
    pm.expect(tv4.validate(data1, schema)).to.be.true; //data1의 틀이 schema와 맞는지 확인
    pm.expect(tv4.validate(data2, schema)).to.be.false; //data2의 틀이 schema와 맞는지 확인
});

기본 snippet을 통하여 값 결과 값 검사, 상태 확인, 결과 format 확인(schema) 등이 가능하다.

schema에 대해서는 별도의 글로 설명한다.

2022.02.08 - [서버 세팅, tool 사용법/postman] - [test] schema validation

 

앞으로 자주 쓸 문법은 아무래도 결과 값을 json화 하여 변수를 다루는 부분일 것이다. 또한 eql과 have/include가 의미적으로 다르기에 헷갈리면 안된다.

앞.to.have.뒤       //앞 항목이 뒤를 가지고 있는지(명확)
앞.to.include.뒤    //앞 항목이 뒤를 포함하는지(존재성)
앞.to.not.have.뒤   //부정할 때 not의 위치 중요(to 다음; include 등에서도 활용가능)

앞.to.eql(뒤)     //앞이 뒤랑 값이 같은지 확인(데이터 값 확인)

pm.response...   // 여기 안에 결과가 들어있고
var jsonData = pm.response.json();   //결과 값을 json으로 만드는 부분; json화 해야 데이터를 가져오기가 쉽다.

 

다음 글에서 샘플 테스팅을 진행한다.


참고

pm object 설명: https://learning.postman.com/docs/writing-scripts/script-references/postman-sandbox-api-reference/#the-pm-object

 

Home

Postman Network Browse APIs, workspaces, and collections inside Postman. Explore Postman →

learning.postman.com

 

포스트맨 테스트 원문: https://learning.postman.com/docs/writing-scripts/intro-to-scripts/

 

Home

Postman Network Browse APIs, workspaces, and collections inside Postman. Explore Postman →

learning.postman.com

728x90
반응형
반응형

포스트맨이란 API 개발을 보다 빠르고 쉽게 구현 할 수 있도록 도와주며, 개발된 API를 테스트하여 문서화 또는 공유 할 수 있도록 도와주는 플랫폼이다. 포스트맨으로는 api 요청을 하고 관련 스크립트를 저장해서 export/import 할 수 있을 뿐만 아니라, 다양한 환경/변수 등에서 테스트를 하고 그 결과를 분석하고, 소스코드와 연동하여 ci를 가능하게 한다. 

what you can do with postman from the perspective of api testing

포스트맨의 다양한 기능이 있지만 당분간 테스트 작성법에 대해 초점을 두고 알아보려고 한다.

앞으로 작성할 글을 읽기 전 전제사항은 다음과 같다.

  • 포스트맨으로 api 요청을 해 본 적이 있다.
  • 포스트맨의 다양한 기능을 사용해 본 적이 있다.
  • 포스트맨의 용어들과 친숙하다.

포스트맨과 친숙하지 않은 사람들을 위해 간략히 아래 참고 링크를 공유한다.

특히 포스트맨의 collection에 대해 알아둬야 할 필요가 있다.

포스트맨은 api를 관리하기 위한 묶음으로 collection을 제공하는데 이 묶음 별로 변수를 설정하고 테스트를 관리할 수 있다(또한 보기도 좋으니 일석이조 아닌가).

collection example

관련 사용법은 아래 링크로 대체한다.

https://gngsn.tistory.com/26

 

Postman, 어렵지 않게 사용하기 - 사용

안녕하세요 😆 이 번 포스팅의 주제는 서버 개발을 할 때 필수적인! POSTMAN을 '제대로' 사용하는 법에 대해 다룰 예정입니다. 서버뿐만 아니라, 클라이언트를 개발하는 분들도 도움이 되는 내용

gngsn.tistory.com

https://m.blog.naver.com/qbxlvnf11/222467061082

 

웹, 서버, API 테스트에 유용한 툴 POSTMAN 사용법

웹, 서버, API 개발 테스트 툴 포스트맨 "예비 개발자" 이번 포스팅에서는 Postman이라는 A...

blog.naver.com

https://kellis.tistory.com/17

 

[Postman 사용법] 1. 환경 설정 (Workspace와 Collection)

이 장에서는 Postman의 Workspace, Collection 그리고 작업한 Workspace를 다른 사람과 공유하는 방법에 대해 알아보겠습니다. Workspace란? Team Workspace 생성 Collection 이란? Collection 생성 Sharing Concl..

kellis.tistory.com


 

이제 테스트에 주로 사용할 화면을 맛보기로 만나본다.

 

1. pre-request script

콜렉션에 여러 api가 있고 collection runner을 통해 순차적으로 테스팅 할 때, 

두 번째 request가 첫 번째 request의 응답 값일 때, 

그래서 첫 번째 응답의 값을 test 에서 변수로 세팅해두고, 두 번째 요청을 하기 전 불러오는 작업이 필요할 때,

그럴 때 필요한 것이 pre-request script 화면이다.

위 그림에서 name이란 변수는 사전에 collection variable로 세팅이 된 것이고,

요청 시 필요한 name_detail값은 요청 전 pre-request script 화면에서 사전에 저장된 name을 불러와 가공되어 전달되는 것을 볼 수 있다.

 

2. test script

 

api를 요청하는 곳에 tests 라는 탭이 있고, 이곳에서 해당 api에 관한 테스트 스크립트를 작성할 수 있다.

해당 탭을 누르면 빈 칸이 보이고 이곳에 스크립트를 작성하면 되는데, 해당 스크립트는 자바스크립트 기반이며, 포스트맨이 제공하는 pm라이브러리를 활용하여 작성하면 된다.

여기서 좋은 점은 오른쪽에 snippets라고 자주 사용하는 코드를 바로 불러서 변형해서 사용할 수 있게 했는데, 코드를 다 외우지 않고 필요할 때마다 조합해서 사용할 수 있도록 해두었다.

또한 위 사진처럼 코드를 손으로 칠 경우 활용가능한 코드들의 리스트가 바로바로 나와 타 ide처럼 작성이 용이하다는 장점이 있다.

(이는 위에서 본 pre-request script에서도 동일하다).


포스트맨을 사용할 때 추천하는 사항은

  1.  계정 로그인을 해서 api 를 관리하기
    • 로그인을 하지 않으면 pc에 임시 저장되고 언제든지 히스토리가 날아갈 우려가 있음
    • 로그인을 하면 관련 히스토리/정보가 계정에 묶이게 되고 어디서 든 로그인만하면 테스트를 할 수 있는 환경이 됨
  2. collection/workspace 적극 활용
    • collection에 등록된 전체 api를 한번에 테스트 가능
    • 수 백건에 해당하는 api를 일회성으로 테스트하고 끝내지말고 할 때마다 제대로 정리하면 다음에 재활용하기가 쉬워짐

 

다음 글부터 본격적으로 알아본다!


참고

https://www.guru99.com/postman-tutorial.html

 

Postman Tutorial: How to Install and use Postman for API Testing

Postman tutorial for beginners: Learn What is Postman? Step by step guide on How to Download and Install POSTMAN, and Test API using Postman.

www.guru99.com

 

728x90
반응형
반응형

이전글: 2022.02.04 - [개발/spring] - [retry] spring-retry for auto retry

 

[retry] spring-retry for auto retry

spring-retry? exception이 난 작업에 대해 자동으로 재시도 해주는 스프링 라이브러리이다. 일시적인 connection error 등에 유용하게 사용할 수 있다. 환경: springboot2.6.2 implementation 'org.springframewo..

bangpurin.tistory.com

 

저번 시간에는 실전 코딩을 해보았다면 이번에는 spring-retry 관련 테스트 코드를 작성해본다.

가. 어노테이션 이용 - @SpringBootTest

1. retry

우선 아래는 service 코드 이다.

@Retryable(
        value = RuntimeException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 3000)
)
public HolderAccountSummary getHolderAccountSummary(String holderID) {
    log.debug(">>> getHolder : {} ", holderID);
    return repository.findByHolderId(holderID)
            .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
}

테스트 코드

@Test
void retryTest_with_bean(){
    //when
    Mockito.when(accountRepository.findByHolderId(ArgumentMatchers.anyString())).thenThrow(RuntimeException.class);
    assertThrows(RuntimeException.class, () -> retryService.getHolderAccountSummary(ArgumentMatchers.anyString()));

    //then
    Mockito.verify(accountRepository, Mockito.times(3)).findByHolderId(ArgumentMatchers.anyString());
}

>> 성공; 3번 재시도해도 실패가 나서 최종적으로 RuntimeException이 난다.

 

2. recovery

역시 service 코드 -> 테스트 코드 순이다.

@Retryable(
        value = RuntimeException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 3000)
)
public HolderAccountSummary getHolderAccountSummary(String holderID) {
    log.debug(">>> getHolder : {} ", holderID);
    return repository.findByHolderId(holderID)
            .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
}

@Recover
private HolderAccountSummary recover(RuntimeException e, String holderID) {
    return HolderAccountSummary.builder()
            .name("recovery")
            .build();
}
@Test
void retry_with_bean(){
    //when
    Mockito.when(accountRepository.findByHolderId(ArgumentMatchers.anyString())).thenThrow(RuntimeException.class);
    assertThrows(RuntimeException.class, () -> retryService.getHolderAccountSummary(ArgumentMatchers.anyString()));

    //then
    Mockito.verify(accountRepository, Mockito.times(3)).findByHolderId(ArgumentMatchers.anyString());
}

@Test
void recovery_with_bean(){
    //when
    Mockito.when(accountRepository.findByHolderId(ArgumentMatchers.anyString())).thenThrow(RuntimeException.class);
    HolderAccountSummary result = retryService.getHolderAccountSummary(ArgumentMatchers.anyString());
    
    //then
    Mockito.verify(accountRepository, Mockito.times(3)).findByHolderId(ArgumentMatchers.anyString());
    Assertions.assertThat(result.getName()).isEqualTo("recovery");
}

이렇게 실행하면 recovery test는 성공하나 retry test가 실패한다.

되던게 안 된다고? 라고 당황해하며 검색을 해봤으나 도무지 말이 안되서 생각해보니..ㅎ

recovery가 생기고 나서는 3번 실패 이후 recovery함수를 타기 때문에 exception이 날 일이 없기 때문이었다. 하하하..

@Recovery 어노테이션을 주석으로 처리하고 다시 돌리면 성공한다.

 

나. retryTemplate 이용 - @SpringBootTest

0. retryConfig 생성

@Configuration
@EnableRetry
public class RetryConfig {
    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate retryTemplate = new RetryTemplate();

        FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
        fixedBackOffPolicy.setBackOffPeriod(2000l); //long type; 딜레이 시간(ms)
        retryTemplate.setBackOffPolicy(fixedBackOffPolicy);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(3); //횟수
        retryTemplate.setRetryPolicy(retryPolicy);
        retryTemplate.registerListener(new RetryListener());

        return retryTemplate;
    }

    @Slf4j
    public static class RetryListener extends RetryListenerSupport {
        @Override
        public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
            log.info(">>>> before retry");
            return super.open(context, callback);
        }

        @Override
        public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
            log.info(">>>> after retry");
        }

        @Override
        public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
            log.info(">>>> error on retry");
        }
    }
}

 

1. retry

public HolderAccountSummary getHolderAccountSummaryTemplate(String holderID) {
    log.info("retry going on");
    return retryTemplate.execute(context -> {
         return repository.findByHolderId(holderID)
                .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
    });
}

기존 테스트 코드에서 위와 같이 서비스 함수만 바꿔주고 실행하면, 아래와 같이 로그가 지나가면서 retryConfig에 설정한대로 적용된 것을 볼 수 있다.(2초 간격으로 3번 재실행, 에러날 때마다 onError 실행 등)

13:04:30.883  INFO 5638 --- [main] com.cqrs.query.service.RetryService      : retry going on
13:04:30.884  INFO 5638 --- [main] c.c.q.config.RetryConfig$RetryListener   : >>>> before retry
13:04:30.887  INFO 5638 --- [main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
13:04:32.892  INFO 5638 --- [main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
13:04:34.895  INFO 5638 --- [main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
13:04:34.895  INFO 5638 --- [main] c.c.q.config.RetryConfig$RetryListener   : >>>> after retry

 

2. recovery

가. 에서는 retry와 recovery를 완벽히 분리하여 작성하였지만, retryTemplate을 사용 할 시에는 execute 함수에 recovery context도 같이 실어 넘기는 방법으로 작성해야한다.

public HolderAccountSummary getHolderAccountSummaryTemplateAndRecovery(String holderID){
    log.info("retry going on");
    return retryTemplate.execute(context -> {
        return repository.findByHolderId(holderID)
                .orElseThrow(() -> new NoSuchElementException("소유주가 존재하지 않습니다."));
    }, recovery -> {
        log.info("recovery start");
        return HolderAccountSummary.builder()
                .name("recovery")
                .build();
    });
}

기존 recovery 테스트 코드에서 함수명만 바꿔주고 실행하면, 아래와 같이 로그가 지나가면서 3회 시도 후 recovery가 진행된 것을 알 수 있다.

14:35:32.460  INFO 7166 --- [   main] com.cqrs.query.service.RetryService      : retry going on
14:35:32.462  INFO 7166 --- [   main] c.c.q.config.RetryConfig$RetryListener   : >>>> before retry
14:35:32.465  INFO 7166 --- [   main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
14:35:34.471  INFO 7166 --- [   main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
14:35:36.474  INFO 7166 --- [   main] c.c.q.config.RetryConfig$RetryListener   : >>>> error on retry
14:35:36.475  INFO 7166 --- [   main] com.cqrs.query.service.RetryService      : recover start
14:35:36.475  INFO 7166 --- [   main] c.c.q.config.RetryConfig$RetryListener   : >>>> after retry

 

다. retryTemplate 이용 - @MockitoExtension

위 가/나 방법 모두 @SpringBootTest 어노테이션을 사용하기 때문에 테스트를 하기 위하여 모든 spring context불러와 테스트가 무겁고 느리다는 단점이 있다. retryTemplate를 사용했을 때에도 @Configuration 빈을 활용하였기 때문에 @SpringBootTest 어노테이션을 사용했는데 이를 아래와 같이 수정하면  @SpringBootTest 어노테이션을 사용하지 않고도 테스트 코드를 작성할 수 있다.

@Service
//@RequiredArgsConstructor
public class RetryService {
    private final AccountRepository repository;
    private final RetryTemplate retryTemplate = new RetryTemplate();

    public RetryService(AccountRepository repository) {
        this.repository = repository;
        FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
        fixedBackOffPolicy.setBackOffPeriod(2000l); //long type; 딜레이 시간(ms)
        retryTemplate.setBackOffPolicy(fixedBackOffPolicy);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(3); //횟수
        retryTemplate.setRetryPolicy(retryPolicy);
        retryTemplate.registerListener(new RetryConfig.RetryListener());
    }
    
...
}

기존에 retryTemplate 빈을 통해 자동 주입하던 것을 수동 주입으로 바꾸고 설정 값을 세팅한다.

@ExtendWith(MockitoExtension.class)
public class RetryServiceTest_template {
    @InjectMocks
    RetryService retryService;
    @Mock
    AccountRepository accountRepository;
    
//@SpringBootTest
//class RetryServiceTest_template {
//
//    @Autowired
//    private RetryService retryService;
//    @MockBean
//    private AccountRepository accountRepository;

테스트 코드에서도 @SpringBootTest 어노테이션을 mockito extension으로 바꾸면 전체 spring context를 불러오지 않고도 테스트가 정상적으로 실행되는 것을 볼 수 있다.

허나 이럴 경우 테스트는 가벼워졌지만, retryTemplate을 매 서비스마다 수동 주입해줘야 하기 때문에 장단점이 있다는 것을 인지해야한다(물론 서비스마다 서로 다른 설정을 해야한다면 오히려 더 나은 선택일 수도 있다).

728x90
반응형
반응형

환경: springboot2.6.2, intellij2021.2.2

junit5기반의 테스트 코드를 돌릴 때 아래와 같은 에러가 나면서 테스트가 안 돌아갈 때가 있다.

Execution failed for task ':query:test'.
> No tests found for given includes: [com.cqrs.query.service.RetryServiceTest.retryTest_with_bean](filter.includeTestsMatching)
import org.junit.jupiter.api.Test;
...

@SpringBootTest
class RetryServiceTest {

    @Autowired
    private RetryService retryService;
    @MockBean
    private AccountRepository accountRepository;

    @Test
    void retryTest_with_bean(){
        //when
        Mockito.when(accountRepository.findByHolderId(ArgumentMatchers.anyString())).thenThrow(NoSuchElementException.class);
        assertThrows(NoSuchElementException.class, () -> retryService.getHolderAccountSummary());

        //then
        Mockito.verify(accountRepository, Mockito.times(3)).findByHolderId(ArgumentMatchers.anyString());
    }
}

아래와 같이 intellij의 run test using설정이 gradle(default)로 되어 있을텐데, 저 값을 gradle -> intellij로 바꾸면 실행된다..

intellij settings

 


추가)

아래와 같이 Mockito.when().thenThrow() 로 exception 발생 시 Exception.class로 지정하면 에러가 나면서 실행이 되지 않는다.

 Mockito.when(accountRepository.findByHolderId(ArgumentMatchers.anyString())).thenThrow(Exception.class);
 -----------
 //에러
Checked exception is invalid for this method!

 

Checked Exception 이란 위 그림에서 초록색 exception 인데, 이를 unchecked exception, 즉 RunTimeException이나 그 하위 exception 으로 바꿔주면 된다.

참고) exception 구분

728x90
반응형

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

[jmh] benchmark test  (0) 2022.08.04
[powermock] mock static method  (0) 2022.07.21
[java] jvm, java, gc  (0) 2022.02.24
[keyword] transient in serialization  (0) 2022.02.16
[junit] test runner  (0) 2022.01.03

+ Recent posts