작은 일체형 접근법
앞서 하나로 배포 가능한 애플리케이션을 완성했다. 이 애플리케이션은 작은 일체형이지만, 처음부터 전체 시스템을 설계하고 그 안에서 서로 다른 컨텍스트 또는 바운디드 컨텍스트(bounded contexts)를 정의한 후 동시에 개발을 진행할 수도 있었을 것이다.
bliki: Bounded Context
Don't try to build a single, unified model for a large domain. Instead DDD advises us to divide such a domain into many bounded contexts with explicit relationships between them.
martinfowler.com
이런 전략이 좋을 수도 있다! 처음부터 여러 팀이 각자 마이크로서비스를 맡아서 개발할 수 있고, 일이 잘 풀린다면 금세 완성할 수도 있을 것이다. 하지만 많은 사람들이 일체형부터 개발하는 방법을 선호한다.
bliki: Monolith First
Going directly to a microservices architecture is risky, so consider building a monolithic system first. Split to microservices when, and if, you need it.
martinfowler.com
처음부터 마이크로서비스로 나눠서 개발한다면, 오히려 일체형보다 더 오래 걸릴 수 있다. 왜 그럴까? 마이크로서비스는 시스템을 배포, 관리, 테스트하기가 기술적으로 더 어렵기 때문이다.
처음부터 마이크로서비스로 시작하지 말아야 할 이유는 또 존재한다. 시스템의 설계가 일체형보다 더 안 좋아진다. 여러 팀이 서로 다른 부분을 맡아서 개발하다 보면 시스템 전체보다 맡은 부분에만 관심을 두게 된다. 초반에 한 설계는 쉽게 변경되기 때문에 마이크로서비스 전체를 관리하지 않으면 팀들은 회사의 가이드와 원칙을 무시하기 시작할 수 있다. 엔드투엔드 테스트는 각자 만드는 코드를 테스트하기 때문에 훨씬 어렵다. API와 여러 마이크로서비스가 커뮤니케이션하는 방법을 명확히 알고 있지 않다면 의심할 여지없이 일체형으로 시작하는 것이 좋다.
마이크로서비스로 시작해 모든 것들을 동시에 개발하는 방식이 무조건 실패한다는 뜻은 아니다. 다만 그런 경우에는 개발, 통합 테스트, 표준 정하기, 명확한 API, 로깅과 모니터링, 에러 처리, 팀 간 커뮤니테이션 방법 등 훨씬 더 많은 주의가 필요하다. 마이크로서비스로 바로 시작한다면 이 중 하나만 실패해도 프로젝트가 위태로워진다.
더 좋은 방법은 바로 일체형 애플리케이션으로 시작하는 것이다! 다음과 같이 계획한다면, 나중에 일체형 애플리케이션을 손쉽게 나눌 수 있다.
- 도메인 컨텍스트를 따라 루트 패키지에서 코드를 분리한다. 예를 들어, 고객 관련 기능과 주문 관련 기능이 있다고 하자. 그럼 루트 패키지에서 바로 계층을 나누는 것이 아니라 고객(customer)과 주문(orders)으로 나눈다. 그리고 고객과 주문 각각 레이어로 패키지를 나누고(예를 들어 controller, domain, service), 패키지 기반으로 클래스의 접근 수준을 제어한다. 이런 구조를 만들면 도메인 컨텍스트에 따라 비즈니스 로직을 분리할 수 있고, 나중에 적은 리팩터링으로 마이크로서비스를 쉽게 분리할 수 있다.
- 의존성 주입의 이점을 활용하자! 인터페이스를 기반으로 코드를 작성하면, 스프링이 해당 구현체를 주입한다. 이 패턴을 사용하면 리팩터링이 훨씬 더 쉬워진다. 예를 들어, 모든 비즈니스 로직을 한 곳에 넣는 대신에 (설계 시 적합하다고 판단된다면) 다른 마이크로서비스를 호출하도록 구현체를 바꿀 수 있다.
- 컨텍스트(예를 들어 고객과 주문)를 정의하고 나면 애플리케이션 전반에서 일관된 이름을 지을 수 있다. 설계 단계가 명확해질 때까지 도메인 로직을 옮기고(작은 일체형에서는 더 쉽다), 그 이후에는 경계를 유지한다. 쉽게 개발하려고 비즈니스 로직을 엉키게 하지 말자! 일체형으로 시작하면 코드를 개선하는 데 도움이 된다.
- 일반적인 패턴을 찾는다. 예를 들어, 나중에 공통 라이브러리로 뽑아낼 부분을 정의한다.
- 다른 사람들도 아키텍처를 이해하고 따를 수 있도록 피어 리뷰(peer review)를 진행한다.
- 프로젝트 매니저와 향후 일체형을 분리할 시간을 정하자. 일체형을 분리하는 전략에 대해 설명하고, 리팩터링은 꼭 필요하고 아무 문제없다는 것에 모두 공감하는 문화를 만들자.
최소 첫 릴리스까지는 작은 일체형을 유지하자. 작은 일체형도 여러 장점이 있다.
- 비즈니스 사용자가 원하는 것을 확인하고 수정 사항을 적용하면서 시스템을 개선할 수 있다.
- 도메인 모델을 쉽게 바꿀 수 있다. 도메인 모델이 적절한지 충분히 확인하고 적용할 수 있다.
- 팀 또는 여러 팀이 공통 기술과 기능적인 회사 가이드라인에 익숙해지게 된다.
- 도메인 간 공통 기능을 정의하고 라이브러리로 뽑아낼 수 있다.
- 팀원 모두가 완성된 시스템의 첫 번째 버전으로 함께 작업한다. 따라서 부분적인 내용뿐만 아니라 전체적인 내용을 이해하기 쉽다.
반면 몇 가지 단점도 존재한다.
- 일체형으로 개발하는 단점*을 이미 알 것이다. 하지만 좋은 패턴을 따라 설계한다면 나중에 분리하기가 쉽다.
- 첫 번째 배포 전략은 나중에 쓸모가 없어진다. 하지만 배포 관련 도구를 활용해 같은 방식으로 서비스를 여러 개에 적용하면 재사용할 수 있다.
- 여러 사람이 동시에 같은 코드를 가지고 작업하는 것은 문제도 많고 비효율적이다. 그래서 되도록 작게 시작하는 것이 좋다. 프로젝트 매니저와 협의해서 작은 일체형을 계획하면 첫 번째 릴리스에서는 많은 인력이 필요하지 않다. 남은 인력과 자원을 다른 방식으로 활용할 수 있다. 작은 일체형으로 개발하되, 같은 기능을 두 팀이 다른 대안으로 개발하고, 첫 번째 릴리스 때 하나를 폐기하는 방법도 있다.
* 일체형으로 개발하는 단점
- 유지보수 어려움: 코드베이스가 커지면서 유지보수하기 어렵다. 작은 변경 사항이라도 전체 시스템에 영향을 미칠 수 있다.
- 배포 복잡성: 한 번에 전체 시스템을 배포해야 한다.
- 기술 선택의 제한: 하나의 기술 스택에 종속되기 쉽다. 특정 모듈에 적합한 다른 기술을 사용하고 싶어도 전체 시스템과의 호환성을 고려해야 하므로 제한이 많다.
사용자 스토리 3
매일 문제를 풀기 위해 좀 더 동기부여가 되면 좋겠어요!
이는 사용자의 관점이지만, 애플리케이션 관리자의 입장에서도 사용자들이 매일 방문해서 재방문으로 수익을 만들고 싶을 것이다. 게임화(gamification) 기법을 적용하여 사용자들이 매일 사이트에 재방문하도록 게임화 기술을 적용한다고 해보자. 게임화는 곱셈을 푸는 것과는 아무 상관이 없다. 새로운 모델을 정의하고 분리된 스프링 부트 애플리케이션을 만들 수 있는 좋은 기회다! 우리가 만든 첫 번째 애플리케이션은 이미 잘 실행되고 있으며, 이제 마이크로서비스 아키텍처를 적용할 차례이다.
기존의 스프링 부트 애플리케이션은 곱셈 마이크로서비스가 되고, 새로운 애플리케이션은 게임화 마이크로서비스가 된다.
점수, 배지, 리더보드
게임을 만드는 기본적인 아이디어는 점수를 도입하는 것이다. 어떤 동작을 할 때마다, 그리고 잘할 때마다 점수를 얻는다. 승점을 받으면 플레이어는 피드백을 받고 발전하고 있음을 느끼게 된다.
리더보드는 모두가 볼 수 있도록 점수를 공개한다. 플레이어는 다른 플레이어에게 경쟁심을 갖고 동기부여가 되다. 다른 플레이어보다 더 많은 점수를 쌓고 더 높은 순위에 오르려고 한다.
마지막으로, 무언가를 달성할 때 얻는 상징인 배지도 중요하다. 동배지(정답 10개), 은배지(정답 25개), 금배지(정답 50개)를 만들고, 처음 정답을 맞히면 '첫 정답' 배지를 지급한다. 이는 사용자에게 빠르고 긍정적인 피드백을 준다.
마이크로서비스 아키텍처로 전환하기
마이크로서비스 아키텍처로 전환하기로 결정했으므로 이제 시스템의 새로운 부분을 만들자! 이는 독립적으로 배포가 가능하고, 기존 비즈니스 로직과 분리된 게임화 마이크로서비스이다. 기존 스프링 부트 애플리케이션(곱셈 마이크로서비스)과 연결해야 하고, 독립적으로 확장 가능해야 한다. 왜 계속 하나의 프로젝트(일체형)로 만들면 안 될까? 현실적인 관점에서 중요한 이유를 알아보자.
관심사를 분리하고 결합도를 낮추기
만약 게임화 로직을 기존 코드에 넣고 함께 배포한다면 두 로직을 합치면서 에러가 발생할 위험이 있고, 관심사를 분리하는 이점이 사라진다. 에러가 발생하지 않을 수도 있지만 실제로 도메인을 함께 두면 둘수록 누군가 대충 개발할 확률이 높아져 에러가 발생할 위험이 커진다. 특히 데이터를 같은 데이터베이스 안에 넣는다면 말이다.
이전에 일체형부터 개발하는 방식의 이점을 설명했듯이 모든 것을 하나로 두면 처음 단계에서는 설계와 개발이 용이하다. 하지만 마이크로서비스로 마이그레이션 하려면 프로젝트의 수명 주기에 따라 일체형이 더 커지는 것을 중지할 적당할 시점을 찾는 것이 중요하다. 그렇지 않으면 작은 일체형이 중간 크기의 일체형이 되고 확실히 문제가 생긴다. 프로젝트의 압력이 가해지고 빡빡한 가이드라인까지 더해진다면 위험은 더욱 커진다! 이런 상황에서 사람들은 대충 개발하게 되고 일체형은 제멋대로 커지게 된다.
결합도를 낮추는 일은 어렵다. 예제에서는 사용자의 점수를 표로 보여줘야 하는데, 많은 개발자들이 조인 쿼리를 이용해 간단히 작성할 것이다. 데이터가 여러 곳에 저장되어 있더라도 하나의 클래스에 여러 도메인을 넣어서 비즈니스 로직을 작성하는 개발자도 있을 것이다. 도메인 컨텍스트가 여러 개라면 문제는 더 잘 발생한다. 여러 서비스가 합쳐지고, 도메인 개체들이 섞이고, 비즈니스 로직은 여기저기 나타난다. 마치 정글처럼 돼 버린 코드들을 풀기 위해 정글칼과 많은 인내심이 필요하다. 곱셈과 게임화를 분리해서 마이크로서비스로 구현하면 결합도를 낮추는 방향으로 생각하게 된다. 데이터를 부분적으로 복제하거나, 다른 서비스를 필요할 때 호출하는 식이다.
독립적인 수정사항
곱셈화와 도메인을 독립적으로 배포 가능한 서비스로 만들면 각자의 API를 이용해 따로 테스트할 수 있다. 앞으로 게임화 팀은 곱셈 팀의 개발 주기에 상관없이 그들의 서비스를 개선할 수 있다. 통신을 위해 새로운 인터페이스가 필요하다면 가짜 호출이나 메시지를 만들고 각자 개발하면 된다. 중요한 건 게임화 팀에서 개발하는 것이 곱셈 서비스에 영향을 주지 않고 서로 방해하지 않는다는 것이다. 따라서, 프로젝트 매니저들은 밤에 두 다리 뻗고 잘 수 있다.
확장성
곱셈 게임이 대박이 났다고 상상해 보자! 수천에서 수만 명의 사용자가 방문해서 곧 클라우드 서버의 리소스가 부족해지고 곱셈 결과 확인이 지연되기 시작한다. 따라서 시스템을 확장하고 로드 밸런싱 기능을 적용하고자 한다.
전체 시스템을 하나로 배포한다면, 인스턴스를 여러 개 만드는 방법밖에 없다. 이는 자원을 낭비하거나, 적어도 우리가 할 수 있는 만큼 유연하지 않다는 것을 의미한다. 서비스가 여러 개 있다면 확장 방법을 유연하게 선택할 수 있다. 이 경우에는 새로운 요구에 대응하기 위해 곱셈 서비스를 확장하고, 게임화 서비스는 확장하지 않는 것이 좋은 전략이다. 왜냐하면 점수와 리더보드는 조금 지연되더라도 크게 중요하지 않기 때문이다! 물론 이런 전략은 앞선 방법보다는 복잡하지만 서비와 클라우드 컴퓨팅은 곧 비용이고, 비용을 절약하는 것은 모든 소프트웨어 프로젝트에서 중요한 일이다.
마이크로서비스 연결하기
게임화 로직을 분리된 마이크로서비스로 만들자. 즉, 어떻게든 기존 비즈니스 로직과 연결해야 한다. 만약 이벤트 중심의 방식으로 개발해보지 않았다면 다음 세 가지 방법을 생각해 볼 수 있다.
- 두 개의 서비스가 데이터베이스를 공유해서 게임화 서비스가 데이터를 바로 사용한다.
- 좋은 방법이 아니다! 서비스들이 서로의 데이터에 접근하고 섞이면서 컨텍스트를 분리하는 이점이 대부분 사라진다.
- 게임화 서비스가 곱셈 서비스에서 주기적으로 데이터를 끌어와 점수와 배지를 준다. 이렇게 하려면 기존 서비스에 REST API를 추가해야 한다.
- 첫 번째 방법보다는 조금 낫지만 새로운 데이터를 지속적으로 끌어와야 하고, 이미 처리된 답안을 추적해야 한다(예를 들어, 받은 답안이 게임화 처리가 됐는지 확인해야 한다).
- 곱셈 서비스에서 무슨 일이 있을 때마다(답안을 받으면) 게임화 서비스를 호출해서 데이터를 전송하고 게임 통계를 업데이트한다. 일종의 원격 프로시저 호출(Remote Procedure Call; RPC) 방식이다.
- 두 번째 방법보다는 낫지만, 더 개선할 점이 있다. 곱셈 서비스는 게임화 서비스를 알 필요가 없다. 곱셈 서비스를 게임화 서비스 없이 그 자체로 동작할 수 있도록 설계해야 한다.
세 번째 방식을 개선하면 여러 서비스가 최대한 분리된 상태로 통신하도록 설계할 수 있다. 곱셈 서비스는 곱셈 답안을 받았다는 것을, 메시지 버스에 이벤트를 전송해서 자신에게 관심을 가진 서비스에게 알려준다. 따라서 다른 비즈니스 프로세스에 영향을 주지 않고 투명하게 연결할 수 있다.
- 정답을 연속으로 너무 많이 보내는 것처럼 사용자가 의심스러운 동작을 보일 때마다 관리자에게 이메일을 보내야 한다고 생각해 보자. MultiplicationSolvedEvent를 구독하면 여러 마이크로서비스에서 비즈니스 로직을 수행할 수 있다.
- 사용자별 또는 시간별 정답의 분석 결과와 통계 정보를 수집해야 한다고 해보자. 새로운 마이크로서비스를 만들면 기존 로직에 영향을 주지 않고도 가능하다.
- 문제를 맞히고 포스팅할 수 있도록 소셜 네트워크 플러그인을 추가해 볼까? 같은 이벤트를 이용하는 마이크로서비스로 독립적으로 수행할 수 있다.
위와 같이 반응형(reactive) 패턴을 이용해 설계하면 유연성이 높아진다. 이런 방식으로 설계하는 것이 이벤트 중심 아키텍처 또는 반응형 시스템이다.
하지만 이벤트 중심 전략이 마이크로서비스 간 상호 작용에 항상 맞는 것은 아니다. 비즈니스 프로세스 중에는 서비스가 이벤트 없이 서로 데이터를 원하는 경우도 있다. 그런 경우에는 요청-응답 패턴이 더 적합하기 때문에 이벤트 중심 방식을 사용하지 않는다. 앞서본 소셜 네트워크 서비스가 그런 예이다. 사용자 닉네임에 접근이 필요하고, 다른 세부사항을 구현해야 한다. 이런 통신 방식을 이벤트 중심의 접근 방식과 어떻게 조합해서 사용하는지 예제 애플리케이션을 통해 실제 사례를 다뤄보자.
이벤트 중심 아키텍처
이런 종류의 아키텍처에서는 중요한 행위가 일어날 때마다 여러 마이크로서비스가 이벤트를 전송한다. 각 마이크로서비스는 메시지 브로커(또는 이벤트 버스)를 통해 이벤트를 주고받는다. 마이크로서비스는 각자 관심이 있는 이벤트를 구독하고, 이벤트를 처리한다.
중요한 건 이벤트는 이미 일어난 사건이라는 점이다. 지나간 일이라 바꿀 수도 없고, 미리 예방할 수도 없다. 그래서 이벤트의 이름은 보통 과거형(ex. MuliplicationSolvedEvent)으로 짓는다. 해당 이벤트를 구독한 마이크로서비스는 각자의 비즈니스 로직을 수행하고 다른 이벤트를 발행(publish)할 수도 있다(ex. ScoreUpdatedEvent). 이런 작용-반작용 패턴을 기반으로 하는 시스템을 반응형 시스템이라고 한다.
참고) 반응형 시스템을 반응형 프로그래밍과 혼동하지 말자! 반응형 프로그래밍은 프로그래밍 스타일로서 전혀 다른 이야기이다.
Reactive programming vs. Reactive systems
Landing on a set of simple reactive design principles in a sea of constant confusion and overloaded expectations.
www.oreilly.com
관련 기법
이벤트 중심 아키텍처는 이벤트 소싱(Event sourcing), 도메인 주도 설계, CQRS 같은 기법과 밀접한 연관이 있다. 이 기법들은 독립적으로 적용할 수 있으며 항상 합리적으로 사용해야 한다. 시스템을 설계할 때 이런 기술 자체에 현혹되지 말고 문제를 해결하는 도구로써 사용해야 한다.
이벤트 소싱은 비즈니스 개체를 저장하는 방식이다. 시간이 지나면서 변하는 정적 상태를 모델링하는 대신, 변경할 수 없는 일련의 이벤트로 모델링하는 것이다.
Customer 같은 일반적인 예제에서는 데이터를 저장하는 고객 테이블 대신 CustomerChanged 이벤트 시퀀스가 있다. 이름이 Frog인 고객이 있다고 해보자. 실수로 이름을 Prog로 바꿔서 다시 Frog로 바꿨다. 기존의 데이터를 저장하는 방법으로는 이름이 Frog인 상태만 조회할 수 있다. 이벤트 소싱을 사용하면 개체는 다음과 같은 이벤트 시퀀스의 최종 상태가 된다.
CustomerChanged → name: Frog(생성), CustomerChanged → name: Prog(실수), CustomerChanged → name: Frog(수정)
은행 애플리케이션을 떠올려보자! 계좌는 시간에 따라 트랜잭션을 모아둔 것이기 때문에 이런 패턴에 적합하다.
이벤트 소싱은 이벤트 중심의 시스템에서 더 쉽게 구현할 수 있다. 물론 거저먹기는 아니다ㅋㅋ. 몇 개의 이벤트로 이벤트 중심 아키텍처를 설계할 수 있지만, 이벤트 소싱을 전체적으로 적용하려면 모델링해야 하는 이벤트 수가 크게 증가한다. 그래서 여기서는 이벤트 중심 아키텍처를 활용하지만 이벤트 소싱 기반으로 저장하지는 않을 것이다.
Event Sourcing pattern - Azure Architecture Center
Use an append-only store to record the full series of events that describe actions taken on data in a domain.
learn.microsoft.com
도메인 주도 설계(Domain-Driven Design, DDD)는 소프트웨어 패턴으로, 에릭 에반스(Eric Evans)가 자신의 책인 《도메인 주도 설계≫에서 처음 설명했다. 이는 하나의 패턴 그 이상으로, 비즈니스 도메인이 시스템의 핵심이라는 소프트웨어 설계 철학이라고 할 수 있다.
도메인 주도 설계: 소프트웨어의 복잡성을 다루는 지혜
소프트웨어의 복잡성을 다스려라! 소프트웨어의 복잡성은 도메인에서 기인하고, 그러한 복잡성을 어떻게 다루느냐가 프로젝트의 성패를 좌우한다. 도메인 주도 설계(Domain-Driven Design)는 복잡한
wikibook.co.kr
DDD 패턴에 따르면, 시스템을 별도로 처리할 수 있도록 하위 도메인 같은 바운디드 컨텍스트(bounded context)를 정의할 수 있다. 이는 마이크로서비스를 설계할 때 유용하다. 마이크로서비스를 바운디드 컨텍스트에 쉽게 매핑하고 DDD 접근 방식의 장점을 활용할 수 있다. 이 프로젝트에서는 DDD 원칙에 따라 개발한다.
CQRS(Command-Query Responsibility Segregation, 명령-쿼리 책임 분리)는 데이터를 조회하는 쿼리 모델과 데이터를 업데이트하는 커맨드 모델을 분리하는 패턴이다. 따라서 훨씬 복잡한 시스템을 사용하는 대신 데이터를 매우 빠르게 읽어올 수 있다. 저장 모델을 이벤트 저장 모델로 하면 이벤트 소싱과 함께 사용할 수 있다.
bliki: CQRS
CQRS (Command Query Responsibility Segregation) is the notion that you can use a different model to update information than the model you use to read information
martinfowler.com
이벤트 중심 아키텍처의 장점과 단점
예제 애플리케이션으로 이벤트 중심 아키텍처의 장단점을 실습과 함께 알아보자. 시나리오는 다음과 같다.
- 사용자가 곱셈 문제를 풀고 답안을 제출하면, 곱셈 마이크로서비스가 해당 답안을 처리하고 MultiplicationSolvedEvent를 전송한다.
- 새로운 게임화 마이크로서비스는 해당 이벤트를 소비하고 사용자에게 점수를 부여한다.
각각의 데이터와 기능은 분리된 상태로 유지된다.
다음 항목 중 일부는 장단점으로 바로 구분할 수 없다. 어떤 특성은 손이 많이 가지만, 다른 한편으로는 이점을 주기도 한다.
결합도 낮추기
이벤트 중심 아키텍처를 사용하면 서비스 간 결합도를 낮출 수 있다. 여러 서비스가 각자 독립적인 방법으로 프로세스를 완료하도록 큰 프로세스를 작은 조각으로 나눈다. 우리 시스템에서는 정담을 맞추면 점수를 주는 프로세스를 두 개의 마이크로서비스로 나눴다. 이것은 커다란 이점을 가져다 준다. 프로세스를 분산함으로써 시스템 내에서 통제하거나 복잡하게 꼬일 만한 부분이 없어진다.
트랜잭션
이벤트 중심 아키텍처에서는 서비스 전체에 ACID 트랜잭션이 없다고 봐야한다(이를 지원하려면 복잡성을 도입해야 한다). 그 대신 모든 이벤트를 전파하고 소비한다면 궁극적으로는 일관된 상태가 된다.
우리의 시나리오에서 게임화 서비스가 멈추면 그것을 막을 방법이 없어 점수는 업데이트 되지 않는다. 이는 곱셈 문제를 풀고 점수를 얻는 트랜잭션에 원자성이 없다는 뜻이다. 이를 해결하려면 최소한 한 번은 이벤트가 전달되도록 보장하는 메시지 브로커를 구현해야 한다.
서비스 간에 트랜잭션이 없다는 것이 그 자체로 나쁜 것은 아니다. 문제는 트랜잭션이 없어서, 기능 요구사항을 설계하고 변환하는 방법을 변경해야 한다는 것이다(예를 들어, 특정 단계에서 프로세스가 중단되면 어떻게 될까?).
장애 허용 능력
이런 시스템에서는 트랜잭션이 없거나 최소화되면서 장애 허용 능력(falut tolerance)이 더욱 중요해진다. 서비스 중 하나가 프로세스 일부를 완료하지 못하더라도 전체 시스템은 멈추지 않는다. 이런 상황을 사전에 방지해야 한다(예를 들면, 고가용성을 위한 마이크로서비스 이중화 및 로드 밸런싱). 그리고 에러에서 복구하는 방법을 생각해야 한다(예를 들어, 유지보수 콘솔을 이용해 이벤트를 재생성).
이벤트 중심 방식뿐 아니라 어떤 시스템에서든 장애 허용 능력을 갖추는 것이 좋다. 잘 구현한다면 일체형보다 더 고가용성을 갖춘 분산 시스템을 만들 수 있다. 일체형에서는 트랜잭션 범위가 넓어 롤백되면 아무것도 할 수가 없다. 하지만 이벤트는 나중에 처리될 수 있도록 대기열에 저장되므로 시스템의 일부가 독립적으로 종료되고 자동으로 재시작된다. 이 방식이 훨씬 더 강력하다!
오케스트레이션과 모니터링
중앙 집중식 오케스트레이션 계층이 없으면 프로세스 모니터링이 중요한 시스템에서 문제가 될 수 있다. 이벤트 중심 아키텍처에서는 이벤트를 발생시키고 발생한 이벤트를 처리하면서 여러 서비스에 걸쳐 프로세스가 진행된다. 마이크로서비스로 분산되어 있기 때문에 중앙 집중 방식으로는 따라갈 수가 없다. 이러한 프로세스를 모니터링하려면 이벤트 흐름을 추적하기 위한 메커니즘을 구현해야 하고, 서비스 간 상태를 기록하는 공통 로깅 시스템이 필요하다.
우리 시스템이 발전해서 첫 번째 이벤트(MultiplicationSolvedEvent)에 네 개의 서비스가 반응하게 만들었다고 해보자! 그리고 이어서 다른 이벤트가 순차적으로 발생한다(ScoreUpdatedEvent → LeaderboardPositionChangedEvent → CongratulactionsEmailSent). 문서에 잘 정리해 놓거나 자동 추적 코드를 도입하지 않는 한 엔드투엔드로 어떤 일이 벌어지고 있는지 추적하기 어렵다. 시스템 내부를 보지 않고서 곱셈을 풀었을 때 이메일이 전송된다는 걸 어떻게 알 수 있을까? 이벤트를 상호 연관시키는 우리 서비스만의 메커니즘을 구현하거나(서비스를 지날 때마다 태그를 지정하도록) 또는 Zipkin 같은 툴을 이용할 수도 있다.
GitHub - openzipkin/zipkin: Zipkin is a distributed tracing system
Zipkin is a distributed tracing system. Contribute to openzipkin/zipkin development by creating an account on GitHub.
github.com
결정을 내리기 전에 따져보기
요약하자면, 이벤트 중심 방식으로 시스템을 개발할 때는 위에서 살펴본 항목의 장단점을 따져보는 것이 중요하다. 장점뿐만 아니라 단점을 잊지말고 이에 대한 해결책을 준비하자!
참고 자료
Event-Driven Data Management for Microservices
Learn how an event-driven microservices architecture solves the distributed data management challenges caused by separate per-service datastores.
www.f5.com
Using Events in Highly Distributed Architectures
Using Events in Highly Distributed Architectures Article 01/14/2009 In this article --> David Chou Summary: SOA succeeded in getting loose coupling at a technical level; now, let us go for the same at a business-process level. Contents Introduction Event-D
learn.microsoft.com
Variations in event-driven architecture - O'Reilly Radar
Editor’s note: this is an advance excerpt from Chapter 2 of the forthcoming Software Architecture Patterns by Mark Richards. This report looks at the patterns that define the basic characteristics and behavior of highly scalable and highly agile applicat
radar.oreilly.com
이벤트 중심 아키텍처 적용하기
이번 장에서는 게임화 로직을 구현하는 새로운 스프링 부트 애플리케이션을 만든다. 그리고 마이크로서비스로 전환한다. 이제 곱셈 애플리케이션(곱셈 마이크로서비스)과 게임화 애플리케이션(게임화 마이크로서비스)으로 기능이 나뉜다.
또한 이벤트 중심 아키텍처를 적용하고 두 개의 다른 컨텍스트(곱셈과 게임화) 간의 상호 작용을 이벤트로 모델링한다.
곱셈 서비스는 지금처럼 잘 동작하면 된다. 다만 곱셈 결과에 관심을 가진 서비스와 통신하는 부분을 추가해야 한다. 그래서 이 비즈니스 로직을 나타내는 MultiplicationSolvedEvent를 모델링해야 한다. 그다음으로 게임화 로직을 새로운 서비스로 모델링하고 새로운 타입의 이벤트를 소비하도록 한다. 새로운 이벤트를 이용해 데이터를 처리하고 사용자에게 점수와 배지를 부여한다.
RabbitMQ와 스프링 AMQP를 이용한 이벤트 중심 설계
RabbitMQ는 스프링 부트와 잘 연동되는 오픈소스 메시지 브로커이다. 게다가 AMQP(Advanced Message Queuing Protocol)를 구현하고 있어서 도구에 의존적이지 않은 일반적인 방법으로 코드를 짤 수 있다.
AMQP 0-9-1 Model Explained | RabbitMQ
<!--
www.rabbitmq.com
시스템에 RabbitMQ 추가
- 메시지를 전송하는 채널인 Exchange를 만들고 기본적인 MultiplicationSolvedEvent 메시지를 전송한다. 메시지는 JSON으로 직렬화한다. JSON은 쉽게 확장할 수 있고 읽기 쉬운 포맷이다.
- Exchange는 메시지를 보내는 가장 유연한 방식인 Topic으로 만든다.
- mutiplication.solved라는 라우팅 키(routing key)를 이용해 이벤트 메시지를 전송한다.
- 구독하는 쪽(게임화 마이크로서비스)에서 Queue를 생성하고, Topic Exchange를 바인딩해서 메시지를 받는다.
- Queue가 내구성을 갖추게 하자. 메시지 브로커인 RabbitMQ가 다운되더라도 메시지는 유지되기 때문에 언제든지 이벤트를 처리할 수 있다.
스프링 AMQP
- 의존성 추가
- 게임화 마이크로서비스는 곱셈 마이크로서비스보다 먼저 시작되는 경우를 대비해서 Topic Exchange를 생성하는 설정을 포함해야 한다. 스프링 AMQP는 이미 존재하는 Exchange나 Queue를 중복해서 생성하지 않기 때문에 중복해서 생성될 걱정은 없다.
곱셈 서비스에서 이벤트 보내기
이벤트 중심 환경에서 동작하도록 곱셈 마이크로서비스를 수정해야 한다. RabbitMQ를 메시지 브로커로 사용하고 스프링 AMQP로 자바 코드에서 브로커와 통신하도록 한다. 먼저 사용자가 답안을 보낼 때마다 곱셈 서비스에서 MultiplicationSolvedEvent를 전송하는데 집중해 보자. 이후에 이 이벤트를 구독하고 처리하는 게임화 마이크로서비스를 구현한다.
RabbitMQ 설정
implementation 'org.springframework.boot:spring-boot-starter-amqp'
package me.progfrog.mallang.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();
}
}
- 기본 자바 직렬화 메커니즘 대신 JSON으로 직렬화하면 몇 가지 장점이 있다.
- 자바 직렬화는 헤더(__TypeId__)를 이용해 클래스 전체 이름에 태그를 지정한다. 즉, 같은 클래스명을 이용해 메시지를 역직렬화하면 구독자가 같은 패키지에 있어야 한다. 그러면 서비스 간 강한 결합이 생긴다.
- 향후 다른 언어로 이루어진 서비스와 연결하려면 자바 직렬화를 사용할 수 없다.
- (최소한 개발 초기 단계에서) 사람이 읽을 수 있는 포맷을 사용하지않는 다면 채널(Queue와 Exchange)에서 발생한 에러를 분석하기가 매우 어렵다.
Sending and receiving JSON messages with Spring Boot AMQP and RabbitMQ
This tutorial shows you how to use RabbitMQ in Spring Boot with guided examples. Includes configuration to serialize your messages using JSON.
thepracticaldeveloper.com
이벤트 모델링
이제 두 마이크로서비스가 정보를 교환할 이벤트를 만들어보자! 이벤트 중심 아키텍처의 핵심을 떠올려보자. 이벤트는 이미 발생한 과거의 일이어야 하고, 일반화되어야 한다(구독자에 대해서는 알지 못한다). 해결된 곱셈 답안과 구독자가 곱셈 마이크로서비스와 무관하다는 것을 알아보자.
새 패키지에 이벤트 클래스를 추가한다. 이 클래스는 JSON 메시지로 변환하기 위해 Serializable을 구현한다.
package me.progfrog.mallang.event;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.io.Serializable;
/**
* 시스템에서 {@link me.progfrog.mallang.domain.Multiplication}
* 문제가 해결되었다는 사실을 모델링한 이벤트
* 곱셈에 대한 컨텍스트 정보를 제공
*/
@RequiredArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class MultiplicationSolvedEvent implements Serializable {
private final Long multiplicationResultAttemptId;
private final Long userId;
private final boolean correct;
}
- 이벤트를 모델링할 때는 이벤트 안에 넣을 정보를 선택해야 한다. MultiplicationResultAttempt 객체 전체를 넣을 수도 있었지만, 그렇게 하면 해당 객체가 참조하고 있는 User와 Multiplication 객체도 함께 전달된다. 굳이 이런 필요 없는 정보를 같이 전달해야 할까?
- 이벤트 객체가 커지면 좋지 않다. 예를 들어, 사용자 정보가 수정될 때 이벤트를 받는다고 가정해 보자. 여러 구독자 중 하나가 메시지를 받는 데 실패해서 메시지 브로커가 전송 거부된 메시지를 다시 보내는 경우를 생각해 보자. 그러면 이벤트 순서는 실제 수정된 순서와 맞지 않는다. 그래서 메시지를 소비하는 쪽에서는 해당 메시지가 최신 상태를 반영하는지 확신할 수가 없다. 이를 해결하기 위해서 이벤트에 대한 타임스탬프를 사용할 수도 있지만, 메시지를 소비하는 쪽에서 오래된 변경사항을 삭제하는 등 시간을 처리하는 추가 로직이 필요하다.
- 객체의 변화하는 데이터를 이벤트에 포함시키는 것은 위험하다. 이보다는 사용자 정보가 수정될 때마다 식별자를 알리고, 메시지를 소비하는 쪽에서 로직을 처리할 때 최신 상태를 요청하게 하는 편이 나을 수도 있다.
- 또 다른 예로 향후 새로운 마이크로서비스(통계 분석 등)를 추가한다고 하자. 이 서비스가 답안의 타임스탬프가 필요하다고 하면 MultiplicationSolvedEvent 안에 타임스탬프 정보를 넣어야 한다. 그러면 발행하는 입장에서는 모든 소비자의 필요에 맞게 이벤트를 수정해야 한다. 결국 이벤트는 커지고, 발행자는 소비자의 비즈니스 로직에 대해 너무 많이 알게 된다. 이런 패턴은 이벤트 중심 아키텍처에서 지양해야 할 패턴이다. 일반적으로 이런 데이터를 이벤트에 포함시키는 것보다 소비자가 필요한 정보를 직접 요청하게 하는 편이 바람직하다.
- 이벤트에는 불변 값을 넣는 것이 좋은데, 우리 케이스에서 답안은 곱셈 서비스가 한번 처리하고 나면 수정이 불가능한 불변 값이다. 따라서, 사용자에 대한 참조(userId)와 정답 여부를 boolean 값으로 전달할 수 있다. 이 정보는 일반적이고, 변하지 않으며, 소비자의 추가 요청을 저장할 수 있다(이건 너무 빈약한 이벤트의 부작용이기도 하다!).
- 보다시피 둘 중 어느 것이 좋은지 명확하지 않다. 명확한 것은 이벤트를 모델링하는 것은 도메인을 모델링하는 것만큼이나 중요하다는 것이다. 따라서, 처음에는 꼭 필요한 정보를 담아 최대한 작고 단순하게 유지해야 한다. 또한, 구독자에게 일관되고 일반적인 정보를 충분히 전달해야 한다.
이벤트 전송: 디스패처 패턴
비동기 통신의 일반적인 두 가지 패턴은 이벤트 디스패처(또는 이벤트 발행자)와 이벤트 핸들러(또는 이벤트 구독자)이다. 모든 클래스에서 서로 이벤트를 발행하고 소비하는 대신 중앙에서 이벤트의 입력 및 출력을 관리함으로써 서비스 상호작용을 쉽게 찾고 이해할 수 있다.
반면 이벤트 디스패처 또는 리스너를 하나의 클래스로 처리하면 클래스가 커지고 리디렉션 로직으로 복잡해진다. 하지만 마이크로서비스 아키텍처에서는 장점으로 볼 수 있다. EventDispatcher 또는 EventHandler 클래스가 너무 커진다면 아마도 마이크로서비스가 적당히 작지 않기 때문일 것이다. 하나의 마이크로서비스에서 너무 많은 이벤트를 다루고 있는 것은 아닐까? 마이크로서비스에 너무 많은 책임이 있진 않은지 살펴봐야 한다. 또한 마이크로서비스에서 실제로 많은 이벤트를 처리해야 할 수도 있다. 그렇다면 비즈니스 로직에 따라 이벤트 디스패처와 핸들러를 여러 클래스로 나누는 것이 좋다.
이제 곱셈 마이크로서비스에 디스패처 패턴을 적용하자. 구독자 쪽 로직은 게임화 마이크로서비스 코드를 작성할 때 다뤄보자.
## RabbitMQ
multiplication.exchange=multiplication_exchange
multiplication.solved.key=multiplication.solved
package me.progfrog.mallang.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 관련 정보를 전달하기 위한 Exchange
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을 가져오고, 애플리케이션 프로퍼티에서 Exchange 이름과 라우팅 키를 가져온다. 그런 다음, 템플릿의 convertAndSend 메서드를 호출한다(설정에 따라서 JSON으로 변환된다). 그리고 MultiplicationSolvedEvent는 multiplication.solved라는 라우팅 키를 사용한다. 이 이벤트는 multiplication.*라는 라우팅 패턴을 사용하는 소비자의 큐로 전해진다.
이제 비즈니스 로직에서 이벤트를 전송하는 부분만 남았다. 스프링 AMQP는 트랜잭션을 지원한다. 메서드에 @Transactional 애노테이션을 사용했기 때문에 예외가 발생하면 이벤트가 전송되지 않는다. 메서드가 시작되는 부분에서 eventDispatcher.send()를 호출하고 나중에 예외가 발생하더라도 말이다.
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);
}
private Multiplication findOrCreateMultiplication(int factorA, int factorB) {
Optional<Multiplication> multiplication = multiplicationRepository.findByFactorAAndFactorB(factorA, factorB);
return multiplication.orElseGet(() -> multiplicationRepository.save(new Multiplication(factorA, factorB)));
}
}
Mockito로 이벤트가 전송이 잘 됐는지 테스트를 해야 한다. 이를 테스트하는 검증(assertion) 코드를 추가하자.
package me.progfrog.mallang.service;
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.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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class MultiplicationServiceImplTest {
@Mock
private RandomGeneratorService randomGeneratorService;
@Mock
private MultiplicationResultAttemptRepository attemptRepository;
@Mock
private MultiplicationRepository multiplicationRepository;
@Mock
private UserRepository userRepository;
@Mock
private EventDispatcher eventDispatcher;
@InjectMocks
private MultiplicationServiceImpl multiplicationServiceImpl;
private final int factorA = 50;
private final int factorB = 60;
private User user;
private Multiplication multiplication;
@BeforeEach
void setUp() {
user = new User("Frog");
multiplication = new Multiplication(factorA, factorB);
}
@Test
@DisplayName("랜덤한 인수에 대한 계산 결과가 잘 나오는 지 확인")
void createRandomMultiplication() {
// given (randomGeneratorService가 처음에 50, 나중에 30을 반환하도록 설정)
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);
// when
Multiplication multiplication = multiplicationServiceImpl.createRandomMultiplication();
// then
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
// assertThat(multiplication.getResult()).isEqualTo(1500);
}
@Test
@DisplayName("계산 결과가 맞으면 true 반환")
void checkAttempt_1() {
// given
given(userRepository.findByAlias(anyString())).willReturn(Optional.of(user));
given(multiplicationRepository.findByFactorAAndFactorB(anyInt(), anyInt())).willReturn(Optional.of(multiplication));
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3000, false);
MultiplicationResultAttempt correctAttempt = new MultiplicationResultAttempt(user, multiplication, factorA * factorB, false);
MultiplicationSolvedEvent event = new MultiplicationSolvedEvent(attempt.getId(), attempt.getUser().getId(), true);
// when
boolean result = multiplicationServiceImpl.checkAttempt(correctAttempt);
// then
assertThat(result).isTrue();
verify(attemptRepository).save(any(MultiplicationResultAttempt.class));
verify(eventDispatcher).send(eq(event));
}
@Test
@DisplayName("계산 결과가 틀리면 false 반환")
void checkAttempt_2() {
// given
given(userRepository.findByAlias(anyString())).willReturn(Optional.of(user));
given(multiplicationRepository.findByFactorAAndFactorB(anyInt(), anyInt())).willReturn(Optional.of(multiplication));
MultiplicationResultAttempt wrongAttempt = new MultiplicationResultAttempt(user, multiplication, 3010, false);
MultiplicationSolvedEvent event = new MultiplicationSolvedEvent(wrongAttempt.getId(), wrongAttempt.getUser().getId(), false);
// when
boolean result = multiplicationServiceImpl.checkAttempt(wrongAttempt);
// then
assertThat(result).isFalse();
verify(attemptRepository).save(any(MultiplicationResultAttempt.class));
verify(eventDispatcher).send(eq(event));
}
@Test
@DisplayName("이미 채점된 상태로 답안을 보낼 때 예외 발생")
public void checkAttempt_3() {
// given
MultiplicationResultAttempt attempt = new MultiplicationResultAttempt(user, multiplication, 3010, true);
// then
assertThrows(IllegalArgumentException.class, () -> {
multiplicationServiceImpl.checkAttempt(attempt);
}, "채점된 상태로 보낼 수 없습니다!");
}
@Test
@DisplayName("사용자의 최근 답안을 보여주기")
public void retrieveStatsTest() {
// given
MultiplicationResultAttempt attempt1 = new MultiplicationResultAttempt(user, multiplication, 3010, false);
MultiplicationResultAttempt attempt2 = new MultiplicationResultAttempt(user, multiplication, 3051, false);
List<MultiplicationResultAttempt> latestAttempts = Lists.newArrayList(attempt1, attempt2);
doReturn(latestAttempts).when(attemptRepository).findTop5ByUserAliasOrderByIdDesc(user.getAlias());
// when
List<MultiplicationResultAttempt> latestAttemptsResult = multiplicationServiceImpl.getStatsForUser(user.getAlias());
// then
assertThat(latestAttemptsResult).isEqualTo(latestAttempts);
}
}
verify(eventDispatcher).send(eq(event))
- verify(eventDispatcher): eventDispatcher 객체의 메서드 호출을 검증
- send(eq(event)): send() 메서드가 event 객체와 함께 호출되었는지 확인한다. eq(event)는 event 객체와 같은 값을 가진 객체가 send 메서드의 인수로 전달되었는지 확인하는 데 사용된다.
- 이는 checkAttempt 메서드가 MultiplicationSolvedEvent를 올바르게 생성하고 이를 eventDispatcher를 통해 전송했는지 검증한다.