Elasticsearch 7.7 feature와 heap 메모리 사용량의 두드러진 감소량

 


줄어든 heap 사용량

Elasticsearch 사용자들은 Elasticsearch 노드에 저장이 가능한 만큼 데이터를 집어 넣지만, 가끔 disk에 저장되기 전에 heap memory 사용량이 초과되는 것을 경험한다. 이는 비용을 줄이기 위해 가능한 노드당 많은 양의 데이터를 넣고 싶은 사용자들에게 문제를 일으킨다. (실제로 현재 운영중인 es에서도 대량의 데이터 삽입 시 가끔 발생함)

 

왜 Elasticsearch에는 데이터를 저장하기 위해 heap memory 영역이 필요한걸까? 왜 디스크 공간만으로 충분하지 않은걸까?? 거기에는 여러 이유가 존재하지만 가장 중요한 이유는 루씬은 디스크 상에 데이터를 찾을 수 있는 위치를 찾아내기 위해서 일부 정보를 메모리에 저장해야 한다.

 

예를 들어 루씬의 inverted index는 terms 사전(디스크 상에 순서대로 블록 형태로 되어있는 terms group)과 terms index(terms 사전에서 빠르게 조회하기 위해 구성된)로 구성되어 있다. 이 terms index는 디스크상의 블록에 prefix starts 위치를 포함하고 있는 terms를 offset과 함께 terms의 prefix 정보로 도식화 하고 있다. 그런데 이 terms 사전은 disk 상에 존재하지만 terms index는 heap 위에서 존재한다.

 

그럼 얼마나 많은 양의 메모리가 필요로 할까? 전형적으로 인덱스 GB당 작은 MB 만큼이 필요로 한다. 이것은 많지는 않지만 사용자가 노드에 terabyte 상당의 데이터를 디스크에 사용한다면 indicies는 indices에 terabyte만큼의 데이터를 저장하기 위해서 10~20GB상당의 heap memory가 필요로 하게 된다.

 

Elasticsearch에서는 30GB이상의 힙메모리를 올리지 말라고는 하지만 종종 집계와 같은 쿼리 시 다른 consumer를 위한 공간을 남기지 않기 때문에 JVM에서 클러스터 관리 작업을 위한 공간이 충분치 않는 경우가 많아 운영에 어려움을 주는 경우가 있다.

 

실제로 기존에 6.x 버전과 7.x 초기버전의 경우에는 10TB 데이터 저장 시 17기가의 힙 메모리가 필요로 했다. 하지만 7.7버전에서는 2.5기가만 필요로 하도록 개선되었다고 한다.

 

어떻게 이게 가능해진걸까? Jvm에서 디스크로 데이터를 옮기는 구조와 메모리에서 hot bits를 유지하기 위해서 파일시스템을 사용하는 등의 기술들이 루씬 indices의 여러 컴포넌트들에게 시간이 흐름에 따라 동일하게 적용되고 있다. 그리고 이 메모리는 여전히 할당된 곳에서 내용을 읽을 수는 있지만 이 메모리에 상당한 부분은 사용사례에 따라 사용 되지 않는 경우가 많았다.

 

예를 들어 디스크상의 _id field의 terms index의 이동으로 삭제된 terms는 오직 GET API와 정확한 IDS로 document들을 인덱싱 했을 때만 사용된다. 하지만 elasticsearch로 메트릭과 로그를 인덱스하는 사용자의 대부분은 해당 기능을 사용하지 않는다. 이렇게 사용되지 않고 있는 자원들을 활용해서 heap의 사용률을 7.7버전 부터는 더 적게 heap 크기를 사용 할 수 있게 되었다.

 

그 밖에 새로운 feature

이 밖에도 검색 결과를 동기로 기다리지 않고 검색결과를 검색 시 사용한 ID를 이용해서 추후해 결과를 얻을 수 있는 async search와 aggregation시 많은 bucket을 할 당할 경우 발생할 수 있는 OOM을 피하기 위해서 주기적으로 memory circuit breaker를 bucket을 추가 할당 하기 전에 체크하는 기능 등이 추가되었다.

 

 

 

 

출처 및 읽어보면 좋은 링크

 

인덱스와 샤드의 관계

https://www.elastic.co/kr/blog/how-many-shards-should-i-have-in-my-elasticsearch-cluster

How many shards should I have in my Elasticsearch cluster?

이 블로그는 여러분의 클러스터에 적합한 인덱스와 샤드의 개수와 크기를 어떻게 가져가야 하는지에 대한 실질적인 가이드라인을 제공합니다.

www.elastic.co

https://www.elastic.co/kr/blog/significantly-decrease-your-elasticsearch-heap-memory-usage

Significantly decrease your Elasticsearch heap memory usage

Fitting as much data per Elasticsearch node as possible is often important to reduce costs. Learn more about the improvements coming in Elasticsearch 7.7 to dramatically reduce the amount of heap memory needed per GB of data.

www.elastic.co

 

댓글()

모던 자바 인 액션 내용 정리

JAVA/고급 자바|2020. 4. 12. 16:46

 

포킹 

자바 8에서 추가된 스트림 api에서 데이터를 필터링, 추출, 그룹화 등의 기능을 진행할 수 있다. 이러한 동작들을 병렬화 할 수 있어 여러 cpu에서 작업을 분산해서 처리할 수 있다. 이런 작업을 포킹 단계라고 한다.

 

 

함수형 인터페이스

- 하나의 추상메서드를 가지고 있는 함수형 인터페이스지만 상속을 받은 인터페이스는 추상메서드를 하나만 가지고 있다고 하여도 함수형 인터페이스가 아니다.

- 디폴트 메소드가 아무리 많아도 추상 메소드가 하나이면 함수형 인터페이스이다.

- @FunctionalInterfeace 애노테이션을 붙이면 함수형 인터페이스가 아닌 경우 컴파일 에러를 발생 시킬 수 있다.

 

 

람다에서 지역변수를 final로 제약하는 이유 

