Microservice Architecture of Spring

TDD 가 개발자에게 좋은 이유

여러 이유가 있지만 가장 중요한 점은 TDD로 요구사항에 대해 더 깊이 있게 생각할 수 있다는 점입니다. 테스트 코드를 먼저 작성하면 특정 상황에서 코드가 어떻게 동작할지 생각하게 됩니다. 이런 과정에서 애매한 요구사항은 명확히 하고, 유효하지 않은 요구사항은 거부할 수 있습니다.

곱셈 하는 예제 프로그램을 만들어 보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Multiplication {
private int factorA;
private int factorB;

private int result;

public Multiplication(int factorA, int factorB) {
this.factorA = factorA;
this.factorB = factorB;
this.result = factorA * factorB;
}

public int getFactorA() {
return factorA;
}

public int getFactorB() {
return factorB;
}

public int getResult() {
return result;
}

@Override
public String toString() {
return "Multiplication{" +
"factorA=" + factorA +
", factorB=" + factorB +
", result(A*B)=" + result +
'}';
}
}
1
2
3
4
5
6
7
8
9
public interface MultiplicationService {
/**
* 두 개의 무작위 인수를 담은 {@link Multiplication} 객체를 생성한다.
* 무작위로 생성되는 숫자의 범위는 11~99
*
* @return 무작위 인수를 담은 {@link Multiplication} 객체
*/
Multiplication createRandomMultiplication();
}
1
2
3
4
5
6
public interface RandomGeneratorService {
/**
* @return 무작위로 만든 11 이상 99 이하의 인수
*/
int generateRandomFactor();
}

곱셈을 계산하는 서비스 (MultiplicationService) 내에서 숫자를 무작위로 생성한다면 테스트를 작성하기 어려워지기 때문에 RandomGeneratorService 인터페이스를 두어서 숫자 생성 부분만 따로 만드는 것입니다.

TDD 를 따르기 위해서 테스트 코드를 먼저 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@SpringBootTest
class MultiplicationServiceTest {
@MockBean
private RandomGeneratorService randomGeneratorService;

@Autowired
private MultiplicationService multiplicationService;

@Test
public void createRandomMultiplicationTest() {
// given (randomGeneratorService가 처음에 50, 나중에 30을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);

// when
Multiplication multiplication = multiplicationService.createRandomMultiplication();

// assert
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}

이 테스트에서 중요한건 @MockBean 입니다. 이 어노테이션은 스프링이 RandomGeneratorService 인터페이스에 맞는 구현 클래스를 찾아서 주입하는 대신 Mock 객체를 주입하는 것을 의미한다.

아직 MultiplicationService 의 구현체를 만들지 않았기 때문에 테스트 결과는 실패합니다. 이게 바로 TDD의 요점입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@SpringBootTest
class MultiplicationServiceTest {
@MockBean
private RandomGeneratorService randomGeneratorService;

@Autowired
private MultiplicationService multiplicationService;

@Test
public void createRandomMultiplicationTest() {
// given (randomGeneratorService가 처음에 50, 나중에 30을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);

// when
Multiplication multiplication = multiplicationService.createRandomMultiplication();

// assert
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}

TDD의 이점을 정리해보자

  • 테스트를 작성하면서 요구사항을 코드로 바꿀 수 있습니다. 요구사항을 살펴보면서 필요한 것과 필요하지 않은 것에 대해 생각해볼 수 있습니다. 지금까진 우리의 첫 번째 요구사항인 무작위로 곱셈을 생성하는 서비스만 있으면 됩니다.

  • 테스트 가능한 코드를 만들게 됩니다. 구현하는 클래스를 먼저 작성하게 된다면 무작위 생성 로직을 그 안에 넣었을 가능성이 높고 그렇게 되면 그 이후에 테스트하기가 굉장히 어려워진다. 테스트 코드를 미리 작성한 덕에 테스트 코드를 더 작성하기 쉬운 구조로 구성할 수 있습니다.

