처음부터 마이크로서비스로 시작하지 말아야 할 이유
1. 설계에 시간이 더 오래 걸리고 일체형보다 더 안 좋아짐
도메인 컨텍스트를 따라 루트 패키지에서 코드를 분리. 예로 들어 고객 관련 기능(개인, 회사, 주소 등)과 주문 관련 기능(주문 생성, 배송, 관리 등)이 있다고 하자. 그럼 루트 패키지에서 바로 계층을 나누는 것이 아니라 먼저 고객(customer)과 주문(orders)로 나눔. 그리고 고객과 주문 각각 레이어로 패키지를 나누고(controller, domain, service), 패키지 기반으로 클래스의 접근 수준을 제어.
-> 이런 구조를 만들면 도메인 컨텍스트에 따라 비즈니스 로직을 분리할 수 있고, 나중에 적은 리팩터링으로 마이크로서비스를 쉽게 분리할 수 있음
의존성 주입의 이점을 활용해라. 예를 들어 모든 비즈니스 로직을 한 곳에 넣는 대신에 다른 마이크로서비스를 호출하도록 구현체를 바꿀 수 있음
컨텍스트(고객과 주문)을 정의하고 나면 애플리케이션 전반에서 일관된 이름을 지을 수 있음
나중에 공통 라이브러리로 뽑아낼 부분을 정의
매일 문제를 풀기 위해 좀 더 동기부여가 되면 좋겠어요. 쉽게 포기하지 않게 말이죠.
-> 게임화
게임화 : 게임에서 사용되는 기법들을 적용하는 설계 프로세스
중요한 행위가 일어날 때마다 여러 마이크로서비스가 이벤트를 전송. 각 마이크로서비스는 메시지 브로커(이벤트 버스)를 통해 이벤트를 주고 받음. 그리고 마이크로서비스는 각자 관심 있는 이벤트를 구독하고 이벤트를 처리함
-> 반응형 시스템이라고 함
이벤트 소싱 : 비즈니스 개체를 저장하는 방식. 시간이 지나면서 변하는 정적 상태로 모델링하는 대신 변경할 수 없는 일련의 이벤트로 모델링하는 것. -> 이벤트 소싱을 전체적으로 적용하려면 모델링해야 하는 이벤트 수가 크게 증가하기 때문에 이벤트 소싱 기반으로 저장하지는 않음(여기서는)
도메인 주도 설계(DDD) : 소프트웨어 패턴으로, 비즈니스 도메인이 시스템의 핵심이라는 소프트웨어 설계 철학
CORS(명령-쿼리 책임 분리) : 데이터를 조회하는 쿼리 모델과 데이터를 업데이트하는 커맨드 모델을 분리하는 패턴 -> 데이터를 매우 빠르게 읽어올 수 있음.
이벤트 중심 아키텍처에서는 서비스 전체에 ACID 트랜잭션이 없다고 봐야 함. 대신 모든 이벤트를 전파하고 소비한다면 궁극적으로는 일관된 상태가 됨
-> 곱셈 결과에 관심을 가진 서비스와 통신하는 부분 추가해야 함 (MultiplicationSolvedEvent)
메시지를 전송하는 채널인 익스체인지를 만들고, 기본적인 MultiplicationSolvedEvent 메시지를 전송함. 메시지는 JSON으로 직렬화함.
익스체인지는 메시지를 보내는 가장 유연한 방식인 토픽으로 만듦
multiplication.solved라는 라우팅 키를 이용해 이벤트 메시지를 전송
구독하는 쪽(게임화 마이크로서비스)에서 큐를 생성하고 토픽 익스체인지를 바인딩해서 메시지를 받음
큐가 내구성을 갖추게 함. 메시지 브로커(RabbitMQ)가 다운되더라도 메시지는 유지되기 때문에 언제든지 이벤트를 처리할 수 있음
스프링 AMQP로 RabbitMQ 브로커와 메시지를 주고받을 것임.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'junit:junit:4.13.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-amqp'
}
package microservices.book.multiplication.configuration;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 이벤트를 사용하기 위한 RabbitMQ 설정
*/
@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();
}
}
package microservices.book.multiplication.event;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.io.Serializable;
/**
* 시스템에서 {@link microservices.book.multiplication.domain.Multiplication} 문제가 해결되었다는 사실을 모델링한 이벤트.
* 곱셈에 대한 컨텍스트 정보를 제공.
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class MultiplicationSolvedEvent implements Serializable {
private final Long multiplicationResultAttemptId;
private final Long userId;
private final boolean correct;
}
비동기 통신의 일반적인 두 가지 패턴은 이벤트 디스패처(이벤트 발행자)와 이벤트 핸들러(이벤트 구독자)
package microservices.book.multiplication.event;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 이벤트 버스와의 통신을 처리
*/
@Component
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);
}
}
-> 스프링의 애플리케이션 컨텍스트에서 RabbitTemplate을 가져오고 애플리케이션 프로퍼티에서 익스체인지의 이름과 라우팅 키를 가져옴. 그런 다음, 템플릿의 convertAndSend 메서드를 호출. 그리고 라우팅 키를 사용함. 이 이벤트는 multiplication.*라는 라우팅 패턴을 사용하는 소비자의 큐로 전해짐
## RabbitMQ 설정
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
package microservices.book.multiplication.service;
import microservices.book.multiplication.domain.Multiplication;
import microservices.book.multiplication.domain.MultiplicationResultAttempt;
import microservices.book.multiplication.domain.User;
import microservices.book.multiplication.event.EventDispatcher;
import microservices.book.multiplication.event.MultiplicationSolvedEvent;
import microservices.book.multiplication.repository.MultiplicationResultAttemptRepository;
import microservices.book.multiplication.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Optional;
@Service
class MultiplicationServiceImpl implements MultiplicationService {
private RandomGeneratorService randomGeneratorService;
private MultiplicationResultAttemptRepository attemptRepository;
private UserRepository userRepository;
private EventDispatcher eventDispatcher;
@Autowired
public MultiplicationServiceImpl(final RandomGeneratorService randomGeneratorService,
final MultiplicationResultAttemptRepository attemptRepository,
final UserRepository userRepository,
final EventDispatcher eventDispatcher) {
this.randomGeneratorService = randomGeneratorService;
this.attemptRepository = attemptRepository;
this.userRepository = userRepository;
this.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) {
// 해당 닉네임의 사용자가 존재하는지 확인
Optional<User> user = userRepository.findByAlias(attempt.getUser().getAlias());
// 조작된 답안을 방지
Assert.isTrue(!attempt.isCorrect(), "채점한 상태로 보낼 수 없습니다!!");
// 답안을 채점
boolean isCorrect = attempt.getResultAttempt() ==
attempt.getMultiplication().getFactorA() *
attempt.getMultiplication().getFactorB();
MultiplicationResultAttempt checkedAttempt = new MultiplicationResultAttempt(
user.orElse(attempt.getUser()),
attempt.getMultiplication(),
attempt.getResultAttempt(),
isCorrect
);
// 답안을 저장
attemptRepository.save(checkedAttempt);
// 이벤트로 결과를 전송
eventDispatcher.send(
new MultiplicationSolvedEvent(checkedAttempt.getId(),
checkedAttempt.getUser().getId(),
checkedAttempt.isCorrect())
);
return isCorrect;
}
@Override
public List<MultiplicationResultAttempt> getStatsForUser(final String userAlias) {
return attemptRepository.findTop5ByUserAliasOrderByIdDesc(userAlias);
}
}
package microservices.book.gamification.domain;
/**
* 사용자가 획득할 수 있는 여러 종류의 배지를 열거
*/
public enum Badge {
// 점수로 획득하는 배지
BRONZE_MULTIPLICATOR,
SILVER_MULTIPLICATOR,
GOLD_MULTIPLICATOR,
// 특정 조건으로 획득하는 배지
FIRST_ATTEMPT,
}
package microservices.book.gamification.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
/**
* 배지와 사용자를 연결하는 클래스
* 사용자가 배지를 획득한 순간의 타임스탬프를 포함
*/
@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;
// JSON/JPA 를 위한 빈 생성자
public BadgeCard() {
this(null, null, 0, null);
}
public BadgeCard(final Long userId, final Badge badge) {
this(null, userId, System.currentTimeMillis(), badge);
}
}
package microservices.book.gamification.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
/**
* 점수와 답안을 연결하는 클래스
* 사용자와 점수가 등록된 시간의 타임스탬프를 포함
*/
@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;
// JSON/JPA 를 위한 빈 생성자
public ScoreCard() {
this(null, null, null, 0, 0);
}
public ScoreCard(final Long userId, final Long attemptId) {
this(null, userId, attemptId, System.currentTimeMillis(), DEFAULT_SCORE);
}
}
package microservices.book.gamification.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 한 번 혹은 여러 번의 게임 결과를 포함하는 객체
* {@link ScoreCard} 객체와 {@link BadgeCard} 로 이루어짐
* 게임 한 번에 변경된 내용 또는 점수와 배지 전체를 나타낼 때 사용됨
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public final class GameStats {
private final Long userId;
private final int score;
private final List<Badge> badges;
// JSON/JPA 를 위한 빈 생성자
public GameStats() {
this.userId = 0L;
this.score = 0;
this.badges = new ArrayList<>();
}
/**
* 빈 인스턴스(0점과 배지 없는 상태)를 만들기 위한 팩토리 메소드
*
* @param userId 사용자 ID
* @return {@link GameStats} 객체(0점과 배지 없는 상태)
*/
public static GameStats emptyStats(final Long userId) {
return new GameStats(userId, 0, Collections.emptyList());
}
/**
* @return 수정불가능한 배지 카드 리스트의 뷰
*/
public List<Badge> getBadges() {
return Collections.unmodifiableList(badges);
}
}
package microservices.book.gamification.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
/**
* 리더보드 내 위치를 나타내는 객체
* 사용자와 전체 점수를 연결
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public final class LeaderBoardRow {
private final Long userId;
private final Long totalScore;
// JSON/JPA 를 위한 빈 생성자
public LeaderBoardRow() {
this(0L, 0L);
}
}
package microservices.book.gamification.repository;
import microservices.book.gamification.domain.BadgeCard;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
/**
* BadgeCard 데이터 작업 처리
*/
public interface BadgeCardRepository extends CrudRepository<BadgeCard, Long> {
/**
* 주어진 사용자의 배지 카드를 모두 조회
*
* @param userId BadgeCard를 조회하고자 하는 사용자의 ID
* @return 최근 획득한 순으로 정렬된 BadgeCard 리스트
*/
List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(final Long userId);
}
package microservices.book.gamification.repository;
import microservices.book.gamification.domain.LeaderBoardRow;
import microservices.book.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;
/**
* ScoreCard CRUD 작업 처리
*/
public interface ScoreCardRepository extends CrudRepository<ScoreCard, Long> {
/**
* ScoreCard 의 점수를 합해서 주어진 사용자의 총 점수를 조회
*
* @param userId 총 점수를 조회하고자 하는 사용자의 ID
* @return 주어진 사용자의 총 점수
*/
@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);
/**
* 사용자와 사용자의 총 점수를 나타내는 {@link LeaderBoardRow} 리스트를 조회
*
* @return 높은 점수 순으로 정렬된 리더 보드
*/
@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();
/**
* 주어진 사용자의 모든 ScoreCard 를 조회
*
* @param userId 사용자 ID
* @return 주어진 사용자의 최근 순으로 정렬된 ScoreCard 리스트
*/
List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}
-> JPQL로 쿼리를 작성
package microservices.book.gamification.service;
import microservices.book.gamification.domain.GameStats;
/**
* 게임화 시스템의 주요 로직을 다루는 서비스
*/
public interface GameService {
/**
* 주어진 사용자가 제출한 답안을 처리
*
* @param userId 사용자 ID
* @param attemptId 필요한 경우 추가로 데이터를 조회하기 위한 답안 ID
* @param correct 답안의 정답 여부
* @return 새로운 점수와 배지 카드를 포함한 {@link GameStats} 객체
*/
GameStats newAttemptForUser(Long userId, Long attemptId, boolean correct);
/**
* 주어진 사용자의 게임 통계를 조회
*
* @param userId 사용자 ID
* @return 사용자의 통계 정보
*/
GameStats retrieveStatsForUser(Long userId);
}
package microservices.book.gamification.service;
import microservices.book.gamification.domain.LeaderBoardRow;
import java.util.List;
/**
* LeaderBoard 에 접근하는 메소드를 제공
*/
public interface LeaderBoardService {
/**
* 최고 점수 사용자와 함께 현재 리더 보드를 검색
*
* @return 최고 점수와 사용자
*/
List<LeaderBoardRow> getCurrentLeaderBoard();
}
package microservices.book.gamification.service;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.domain.*;
import microservices.book.gamification.repository.BadgeCardRepository;
import microservices.book.gamification.repository.ScoreCardRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Slf4j
class GameServiceImpl implements GameService {
private ScoreCardRepository scoreCardRepository;
private BadgeCardRepository badgeCardRepository;
GameServiceImpl(ScoreCardRepository scoreCardRepository,
BadgeCardRepository badgeCardRepository) {
this.scoreCardRepository = scoreCardRepository;
this.badgeCardRepository = badgeCardRepository;
}
@Override
public GameStats newAttemptForUser(final Long userId,
final Long attemptId,
final boolean correct) {
// 처음엔 답이 맞았을 때만 점수를 줌
if (correct) {
ScoreCard scoreCard = new ScoreCard(userId, attemptId);
scoreCardRepository.save(scoreCard);
log.info("사용자 ID {}, 점수 {} 점, 답안 ID {}",
userId, scoreCard.getScore(), attemptId);
List<BadgeCard> badgeCards = processForBadges(userId, attemptId);
return new GameStats(userId, scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadge)
.collect(Collectors.toList()));
}
return GameStats.emptyStats(userId);
}
/**
* 조건이 충족될 경우 새 배지를 제공하기 위해 얻은 총 점수와 점수 카드를 확인
*/
private List<BadgeCard> processForBadges(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, 100, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList,
Badge.SILVER_MULTIPLICATOR, totalScore, 500, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList,
Badge.GOLD_MULTIPLICATOR, totalScore, 999, userId)
.ifPresent(badgeCards::add);
// 첫 번째 정답 배지
if (scoreCardList.size() == 1 &&
!containsBadge(badgeCardList, Badge.FIRST_WON)) {
BadgeCard firstWonBadge = giveBadgeToUser(Badge.FIRST_WON, userId);
badgeCards.add(firstWonBadge);
}
return badgeCards;
}
@Override
public GameStats retrieveStatsForUser(final Long userId) {
int score = scoreCardRepository.getTotalScoreForUser(userId);
List<BadgeCard> badgeCards = badgeCardRepository
.findByUserIdOrderByBadgeTimestampDesc(userId);
return new GameStats(userId, score, badgeCards.stream()
.map(BadgeCard::getBadge).collect(Collectors.toList()));
}
/**
* 배지를 얻기 위한 조건을 넘는지 체크하는 편의성 메소드
* 또한 조건이 충족되면 사용자에게 배지를 부여
*/
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;
}
}
package microservices.book.gamification.controller;
import microservices.book.gamification.domain.LeaderBoardRow;
import microservices.book.gamification.service.LeaderBoardService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Gamification 리더보드 서비스의 REST API
*/
@RestController
@RequestMapping("/leaders")
class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
public LeaderBoardController(final LeaderBoardService leaderBoardService) {
this.leaderBoardService = leaderBoardService;
}
@GetMapping
public List<LeaderBoardRow> getLeaderBoard() {
return leaderBoardService.getCurrentLeaderBoard();
}
}
package microservices.book.gamification.controller;
import microservices.book.gamification.domain.GameStats;
import microservices.book.gamification.service.GameService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Gamification 사용자 통계 서비스의 REST API
*/
@RestController
@RequestMapping("/stats")
class UserStatsController {
private final GameService gameService;
public UserStatsController(final GameService gameService) {
this.gameService = gameService;
}
@GetMapping
public GameStats getStatsForUser(@RequestParam("userId") final Long userId) {
return gameService.retrieveStatsForUser(userId);
}
}
server.port=8081
이벤트를 구독하는 쪽(게임화 서비스)을 알아봄
package microservices.book.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(final 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.*
package microservices.book.gamification.event;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.service.GameService;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* 이벤트를 받고 연관된 비즈니스 로직을 동작시킴
*/
@Slf4j
@Component
class EventHandler {
private GameService gameService;
EventHandler(final GameService gameService) {
this.gameService = gameService;
}
@RabbitListener(queues = "${multiplication.queue}")
void handleMultiplicationSolved(final MultiplicationSolvedEvent event) {
log.info("Multiplication Solved Event 수신: {}", event.getMultiplicationResultAttemptId());
try {
gameService.newAttemptForUser(event.getUserId(),
event.getMultiplicationResultAttemptId(),
event.isCorrect());
} catch (final Exception e) {
log.error("MultiplicationSolvedEvent 처리 시 에러", e);
// 해당 이벤트가 다시 큐로 들어가거나 두 번 처리되지 않도록 예외 발생
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
@GetMapping("/{resultId}")
ResponseEntity<MultiplicationResultAttempt> getResultById(final @PathVariable("resultId") Long resultId) {
return ResponseEntity.ok(
multiplicationService.getResultById(resultId)
);
}
/**
* ID에 해당하는 답안 조회
*
* @param resultId 답안의 식별자
* @return ID에 해당하는 {@link MultiplicationResultAttempt} 객체, 없으면 null
*/
MultiplicationResultAttempt getResultById(final Long resultId);
@Override
public MultiplicationResultAttempt getResultById(final Long resultId) {
return attemptRepository.findOne(resultId);
}
MultiplicationResultAttempt findOne(Long resultId);
package microservices.book.gamification.client.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import microservices.book.gamification.client.MultiplicationResultAttemptDeserializer;
/**
* User 가 Multiplication 을 계산한 답안을 정의한 클래스
*/
@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;
// JSON/JPA 를 위한 빈 생성자
MultiplicationResultAttempt() {
userAlias = null;
multiplicationFactorA = -1;
multiplicationFactorB = -1;
resultAttempt = -1;
correct = false;
}
}
# REST 클라이언트 설정
multiplicationHost=http://localhost:8080
package microservices.book.gamification.client;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
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 microservices.book.gamification.client.dto.MultiplicationResultAttempt;
import java.io.IOException;
/**
* Multiplication 마이크로서비스로부터 오는 답안을
* Gamification 에서 사용하는 형태로 역직렬화
*/
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());
}
}
package microservices.book.gamification.client;
import microservices.book.gamification.client.dto.MultiplicationResultAttempt;
/**
* Multiplication 마이크로서비스와 연결하는 인터페이스
* 통신 방식은 상관 없음
*/
public interface MultiplicationResultAttemptClient {
MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(final Long multiplicationId);
}
package microservices.book.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;
/**
* REST 클라이언트 설정
*/
@Configuration
public class RestClientConfiguration {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}
package microservices.book.gamification.client;
import microservices.book.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
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);
}
}
package microservices.book.gamification.domain;
/**
* 사용자가 획득할 수 있는 여러 종류의 배지를 열거
*/
public enum Badge {
// 점수로 획득하는 배지
BRONZE_MULTIPLICATOR,
SILVER_MULTIPLICATOR,
GOLD_MULTIPLICATOR,
// 특정 조건으로 획득하는 배지
FIRST_ATTEMPT,
FIRST_WON,
LUCKY_NUMBER
}
package microservices.book.gamification.service;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.client.MultiplicationResultAttemptClient;
import microservices.book.gamification.client.dto.MultiplicationResultAttempt;
import microservices.book.gamification.domain.*;
import microservices.book.gamification.repository.BadgeCardRepository;
import microservices.book.gamification.repository.ScoreCardRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Slf4j
class GameServiceImpl implements GameService {
public static final int LUCKY_NUMBER = 42;
private ScoreCardRepository scoreCardRepository;
private BadgeCardRepository badgeCardRepository;
private MultiplicationResultAttemptClient attemptClient;
GameServiceImpl(ScoreCardRepository scoreCardRepository,
BadgeCardRepository badgeCardRepository,
MultiplicationResultAttemptClient attemptClient) {
this.scoreCardRepository = scoreCardRepository;
this.badgeCardRepository = badgeCardRepository;
this.attemptClient = attemptClient;
}
@Override
public GameStats newAttemptForUser(final Long userId,
final Long attemptId,
final boolean correct) {
// 처음엔 답이 맞았을 때만 점수를 줌
if (correct) {
ScoreCard scoreCard = new ScoreCard(userId, attemptId);
scoreCardRepository.save(scoreCard);
log.info("사용자 ID {}, 점수 {} 점, 답안 ID {}",
userId, scoreCard.getScore(), attemptId);
List<BadgeCard> badgeCards = processForBadges(userId, attemptId);
return new GameStats(userId, scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadge)
.collect(Collectors.toList()));
}
return GameStats.emptyStats(userId);
}
/**
* 조건이 충족될 경우 새 배지를 제공하기 위해 얻은 총 점수와 점수 카드를 확인
*/
private List<BadgeCard> processForBadges(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, 100, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList,
Badge.SILVER_MULTIPLICATOR, totalScore, 500, userId)
.ifPresent(badgeCards::add);
checkAndGiveBadgeBasedOnScore(badgeCardList,
Badge.GOLD_MULTIPLICATOR, totalScore, 999, 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;
}
@Override
public GameStats retrieveStatsForUser(final Long userId) {
int score = scoreCardRepository.getTotalScoreForUser(userId);
List<BadgeCard> badgeCards = badgeCardRepository
.findByUserIdOrderByBadgeTimestampDesc(userId);
return new GameStats(userId, score, badgeCards.stream()
.map(BadgeCard::getBadge).collect(Collectors.toList()));
}
/**
* 배지를 얻기 위한 조건을 넘는지 체크하는 편의성 메소드
* 또한 조건이 충족되면 사용자에게 배지를 부여
*/
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;
}
}