람다에서 지역변수가 final로 사용되는지 궁금한데 이는 인터페이스 변수는 힙에 저장되고 지역변수는 스택에 저장되는 이유가 대표적이다. 람다에서 지역변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수가 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 그렇기 때문에 람다 내부에서 사용하는 값은 복사본으로써 그 값은 변경되면 안된다는 제약이 존재한다. (그래서  무조건 final로 제약하는 것)

 

 

predicate에는 여러 and, or, negete등이 사용이 가능하다.

and와 or로 predicate를 연결 할 수 있고 negete로 반대의 값을 가져올수도 있다.

 

List<String> dish = Arrays.asList("banana", "pizza", "chicken");

Predicate<String> isBanana = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("banana");
	}
};

Predicate<String> isChicken = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("chicken");
	}
};

Predicate<String> isNotPizza = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return !s.equals("pizza");
	}
};

// predicate or
System.out.println(dish.stream().filter(isBanana.or(isChicken)).collect(Collectors.joining(", ")));

// predicate and
System.out.println(dish.stream().filter(isBanana.and(isChicken)).collect(Collectors.joining(", ")));

// predicate negate
System.out.println(dish.stream().filter(isBanana.negate()).collect(Collectors.joining(", ")));

 

 

Map의 문제 FlatMap으로 해결

- 문제상황

["hello", "world"] 두개의 단어에서 알파벳 중복된 걸 빼고 하나의 알파벳 배열로 합쳐 보자. 구하고자 하는 결과물은 다음과 같다. ["H", "e", "l", "o", "W", "r", "d"]

 

- Map으로 진행

List<String> str = Arrays.asList("hello", "world");

str.stream()
    .map(data -> data.split(""))
    .distinct()
    .collect(Collectors.toList());

이렇게 하면 각 문자열에서 문자로 쪼개고 거기서 distinct작업을 한뒤 합쳐서 성공적으로 하나의 list안에 중복된 문자가 없을 것 같지만 실제로는 ["hello"], ["world"]가 노출 된다. 

 

왜냐하면 실제로 동작하는은 다음과 같이 진행되기 때문이다.

hello → ["h","e","l","l","o"] (split하면 문자열 배열로 반환) 
world → ["w", "o", "r", "l", "d"] (split하면 문자열 배열로 반환) 

최종
→ distinct 대상이 string[] 결국 두개가 합쳐져 Stream<String[]>이 된다.

그럼 이를 해결 하기 위해서 map에서 생성된 배열이 dinstinct로 넘어갈 때 각 값을 다른 스트림으로 만든 다음 모든 스트림을 하나의 스트림으로 연결해야 하는데 이를 flatMap으로 해결이 가능하다.

 

List<String> str = Arrays.asList("hello", "world");
        System.out.println(str.stream()
            .map(data -> data.split(""))
            .flatMap(Arrays::stream)
            .distinct()
            .collect(Collectors.toList()));

map으로 반환된 Stream<String[]>에서 각 String[]을 Stream으로 변형한 후 하나의 stream으로 합쳐서 distinct를 진행하면 정상적인 결과가 도출된다.

 

flatmap은 각 요소를 별도의 스트림으로 만든 후 다시 합쳐준다. 또한 flatMap은 스트림을 받아 다른 스트림으로 변경해주는 역할을 한다.

예를 들어 (1, 2, 3) 배열과 (4, 5) 배열이 있을 때 이 두개를 합쳐서 (1, 4), (1, 5), (2, 4), (2, 5), (3, 4), (3,5) 이렇게 묶고 싶으면 다음과 같이 flatMap을 사용하여 두개를 합칠 수 있다.

List<Integer> data1 = Arrays.asList(1, 2, 3);
List<Integer> data2 = Arrays.asList(4, 5);
data1.stream()
	.flatMap(
		data -> data2.stream().map(j -> new int[] {data, j})
	)
	.collect(Collectors.toList());

