4장 마이크로서비스 시작하기

sua·2022년 9월 2일
0
post-thumbnail

작은 일체형 접근법

처음부터 마이크로서비스로 시작하지 말아야 할 이유
1. 설계에 시간이 더 오래 걸리고 일체형보다 더 안 좋아짐

일체형 애플리케이션을 나누기 위한 계획

  1. 도메인 컨텍스트를 따라 루트 패키지에서 코드를 분리. 예로 들어 고객 관련 기능(개인, 회사, 주소 등)과 주문 관련 기능(주문 생성, 배송, 관리 등)이 있다고 하자. 그럼 루트 패키지에서 바로 계층을 나누는 것이 아니라 먼저 고객(customer)과 주문(orders)로 나눔. 그리고 고객과 주문 각각 레이어로 패키지를 나누고(controller, domain, service), 패키지 기반으로 클래스의 접근 수준을 제어.
    -> 이런 구조를 만들면 도메인 컨텍스트에 따라 비즈니스 로직을 분리할 수 있고, 나중에 적은 리팩터링으로 마이크로서비스를 쉽게 분리할 수 있음

  2. 의존성 주입의 이점을 활용해라. 예를 들어 모든 비즈니스 로직을 한 곳에 넣는 대신에 다른 마이크로서비스를 호출하도록 구현체를 바꿀 수 있음

  3. 컨텍스트(고객과 주문)을 정의하고 나면 애플리케이션 전반에서 일관된 이름을 지을 수 있음

  4. 나중에 공통 라이브러리로 뽑아낼 부분을 정의

일체형 분석하기

두번째 스프링 부트 애플리케이션

매일 문제를 풀기 위해 좀 더 동기부여가 되면 좋겠어요. 쉽게 포기하지 않게 말이죠.
-> 게임화


게임화 기초

게임화 : 게임에서 사용되는 기법들을 적용하는 설계 프로세스

점수, 배지, 리더보드

  1. 점수 : 어떤 동작을 할 때마다, 그리고 잘 할 때마다 점수를 얻음
  2. 리더보드 : 모두가 볼 수 있도록 점수를 공개 -> 경쟁심을 갖고 동기부여가 됨
  3. 배지 : 무언가를 달성할 때 얻는 상징인 배지도 중요

예제에 게임화 적용하기

  1. 사용자가 정답을 제출할 때만 점수를 10점 줌
  2. 상위 점수를 받은 사용자를 보여주는 리더보드를 페이지에 보이게 함.
  3. 동배지(정답 10개), 은배지(정답 25개), 금배지(정답 50개) 만듦, 첫 정답 배지도 만듦

이벤트 중심 아키텍처

중요한 행위가 일어날 때마다 여러 마이크로서비스가 이벤트를 전송. 각 마이크로서비스는 메시지 브로커(이벤트 버스)를 통해 이벤트를 주고 받음. 그리고 마이크로서비스는 각자 관심 있는 이벤트를 구독하고 이벤트를 처리함
-> 반응형 시스템이라고 함

관련 기법

  1. 이벤트 소싱 : 비즈니스 개체를 저장하는 방식. 시간이 지나면서 변하는 정적 상태로 모델링하는 대신 변경할 수 없는 일련의 이벤트로 모델링하는 것. -> 이벤트 소싱을 전체적으로 적용하려면 모델링해야 하는 이벤트 수가 크게 증가하기 때문에 이벤트 소싱 기반으로 저장하지는 않음(여기서는)

  2. 도메인 주도 설계(DDD) : 소프트웨어 패턴으로, 비즈니스 도메인이 시스템의 핵심이라는 소프트웨어 설계 철학

  3. CORS(명령-쿼리 책임 분리) : 데이터를 조회하는 쿼리 모델과 데이터를 업데이트하는 커맨드 모델을 분리하는 패턴 -> 데이터를 매우 빠르게 읽어올 수 있음.

트랜잭션

이벤트 중심 아키텍처에서는 서비스 전체에 ACID 트랜잭션이 없다고 봐야 함. 대신 모든 이벤트를 전파하고 소비한다면 궁극적으로는 일관된 상태가 됨

이벤트 중심 아키텍처 적용하기


-> 곱셈 결과에 관심을 가진 서비스와 통신하는 부분 추가해야 함 (MultiplicationSolvedEvent)


RabbitMQ와 스프링 AMQP를 이용한 이벤트 중심 설계