  • 중요한 로직에 초점을 맞추고 나머지는 나중에 구현할 수가 있습니다. 무작위 숫자 생성을 개발하고 테스트할 때 RandomGeneratorService 의 구현체를 작성할 필요가 없습니다.

@SpringBootTest 를 남용하지 말자.

SpringRunner 와 @SpringBootTest 어노테이션은 애플리케이션 컨텍스트를 초기화하고 필요한 객체를 주입합니다. 다행이 컨텍스트는 캐싱이 되어 재사용이 가능해 테스트 당 한 번만 로딩됩니다. 그러나 여기 테스트 처럼 하나의 테스트만 필요할 때는 어플리케이션 컨텍스트를 초기화해서 사용하는 것 보다 그냥 해당 객체를 직접 구현해서 테스트하는 것이 보다 효율적입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RandomGeneratorServiceImplTest {
private RandomGeneratorServiceImpl randomGeneratorServiceImpl;

@Before
public void setUp() {
randomGeneratorServiceImpl = new RandomGeneratorServiceImpl();
}

@Test
public void generateRandomFactorIsBetweenExpectedLimits() throws Exception {
// 무작위 숫자를 생성
List<Integer> randomFactors = IntStream.range(0, 1000)
.map(i -> randomGeneratorServiceImpl.generateRandomFactor())
.boxed()
.collect(Collectors.toList());

// 적당히 어려운 계산을 만들기 위해
// 생성한 인수가 11~99 범위에 있는지 확인
assertThat(randomFactors).containsOnlyElementsOf(IntStream.range(11, 100)
.boxed().collect(Collectors.toList()));
}
}

RandomGeneratorServiceImpl 은 어플리케이션 컨텍스트를 불러오지 않고 바로 객체를 구현해서 테스트를 진행하였다. 위 테스트가 통과될 수 있도록 실제 소스 코드를 작성하자.

1
2
3
4
5
6
7
8
9
public class RandomGeneratorServiceImpl implements RandomGeneratorService {
final static int MINIMUM_FACTOR = 11;
final static int MAXIMUM_FACTOR = 99;

@Override
public int generateRandomFactor() {
return new Random().nextInt((MAXIMUM_FACTOR - MINIMUM_FACTOR) + 1) + MINIMUM_FACTOR;
}
}

MultiplicationServiceImplTest 에도 위처럼 어플리케이션 컨텍스트를 사용하지 않는 방식으로 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MultiplicationServiceImplTest {
private MultiplicationServiceImpl multiplicationServiceImpl;

@Mock
private RandomGeneratorService randomGeneratorService;

@Before
public void setUp() {
// 목 객체를 초기화합니다.
MockitoAnnotations.initMocks(this);
multiplicationServiceImpl = new MultiplicationServiceImpl(randomGeneratorService);
}

@Test
public void createRandomMultiplicationTest() {
// given (목 객체가 처음에 50, 나중에 30 을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);

// when
Multiplication multiplication = multiplicationServiceImpl.createRandomMultiplication();

// assert
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}

@MockBean 대신 @Mock 을 사용해 목 객체를 생성하였다. 어플리케이션 컨텍스트를 띄우지 않으니 빈을 사용하지 않기 때문이다.

도메인은 설계하자


  • Multiplication : 곱셈의 인수와 연산을 포함

  • User : 곱셈 문제를 푸는 사용자를 식별

  • MultiplicationResultAttempt : Multiplication 과 User 의 참조를 포함하고 사용자가 제출한 값과 채점 결과를 포함.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 애플리케이션에서 곱셈을 나타내는 클래스 (a * b)
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class Multiplication {
// 두 인수
private final int factorA;
private final int factorB;

// JSON (역)직렬화를 위한 빈 생성자
Multiplication() {
this(0, 0);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 사용자 정보를 저장하는 클래스
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public final class User {
private final String alias;

// JSON (역)직렬화를 위한 빈 생성자
protected User() {
alias = null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class MultiplicationResultAttempt {
private final User user;
private final Multiplication multiplication;
private final int resultAttempt;

// JSON (역)직렬화를 위한 빈 생성자
MultiplicationResultAttempt() {
user = null;
multiplication = null;
resultAttempt = -1;
}
}

이제 필요한 객체들의 책임 (= 비즈니스 로직) 을 생각해 봅시다. 요구사항을 고려해보면 앞으로 구현해야할 책임들은 아래와 같습니다.


  • 제출한 답안의 정답 여부 확인

  • 적당히 어려운 곱셈 만들어내기


‘제출한 답안의 정답 여부 확인’ 책임을 MultiplicationService 인터페이스에 넣고, 해당 책임을 테스트하는 코드를 작성합니다.

1
2
3
4
5
6
7
8
public interface MultiplicationService {
// ...

/**
* @return 곱셈 계산 결과가 맞으면 true, 아니면 false
*/
boolean checkAttempt(final MultiplicationResultAttempt resultAttempt);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class MultiplicationServiceImplTest {
// ...

@Test
public void checkCorrectAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3000);

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isTrue();
}

@Test
public void checkWrongAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3010);

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isFalse();
}
}

checkAttempt() 가 원하는 동작을 하도록 구현합시다

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class MultiplicationServiceImpl implements MultiplicationService {

// ...

@Override
public boolean checkAttempt(MultiplicationResultAttempt resultAttempt) {
return resultAttempt.getResultAttempt() ==
resultAttempt.getMultiplication().getFactorA() *
resultAttempt.getMultiplication().getFactorB();
}
}

이제 REST API 를 만들어 봅시다 만들어야 할 API 는 아래와 같습니다.


  • GET /multiplications/random : 무작위로 생성한 곱셈을 반환

  • POST /results/ : 결과를 전송하는 엔드포인트

  • GET /results?user=[user_alias] : 특정 사용자의 계산 결과를 검색


1
2
3
4
5
6
7
8
9
@RestController
public class MultiplicationController {
private final MultiplicationService multiplicationService;

@Autowired
public MultiplicationController(final MultiplicationService multiplicationService) {
this.multiplicationService = multiplicationService;
}
}

TDD 에 맞추어서 우선 컨트롤러 관련 테스트 코드를 먼저 작성하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@RunWith(SpringRunner.class)
@WebMvcTest(MultiplicationController.class)
public class MultiplicationControllerTest {
@MockBean
private MultiplicationService multiplicationService;

@Autowired
private MockMvc mvc;

// 이 객체는 initFields() 메서드를 이용해 자동으로 초기화
private JacksonTester<Multiplication> json;

@Before
public void setUp() {
JacksonTester.initFields(this, new ObjectMapper());
}

@Test
public void getRandomMultiplicationTest() throws Exception {
// given
given(multiplicationService.createRandomMultiplication())
.willReturn(new Multiplication(70, 20));

// when
MockHttpServletResponse response = mvc.perform(
get("/multiplication/random")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();

// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString())
.isEqualTo(json.write(new Multiplication(70, 20)).getJson());
}
}

아직 소스 코드 구현을 하지 않았기 때문에 테스트를 돌리면 실패할 것이다. 위 테스트 코드를 간단하게 살펴보면

  • @WebMvcTest 는 @SpringBootTest 어노테이션과 동일하게 스프링 웹 어플리케이션 컨텍스트를 초기화합니다. 그러나 다른 점은 오직 MVC 레이어 (컨트롤러) 와 관련된 설정만 불러옵니다. 이 어노테이션은 MockMvc 빈도 불러옵니다.

  • JacksonTester 객체를 사용해 JSON 의 내용을 쉽게 확인할 수 있습니다. JacksonTester 객체는 자동으로 설정할 수 있고 @JsonTest 어노테이션을 이용해 자동으로 주입할 수 있습니다. 예제에서는 @WebMvcTest 어노테이션을 사용하기 때문에 수동으로 설정해야 합니다. (@Before 메서드 안에서 설정하고 있습니다)

@WebMvcTest 와 @SpringBootTest 의 차이

@WebMvcTest 는 컨트롤러를 테스트하는 어노테이션입니다. HTTP 요청과 응답은 Mock 을 이용해 가짜로 이뤄지고 실제 연결은 생성되지 않습니다. 반면 @SpringBootTest 는 웹 어플리케이션 컨텍스트와 설정을 모두 불러와서 실제 웹 서버 연결을 시도합니다. 이런 경우에는 MockMvc 가 아니라 RestTemplate 혹은 TestRestTemplate 을 대신 사용하면 된다.

보통 @WebMvcTest 는 서버에서 컨트롤러만 테스트할 때 사용하고, @SpringBootTest 는 클라이언트부터 상호작용을 확인하는 통합 테스트에서 사용하는 것이 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 곱셈 어플리케이션의 REST API 를 구현한 클래스
*/
@RestController
public class MultiplicationController {

// ...

@GetMapping("/random")
Multiplication getRandomMultiplication() {
return multiplicationService.createRandomMultiplication();
}
}

MultiplicationResultAttemptController 구현 전 테스트 코드를 작성하자. 사용자가 보낸 답안이 맞을 경우와 틀릴 경우를 모두 테스트한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@RunWith(SpringRunner.class)
@WebMvcTest(MultiplicationResultAttemptController.class)
public class MultiplicationResultAttemptControllerTest {
@MockBean
private MultiplicationService multiplicationService;

@Autowired
private MockMvc mvc;

private JacksonTester<MultiplicationResultAttempt> jsonResult;
private JacksonTester<ResultResponse> jsonResponse;

@Before
public void setUp() {
JacksonTester.initFields(this, new ObjectMapper());
}

@Test
public void postResultReturnCorrect() throws Exception {
genericParameterizedTest(true);
}

@Test
public void postResultReturnNotCorrect() throws Exception {
genericParameterizedTest(false);
}

void genericParameterizedTest(final boolean correct) throws Exception {
// given (지금 서비스를 테스트하는 것이 아님)
given(multiplicationService
.checkAttempt(any(MultiplicationResultAttempt.class)))
.willReturn(correct);
User user = new User("john");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3500);

// when
MockHttpServletResponse response = mvc.perform(
post("/results").contentType(MediaType.APPLICATION_JSON)
.content(jsonResult.write(attempt).getJson()))
.andReturn().getResponse();

// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonResponse.write(new ResultResponse(correct)).getJson());
}
}

비슷한 기능을 하는 부분을 뽑아서 genericParameterizedTest 메서드를 만들었다. 서비스는 결과가 맞는지 아닌지를 판단하고 맞으면 true, 아니면 false 를 반환합니다. 테스트 코드 작성이 완료되면 소스를 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/results")
final class MultiplicationResultAttemptController {

// ...

@PostMapping
ResponseEntity<ResultResponse> postResult(@RequestBody MultiplicationResultAttempt multiplicationResultAttempt) {
return ResponseEntity.ok(
new ResultResponse(multiplicationService
.checkAttempt(multiplicationResultAttempt)));
}

// ...
}

REST API 까지 완성했으니, 이제 기본적인 UI를 만들어 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Multiplication v1</title>
<link rel="stylesheet" type="text/css" href="styles.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="multiplication-client.js"></script>
</head>

<body>
<div>
<h1>안녕하세요, 소셜 곱셈입니다!</h1>
<h2>오늘의 문제:</h2>
<h1>
<span class="multiplication-a"></span> x <span class="multiplication-b"></span> =
</h1>
<p>
<form id="attempt-form">
답은? <input type="text" name="result-attempt"><br>
닉네임: <input type="text" name="user-alias"><br>
<input type="submit" value="확인">
</form>
</p>
<h2><span class="result-message"></span></h2>
</div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
html, body {
height: 100%;
}

html {
display: table;
margin: auto;
}

body {
display: table-cell;
vertical-align: middle;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function updateMultiplication() {
$.ajax({
url: "http://localhost:8080/multiplications/random"
}).then(function (data) {
// 폼 비우기
$("#attempt-form").find("input[name='result-attempt']").val("");
$("#attempt-form").find("input[name='user-alias']").val("");
// 무작위 문제를 API로 가져와서 추가하기
$('.multiplication-a').empty().append(data.factorA);
$('.multiplication-b').empty().append(data.factorB);
});
}

$(document).ready(function () {

updateMultiplication();

$("#attempt-form").submit(function (event) {

// 폼 기본 제출 막기
event.preventDefault();

// 페이지에서 값 가져오기
var a = $('.multiplication-a').text();
var b = $('.multiplication-b').text();
var $form = $(this),
attempt = $form.find("input[name='result-attempt']").val(),
userAlias = $form.find("input[name='user-alias']").val();

// API 에 맞게 데이터를 조합하기
var data = {user: {alias: userAlias}, multiplication: {factorA: a, factorB: b}, resultAttempt: attempt};

// POST를 이용해서 데이터 보내기
$.ajax({
url: '/results',
type: 'POST',
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function (result) {
if (result.correct) {
$('.result-message').empty().append("정답입니다! 축하드려요!");
} else {
$('.result-message').empty().append("오답입니다! 그래도 포기하지 마세요!");
}
}
});

updateMultiplication();
});
});

화면작업 마무리 되었으면 어플리케이션을 실행해서 확인해보자.

새로운 요구사항

최근에 제출한 답안을 보고 싶어요. 그러면 시간이 지나면서 내가 얼마나 잘하고 있는지 또는 못하고 있는지 알 수 있어요.


  • MultiplicationResultAttempt 클래스의 인스턴스를 모두 저장합니다. 그렇게 하면 나중에 추출해서 사용할 수 있습니다.

  • 특정 사용자의 최근 답안을 가져오는 새로운 REST 엔드포인트를 만듭니다.

  • 답안을 검색하는 새로운 서비스(비지니스 로직)을 만듭니다.

  • 사용자가 답안을 제출하면 답안 내역을 보여주는 웹 페이지를 만듭니다.


애자일과 리팩토링

애자일 방법론에 따라 일하려면 리팩토링을 일의 일부분으로 받아들여야 한다. 프로젝트 초기 단계에서 설계에 많은 시간을 투자하는 것은 잘못됐다는 것을 의미한다.

균형을 찾는 것이 핵심이다. 실제 비지니스는 사업이고 시간과의 싸움이기 때문에 기술적으로 완벽함을 찾는 것이나, 완벽한 설계도를 그리는 것에 시간을 쏟지 말아라는 의미 같다. 리팩토링은 필수적인 것이며, 요구사항이 바뀌어 가면서 소스도 바뀌어 가는 것. 그것이 애자일 방법론의 생각인 것 같다.

요구사항이 변경되어 소스에 점점 문제가 보이는 것을 방치한다면 그것은 기술부채가 되며 나중에 점점 큰 자원이 들어가게 되는 부분이 된다. 그렇기 때문에 이 애자일 방법론을 따를 때 중요한 것은 기술부채가 발생하지 않도록 문제가 발생했다면 바로바로 리팩토링하는 것이 중요하다.

불필요한 계산을 피하기 위해 코드를 수정합니다. 답안에 불린 값을 저장하고 데이터베이스에서 쿼리를 이용해 사용자가 맞춘 답안을 읽어올 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* {@link User}가 {@link Multiplication}을 계산한 답안을 정의한 클래스
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@Entity
public class MultiplicationResultAttempt {

@Id
@GeneratedValue
private Long id;

@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "USER_ID")
private final User user;

@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "MULTIPLICATION_ID")
private final Multiplication multiplication;
private final int resultAttempt;

private final boolean correct;

// JSON (역)직렬화를 위한 빈 생성자
MultiplicationResultAttempt() {
user = null;
multiplication = null;
resultAttempt = -1;
correct = false;
}
}

생성자가 변경되었기 때문에 오류가 발생하는 MultiplicationServiceImplTest 객체를 수정하자. 생성자 보면 correct 필드를 위해 false 값을 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MultiplicationServiceImplTest {

// ...

@Test
public void checkCorrectAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3000, false);

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isTrue();
}

@Test
public void checkWrongAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3010, false);

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isFalse();
}
}