만약 map(data → data.2stream().map(j → new int[] {data, })로 사용했으면 결국 최종적으로 Stream<Stream<int[]>>가 만들어지는 아쉬운 결과가 나올 수 있다.

 

 

findFirst와 findAny 차이

findFirst와 findAny는 같은 결과를 가져오지만 병렬 스트림에서는 findFirst로 첫 번째 요소를 가져오는게 어렵기 때문에 그런 경우에는 findAny를 사용한다.

 

 

parallel stream과 fork/join

parallel을 사용하여 fork/join 병렬을 진행 할 수 있다. 병렬 스트림은 기본적으로 ForkJoinPool을 사용하는데 이는 프로세스 수 즉 Runtime.getRuntime().availableProcessor()가 반환하는 값에 상응하는 스레드를 갖는다. 이 fork/join의 스레드 수는 조절이 가능하다.

 

 

Optional Serialize

Optional의 경우에 serialize를 구현하지 않았기 때문에 이는 직렬화 될 수 없다.

 

 

옳지 못한 상속 차단

코드 양이 많고 동작이 변경 되기를 원치 않는 클래스의 경우 final로 선언 또는 private 생성자를 지정한다. 이렇게 되어 있는 클래스의 기능을 이용하기 위해서는 델리게이션 즉 멤버 변수로 이용해서 클래스에서 필요한 메서드를 직접 호출하는 메서드를 작성하는 것이 좋다.

 

 

다중 상속 상황에서 동일한 이름의 메소드 우선순위

1. 클래스가 무조건 이긴다.

2. 서브 인터페이스가 이긴다. 예를 들어 B 클래스가 A 클래스를 상속받는다면 B 클래스가 무조건 A 클래스를 이긴다.

3. 이래도 불명확한 경우에는 여러 인터페이스를 상속받은 클래스에서 명시적으로 받고자 하는 곳을 지정할 수 있다.

 

 

CompletableFuture와 리액티브 프로그래밍 컨셉

값이 있을 때는 onNext, 도중에 에러가 발생했을 때는 onError, 값을 다 소진했거나 에러가 발생해서 더 이상 처리할 데이터가 없을 때 onComplete 등 각각의 콜백이 호출 된다. 이런 이벤트는 보통 순서의 개의치 않는다.

 

CompletableFuture 클래스의 join는 Future 인터페이스의 get 메소드와 동일한 의미로써 작업이 끝날 때 까지 기다린다. 다만 join은 어떠한 예외도 발생시키지 않는다는 차이점이 존재한다.

 

publisher는 subscriber가 자신을 구독하는 경우 최초로 onSubscribe를 호출하여 SubScription 객체를 전달한다. 이 Subscription 인터페이스는 publisher에게 이벤트 요청을 알리는 request 메소드가 있고 더 이상 받지 않겠다고 하는 cancel이 존대한다. 

 

publisher는 반드시 Subscription의 request 메소드에 정의된 개수 이하의 요소만 Subscriber에게 전달 할 수 있다. 해당 요청을 받은 publisher는 subscriber에게 onNext를 통해 여러번에 통해 데이터를 전달 할 수 있고 전달된 값에 따라 onComplete, onError를 통해 publisher에게 데이터 전달 성공 유무를 전달 할 수 있다. 이 대화는 publisher가 subscriber에게 전달한 subscription을 통해서 이루어진다.

 

 

 

 

 

책 전체가 조금 재미없게 구성되어 있다. 그래도 정리할 수 있어 좋았다.

댓글()

Too many dynamic script compilations 에러

elasticsearch를 사용하여 개발을 하다보면 스크립트를 사용하는 경우가 굉장히 많이 발생한다. 나는 아무생각없이 스크립트를 만들어서 사용했는데 어느날 운영에 반영을 하는데 본적이 없던 에러를 발견했다.

 

java.lang.AssertionError: 
Expecting code not to raise a throwable but caught
  <"ElasticsearchStatusException[Elasticsearch exception [type=search_phase_execution_exception, reason=all shards failed]]; nested: ElasticsearchException[Elasticsearch exception [type=circuit_breaking_exception, reason=[script] Too many dynamic script compilations within, max: [75/5m]; please use indexed, or scripts with parameters instead; this limit can be changed by the [script.max_compilations_rate] setting]];

스크립트 컴파일 에러가 발생했다고 하는데 왜 발생한 것인지... 몰라 검색해봤다.

 

 

에러 원인

elasticsearch는 기본적으로 컴파일하여 사용할 수 있는 스크립트 수를 제한을 한다고 한다. 그 제한의 기본값은 75/5m rate 즉 5분동안 75개의 스크립트만 컴파일하여 사용이 가능하다고 한다.

 

그래서 그 이상의 스크립트를 컴파일 하려고 할 시 Elasticsearch에서 out of meory 방지를 위해 circuit을 열어버린다. 

https://www.elastic.co/guide/en/elasticsearch/reference/current/circuit-breaker.html

 

근데 나는 스크립트를 그 정도로 많이 만들어서 사용하지는 않았는데 어떻게 그럴수가 있을까 하고 있던 찰나에 같이 일하는 동료분께서 내가 짠 스크립트에서 문제가 있다고 확인해주셨다.

 

바로 데이터를 스크립트에 사용되는 유동적인 데이터 중 일부가 param의 형태로 들어가지 않고 스크립트를 만들 때 동적으로 들어가게 해놨던 것이었다....

 

예를 들면 다음과 같이 스크립트를 매번 만들었던 것이었다. ㅜㅜ 그래서 당연히 초당 요청이 엄청 많은 우리 서비스에서 75요청은 그냥 넘어갔고 그 결과 서킷이 열려버려서 elasticsearch에서 400에러를 내뱉었다.

for (int i = 0; i <= 76; i++) {
	testRepository.search(QueryBuilders.scriptQuery(new Script(ScriptType.INLINE, "painless", "return 0 <= " + i + ";", Collections.emptyMap())));
}

아뿔싸 나 때문에 오전부터 고생했던 배포를 못하게 되었다 ㅜㅜ

 

 

해결방법

이 문제를 해결하기 위한 방식은 2가지가 있다.

 

 

먼저 유동적으로 들어오는 데이터로인해 스크립트가 계속 새로 컴파일 되지 못하도록 유동성 데이터는 param으로 넘겨서 사용하는 방식이다. 나 또한 이 방식으로 데이터를 바꿨다. 위의 예시 기준으로 다음과 같이 변경하였다.

for (int i = 0; i <= 76; i++) {
	testRepository.search(QueryBuilders.scriptQuery(new Script(ScriptType.INLINE, "painless", "return 0 <= params[i];", Collections.emptyMap("i", i))));
}

 

 

그리고 또다른 방식으로는 실제로 스크립트가 많이 컴파일 되어야 할 때는 상황에 맞게 그 rate를 조절해야 할 수도 있다. 이때는 다음과 같이 변경해주면된다.

// dsl
PUT http://localhost:9200/_cluster/settings
{
  "transient": {
    "script.max_compilations_rate": "150/1m"
}

// java (rest high level client)
public void changeScriptMaxCompileRate(String rate) {

	ClusterUpdateSettingsRequest request = new ClusterUpdateSettingsRequest();
	request.transientSettings(ImmutableMap.of(
		"script.max_compilations_rate", rate
	));

	restHighLevelClient.cluster().putSettings(
		request, RequestOptions.DEFAULT
	);
}

 

하지만 이와 관련된 이슈에서 다음과 같은 문구를 찾았다. 

The best solution is actually to not to increase the limit. If a test suite breaks the amount of compilations allowed, it will absolutely blow up in any serious environments.

The best solution is to figure out which painless script(s) are always recompiling, and parameterize them instead. I've had that happen on a few occasions, and I just needed to move some literal values out of the script and into params. Check out this PR: https://github.com/elastic/beats/pull/9308/files#diff-759f580883147ab049f76cd3501ec965R32

 

무조건 limit를 늘리는건 방식이 아니고 재 컴파일 되지 않도록 수정하는게 옳은 방식이라고 한다.

물론 나 때문에 문제가 발생하였지만 몰랐고 놓쳤던 부분을 알게 되어서 값비싼 수업을 들은 기분이 들었다.

댓글()

Spring boot2 resilience4j를 이용한 circuit breaker 사용

web/Spring|2020. 2. 23. 21:37

fault tolerance library (장애 허용 시스템)


fault tolerance library는 무엇인가? 간단하게 이야기해보자. MSA 환경에서 한 개의 서비스에서 다른 api를 호출 할 때 일시적으로 에러가 발생하고 있다고 가정해보자. 만약 이 시기에 요청이 계속 들어오면 계속 500에러를 내보내게 된다. 그럼 사용자들은 이 서비스에 대해서 신뢰를 잃어 버리게 되고 안좋은 인식을 만들 수 있다.

그래서 특정 api 호출과 같은 작업에 에러가 발생했을 때, 그 횟수를 정해놓고 그 횟수 이상 에러를 초과하면 기존에 설정해 놓은 fallback에 맞게 동작하게 하고 일정 시간 후에 다시 시도하여 진행하는 등에 작업이 필요하다. 이게 바로 fault tolerance library (장애 허용 시스템) 이다.

요새 같이 msa로 동작하는 환경이 많아지면서 이에 대한 작업이 많이 필요해졌다.

 

 

circuit breaker


circuit breaker는 fault tolerance library 시스템에서 사용되는 대표적인 패턴으로써 서비스에서 타 서비스 호출 시 에러가 계속 발생하게 되면 circuit를 열어서 메시지가 다른 서비슬 전파되지 못하도록 막고 미리 정의해 놓은 fallback response를 보내어 서비스 장애가 전파되지 않도록 하는 패턴이다.

 

 

resilience4j 


그럼 스프링 부트에서 어떻게 사용하면 될까? 그래서 라이브러리를 알아보다가 Hystrix가 유명하다는 걸 알게 되었다. 이 라이브러리는 netflix에서 만들어서 spring에 기본 라이브러리로 사용되었으나, 넷플릭스에서 더 이상 추가 개발 하지 않고 유지보수만 하겠다고 발표하였으며 resilience4j를 사용하기를 권고했다.

기본적으로 Resilience4j는 Ring Bit Buffer라는 곳에 결과를 저장하게 되는데 성공 여부에 따라 0(실패) 또는 1(성공)로 저장한다. 해당 buffer에 크기는 조정이 가능하다.

그래서 resilience4j를 사용해 보기로 했다.

spring boot2, webflux에서 필요한 라이브러리는 다음과 같다.

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
    annotationProcessor group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

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

    //Resilience4J
    compile("io.github.resilience4j:resilience4j-spring-boot2:1.3.0")
    compile("io.github.resilience4j:resilience4j-reactor:1.3.0")
    compile("io.github.resilience4j:resilience4j-timelimiter:1.3.0")

    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'io.projectreactor:reactor-test'
}

 

resilience4j에서 사용할 수 있는 여러 옵션이 있는데 대표적인 옵션은 다음과 같다.

resilience4j:
  circuitbreaker:
    backends:
      wedul:
        ringBufferSizeInClosedState: 30
        ringBufferSizeInHalfOpenState: 30
        waitDurationInOpenState: 5000ms
        failureRateThreshold: 20
        registerHealthIndicator: false
옵션명 설명
ringBufferSizeInClosedState Returns the ring buffer size for the circuit breaker while in closed state.
Circuit이 닫혀있을 때(정상) Ring Buffer 사이즈, 기본값은 100
ringBufferSizedHalfOpenState Returns the ring buffer size for the circuit breaker while in half open state.
half-open 상태일 때 RingBuffer 사이즈 기본값은 10
waitDurationInOpenState Returns the wait duration the CircuitBreaker will stay open, before it switches to half closed
half closed전에 circuitBreaker가 open 되기 전에 기다리는 기간
failureRateThreshold Returns the failure rate threshold for the circuit breaker as percentage.
Circuit 열지 말지 결정하는 실패 threshold 퍼센테이지

 

 

Circuit Open Test


그럼 실제로 실패가 발생하였을 때 circuit이 열리고 fallback이 정상적으로 전달되고 정해진 시간내에 다시 시도하여 정상을 돌아오는지 테스트 해보자.

우선 간단하게 테스트하기 위해서 Ring Buffer 사이즈와 failureRateThreshold 수를 줄여보자.

resilience4j:
  circuitbreaker:
    backends:
      wedul:
        ringBufferSizeInClosedState: 10
        ringBufferSizeInHalfOpenState: 30
        waitDurationInOpenState: 10000ms
        failureRateThreshold: 20
        registerHealthIndicator: false

위에 설정대로라면 실패가 10개의 ringBuffer 20Percent 이상 발생하였을 때  10초동안 fallback 메시지를 보내고 api가 정상적을 돌아오면 정상적으로 돌아오는지 테스트 해보자.

우선 CircuitBreaker를 생성한다. circuitName은 yml에서 설정했던 이름과 동일하게 하면 기본 설정이 Override되어서 지정된다.

@Configuration
public class WedulConfig {

    private static final String CIRCUIT_NAME = "wedul";

    @Bean
    public io.github.resilience4j.circuitbreaker.CircuitBreaker circuitBreaker(CircuitBreakerRegistry registry) {
        return registry.circuitBreaker(CIRCUIT_NAME);
    }

}

 

그다음 webClient에 해당 Circuit Breaker를 사용하도록 지정하고 특정 api를 찌르도록 한다.

return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(HttpClient.create()
                .tcpConfiguration(tcpClient ->
                        tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, properties.getConnetTimeout())
                    )
            ))
            .uriBuilderFactory(new DefaultUriBuilderFactory(
                UriComponentsBuilder
                    .newInstance()
                    .scheme("http")
                    .host(properties.getUrl())
                    .port(properties.getPort())))
            .build()
            .get()
            .uri(uriBuilder -> uriBuilder.path("/result")
                .queryParam("name", request.getName())
                .queryParam("price", request.getPrice())
                .build()
            )
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .bodyToMono(WedulResponse.class)
            .doOnError(error -> log.error("에러 발생!!!"))
            .transform(CircuitBreakerOperator.of(circuitBreaker))
            .timeout(Duration.ofMillis(properties.getTimeout()));

 