시스템에 RabbitMQ 추가

  1. 메시지를 전송하는 채널인 익스체인지를 만들고, 기본적인 MultiplicationSolvedEvent 메시지를 전송함. 메시지는 JSON으로 직렬화함.

  2. 익스체인지는 메시지를 보내는 가장 유연한 방식인 토픽으로 만듦

  3. multiplication.solved라는 라우팅 키를 이용해 이벤트 메시지를 전송

  4. 구독하는 쪽(게임화 마이크로서비스)에서 큐를 생성하고 토픽 익스체인지를 바인딩해서 메시지를 받음

  5. 큐가 내구성을 갖추게 함. 메시지 브로커(RabbitMQ)가 다운되더라도 메시지는 유지되기 때문에 언제든지 이벤트를 처리할 수 있음


스프링 AMQP

스프링 AMQP로 RabbitMQ 브로커와 메시지를 주고받을 것임.


곱셈 서비스에서 이벤트 보내기

RabbitMQ 설정

  1. build.gradle 종속성 추가
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'
}

  1. RabbitMQConfiguration 클래스 추가
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();
  }

}

이벤트 모델링

  1. 이벤트 클래스 추가
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;

}
  • 객체의 변화하는 데이터를 이벤트에 포함시키는 것은 위험함 -> 사용자 정보가 수정될 때마다 식별자를 알리고, 메시지를 소비하는 쪽에서 로직을 처리할 때 최신 상태를 요청하게 하는 편이 나음

이벤트 전송: 디스패처 패턴

비동기 통신의 일반적인 두 가지 패턴은 이벤트 디스패처(이벤트 발행자)와 이벤트 핸들러(이벤트 구독자)

  1. EventDispatcher 작성
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.*라는 라우팅 패턴을 사용하는 소비자의 큐로 전해짐


  1. application.properties 수정
## RabbitMQ 설정
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
  1. MultiplicationServiceImpl에 이벤트 로직 추가
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);
    }


}

게임화 마이크로서비스 살펴보기

도메인

  • ScoreCard : 주어진 사용자가 주어진 시간에 획득한 점수의 모델
  • Badge : 게임에서 사용하는 모든 배지의 목록
  • BadgeCard : 특정 사용자가 특정 시간에 획득한 배지
  • LeaderBoardRow : 리더보드(다른 사용자와 종합 스코어가 표시)에서의 위치
  • GameStatus : 주어진 사용자의 점수와 배지, 하나의 게임 결과 또는 점수와 배지 합산에 사용될 수 있음
  1. Badge 도메인 생성

package microservices.book.gamification.domain;

/**
 * 사용자가 획득할 수 있는 여러 종류의 배지를 열거
 */
public enum Badge {

  // 점수로 획득하는 배지
  BRONZE_MULTIPLICATOR,
  SILVER_MULTIPLICATOR,
  GOLD_MULTIPLICATOR,

  // 특정 조건으로 획득하는 배지
  FIRST_ATTEMPT,

}

  1. BadgeCard 도메인 생성
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);
  }

}

  1. ScordCard 도메인 생성
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);
  }

}

  1. GameStats 도메인 생성
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);
  }
}

  1. LeaderBoardRow 도메인 생성
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);
  }
}

데이터

  1. BadgeCardRepository 생성
  • 배지는 최근 획득한 순으로 정렬됨
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);

}

  1. ScordCardRepository 생성 (총 점수를 계산)
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로 쿼리를 작성


비즈니스 로직

  • GameService 인터페이스와 GameServiceImpl 구현체 : 받은 답안을 기반으로 점수와 배지를 계산
  • LeaderBoardService 인터페이스와 LeaderBoardServiceImpl 구현체 : 총 점수가 제일 높은 사용자 10명을 조회
  1. GameService 인터페이스 정의
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);

}

  1. LeaderBoardService 인터페이스 정의
package microservices.book.gamification.service;

import microservices.book.gamification.domain.LeaderBoardRow;

import java.util.List;

/**
 * LeaderBoard 에 접근하는 메소드를 제공
 */
public interface LeaderBoardService {

  /**
   * 최고 점수 사용자와 함께 현재 리더 보드를 검색
   *
   * @return 최고 점수와 사용자
   */
  List<LeaderBoardRow> getCurrentLeaderBoard();
}

  1. GameServiceImpl 구현체 작성
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;
    }

}

REST API (컨트롤러)

  1. LeaderBoardController 생성
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();
    }
}

  1. UserStatsController 작성
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);
  }
}

  1. HTTP 포트를 8081로 수정
server.port=8081

RabbitMQ로 이벤트 받기

구독자 측면

이벤트를 구독하는 쪽(게임화 서비스)을 알아봄