실제 답안을 체크하는 비지니스 로직 checkAttemp() 함수를 수정하자
여기 조금 의아한 부분이 객체지향 설계가 안되어 있다. MultiplicationResultAttempt 도메인이 가져야할 로직들이 이 서비스 로직에 드러나 있다. 이 글의 요점이 아니라 그런건가..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class MultiplicationServiceImpl implements MultiplicationService {

// ...

@Override
public boolean checkAttempt(MultiplicationResultAttempt attempt) {
// 답안을 채점
boolean correct = attempt.getResultAttempt() ==
attempt.getMultiplication().getFactorA() *
attempt.getMultiplication().getFactorB();

// 조작된 답안을 방지
Assert.isTrue(!attempt.isCorrect(), "채점한 상태로 보낼 수 없습니다!!");

// 복사본을 만들고 crrect 필드를 상황에 맞게 설정
MultiplicationResultAttempt checkAttempt =
new MultiplicationResultAttempt(attempt.getUser(), attempt.getMultiplication(), attempt.getResultAttempt(), correct);

// 결과를 반환
return correct;
}
}

기존에는 ResultResponse 객체를 사용해서 응답을 내렸는데, 이번에는 DTO 아예 없애고 MultiplicationResultAttempt 도메인 객체로 내린다.
관련해서 컨트롤러 쪽도 수정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class MultiplicationResultAttemptController {

// ...

@PostMapping
ResponseEntity<MultiplicationResultAttempt> postResult(@RequestBody MultiplicationResultAttempt multiplicationResultAttempt) {
boolean isCorrect = multiplicationService.checkAttempt(multiplicationResultAttempt);
MultiplicationResultAttempt attemptCopy = new MultiplicationResultAttempt(
multiplicationResultAttempt.getUser(),
multiplicationResultAttempt.getMultiplication(),
multiplicationResultAttempt.getResultAttempt(),
isCorrect
);

return ResponseEntity.ok(attemptCopy);
}
}