특정 api는 name을 파라미터로 받는데 이름이 wedul이 아니면 RuntimeException을 발생시키도록 하고 wedul이면 isExist를 true로 반환하도록 한다.

// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {

    private final WedulService wedulService;

    @GetMapping("/test")
    public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
        return wedulService.circuitTest(wedulRequest)
            .map(ResponseEntity::ok);
    }

}


// 서비스
@Slf4j
@Service
public class ResultService {

    public Mono<WedulResponse> result(WedulRequest request) {
        if (!request.getName().equals("wedul")) {
            throw new RuntimeException("error");
        }

        return Mono.just(WedulResponse.builder().isExist(true).build());
    }

}

 

그리고 해당 기능을 호출할 간단한 api를 만든다,

// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {

    private final WedulService wedulService;

    @GetMapping("/test")
    public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
        return wedulService.circuitTest(wedulRequest)
            .map(ResponseEntity::ok);
    }

}

// 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class WedulService {

    private final WedulClient wedulClient;

    public Mono<WedulResponse> circuitTest(WedulRequest request) {
        return wedulClient.isWedulExist(request);
    }

}

 

도식을 간단하게 그리면 다음과 같이 circuit/test 엔드포인트로 들어온 요청을 Circuit Breaker가 설정되어있는 webClient를 사용해서 /result api를 찔러 결과를 리턴 받는다. 이때 에러가 지정한 percent이상 ring buffer에 발생하였을 때 circuit 스위치가 열리는지 확인해보면 된다.

 

