[개인프로젝트/말랑말랑] 5. 마이크로서비스 시작하기 2
게임화 마이크로서비스 살펴보기
개요
새로운 프로젝트를 생성한다.
+ JPA 의존성
도메인 추가
게임 도메인 모델은 다음과 같이 구성된다.
- ScoreCard: 주어진 사용자가 주어진 시간에 획득한 점수의 모델
- Badge: 게임에서 사용하는 모든 배지의 목록
- BadgeCard: 특정 사용자가 특정 시간에 획득한 배지
- LeaderBoardRow: 리더보드(다른 사용자와 종합 점수가 표시)에서의 위치
- GameStats: 주어진 사용자의 점수와 배지, 하나의 게임 결과 또는 점수와 배지 합산에 사용될 수 있음
카드(점수와 배지)는 획득한 시간 정보를 포함한다. 게임 결과는 하나 이상의 ScoreCard와 하나 이상의 BadgeCard를 포함한다.
리포지토리 추가
엄밀히 말하면 저장해야 할 모델은 사용자의 점수와 얻은 배지이다. 하나의 객체나 행에 점수를 누적하는 대신 카드를 저장한다. 그리고 사용자의 총 점수를 계산할 때 카드를 이용해 집계한다. 이렇게 하면 사용자의 점수를 계속해서 추적할 수 있다.
따라서, BadgeCard 클래스와 ScoreCard 클래스를 두 개의 테이블에 저장할 것이다.
package me.progfrog.mallang_gamification.repository;
import me.progfrog.mallang_gamification.domain.BadgeCard;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
public interface BadgeCardRepository extends CrudRepository<BadgeCard, Long> {
/**
* 주어진 사용자의 배지 카드를 모두 조회
* @param userId BadgeCard를 조회하고자 하는 사용자의 ID
* @return 최근 획득한 순으로 정렬된 BadgeCard 리스트
*/
List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(final Long userId);
}
package me.progfrog.mallang_gamification.repository;
import me.progfrog.mallang_gamification.domain.LeaderBoardRow;
import me.progfrog.mallang_gamification.domain.ScoreCard;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ScoreCardRepository extends CrudRepository<ScoreCard, Long> {
/**
* ScoreCard의 점수를 합해서 사용자의 총 점수를 조회
* @param userId 총 점수를 조회하고자 하는 사용자의 ID
* @return 사용자의 총 점수
*/
@Query("SELECT SUM(s.score) "
+ "FROM me.progfrog.mallang_gamification.domain.ScoreCard s "
+ "WHERE s.userId = :userId GROUP BY s.userId")
int getTotalScoreForUser(@Param("userId") final Long userId);
/**
* 사용자와 사용자의 총 점수를 나타내는 {@link LeaderBoardRow} 리스트를 조회
* @return 높은 점수 순으로 정렬된 리터보드
*/
@Query("SELECT NEW me.progfrog.mallang_gamification.domain.LeaderBoardRow(s.userId, SUM(s.score)) "
+ "FROM me.progfrog.mallang_gamification.domain.ScoreCard s "
+ "GROUP BY s.userId ORDER BY SUM(s.score) DESC")
List<LeaderBoardRow> findFirst10();
/**
* 사용자의 모든 ScoreCard를 조회
* @param userId 사용자 ID
* @return 특정 사용자의 최근순으로 정렬된 ScoreCard 리스트
*/
List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}
JPQL(Java Persistence Query Language)
- 명명 패턴을 이용한 쿼리 메서드로는 원하는 쿼리를 만들어내지 못하는 경우가 있다.
- 이럴 때는 JPQL로 쿼리를 작성한다.
- 특정 데이터베이스 엔진을 기반으로 쿼리가 아니라, 코드를 기반으로 하는 일반적인 쿼리
- 데이터베이스의 추상화를 유지하면서 쿼리를 사용할 수 있다는 것이 JPQL의 큰 장점
- JPQL은 대부분의 쿼리를 수행하기에 충분한 함수, 연산자, 표현식 등을 제공
- 그러나 JPQL은 표준이 아니므로 일부 데이터베이스에서는 완전하게 지원하지 않는다.
비즈니스 로직
다음 두 개의 서비스는 게임화 마이크로서비스의 비즈니스 로직을 담당한다.
- GameService 인터페이스와 GameServiceImpl 구현체: 받은 답안을 기반으로 점수와 배지를 계산
- LeaderBoardService 인터페이스와 LeaderBoardServiceImpl 구현체: 총 점수가 제일 높은 사용자 10명을 조회
TDD 방식: 인터페이스 작성 -> 구현체 껍데기만 작성 후, 테스트 코드 작성 -> 구현체 작성 순으로 구현
REST API(컨트롤러) 추가
- LeaderBoardController의 /leaders 경로로 현재 리더보드를 조회하는 엔트포인트 노출
- UserStatsController는 GameStats 객체를 JSON으로 표현한 결과를 /stats 라는 엔드 포인트로 노출
- 여기엔 점수와 배지 정보가 담겨있다.
- 특정 사용자의 통계 정보를 조회하기 위해 userId를 매개변수로 받는다.
- 로컬 머신에서 실행 시 충돌을 피하기 위해 기본 HTTP 포트를 8081로 수정한다.
server.port=8081
RabbitMQ로 이벤트 받기
구독차 측면
이벤트를 구독하는 쪽(게임화 서비스)을 에서 RabbitMQ를 설정하고 이벤트를 받는 방법을 알아보자!
RabbitMQ 설정
이 설정 내용을 이해하기 위해서는 큐를 익스체인지에 바인딩하는 개념을 이해해야 한다. 구독자는 메시지를 소비하는 큐를 생성한다. 이 메시지는 라우팅 키(예제에서는 multiplication.solved)를 이용해 익스체인지에 발행된다. 이는 토픽을 유연하게 교환하는데 도움이 된다. 익스체인지를 통해 전송되는 모든 메시지는 라우팅 키로 태그를 붙이고, 소비자는 큐를 익스체인지에 바인딩할 때 라우팅 키 또는 패턴(예제에서는 multiplication.*)을 이용해 큐로 전송되는 메시지를 선택한다.
- 처음 세 개의 메서드는 새로운 큐(gamificationMultiplicationQueue() 메서드로 정의됨)와 TopicExchange(multiplicationExchange() 메서드로 정의됨)를 함께 바인딩해서 연결한다(binding() 메서드는 익스체인지와 큐를 매개변수로 받는다).
- 큐를 내구성 있게 만든다(큐를 생성할 때, 두 번째 인자를 true로 설정). 이것은 브로커가 중지된 후에도 대기 중인 이벤트를 처리할 수 있다.
- multiplication.exchange 프로퍼티는 곱셈 서비스에서 정의된 것과 같아야한다(Multiplication_exchange). multiplication으로 시작하는 라우팅 키를 찾기 위해 multiplication.* 패턴을 사용한다(multiplication.anything.routing-key). 큐 이름(multiplication.queue)은 선호하는 모든 규칙을 사용할 수 있다. 이 모든 값은 application.properties 파일에 정의한다.
- 마지막 세 개의 메서드는 구독자 쪽 JSON 역직렬화를 설정한다. 곱셈 서비스의 RabbitMQConfiguration과 다르게 동작한다. 이 서비스에서는 메시지를 전송하지 않기 때문에 RabbitTemplate을 사용하지 않고 대신 @RabbitListener를 사용한다. 따라서 MappingJackson2MessageConverter로 RabbitListenerEndpointRegister를 구성한다.
package me.progfrog.mallang_gamification.configuration;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.RabbitListenerConfigurer;
import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistrar;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;
/**
* 이벤트를 사용하기 위한 RabbitMQ 설정
*/
@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(RabbitListenerEndpointRegistrar registrar) {
registrar.setMessageHandlerMethodFactory(messageHandlerMethodFactory());
}
}
## RabbitMQ
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
multiplication.queue=gamification_multiplication_queue
multiplication.anything.routing-key=multiplication.*
이벤트 핸들러
이벤트 디스패처와 이벤트 핸들러 패턴을 떠올려보자. 이 두 가지 방식은 목표가 비슷하다. 바로 받은 이벤트를 처리하고 연관된 비즈니스 로직을 중앙 집중 방식으로 구현하는 것이다.
@RabbitListener 애노테이션으로 이벤트를 소비하는 메서드를 만든다. 이 애노테이션은 브로커가 보낸 메시지를 우리가 정의한 큐를 통해 받는 복잡한 과정을 처리한다(애노테이션 파라미터로 큐 이름을 전달해야 한다).
MultiplicationSolvedEvent를 매개변수로 넘겨주고 있기 때문에 메시지 컨버터(RabbitMQConfiguration에서 설정)는 받은 JSON을 객체로 역직렬화한다. 마이크로서비스 간 종속을 피하기 위해 MultiplicationSolvedEvent 클래스를 게임화 프로젝트로 복사한다(이에 대해서는 후반부에서 도메인 격리에 대해 이야기할 때 알아보자!).
package me.progfrog.mallang_gamification.event;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.progfrog.mallang_gamification.service.GameService;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
@Slf4j
class EventHandler {
private final GameService gameService;
@RabbitListener(queues = "${multiplication.queue}")
void handleMultiplicationSolved(final MultiplicationSolvedEvent event) {
log.info("Multiplication Solved Event 수신: {}", event.getMultiplicationResultAttemptId());
try {
gameService.processNewAttemptForUser(event.getUserId(), event.getMultiplicationResultAttemptId(), event.isCorrect());
} catch (final Exception e) {
log.error("MultiplicationSolvedEvent 처리 시 에러", e);
// 해당 이벤트가 다시 큐로 들어가거나 두 번 처리되지 않도록 예외를 발생시킴
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
기본적으로 문제가 생겼을 때는 해당 이벤트가 반복적으로 큐에 들어가게 되는데 로직을 try-catch 블록으로 감싸고 예외가 발생한 경우에 AmqpRejectAndDontRequeueException을 발생시켜 바로 이벤트를 거부하게 한다.
거부된 이벤트를 처리할 만한 것이 없기 때문에 해당 이벤트는 폐기된다. 만약 제대로 처리하고 싶다면 데드 레터 익스체인지(dead letter exchange)를 구성하고, 전송 실패한 메시지를 어떻게 처리할지 고민해야 한다(재전송, 로깅, 알람 발생 등).
Dead Letter Exchanges | RabbitMQ
<!--
www.rabbitmq.com
마이크로서비스 간 데이터 요청
반응형 패턴과 REST의 결합
게임화 서비스의 설계를 변경한다고 해보자. 게임 디자이너들이 행운의 숫자라는 새로운 배지를 생각해 냈다! 42라는 숫자가 포함된 곱셈 답안을 제출한 경우에만 얻을 수 있는 배지이다.
하지만 작은 문제가 하나 있다. 게임화 서비스는 곱셈에 사용되는 인수를 모른다. MultiplicationSolvedEvent에도 이 값이 들어오지 않는다. 또한 소비자에게 필요하다는 이유만으로 이벤트에 인수를 포함시키는 것은 좋지 않다. 발행자를 소비자에게 맞추다 보면 결국에 너무 거대하고 똑똑한 이벤트가 만들어진다.
마이크로서비스 간 데이터를 공유하기 위해 반응형 패턴 대신 요청/응답 패턴을 사용할 수도 있다. 이벤트는 이미 발생했기 때문이다. 동일한 기술(AMQP/RabbitMQ)을 활용할 수도 있지만, 요청/응답의 가장 일반적인 구현체인 REST API가 훨씬 쉽다.
새로운 문제를 해결하기 위해 게임화 마이크로서비스는 곱셈 마이크로서비스에게 답안 ID로 곱셈 인수를 물어봐야 한다. 그리고 행운의 숫자가 있다면 배지를 부여한다. 이런 아키텍처가 마이크로서비스의 결합도를 높이게 될까? 다음 장에서 결합도를 낮추는 방법을 알아보자.
곱셈 마이크로서비스에서 답안 ID로 답안 정보(곱셈 인수를 포함)를 제공하는 새로운 엔드포인트를 노출해야 한다. 그런 다음 게임화 마이크로서비스에서 인수를 조회하는 REST 클라이언트를 만들어야 한다. 마지막으로, 그중 하나가 행운의 숫자라면 배지를 부여하는 로직을 작성한다.
- /results/{resultId}
package me.progfrog.mallang.service;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import java.util.List;
public interface MultiplicationService {
/**
* @return 무작위 인수를 담은 {@link Multiplication} 객체
*/
Multiplication createRandomMultiplication();
/**
* @return 곱셈 계산 결과가 맞으면 true, 아니면 false
*/
boolean checkAttempt(final MultiplicationResultAttempt resultAttempt);
/**
* 해당 사용자의 통계 정보를 조회
*
* @param userAlias 해당 사용자의 닉네임
* @return 해당 사용자가 전에 제출한 답안 객체 {@link MultiplicationResultAttempt}의 리스트
*/
List<MultiplicationResultAttempt> getStatsForUser(String userAlias);
/**
* ID에 해당하는 답안 조회
*
* @param resultId 답안의 식별자
* @return ID에 해당하는 {@link MultiplicationResultAttempt} 객체, 없으면 null
*/
MultiplicationResultAttempt getResultById(final Long resultId);
}
package me.progfrog.mallang.service;
import lombok.RequiredArgsConstructor;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.domain.User;
import me.progfrog.mallang.event.EventDispatcher;
import me.progfrog.mallang.event.MultiplicationSolvedEvent;
import me.progfrog.mallang.repository.MultiplicationRepository;
import me.progfrog.mallang.repository.MultiplicationResultAttemptRepository;
import me.progfrog.mallang.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class MultiplicationServiceImpl implements MultiplicationService {
private final RandomGeneratorService randomGeneratorService;
private final MultiplicationRepository multiplicationRepository;
private final MultiplicationResultAttemptRepository attemptRepository;
private final UserRepository userRepository;
private final EventDispatcher eventDispatcher;
@Override
public Multiplication createRandomMultiplication() {
int factorA = randomGeneratorService.generateRandomFactor();
int factorB = randomGeneratorService.generateRandomFactor();
return new Multiplication(factorA, factorB);
}
@Transactional
@Override
public boolean checkAttempt(final MultiplicationResultAttempt attempt) {
// 조작된 답안을 방지
Assert.isTrue(!attempt.isCorrect(), "채점된 상태로 보낼 수 없습니다!");
// 해당 닉네임의 사용자가 존재하는 지 확인
Optional<User> user = userRepository.findByAlias(attempt.getUser().getAlias());
// 이미 존재하는 문제인지 확인
Multiplication multiplication = findOrCreateMultiplication(
attempt.getMultiplication().getFactorA(), attempt.getMultiplication().getFactorB());
// 답안을 채점
boolean correct = attempt.getResultAttempt()
== multiplication.getFactorA() * multiplication.getFactorB();
MultiplicationResultAttempt checkedAttempt = new MultiplicationResultAttempt(
user.orElse(attempt.getUser()),
multiplication,
attempt.getResultAttempt(),
correct
);
// 답안을 저장
attemptRepository.save(checkedAttempt);
// 이벤트로 결과를 전송
eventDispatcher.send(new MultiplicationSolvedEvent(
checkedAttempt.getId(),
checkedAttempt.getUser().getId(),
checkedAttempt.isCorrect()
));
return correct;
}
@Transactional(readOnly = true)
@Override
public List<MultiplicationResultAttempt> getStatsForUser(String userAlias) {
return attemptRepository.findTop5ByUserAliasOrderByIdDesc(userAlias);
}
@Override
public MultiplicationResultAttempt getResultById(Long resultId) {
return attemptRepository.findById(resultId).orElseThrow(() -> new IllegalArgumentException(
"요청한 resultId[%d]는 존재하지 않습니다.".formatted(resultId)));
}
private Multiplication findOrCreateMultiplication(int factorA, int factorB) {
Optional<Multiplication> multiplication = multiplicationRepository.findByFactorAAndFactorB(factorA, factorB);
return multiplication.orElseGet(() -> multiplicationRepository.save(new Multiplication(factorA, factorB)));
}
}
package me.progfrog.mallang.controller;
import lombok.RequiredArgsConstructor;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.service.MultiplicationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RequiredArgsConstructor
@RestController
@RequestMapping("/results")
final class MultiplicationResultAttemptController {
private final MultiplicationService multiplicationService;
@PostMapping
ResponseEntity<MultiplicationResultAttempt> postResult(@RequestBody MultiplicationResultAttempt attempt) {
boolean correct = multiplicationService.checkAttempt(attempt);
MultiplicationResultAttempt attemptCopy = new MultiplicationResultAttempt(
attempt.getUser(),
attempt.getMultiplication(),
attempt.getResultAttempt(),
correct
);
return ResponseEntity.ok(attemptCopy);
}
@GetMapping
ResponseEntity<List<MultiplicationResultAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(multiplicationService.getStatsForUser(alias));
}
@GetMapping("/{resultId}")
ResponseEntity<MultiplicationResultAttempt> getResultById(final @PathVariable("resultId") Long resultId) {
return ResponseEntity.ok(multiplicationService.getResultById(resultId));
}
}
package me.progfrog.mallang.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.mallang.domain.Multiplication;
import me.progfrog.mallang.domain.MultiplicationResultAttempt;
import me.progfrog.mallang.domain.User;
import me.progfrog.mallang.service.MultiplicationService;
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ExtendWith(SpringExtension.class)
@WebMvcTest(MultiplicationResultAttemptController.class)
class MultiplicationResultAttemptControllerTest {
@MockBean
private MultiplicationService multiplicationService;
@Autowired
private MockMvc mvc;
// 이 객체는 initFields() 메서드를 이용해 자동으로 초기화
private JacksonTester<MultiplicationResultAttempt> jsonResultAttempt;
private JacksonTester<List<MultiplicationResultAttempt>> jsonResultAttemptList;
@BeforeEach
void setUp() {
JacksonTester.initFields(this, new ObjectMapper());
}
@Test
@DisplayName("사용자가 보낸 답안이 맞음")
void postResultReturnCorrect() throws Exception {
genericParameterizedTest(true);
}
@Test
@DisplayName("사용자가 보낸 답안이 틀림")
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("Frog");
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(jsonResultAttempt.write(attempt).getJson()))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(jsonResultAttempt.write(
new MultiplicationResultAttempt(
attempt.getUser(),
attempt.getMultiplication(),
attempt.getResultAttempt(),
correct)
).getJson());
}
@Test
@DisplayName("사용자의 통계 정보를 조회")
public void getUserStats() throws Exception {
// given
User user = new User("Frog");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3500, true);
List<MultiplicationResultAttempt> recentAttempts = Lists.newArrayList(attempt, attempt);
given(multiplicationService.getStatsForUser("Frog")).willReturn(recentAttempts);
// when
MockHttpServletResponse response = mvc.perform(get("/results")
.param("alias", "Frog"))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(jsonResultAttemptList.write(recentAttempts).getJson());
}
@Test
@DisplayName("Id로 답안 조회하기")
void getResultById() throws Exception {
// given
User user = new User("Frog");
Multiplication multiplication = new Multiplication(50, 70);
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3500, true);
given(multiplicationService.getResultById(4L)).willReturn(attempt);
// when
MockHttpServletResponse response = mvc.perform(
get("/results/4"))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(jsonResultAttempt.write(attempt).getJson());
}
}
도메인을 격리된 상태로 유지하기
이 새로운 기능을 추가하는 것은 아주 중요하다. 게임화 마이크로서비스도 답안을 처리해야 하기 때문에 두 마이크로서비스 모두 답안과 관련된 비즈니스 개념을 이해해야만 한다. 즉, 게임화 서비스에서도 어떻게든 MultiplicationResultAttempt를 모델링해야 한다.
일반적으로 빠질 수 있는 함정은 곱셈화 마이크로서비스에서 도메인 패키지를 게임화 마이크로서비스와 공유할 수 있는 별도의 라이브러리로 뽑아내서 MultiplicationResultAttempt에 접근하는 것이다. 이건 정말 좋지 않은 방법이다! 다른 마이크로서비스의 로직에서 이 도메인을 사용할 수 있게 되면서 해당 도메인은 통제 불능 상태가 된다. 누군가 모델을 수정해야 하는 경우 마이크로서비스 간에 상호 의존성이 생긴다. 따라서 반드시 도메인 개체의 소유권은 하나의 마이크로서비스만 가지고 있어야 한다.
좀 더 나은 대안은 단순히 모델을 복사해서 공유하는 것이다. 이는 데이터 전달 객체(Data Transfer Object, DTO)에 기반을 둔 방식이다. 하지만 이런 방식은 DTO를 관리하는 시간이 걸리고 좋은 방법을 따르지 않으면 의존성도 생길 수 있다. 예를 들어 REST API로 보내기 위해 JSON 객체를 자바로 표현한 DTO라면, 각 API 버전별로 DTO 패키지를 관리해야 한다. 또는 DTO 구조를 변경하면 JSON을 역직렬화할 수 없으므로 소비자에게 큰 골칫거리가 된다. 반면 API 소비자가 많을 때는 개발 시간을 크게 절약할 수 있다. 데이터 구조를 따로 복제할 필요가 없기 때문이다.
소비자가 가공하지 않은 응답(JSON 등)을 보내도 상관없다면 마이크로서비스를 가능한 한 격리시키기 위한 가장 좋은 방법은 아무것도 공유하지 않는 것이다. 그러면 종속성을 최소화할 수 있다. 필드 두 개만 필요하다면 해당 필드만 역직렬화하고 나머지는 무시하면 된다. 이렇게 하면 마이크로서비스는 특정 필드가 변경되는 경우에만 영향을 받는다.
아무것도 공유하지 않을 때 얻을 수 있는 또 다른 이점은 외부 모델 개체를 원하는 대로 적용할 수 있다는 점이다. 곱셈이 필요하지 않다면 답안-곱셈-사용자 구조를 하나의 클래스로 축소할 수 있다.
package me.progfrog.mallang_gamification.client.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import lombok.*;
import me.progfrog.mallang_gamification.client.MultiplicationResultAttemptDeserializer;
/**
* User가 곱셈을 푼 답안을 정의한 클래스
*/
@NoArgsConstructor(force = true)
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
@JsonDeserialize(using = MultiplicationResultAttemptDeserializer.class)
public final class MultiplicationResultAttempt {
private final String userAlias;
private final int multiplicationFactorA;
private final int multiplicationFactorB;
private final int resultAttempt;
private final boolean correct;
}
원래 클래스에서 식별자를 없애고 필드를 축소한 버전이다. @JsonDeserialize 애노테이션은 아직 우리가 만들지 않은 클래스를 가리키고 있다. 이는 @RestTemplate의 메시지 컨버터가 JSON 데이터를 읽어와 역직렬화할 때 특정 클래스를 이용하게 한다. 우리가 받을 JSON 구조가 자바 클래스와 매치되지 않기 때문에 필요하다. 이 클래스는 곱셈 마이크로서비스에 있는 원래 MultiplicationResultAttempt와 다르기 때문이다! 따라서, 기본 역직렬화 클래스는 사용할 수 없다. 이 구현체는 다음 절에서 다룬다.
REST 클라이언트 구현
우선 게임화 마이크로서비스가 곱셈 마이크로서비스를 찾게 해야 한다. application.properties에 설정을 추가하고 코드에서 참조한다. 지금은 마이크로서비스가 배포된 호스트와 포트를 알고 있으므로 직접 지정하고 있지만, 다음 장에서 서비스 디스커버리와 라우팅을 배우면서 제대로 된 방법을 살펴본다.
# REST Client
multiplicationHost=http://localhost:8080
이제 MultiplicationResultAttempt를 위한 커스텀 JSON 역직렬화 구현체를 만들어보자. 스프링 부트에 포함된 Jackson 라이브러리의 클래스를 활용한다.
package me.progfrog.mallang_gamification.client;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import me.progfrog.mallang_gamification.client.dto.MultiplicationResultAttempt;
import java.io.IOException;
public class MultiplicationResultAttemptDeserializer extends JsonDeserializer<MultiplicationResultAttempt> {
@Override
public MultiplicationResultAttempt deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
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());
}
}
다음은 추상화된 통신 로직 인터페이스를 작성한다. 비즈니스 로직의 관점에서 보면 어떤 인터페이스를 사용하든 간에 답안을 조회하고 싶을 것이다.
package me.progfrog.mallang_gamification.client;
import me.progfrog.mallang_gamification.client.dto.MultiplicationResultAttempt;
/**
* Multiplication 마이크로서비스와 연결하는 인터페이스
* 통신 방식은 상관 없음
*/
public interface MultiplicationResultAttemptClient {
MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(final Long multiplicationId);
}
REST API로 쉽게 통신하기 위해 스프링의 RestTemplate을 사용한다. 스프링 애플리케이션 컨텍스트에서 사용하려면 해당 클래스를 빈으로 설정해야 한다.
package me.progfrog.mallang_gamification.configuration;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestClientConfiguration {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}
그다음 MultiplicationResultAttemptClientImpl 안에 MultiplicationHost 프로퍼티와 RestTemplate을 주입하고, 전달된 식별자로 GET 요청을 수행한다.
package me.progfrog.mallang_gamification.client;
import me.progfrog.mallang_gamification.client.dto.MultiplicationResultAttempt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* Multiplication 마이크로서비스와 REST로 연결하기 위한
* MultiplicationResultAttemptClient 인터페이스 구현체
*/
@Component
public class MultiplicationResultAttemptClientImpl implements MultiplicationResultAttemptClient {
private final RestTemplate restTemplate;
private final String multiplicationHost;
@Autowired
public MultiplicationResultAttemptClientImpl(
final RestTemplate restTemplate,
@Value("${multiplicationHost}") final String multiplicationHost) {
this.restTemplate = restTemplate;
this.multiplicationHost = multiplicationHost;
}
@Override
public MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(final Long multiplicationResultAttemptId) {
return restTemplate.getForObject(
multiplicationHost + "/results/" + multiplicationResultAttemptId,
MultiplicationResultAttempt.class);
}
}
MultiplicationResultAttempt에서 애노테이션으로 설정해놓은 덕분에 여기서 역직렬화 객체를 연결할 필요가 없다. getForObject() 메서드는 매개변수로 클래스를 받는데, 이는 커스텀 역직렬화 객체를 사용해야 한다는 것을 의미한다.
게임화 비즈니스 로직 업데이트
이제 사용자가 답안을 제출할 때 인수에 42라는 숫자가 있으면 새로운 배지를 받는지 검증하는 로직을 GameServiceImplTest에 추가하고, GameServiceImpl 클래스에 적용한다.
package me.progfrog.mallang_gamification.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.progfrog.mallang_gamification.client.MultiplicationResultAttemptClient;
import me.progfrog.mallang_gamification.client.dto.MultiplicationResultAttempt;
import me.progfrog.mallang_gamification.domain.Badge;
import me.progfrog.mallang_gamification.domain.BadgeCard;
import me.progfrog.mallang_gamification.domain.GameStats;
import me.progfrog.mallang_gamification.domain.ScoreCard;
import me.progfrog.mallang_gamification.repository.BadgeCardRepository;
import me.progfrog.mallang_gamification.repository.ScoreCardRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@RequiredArgsConstructor
@Service
@Slf4j
public class GameServiceImpl implements GameService {
public static final int LUCKY_NUMBER = 42;
private final ScoreCardRepository scoreCardRepository;
private final BadgeCardRepository badgeCardRepository;
private final MultiplicationResultAttemptClient attemptClient;
@Override
public GameStats processNewAttemptForUser(Long userId, Long attemptId, boolean correct) {
// 처음엔 답이 맞았을 때만 점수를 줌
if (correct) {
ScoreCard scoreCard = new ScoreCard(userId, attemptId);
scoreCardRepository.save(scoreCard);
log.info("사용자 ID {}, 점수 {} 점, 답안 ID {}", userId, scoreCard.getScore(), attemptId);
List<BadgeCard> badgeCards = provideBadgeOnCondition(userId, attemptId);
return new GameStats(userId, scoreCard.getScore(),
badgeCards.stream()
.map(BadgeCard::getBadge)
.toList());
}
return GameStats.emptyStats(userId);
}
@Override
public GameStats retrieveStatsForUser(Long userId) {
int score = scoreCardRepository.getTotalScoreForUser(userId);
List<BadgeCard> badgeCards = badgeCardRepository.findByUserIdOrderByBadgeTimestampDesc(userId);
return new GameStats(userId, score,
badgeCards.stream()
.map(BadgeCard::getBadge)
.toList());
}
/**
* 조건이 충족될 경우 새 배지를 지급하기 위해 얻은 총 점수와 점수 카드를 확인
*/
private List<BadgeCard> provideBadgeOnCondition(final Long userId, final Long attemptId) {
List<BadgeCard> badgeCards = new ArrayList<>();
int totalScore = scoreCardRepository.getTotalScoreForUser(userId);
log.info("사용자 ID {} 의 새로운 점수 {}", userId, totalScore);
List<ScoreCard> scoreCardList = scoreCardRepository.findByUserIdOrderByScoreTimestampDesc(userId);
List<BadgeCard> badgeCardList = badgeCardRepository.findByUserIdOrderByBadgeTimestampDesc(userId);
// 점수 기반 배지 지급
checkAndGiveBadgeBasedOnScore(badgeCardList, Badge.BRONZE_MULTIPLICATOR,
totalScore, 10, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList, Badge.SILVER_MULTIPLICATOR,
totalScore, 25, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList, Badge.GOLD_MULTIPLICATOR,
totalScore, 50, userId)
.ifPresent(badgeCards::add);
// 첫 번째 정답 배지 지급
if (scoreCardList.size() == 1 && !containsBadge(badgeCardList, Badge.FIRST_WON)) {
BadgeCard firstWonBadge = giveBadgeToUser(Badge.FIRST_WON, userId);
badgeCards.add(firstWonBadge);
}
// 행운의 숫자 배지
MultiplicationResultAttempt attempt = attemptClient.retrieveMultiplicationResultAttemptById(attemptId);
if (!containsBadge(badgeCardList, Badge.LUCKY_NUMBER) &&
(LUCKY_NUMBER == attempt.getMultiplicationFactorA() || LUCKY_NUMBER == attempt.getMultiplicationFactorB())) {
BadgeCard luckyNumberBadge = giveBadgeToUser(Badge.LUCKY_NUMBER, userId);
badgeCards.add(luckyNumberBadge);
}
return badgeCards;
}
/**
* 배지를 얻기 위한 조건을 넘는지 체크하는 편의성 메서드
* 또한 조건이 충족되면 사용자에게 배지를 지급
*/
private Optional<BadgeCard> checkAndGiveBadgeBasedOnScore(
final List<BadgeCard> badgeCards,
final Badge badge,
final int score,
final int scoreThreshold,
final Long userId) {
if (score >= scoreThreshold && !containsBadge(badgeCards, badge)) {
return Optional.of(giveBadgeToUser(badge, userId));
}
return Optional.empty();
}
/**
* 배지 목록에 해당 배지가 포함되어 있는지 확인하는 메서드
*/
private boolean containsBadge(
final List<BadgeCard> badgeCards,
final Badge badge) {
return badgeCards.stream().anyMatch(b -> b.getBadge().equals(badge));
}
/**
* 주어진 사용자에게 새로운 배지를 부여하는 메서드
*/
private BadgeCard giveBadgeToUser(final Badge badge, final Long userId) {
BadgeCard badgeCard = new BadgeCard(userId, badge);
badgeCardRepository.save(badgeCard);
log.info("사용자 ID {} 새로운 배지 획득: {}", userId, badge);
return badgeCard;
}
}
마이크로서비스 가지고 놀기
RabbitMQ 설치
docker run -it --rm --name mallang-rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13-management
RabbitMQ 웹 UI
http://localhost:15672
동작 확인
정리
이번 장에서는 마이크로서비스를 적용했다. 마이크로서비스를 파헤치기 전에 일체형부터 시작하는 접근법을 알아보았다. 처음부터 마이크로서비스로 시작하는 것이 얼마나 비용이 많이 드는지 비교하고, 일체형으로 시작해서 나중에 나누는 방법을 다뤘다.
사용자 스토리 형태로 새로운 요구사항을 받았고, 게임화 기법을 기반으로 두 번째 서비스를 설계하고 구현했다. 점수, 배지, 리더보드처럼 사용자에게 동기를 부여하는 기본 기법을 배웠다.
마이크로서비스를 연결하는 다양한 방법을 살펴보고, 이벤트 중심 설계를 선택했다. 기술적인 과대 광고에 속지 않도록 요구사항에 부합하는 기술을 제대로 평가해야 한다.
이번 장의 후반부에서는 이벤트 중심 설계를 위한 비동기 통신을 스프링 AMQP와 RabbitMQ로 구현했다. 먼저 발행자 쪽인 곱셈화 마이크로서비스를 수정하고, 새로운 마이크로서비스인 게임화 스프링 부트 애플리케이션을 구현했다. 그 과정에서 구독자 측면을 구현하는 법을 살펴봤고, 데이터베이스와 게임화 모델에 더 복잡한 쿼리를 사용하는 새로운 방법과 코드를 살펴봤다.
이번 장의 마지막 부분에서는 마이크로서비스를 완성했지만 아직 세 번째 사용자 스토리를 완성하지 못했다. UI로 접속할 수 있는 리더보드를 제공하고, 기술적인 측면에서 애플리케이션 사이의 강한 연결을 개선해야 한다(현재는 호스트와 포트를 명시하고 있다).
다음 장에서 시스템을 제대로 나누기 위해 코드를 리팩터링한다. 그리고 리팩터링을 하는 동안 마이크로서비스의 두 가지 중요한 개념인 라우팅과 서비스 디스커버리를 이해하고 적용해 보도록 하겠다!