MultiplicationResultAttemptController 컨트롤러 객체 응답이 변경되었으니 이에 맞추어 테스트 코드도 수정하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class MultiplicationResultAttemptControllerTest {

// ...

void genericParameterizedTest(final boolean correct) throws Exception {
// given (지금 서비스를 테스트하는 것이 아님)
given(multiplicationService
.checkAttempt(any(MultiplicationResultAttempt.class)))
.willReturn(correct);
User user = new User("john");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3500, correct);

// when
MockHttpServletResponse response = mvc.perform(
post("/results").contentType(MediaType.APPLICATION_JSON)
.content(jsonResult.write(attempt).getJson()))
.andReturn().getResponse();

// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonResult.write(
new MultiplicationResultAttempt(
attempt.getUser(),
attempt.getMultiplication(),
attempt.getResultAttempt(), correct)
).getJson());
}
}

데이터 레이어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- ... -->

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!-- ... -->
1
2
3
4
5
6
7
8
# H2 데이터베이스 웹 콘솔에 접속
spring.h2.console.enabled=true
# 데이터베이스가 '없는 경우에만' 데이터베이스를 생성
spring.jpa.hibernate.ddl-auto=update
# 파일로 데이터베이스를 생성
spring.datasource.url=jdbc:h2:file:~/social-multiplication;DB_CLOSE_ON_EXIT=FALSE;
# 학습 목적으로 콘솔에 SQL을 출력
spring.jpa.properties.hibernate.show_sql=true