먼저 10번 실행 중 3개 에러 발생 시켜서 에러 퍼센트를 만들어주면 다음 요청에 switch가 열리는지 확인해보자. 우선 10개 요청을 실패 7개 성공 3개를 나눠서 실행시켜보자.

curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:20.235+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"fdc3768a"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:22.995+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"284c6869"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.105+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"b890c0c6"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.720+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"5bae49c7"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:25.408+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"e7121964"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:26.125+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"c500e446"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.052+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"a5f1f79c"}%

아직 까지는 circuit이 열리지 않았다. 그럼 11번째 요청부터는 circuit이 열리는지 확인해보자. 

➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.987+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"0a553bba"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:29.524+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"6fb4e23a"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:30.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"36235d1f"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:31.392+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"41a434df"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:32.371+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51661969"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:33.443+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"55be1c74"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:34.416+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"34150e67"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:35.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51a9b6a6"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:36.523+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"8ac98f8d"}%

정상적으로 지정해놓은 10초동안 circuit이 열려있는 걸 확인 할 수있다.

그리고 10초 뒤에 다시 실행시켜보면 정상적으로 circuit이 다시 닫혀서 요청을 실행하는걸 볼 수 있다.

➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%

 

 

다른 옵션으로 retry, bulkhead등에 동작이 있는데 상황에 따라 지정해서 사용해보면 될 것 같다. 테스트에 사용했던 소스는 git에 있다.

https://github.com/weduls/circuit_breaker_test

 

weduls/circuit_breaker_test

circuit_break_test. Contribute to weduls/circuit_breaker_test development by creating an account on GitHub.

github.com

참고

https://resilience4j.readme.io/docs/circuitbreaker

https://dlsrb6342.github.io/2019/06/03/Resilience4j란/

 

댓글()

spring cloud resilience4j 사용시 CircuitBreakerConfiguration 에러

web/Spring|2020. 2. 23. 20:32

CircuitBreaker 테스트를 위해서 Resilience4j를 사용했다.

버전은 1.3.0을 사용하려고 했다.

//Resilience4J
compile("io.github.resilience4j:resilience4j-spring-boot2:1.3.0")
compile("io.github.resilience4j:resilience4j-reactor:1.3.0")
compile("io.github.resilience4j:resilience4j-timelimiter:1.3.0")

 

그런데 분명 1.3.0을 사용한다고 명시하였고 gradle도 clean하고 사용하는 denpendency도 확인하였는데 계속해서 다음과 같이 1.1.0 라이브러리를 사용하려고 해서 문제가 발행했다.

Cannot resolve method 'of(java.util.Map<java.lang.String,io.github.resilience4j.circuitbreaker.CircuitBreakerConfig>, io.github.resilience4j.core.registry.RegistryEventConsumer<io.github.resilience4j.circuitbreaker.CircuitBreaker>, io.vavr.collection.HashMap<K,V>)'

 

그래서 알아보던 중 반갑게도 git에 해당 내용에 대한 이슈가 있었다.

이유는 spring cloud를 dependencyManamement로 선언해서 사용해서였다. spring cloud에 경우 내부적으로 1.1.0 라이브러리를 사용하고 있기 때문에 버전 충돌이 나서 그런것이었다. ㅋㅋ

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

 

에휴 한시간을 방황한 것 같다.

https://github.com/resilience4j/resilience4j/issues/596#issuecomment-582697615

댓글()

[토이프로젝트] TimeLine 2.0 개선 오픈

IT 지식/기타지식|2020. 1. 27. 21:41

설계, 운영 모든면에서 실패

토이프로젝트 TimeLine을 만들어서 오픈했었다. 

개발의 목표는 단순하게 개발 블로그, 채용등에 대해 한번에 볼 수 있는 개발자를 위한 통합 페이지를 만드는 것이었다.

https://wedul.site/626

[토이 프로젝트 소개] 개발자 채용, 기술 블로그 정보를 모아주는 TimeLine

혼자 공부를 집에서 어떻게 하면 효율적일까 고민을 많이했다. 집에서 주구장창 책을 읽고 해보면 스킬이 늘까? 그렇게 해봤지만 그게 정답은 아니었다. 남들에게는 모르겠으나 나에게는 아니었다. 회사에서 하는..

wedul.site