RabbitMQ 설정

  1. RabbitMQConfiguration 클래스 생성 : 총 6개의 메서드로, 5개는 빈을 정의한 것이고 마지막 하나는 RabbitListenerConfigurer 인터페이스를 구현한 것
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());
    }
}

[중요한 점]

  • 처음 세개의 메서드는 새로운 큐와 TopicExchange를 함께 바인딩해서 연결함
  • 큐를 내구성 있게 만듦(큐 생성시 두번째 매개변수를 true로 설정) -> 브로커가 중지된 후에도 대기중인 이벤트를 처리가능
  • multiplication.exchane 프로퍼티는 곱셈 서비스에서 정의된 것과 같아야 함. multiplication으로 시작하는 라우팅 키를 찾기 위해 multiplication.* 패턴을 사용함. 큐 이름은 선호하는 모든 규칙을 사용할 수 있음.
  • 마지막 세 개의 메서드는 구독자 쪽 JSON 역직렬화를 설정함

  1. application.properties 수정
## RabbitMQ 설정
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
multiplication.queue=gamification_multiplication_queue
multiplication.anything.routing-key=multiplication.*

이벤트 핸들러

  • @RabbitListener 애너테이션으로 이벤트를 소비하는 메서드를 만듦 : 브로커가 보낸 메시지를 우리가 정의한 큐를 통해 받는 복잡한 과정을 처리함
  1. EventHandler 작성
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);
    }
  }
}

마이크로서비스 간 데이터 요청

반응형 패턴과 REST의 결합

  1. MultiplicationResultAttemptController 수정
@GetMapping("/{resultId}")
  ResponseEntity<MultiplicationResultAttempt> getResultById(final @PathVariable("resultId") Long resultId) {
    return ResponseEntity.ok(
            multiplicationService.getResultById(resultId)
    );
  }

  1. MultiplicationService 수정
/**
   * ID에 해당하는 답안 조회
   *
   * @param resultId 답안의 식별자
   * @return ID에 해당하는 {@link MultiplicationResultAttempt} 객체, 없으면 null
   */
  MultiplicationResultAttempt getResultById(final Long resultId);

  1. MultiplicationServiceImpl 수정
@Override
  public MultiplicationResultAttempt getResultById(final Long resultId) {
    return attemptRepository.findOne(resultId);
  }

  1. MultiplicationResultAttemptRepository 수정
MultiplicationResultAttempt findOne(Long resultId);

도메인을 격리된 상태로 유지하기

  1. client.dto 패키지에 MultiplicationResultAttempt 생성
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 클라이언트 구현

  1. 게임화 마이크로서비스가 곱셈 마이크로서비스를 찾을 수 있게 설정 추가
# REST 클라이언트 설정
multiplicationHost=http://localhost:8080

  1. MultiplicationResultAttempt를 위한 커스텀 JSON 역직렬화 구현체 생성
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());
  }
}

  1. 추상화된 통신 로직 인터페이스를 작성
package microservices.book.gamification.client;

import microservices.book.gamification.client.dto.MultiplicationResultAttempt;

/**
 * Multiplication 마이크로서비스와 연결하는 인터페이스
 * 통신 방식은 상관 없음
 */
public interface MultiplicationResultAttemptClient {

  MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(final Long multiplicationId);

}

  1. 스프링 애플리케이션 컨텍스트에서 RestTemplate 사용하기 위해 빈으로 설정하는 클래스 생성

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();
  }

}

  1. MultiplicationResultAttemptClientImpl 안에 multiplicationHost 프로퍼티와 RestTemplate을 주입 -> 전달된 식별자로 GET 요청 수행
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);
  }
}

게임화 비즈니스 로직 업데이트

  1. 열거형에 새로운 배지 추가

package microservices.book.gamification.domain;

/**
 * 사용자가 획득할 수 있는 여러 종류의 배지를 열거
 */
public enum Badge {

    // 점수로 획득하는 배지
    BRONZE_MULTIPLICATOR,
    SILVER_MULTIPLICATOR,
    GOLD_MULTIPLICATOR,

    // 특정 조건으로 획득하는 배지
    FIRST_ATTEMPT,
    FIRST_WON,
    LUCKY_NUMBER

}

  1. 게임 로직에 클라이언트를 주입하고 반환된 답안을 처리해서 행운의 숫자가 있다면 배지 부여하도록 수정 (GameServiceImpl)
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;
    }
}

마이크로서비스 가지고 놀기

  1. http://localhost:8080/index.html로 접속해 문제 풀어보기
  2. 게임화 서비스의 로그 확인 (성공)

profile
가보자고

0개의 댓글