Multiplication 도메인도 JPA 를 활용해 데이터 모델로 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 애플리케이션에서 곱셈을 나타내는 클래스 (a * b)
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@Entity
public class Multiplication {

@Id
@GeneratedValue
@Column(name = "MULTIPLICATION_ID")
private Long id;

// 두 인수
private final int factorA;
private final int factorB;

// JSON (역)직렬화를 위한 빈 생성자
Multiplication() {
this(0, 0);
}
}

  • @Entity 애너테이션으로 JPA 저장소에 저장할 수 있는 JPA의 개체임을 명시합니다. JPA에서 리플렉션을 통해 객체를 인스턴스화할 때 필요한 빈 생성자도 있습니다.

  • 기본키로 유일한 식별자를 사용하기 위해 자바 Long 클래스를 활용합니다. @Id 애너테이션은 기본키를 의미하고 @GeneratedValue는 따로 값을 지정하지 않아도 자동으로 생성되는 값입니다.

  • 컬럼명을 JPA가 지정해주는 대신 명시적으로 설정해야 할 경우가 있습니다. 이런 경우에는 @Column 애너테이션으로 컬럼명을 지정합니다.


User 클래스도 수정하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 사용자 정보를 저장하는 클래스
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@Entity
public final class User {

@Id
@GeneratedValue
@Column(name = "USER_ID")
private Long id;

private final String alias;

// JSON (역)직렬화를 위한 빈 생성자
protected User() {
alias = null;
}
}

Repository 쪽을 만들어보자.

1
2
3
4
5
6
7
8
9
/**
* 답안을 저장하고 조회하기 위한 인터페이스
*/
public interface MultiplicationResultAttemptRepository extends CrudRepository<MultiplicationResultAttempt, Long> {
/**
* @return 닉네임에 해당하는 사용자의 최근 답안 5개
*/
List<MultiplicationResultAttempt> findTop5ByUserAliasOrderByIdDesc(String userAlias);
}
1
2
3
4
5
/**
* {@link microservices.book.multiplication.multiplication.domain.Multiplication} 을 저장하고 조회하기 위한 인터페이스
*/
public interface MultiplicationRepository extends CrudRepository<Multiplication, Long> {
}
1
2
3
4
5
6
/**
* {@link microservices.book.multiplication.multiplication.domain.User} 를 저장하고 조회하기 위한 인터페이스
*/
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByAlias(final String alias);
}

리포지토리를 만들때에는 왜 TDD를 사용하지 않는가? 간단합니다. 이건 새로운 코드가 아니라 스프링에서 제공하는 코드이기 때문에 믿을 수 있고 따로 단위 테스트를 작성할 필요가 없습니다

TDD로 돌아가서 위 Repository를 사용하는 Service 코드를 검증하는 단위 테스트 코드를 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class MultiplicationServiceImplTest {

// ...

@Mock
private MultiplicationResultAttemptRepository attemptRepository;

@Mock
private UserRepository userRepository;

@Before
public void setUp() {
// initMocks 를 호출해 Mockito 가 어노테이션을 처리하도록 지시
MockitoAnnotations.initMocks(this);
multiplicationServiceImpl = new MultiplicationServiceImpl(randomGeneratorService, attemptRepository, userRepository);
}

@Test
public void createRandomMultiplicationTest() {
// given (목 객체가 처음에 50, 나중에 30 을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);

// when
Multiplication multiplication = multiplicationServiceImpl.createRandomMultiplication();

// assert
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
}

@Test
public void checkCorrectAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3000, false);
MultiplicationResultAttempt verifiedAttempt = new MultiplicationResultAttempt(user, multiplication, 3000, true);
given(userRepository.findByAlias("john_doe")).willReturn(Optional.empty());

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isTrue();
verify(attemptRepository).save(verifiedAttempt);
}

@Test
public void checkWrongAttemptTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3010, false);
given(userRepository.findByAlias("john_doe")).willReturn(Optional.empty());

// when
boolean attemptResult = multiplicationServiceImpl.checkAttempt(attempt);

// then
assertThat(attemptResult).isFalse();
verify(attemptRepository).save(attempt);
}
}

  • 서비스 계층의 단위 테스트에 집중하기 위해 MultiplicationResultAttemptRepository 와 UserRepository 의 목 객체가 필요합니다. MultiplicationServiceImpl 생성자에 인자로 넘겨주고 있는데, 나중에 해당 클래스를 수정할 때 수정하거나 아니면 지금 새로운 생성자를 만들 수도 있습니다.

  • checkCorrectAttemptTest() 메서드는 실제처럼 하기 위해 attempt 의 복사본(verifiedAttempt)을 만듭니다. 사용자가 보낸 답안은 correct 필드가 false 여야만 합니다. 그리고 마지막 줄에서 Mockito 로 correct 가 true 인 답안을 저장하는 경우를 verify 합니다.

  • checkWrongAttemptTest() 메서드는 틀린 답을 검증합니다. 호출이 실제로 실행되는 것은 아닙니다. 목 객체가 해당 인자를 가지고 동작하는지 테스트하는 것뿐입니다.


Layer Connection

To show the list registed recently, let’s create new API and service unit.

We should create the message at ‘MultiplicationService’ interface for getting the list graded recently.

1
2
3
4
5
6
public interface MultiplicationService {

// ...

List<MultiplicationResultAttempt> getStatsForUser(final String userAlias);
}

and let’s it implements.

1
2
3
4
5
6
7
8
9
10
@Service
public class MultiplicationServiceImpl implements MultiplicationService {

// ...

@Override
public List<MultiplicationResultAttempt> getStatsForUser(String userAlias) {
return attemptRepository.findTop5ByUserAliasOrderByIdDesc(userAlias);
}
}

let’s make the test code for getStatsForUser().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MultiplicationServiceImplTest {

// ...

@Test
public void retrieveStatsTest() {
// given
Multiplication multiplication = new Multiplication(50, 60);
User user = new User("john_doe");
MultiplicationResultAttempt attempt1 = new MultiplicationResultAttempt(
user, multiplication, 3010, false);
MultiplicationResultAttempt attempt2 = new MultiplicationResultAttempt(
user, multiplication, 3051, false);
List<MultiplicationResultAttempt> latestAttempts = Lists.newArrayList(attempt1, attempt2);
given(userRepository.findByAlias("john_doe")).willReturn(Optional.empty());
given(attemptRepository.findTop5ByUserAliasOrderByIdDesc("john_doe"))
.willReturn(latestAttempts);

// when
List<MultiplicationResultAttempt> latestAttemptsResult =
multiplicationServiceImpl.getStatsForUser("john_doe");

// then
assertThat(latestAttemptsResult).isEqualTo(latestAttempts);
}
}