하지만 사용하는데 생각보다 불편한 부분이 많았고 설계부분에도 현재 시스템을 고려하지 않고 만들었던게 운영에 걸림돌이 되어버렸다. 그래서 서비스를 잠정적 중단하고 2.0을 만들어서 오픈하기로 했다.

 

 

설계 실패 요인

실패 요인 1.

- 젠킨스를 실행시키면 별도의 관리포인트가 생길것으로 우려해 batch application을 계속 실행시켰던 부분이 큰 문제였다. 내 서버는 배치, db, api모두 하나의 서버에서 돌아가는데 4기가라는 부족한 메모리 상황에서 불필요한 batch application을 실행시켜서 다른 애플리케이션에서 메모리가 많이 부족하게 만들었다. 그래서 젠킨스를 작은 메모리를 할당하여 실행시키고 batch job을 세분화하여 batch application이 계속 실행되지 않게 하였다.

 

 

 

각 모듈을 build하는 jenkins job
rss, 스크래핑 등을 진행하는 indexing job
현재 api를 교체하는 job

 

 

 

실패요인 2.

실패요인1과 같이 시스템의 리소스가 많이 부족한 상황에서 api에 호출이 늘어나면 부하가 걸렸다. 그래서 이를 조금이라도 줄여보기 위해서 api에 비동기를 붙이기로 했다. 하지만 사용하고 있는 rdb에서는 비동기가 지원을 하고 있지 않은 상황에서 어떻게 해야하지 고민했는데 마침 r2dbc mysql이 정식 release했다. 굿굿 그래서 이를 사용해서 적용했다. 실제로 드라마틱하게 api 성능이 오른건 아니지만 그래도 확실히 속도가 더 빨라졌다.

 

실패요인 3.

욕심이 생겨 batch에서 긁은 데이터를 writer를 통해 업데이트 하는게 아니라 별도 kafka에 메시지를 보내서 별도 모듈에서 업데이트를 했다. ㅋㅋ 결국 그 욕심이 적은 리소스에 또 문제를 일으켰고 애플케이션이 메모리부족으로 많이 죽었다 에휴 작은 환경에서 무리한 오버스펙은 오히려 문제를 야기한다는걸 더욱 느끼게 되었다. (심지어 필요없는 레디스도 실행시켰다.)

 

실패요인 4.

UI구성을 router, component 구분없이 하나의 컴포넌트에 구분없이 추가하여 확장성 고려도 path구분도 되지 않는 상황이었다. 이를 해결하지 못해서 결국 기능추가도 공유하기도 어려웠다. 그래서 router를 추가하고 페이지를 만들고 각 page에서 사용할 공통 component를 만들어서 편하게 가져다 쓸 수 있도록 구성했다. 그 결과 이제 페이지 하나 늘려서 컴포넌트를 추가하고 빼는건 일도 아니게 되었다. 그리고 하단의 footer를 없애고 vue-sidebar-menu 라이브러리를 사용하여 보는 페이지 뷰를 늘려주었다. 

 

 

변경된 UI

기존의 UI에는 몇 가지 문제가 있었다. 

 

UI 문제 1.

데이터를 보여주는 영역은 가지고 온 제목, 로고, 데이터등을 다 보여주는데 데이터 유형이 모두 달라서 같은 유형으로 보여주기 어려웠다. 채용의 경우 큰 문제가 되지 않았지만 개발 블로그 경우에는 컨텐츠 표시 형식이 모두 달라서 보여주는 영역마다 다 깨졌다. 그래서 채용은 기존틀을 유지하고 테크는 타이틀과 컨텐츠 대표 이미지를 사용해서 리스트를 모여줬다.

좌, 테크 (기술블로그) 영역 리스팅

 

UI 문제 2.

결국 모든 데이터를 아웃링크로 보내다보니 트래픽이 많이 빠졌다. 이를 해결하려면 내부에서 화면을 보여주는게 좋다고 생각했다. 그래서 iframe을 사용해서 화면을 보여주도록 변경했다. 하지만 iframe을 막은 사이트에 경우에는 아웃링크로 가서 볼 수 있도록 아웃링크로 가는 링크를 만들어주었다.

 

 

 

 

 

UI 문제 3.

보고 싶은 업체만 보고 싶은 경우에 볼 수 있는 방법이 없었다. 그래서 이를 해결하기 위해서 상단의 리스트 바를 만들어서 자유롭게 골라서 볼 수 있도록 구성했다.

 

 

업체 리스팅

 

 

 

 

회고