and after that, We should add the function for rest api at MultiplicationResultAttemptController

1
2
3
4
5
6
7
8
9
10
// ...
final class MultiplicationResultAttemptController {

// ...

@GetMapping
ResponseEntity<List<MultiplicationResultAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(multiplicationService.getStatsForUser(alias));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MultiplicationResultAttemptControllerTest {

// ...

@Test
public void getUserStats() throws Exception {
// given
User user = new User("john_doe");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(
user, multiplication, 3500, true);
List<MultiplicationResultAttempt> recentAttempts = Lists.newArrayList(attempt, attempt);
given(multiplicationService
.getStatsForUser("john_doe"))
.willReturn(recentAttempts);

// when
MockHttpServletResponse response = mvc.perform(
get("/results").param("alias", "john_doe"))
.andReturn().getResponse();

// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonResultAttemptList.write(recentAttempts).getJson());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
function updateStats(alias) {
$.ajax({
url: "http://localhost:8080/results?alias=" + alias,
}).then(function (data) {
$('#stats-body').empty();
data.forEach(function (row) {
$('#stats-body').append('<tr><td>' + row.id + '</td>' +
'<td>' + row.multiplication.factorA + ' x ' + row.multiplication.factorB + '</td>' +
'<td>' + row.resultAttempt + '</td>' +
'<td>' + (row.correct === true ? 'YES' : 'NO') + '</td></tr>');
});
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Multiplication v1</title>
<link rel="stylesheet" type="text/css" href="styles.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="multiplication-client.js"></script>
</head>

<body>
<div>
<h1>안녕하세요, 소셜 곱셈입니다!</h1>
<h2>오늘의 문제:</h2>
<h1>
<span class="multiplication-a"></span> x <span class="multiplication-b"></span> =
</h1>
<p>
<form id="attempt-form">
답은? <input type="text" name="result-attempt"><br>
닉네임: <input type="text" name="user-alias"><br>
<input type="submit" value="확인">
</form>
</p>
<h2><span class="result-message"></span></h2>
<h2>통계</h2>
<table id="stats" style="width:100%">
<tr>
<th>답안 ID</th>
<th>곱셈</th>
<th>입력한 값</th>
<th>정답?</th>
</tr>
<tbody id="stats-body"></tbody>
</table>
</div>
</body>
</html>

Let’s start to do microservice

microservice isn’t fit with creating the prototype. because the prototype usually should be created as soon as possible. but building the microservice isn’t easier than the monolithic one.

so when you launch the new service, I recommend you to build it as monolithic system.

Refactoring is always needed in the agile progamming paradigm so Don’t be avoid it.

Scalability

let’s suppose that the one of the microservices takes traffics a lot, then we gotta expand the microservice without anothers.

but it’s impossible to control each service at monolithic system.

Server and Infra are always the cost so Software enginner should consider it.

Connecting each microservices

How can do we connect between each microservices?

  • Sharing the same database. This solution is not proper with microservice essentially. because the one of purpose of microservice is separating the business context, but this solution is not keep this rule.

  • Building the batch service pushing the updated list between each one. but this way couldn’t offer the real time service.

Event Driven Architecture

Most of companies with microservice structure use Event to notify any actions, changes for each microservices.

Each microservices take the published event through Event Bus and they can publish the event when they want.

The benfit and disadvantage of Event Driven Architecture

  • Loose coupling

Each microservices can have own context area. It should be independent, then the changes of their process are not affect to other one.

  • Transaction

Event Driven Architecture can’t keep ACID Transaction principle. if one transaction needs multiple services, we have to do lots of things to keep the ACID principle like Rollback processing.

  • Fault Tolerance

When the one of microservices is shuted down, another microservices depended it should have the policy how can handle this problem.

  • Difficulty to trace log.

Event Driven Architecture is really hard to trace the log because it is built separately. so We should have the occastration layer to check it like a gateway.

RabbitMQ

let’s inject dependency of amqp.

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  • setting exchange key at application.properties
1
2
3
## RabbitMQ 설정
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
  • Configuration RabbitTemplate, TopicExchange

RabbitMQ is configured enable to communicate as Json type as you can see.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class RabbitMQConfiguration {

@Bean
public TopicExchange multiplicationExchange(@Value("${multiplication.exchange}") final String exchangeName) {
return new TopicExchange(exchangeName);
}

@Bean
public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) {
final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(producerJackson2MessageConverter());
return rabbitTemplate;
}

@Bean
public Jackson2JsonMessageConverter producerJackson2MessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
  • Modeling Event object.

It’s dangerous to use the changeable data as the event field. these data will make any confuse to each microservices.

1
2
3
4
5
6
7
8
9
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class MultiplicationSolvedEvent implements Serializable {
private final Long multiplicationResultAttemptId;
private final Long userId;
private final boolean correct;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class EventDispatcher {
private RabbitTemplate rabbitTemplate;

// Multiplication 관련 정보를 전달하기 위한 익스체인지
private String multiplicationExchange;

// 특정 이벤트를 전송하기 위한 라우팅 키
private String multiplicationSolvedRoutingKey;

@Autowired
EventDispatcher(final RabbitTemplate rabbitTemplate,
@Value("${multiplication.exchange}") final String multiplicationExchange,
@Value("${multiplication.solved.key}") final String multiplicationSolvedRoutingKey) {
this.rabbitTemplate = rabbitTemplate;
this.multiplicationExchange = multiplicationExchange;
this.multiplicationSolvedRoutingKey = multiplicationSolvedRoutingKey;
}

public void send(final MultiplicationSolvedEvent multiplicationSolvedEvent) {
rabbitTemplate.convertAndSend(
multiplicationExchange,
multiplicationSolvedRoutingKey,
multiplicationSolvedEvent);
}
}

AMQP supports Transaction. so It will not send the event when the method occurs the exception with @Transaction annotation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MultiplicationServiceImpl implements MultiplicationService {

// ...

@Transactional
@Override
public boolean checkAttempt(MultiplicationResultAttempt attempt) {

// ...

// 이벤트로 결과를 전송
eventDispatcher.send(
new MultiplicationSolvedEvent(checkAttempt.getId(),
checkAttempt.getUser().getId(),
checkAttempt.isCorrect())
);

return isCorrect;
}
}

after this code, you should fix MultiplicationServiceImplTest class. because the existing one doesn’t have the code to check whether event message is sent well or not.

1
2
3
4
5
6
7
8
9
10
11
public enum Badge {
// 점수로 획득하는 배지
BRONZE_MULTIPLICATOR,
SILVER_MULTIPLICATOR,
GOLD_MULTIPLICATOR,

// 특정 조건으로 획득하는 배지
FIRST_ATTEMPT,
FIRST_WON,
LUCKY_NUMBER
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@Entity
public final class BadgeCard {
@Id
@GeneratedValue
@Column(name = "BADGE_ID")
private final Long badgeID;

private final Long userId;
private final long badgeTimestamp;
private final Badge badge;

public BadgeCard() {
this(null, null, 0, null);
}

public BadgeCard(final Long userId, final Badge badge) {
this(null, userId, System.currentTimeMillis(), badge);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public final class GameStats {
private final Long userId;
private final int score;
private final List<Badge> badges;

public GameStats() {
this.userId = 0L;
this.score = 0;
this.badges = new ArrayList<>();
}

public static GameStats emptyStats(final Long userId) {
return new GameStats(userId, 0, Collections.emptyList());
}

public List<Badge> getBadges() {
return Collections.unmodifiableList(badges);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public final class LeaderBoardRow {
private final Long userId;
private final Long totalScore;

public LeaderBoardRow() {
this(0L, 0L);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 점수와 답안을 연결하는 클래스
* 사용자와 점수가 등록된 시간의 타임스탬프를 포함
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@Entity
public final class ScoreCard {
// 명시되지 않은 경우 이 카드에 할당되는 기본 점수
public static final int DEFAULT_SCORE = 10;

@Id
@GeneratedValue
@Column(name = "CARD_ID")
private final Long cardId;

@Column(name = "USER_ID")
private final Long userId;

@Column(name = "ATTEMPT_ID")
private final Long attemptId;

@Column(name = "SCORE_TS")
private final long scoreTimestamp;

@Column(name = "SCORE")
private final int score;

public ScoreCard() {
this(null, null, null, 0, 0);
}

public ScoreCard(final Long userId, final Long attemptId) {
this(null, userId, attemptId, System.currentTimeMillis(), DEFAULT_SCORE);
}
}

Only ScoreCard and BadgeCard are the domain what you should save in database through repository among those five domain classes.

1
2
3
public interface BadgeCardRepository extends CrudRepository<BadgeCard, Long> {
List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(final Long userId);
}
1
2
3
4
5
6
7
8
9
10
11
12
public interface ScoreCardRepository extends CrudRepository<ScoreCard, Long> {

@Query("SELECT SUM(s.score) FROM microservices.book.gamification.domain.ScoreCard s WHERE s.userId = :userId GROUP BY s.userId")
int getTotalScoreForUser(@Param("userId") final Long userId);

@Query("SELECT NEW microservices.book.gamification.domain.LeaderBoardRow(s.userId, SUM(s.score)) " +
"FROM microservices.book.gamification.domain.ScoreCard s " +
"GROUP BY s.userId ORDER BY SUM(s.score) DESC")
List<LeaderBoardRow> findFirst10();

List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}

ScoreCardRepository is built by JPQL(Java Persistence Query Language). and JPQL keeps the abstraction rules to use itself for any database not for specific database.

Business Logics

This system is required two business logics. the one is GameService and the other is LeaderBoardService.

  • GameService : It’s for calculating the scores and the badges based on grading what is taken.

  • LeaderBoardService : It’s for Looking up the 10 users who takes highest the scores.

Initialization RabbitMQ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
public class RabbitMQConfiguration implements RabbitListenerConfigurer {
@Bean
public TopicExchange multiplicationExchange(@Value("${multiplication.exchange}") final String exchangeName) {
return new TopicExchange(exchangeName);
}

@Bean
public Queue gamificationMultiplicationQueue(@Value("${multiplication.queue}") final String queueName) {
return new Queue(queueName, true);
}

@Bean
Binding binding(final Queue queue, final TopicExchange exchange, @Value("${multiplication.anything.routing-key}") final String routingKey) {
return BindingBuilder.bind(queue).to(exchange).with(routingKey);
}

@Bean
public MappingJackson2MessageConverter consumerJackson2MessageConverter() {
return new MappingJackson2MessageConverter();
}

@Bean
public DefaultMessageHandlerMethodFactory messageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory factory = new DefaultMessageHandlerMethodFactory();
factory.setMessageConverter(consumerJackson2MessageConverter());
return factory;
}

@Override
public void configureRabbitListeners(final RabbitListenerEndpointRegistrar registrar) {
registrar.setMessageHandlerMethodFactory(messageHandlerMethodFactory());
}
}

this RabbitMQ configuration bind with Exchange and Routing Key to Queue. and consumerJackson2MessageConverter(), messageHandlerMethodFactory(), configureRabbitListeners() methods are for setting Json Deserialization.

Data Communication between each microservices.

Reactive pattern means communication based on Event and unlike that pattern, Request/Response pattern means communication based on REST API.

Event only should have immutable data. and the reason was already refer the above.

Keeping the domain as isolaed status.

if the specific domain can be accessed all of microservices, then Each microservices have dependency between themselves because of this domain. It means that all of microservices using the domain have to be considered whenever the domain is changed.

1
@JsonDeserialize(using = MultiplicationResultAttemptDeserializer.class)

@JsonDeserialize annotation can offer the customization of Json type deserialization when RestTemplate is used. and that is MultiplicationResutAttemptDeserializer object

1
2
3
4
5
6
7
8
9
10
11
12
public class MultiplicationResultAttemptDeserializer extends JsonDeserializer<MultiplicationResultAttempt> {
@Override
public MultiplicationResultAttempt deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
ObjectCodec oc = jsonParser.getCodec();
JsonNode node = oc.readTree(jsonParser);
return new MultiplicationResultAttempt(node.get("user").get("alias").asText(),
node.get("multiplication").get("factorA").asInt(),
node.get("multiplication").get("factorB").asInt(),
node.get("resultAttempt").asInt(),
node.get("correct").asBoolean());
}
}

Installing RabbitMQ server based on Docker

Firstly, we should pull the rabbitmq image from dockerhurbs.

and let’s launch this rabbitmq image.

1
2
3
> docker pull rabbitmq:management

> docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 -p 25672:25672 -p 35197:35197 --restart=unless-stopped -e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest rabbitmq:management

How can solve the problem what docker is not operated will with showing the error message ‘Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?’

this situation is occured when your docker daemon process is stopped. in my case, i just solved this problem as re-installing docker program.

Solving the error ‘homebrew-core is a shallow clone’ when you use brew command.

the solution in already in the message what you taken in terminal with brew command. you just fetch the code from the below two repositories related with brew.

1
2
git -C /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core fetch --unshallow
git -C /usr/local/Homebrew/Library/Taps/homebrew/homebrew-cask fetch --unshallow

Installing rabbitmq in local with brew command

1
2
3
4
5
6
7
8
9
10
11
> brew update

> brew install rabbitmq

// set environment variable for rabbitmq
> export PATH=$PATH:/user/local/sbin

> rabbitmq-server

// It's the launching command without setting envrionment variables
> /usr/local/sbin/rabbitmq-server

Creating new user with rabbitmqctl command

1
2
3
4
5
> rabbitmqctl add_user test test

> rabbitmqctl set_user_tags test administrator

> rabbitmqctl set_permissions -p / test ".*" ".*" ".*"

Creating new user with rabbitmqctl in Docker

1
2
3
4
5
> docker exec rabbitmq rabbitmqctl add_user test test

> docker exec rabbitmq rabbitmqctl set_user_tags test administrator

> docker exec rabbitmq rabbitmqctl set_permissions -p / test ".*" ".*" ".*"

Turning on rabbitmq management option

Rabbitmq Web management service is basically not turned on. so You should change the rabbitmq-plugins option to use it.

1
> docker exec rabbitmq rabbitmq-plugins enable rabbitmq_management

Migrating static contents from multiplication to new server

The book use Jetty web server to service the UI contents

First of all, We will install Jetty with Homebrew.

1
> brew install jetty

Installing Jetty with Docker

1
2
3
4
5
> docker pull jetty

> docker run -d --name jetty -p 9090:8080 -p 9443:8443 jetty

> docker exec -it jetty /bin/bash

The path of webapps directory in jetty docker container

1
> /var/lib/jetty/webapps

Copying file betweeen host system and docker container

1
2
3
4
5
// host -> container
> docker cp [host file] [container name]:[container path]

// container -> host
> docker cp [container name]:[container path] [host file]

AopInvocationException: Null return value from advice does not match primitive return type for

This exception is occured when your entities has a primitive type data. These data should be changed to boxed type like Interger, Float..

access h2-console as file db.

1
spring.datasource.url=jdbc:h2:file:~/gamification;DB_CLOSE_ON_EXIT=FALSE;USER=myuser;PASSWORD=mypass

if the above properties is configured in your h2 db, then you can access h2-console with the below information.

1
2
3
4
5
6
7
http://localhost:8081/h2-console

jdbc:h2:file:~/gamification

user : myuser

password : mypass

furthermore, you have to know one thing that you gotta delete the h2 file db when you want to change the user or password

the location is almostly C:/User/USER/

The components of Service Discovery

  • Service Registry
  • Register Agent
  • Service Discovery Client

The version Error between Spring boot and Spring Cloud

The latest version of Spring boot is 2.6.1 but i don’t know the way add the spring cloud dependencies with this spring boot version. someday, I should solve this problems.. actually i learned one thing about this.

1
https://spring.io/projects/spring-cloud

You can get the version information of spring cloud.

Zuul routing setting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
zuul:
prefix: /api
routes:
multiplications:
path: /multiplications/**
url: http://localhost:8080/multiplications
results:
path: /results/**
url: http://localhost:8080/results
leaders:
path: /leaders/**
url: http://localhost:8081/leaders
stats:
path: /stats/**
url: http://localhost:8081/stats

prefix is deleted when Api Gateway call the each microservices.
ex) http://localhost:8000/api/multiplications -> http://localhost:8080/multiplications

after applying Api gateway, the clients don’t need to remember each endpoints of microservices. it just only should keep the endpoint of api gateway.

Spring Cloud Version Issue

When you want to use Spring Cloud in your spring boot project. You must consider the version.

the version is dependented deeply between spring boot and spring cloud.

in Spring boot 2.6.0 version case, you can check how we add the proper dependency of Spring Cloud at the below site.

The below code is the sample adding dependency of Spring Cloud in Maven and Gradle.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    <repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.0-M1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
...
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
buildscript {
dependencies {
classpath "io.spring.gradle:dependency-management-plugin:1.0.2.RELEASE"
}
}

repositories {
maven {
url 'https://repo.spring.io/milestone'
}
}

apply plugin: "io.spring.dependency-management"

dependencyManagement {
imports {
mavenBom 'org.springframework.cloud:spring-cloud-dependencies:2021.0.0-M1'
}
}

dependencies {
compile 'org.springframework.cloud:spring-cloud-starter-config'
compile 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
...
}

as you can see at there, You should know the name of repositories for Spring Cloud is ‘https://repo.spring.io/milestone‘.

It’s not default repository, so You have add the repository when you add Spring Cloud.

Share