2.0을 만들겠다고 생각했을 때 나 혼자서 만들기는 어렵다고 판단해서 같이 전직장에서 일했던 친구에게 부탁했다. 친구(https://lelecoder.com/)가 마침 이직을 하기 전이라 놀고 있어서 업무를 많이 진행해줬고 그래서 더 많은 컨텐츠를 가져올 수 있었고 수집된 컨텐츠 정보와 에러 정보를 slack으로 받는 기능등을 추가 받을 수 있었다. 함께하니 더 재밌고 좋았다. 나중에 같이 일할 수 있는 기회가 또 왔으면 좋겠다.

 

 

수집한 컨텐츠 정보를 알려주는 slack과 에러 발생시 알려주는 slack

 

 

 

추가된 정보와 변경된 구조 그리고 ui가 모두 맘에 든다 많은 분들이 구경와서 더 좋은 컨텐츠를 모아서 보여줄 수 있는 서비스가 되었으면 좋겠다.

해당 프로젝트는 비영리적 목적으로 해당 코드를 가지고 수정하거나 상업적 용도로 사용하는 것을 금지한다.

http://wedul.space

 

 

새로워진 TimeLine 2.0

 

 

 

댓글()
  1. jayden 2020.01.27 23:23 댓글주소  수정/삭제  댓글쓰기

    좋은 정보네요. 타임라인 사이트 자주 찾아갈게요.

  2. byrage 2020.01.28 23:36 댓글주소  수정/삭제  댓글쓰기

    좋은서비스에 쿨한회고 조합 멋지네요. 고생하셨습니다~

  3. Favicon of https://lelecoder.com BlogIcon jayden-lee 2020.01.29 16:43 신고 댓글주소  수정/삭제  댓글쓰기

    wedul은 무슨 뜻인가요?

  4. 감자아빠 2020.01.30 14:54 댓글주소  수정/삭제  댓글쓰기

    해당 채용 정보를 다시 RSS로 제공해도 좋을것 같습니다.

Junit5 Test Container사용하여 테스트 환경 구축하기 (인프런 백기선님 강의 정리)

web/Junit|2019. 12. 26. 15:21

도커와 테스트 (TestContainers)

테스트를 위해서는 운영과 동일한 형태의 개발 환경에서 테스트 하는 것이 중요하다. 하지만 매번 동일하게 환경을 구축할 수 없고 모든 개발 자들과 같은 환경을 맞추기도 쉽지 않다.

그래서 Docker를 이용해서 테스트마다 테스트를 위한 컨테이너를 실행시켜서 테스트하고 컨테이너를 제거해주면 좋은데 그런 기능을 TestContainer를 이용해서 가능하다. 실제로 이번에 이직한 회사에서 동일하게 테스트 환경을 사용하는 것을 봤다. 막연하게 그 기능을 사용할 수 있었지만 이번 Junit5 백기선님 강의를 들어서 확실하게 정리할 수 있어서 좋았다. 역시 듣길 잘했다. 꼭 들어보길 강추한다. 그럼 그 내용을 정리해보자.

테스트 컨테이너(Test Container) 라이브러리 추가 및 설정

https://www.testcontainers.org를 보고 테스트에 필요한 모듈의 라이브러리를 추가하면 된다. 나는 gradle을 사용하여 mysql을 사용해야 하기에 다음과 같이 추가했다.

testCompile "org.testcontainers:testcontainers:1.12.4"
testCompile "org.testcontainers:junit-jupiter:1.12.4"
testCompile "org.testcontainers:mysql:1.12.4"

그리고 생성된 mysql test container에서 사용할 설정 값들에 대한 설정이 필요한대 기존 설정과는 다르게 다음과 같이 별도의 jdbc url을 적어줘야한다. 이도 testcontainer 홈페이지에 모듈 설명에 나와있다.

spring:
  datasource:
    url: jdbc:tc:mysql:5.7.22://localhost:3306/test
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver

    dbcp2:
      driver-class-name: com.mysql.jdbc.Driver
      test-on-borrow: true
      validation-query: SELECT 1
      max-total: 1

  jpa:
    show-sql: true

테스트 수행

라이브러리와 연결에 대한 설정을 모두 완료하였으면 테스트를 진행해야한다. 테스트 컨테이너를 이용해서 테스트를 진행하기 위해서는 클래스에 @TestContainers 어노테이션을 붙이고 불러들인 MysqlContainer에 어노테이션 @Container를 붙여주면 된다. 그리고 container가 테스트가 실행될 때 시작하고 종료되면 같이 끝나기 위해서 @BeforeAll과 @AfeterAll을 지정해준다.

package com.wedul.javajunit5studyjunit.docker;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.transaction.Transactional;
import static org.assertj.core.api.Assertions.*;
/**
 * java-junit5-study
 *
 * @author wedul
 * @since 2019/12/24
 **/
@ActiveProfiles("test")
@SpringBootTest
@Testcontainers
class StudentServiceTest {

    @Autowired
    StudentService studentService;

    @Container
    static MySQLContainer mariaDBContainer = new MySQLContainer();

    @Test
    @DisplayName("학생 추가하기")
    @Transactional
    void create_student_test() {
        studentService.createStudent(Student.builder()
            .studentId(2L)
            .age(10)
            .name("wedul")
            .address("seoul jamsil")
            .build()
        );
    }

    @DisplayName("student 조회 테스트")
    @Test
    @Transactional
    void find_student_test() {
        studentService.createStudent(Student.builder()
            .studentId(2L)
            .age(10)
            .name("wedul")
            .address("seoul jamsil")
            .studentNickname("duri")
            .build()
        );
        Student student = studentService.getStudent(2L);
        assertThat(student).isNotNull();
    }

}

만약 별도록 MysqlContainer처럼 지정이 되어 있지 않은 컨테이너를 올려서 테스트 하고 싶을 때는 GenericContainers를 사용하여 공식 이미지를 다운받아서 사용할수도 있다. 자세한건 강의를 참조하면 좋다.

@Container
static GenericContainer genericContainer = new GenericContainer("mysql");

Docker container 값을 스프링 value로 사용하기

서비스로 올라간 Docker container에 속성을 spring에서 사용하고 싶을때는 Application의 설정값을 읽어서 사용할수 있도록 해주는 ApplicationContextInitializer를 구현하여 값을 application에 전달하여 사용할 수 있다.

우선 ApplicaionContextInitializer를 구현하여 컨테이너 속성 값을 environment에 넘겨주고 이를 스프링 value로 꺼내서 사용하면 된다.

@ActiveProfiles("test")
@SpringBootTest
@Testcontainers
@ContextConfiguration(initializers = StudentServiceTest.ContainerPropertyInitializer.class)
class StudentServiceTest {

    @Autowired
    StudentService studentService;

    @Container
    static MySQLContainer mySQLContainer = new MySQLContainer();

    @Value("${container.databaseName}")
    private String databaseName;

    @Test
    @DisplayName("학생 추가하기")
    @Transactional
    void create_student_test() {
        System.out.println(databaseName);
        studentService.createStudent(Student.builder()
            .studentId(2L)
            .age(10)
            .name("wedul")
            .address("seoul jamsil")
            .build()
        );
    }

    @DisplayName("student 조회 테스트")
    @Test
    @Transactional
    void find_student_test() {
        studentService.createStudent(Student.builder()
            .studentId(2L)
            .age(10)
            .name("wedul")
            .address("seoul jamsil")
            .studentNickname("duri")
            .build()
        );
        Student student = studentService.getStudent(1L);
        assertThat(student).isNotNull();
    }

    static class ContainerPropertyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertyValues.of("container.databaseName=" + mySQLContainer.getDatabaseName())
                .applyTo(applicationContext.getEnvironment());
        }
    }

}

Docker Compose를 사용하여 테스트하기

매번 새로운 설정을 프로그램상이나 yml으로 정의하는건 너무 번거롭다. 그래서 생성할 컨테이너들을 한번에 기재해서 컨테이너로 올릴 때 사용하는 docker compose를 사용하면 편리하다. 간단하게 mysql 컨테이너를 올릴 yml을 만들고 테스트에서 사용해보자. docker-compose 파일을 읽어들일 때는 DockerComposeContainer를 사용해서 올리면 된다.

### docker-compose.yml 파일

version: '3.1'

services:
  maria:
    image: mariadb:latest
    restart: "always"
    ports:
    - "13306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=test
      - MYSQL_DATABASE=test
      - MYSQL_USER=wedul_dev
      - MYSQL_PASSWORD=test
// 사용방법
@Container
static DockerComposeContainer composeContainer =
    new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"));

그리고 위에서는 host에서 연결할 port 13306을 지정하였으나 실제로는 지정하지 않고 하면 랜덤포트를 자유롭게 이용하기 때문에 더 좋을 것 같다. 왜냐하면 해보니까 내가 지정한 포트가 다른 개발자 컴퓨터에서는 이미 다른 컨테이너로써 운영중일 수도 있기 때문에 랜덤 포트를 사용하게 하는게 좋은 것 같다.

ports:
- "3306"

 

근데 사용해보니 단점이 있다. container_name과 같이 몇개의 docker-compose에서 제공하는 몇개의 부가 속성들을 제대로 읽어들이지 못하고 오류를 뱉어내기도 한다. 다음과 같이 에러가 발생되어서 지웠다 ㅜㅜ

docker-compose.yml has 'container_name' property set for service 'maria' but this property is not supported by Testcontainers, consider removing it

docker-compose를 매번 테스트할때마다 레포지토리에 dev-tool에 놔뒀다가 사용했었는데 확실히 편해지고 좋을 것 같다. 잘 활용해보자.

출처 : https://www.inflearn.com/course/the-java-application-test
github : https://github.com/weduls/junit5

댓글()

Spring Junit5 test Mockito (백기선님 인프런 강의)

web/Junit|2019. 12. 23. 21:28

mockito는 실제 객체와 비슷하게 동작하도록 하여 검증할 수 있는 방법을 제공해주는 라이브러리 이다. 

spring-boot-starter-test 모듈에 기본적으로 포함되어 있으며, 이 모듈을 사용하지 않을 경우 mockito-core, mockito-junit-jupiter 모듈을 추가하면 된다.

 

Mock 객체 만들기

Mock 객체를 만들어서 테스트를 진행할 수 있다. Mock객체로 만들고 싶은 객체에 @Mock 어노테이션을 달기만 하면 되는데 이때 만들어진 Mock 객체는 Null이기 때문에 그렇게 하지 않기 위해서 @ExtendWith(MockitoExtension.class)를 추가한다.

@ExtendWith(MockitoExtension.class)
class MockWedulTest {

    @Mock
    WedulRepository wedulRepository;

    WedulService wedulService;

    @BeforeEach
    void setup() {
        this.wedulService = new WedulService(wedulRepository);
    }

    @Test
    @DisplayName("Mock test")
    void mock_test() {

    }

}

모든 Mock 객체의 반환 타입은 다음과 같다.

  • 객체는 Null
  • Option 타입은 Optional.empty 리턴
  • Primitive 타입은 기본 Primitive 값
  • 콜렉션은 비워있는 콜렉션
  • void 반환값의 메소드는 아무런 일이 발생되지 않는다.

 

Stubbing

 

Mock 객체에  원하는 동작을 미리 지정해주는 것을 stub라고 하는데 이를 한번 수행해보자.

여러가지 stub 있겠지만 대표적으로 when을 많이 사용한다.

@Test
@DisplayName("Mock test")
void mock_test() {
	Wedul wedul = new Wedul();

	when(wedulRepository.getWedul(anyLong())).thenReturn(wedul);
	assertThat(wedulService.getWedul(1L)).isEqualTo(wedul);
}

 

Stubbing 확인

Mock 객체의 특정 행위가 몇번 호출되었는지, 추가적으로 interaction이 발생되었는지 여부등도 확인이 가능하다.

@Test
@DisplayName("stubbing verify 테스트")
void verify_stub_test() {
	Wedul wedul = new Wedul();
    when(wedulRepository.getWedul(anyLong())).thenReturn(wedul);
    assertThat(wedulRepository.getWedul(1L)).isEqualTo(wedul);

	// 목 객체의 getWedul()이 한번 실행되었는지 검증
    verify(wedulRepository, times(1)).getWedul(1L);
    // 목 객체 validate()가 한번도 안 실행되었는지 검증
    verify(wedulRepository, never()).validate();
    // 해당 Mock이 더 이상 interactiondl 발생되지 않아야 한다.
    verifyNoMoreInteractions(wedulRepository);
}

 

BDD Mockito

BDD(Behaviour-Driven Development)는 행동 기반 테스트인데 Mockito에서 제공하는 기능들을 이용하면 Given / When / Then 순서대로 검증이 가능하다.

- when이라는 subbing 메서드와 동일한 역할을 하는 given은 BDD를 위해서 when 대신 Given으로 사용한다.

- then을 통해서 검증이 가능하다.

@Test
@DisplayName("BDD 테스트")
void bdd() {	
	// given
    Wedul wedul = new Wedul();
    given(wedulRepository.getWedul(1L)).willReturn(wedul);

	// when
    Wedul selectWedul = wedulService.getWedul(1L);

	// then
    assertThat(selectWedul).isEqualTo(wedul);
    then(wedulRepository).should(times(1)).getWedul(1L);
    then(wedulRepository).shouldHaveNoMoreInteractions();
}

 

출처 : https://www.inflearn.com/course/the-java-application-test/

Github : https://github.com/weduls/junit5

댓글()