이번에는 멤버 서버의 SNS로 발행한 메시지를 받은 레시피 서버의 SQS리스너 동작에 대해서 설명한다.
이 글은 이전에 작성한 MSA 이벤트 정합성 보장방법 1탄에서 이어지는 내용이라 이것을 읽기 전에 꼭 아래의 글을 보고 오자
1. 포스트 내용 요약
1-1. 이전 포스트의 내용 요약
내가 구성한 MSA 프로젝트에서는 멤버 서버와 또 다른 서버가 상호 작용한다. 만약 유저가 닉네임을 변경하면 동작하는 멤버 서버의 닉네임 변경 메서드는 바로 멤버 테이블 안의 닉네임을 변경한다. 이후 스프링 이벤트를 발행한다. 그럼 이걸 구독하고 있는 2개의 스프링 이벤트 리스너가 동작하는데 한 구독자는 AWS의 SNS에 이벤트를 발행하고 다른 구독자는 멤버 서버의 DB에 SNS이벤트를 발행했다는 기록을 남긴다.
- 이전 포스트에서 나는 닉네임 변경을 할 때 스프링 이벤트를 통해서 2개의 스프링 이벤트 리스너를 동작시켰고 첫 번째 리스너를 통해서 이벤트의 발행 정보를 기록하고 두 번째 리스너를 통해서는 SNS 이벤트를 발행시켰다. 이때 두 번째 리스너가 AWS의 SNS로 이벤트를 발행하면 이 SNS를 동시에 구독하고 있는 여러 개의 SQS가 메시지를 받게 된다.
1-2. 이번 포스트에서 설명할 내용 요약
이번 포스트에서는 멤버 서버에서 "닉네임 변경"이라는 메시지가 SNS를 통해 발행되면 어떤 일이 일어나는지, 레시피 서버의 반응은 무엇인지 살펴볼 것이다. 이를 이해하기 위해 먼저 SNS와 SQS가 어떻게 함께 작동하는지 알아보자.
1-2-1. SNS와 SQS의 동작에 대한 이해
메시지 중심 아키텍처의 핵심인 SNS와 SQS의 관계를 살펴보자. 여러 SQS가 하나의 SNS를 구독할 때 발생하는 일에 주목해야 한다. 여러 개의 SQS가 하나의 SNS를 구독하고 있을 때, SNS가 발행하는 모든 메시지는 이 SQS들에 동시에 전달된다. 스프링 부트에서는 @SqsListener 어노테이션을 사용해 SQS 메시지를 감지하고 처리한다. 이때, 중요한 건 SQS의 이름을 application.yml에 정확히 설정하는 것이다.
1-2-2. 본격적인 내용 설명
이제 본론으로 넘어가 보자. 상황은 이렇다: 멤버 서버에서 "닉네임 변경" 메시지가 SNS를 통해 발행되었을 때, 무슨 일이 벌어질까? 이 메시지는 레시피 서버의 SQS 리스너에 의해 감지될 것이다. 그리고 레시피 서버에 있는 SQS 리스너에 정의된 메서드가 활성화되어 데이터베이스 업데이트 과정을 진행하게 된다.
이 과정에서 특히 흥미로운 점은 데이터베이스 업데이트 방식이다. 나는 ZeroPayload 방식을 채택하여, FeignClient를 통해 멤버 서버에 "최신 닉네임 데이터를 달라"는 API 요청을 보냈다. 이 요청에는 효율성을 위해 memberId만을 인자로 포함시켰다. 멤버 서버로부터 필요한 데이터를 받은 후, 레시피 서버는 이를 활용해 자신의 닉네임 정보를 최신 상태로 업데이트한다.
1-2-3. ZeroPayload방식의 이해
- 아래의 그림은 권용근 연사님께서 준비해 주신 발표자료의 내용을 내가 사용 중인 서버로 변경해서 그대로 그린 그림이다.
ZeroPayload 방식은 통신 과정에서 실제로 전송되는 데이터의 양을 최소화하는 전략이다. 이 방식의 핵심은 필요한 정보만을 전송하고, 나머지는 요청을 받은 시스템에서 직접 처리하도록 하는 것이다.
- 데이터 전송의 최소화
- ZeroPayload 방식에서는 메시지나 요청에 필요한 최소한의 정보만을 포함시킨다. 예를 들어, "닉네임 변경"의 경우, 닉네임 자체보다는 해당 변경 요청을 식별할 수 있는 memberId 같은 식별자만을 전송한다. (내가 이 방식을 적용했다.)
- ZeroPayload 방식에서는 메시지나 요청에 필요한 최소한의 정보만을 포함시킨다. 예를 들어, "닉네임 변경"의 경우, 닉네임 자체보다는 해당 변경 요청을 식별할 수 있는 memberId 같은 식별자만을 전송한다. (내가 이 방식을 적용했다.)
- 효율적인 네트워크 사용
- 전체 데이터 대신 필요한 최소한의 정보만 전송함으로써 네트워크 부하를 줄이고, 전송 속도를 높인다. 이는 네트워크 트래픽을 줄이는 데도 도움이 된다.
- 전체 데이터 대신 필요한 최소한의 정보만 전송함으로써 네트워크 부하를 줄이고, 전송 속도를 높인다. 이는 네트워크 트래픽을 줄이는 데도 도움이 된다.
- 보안 강화
- 민감한 데이터나 개인 정보를 직접 전송하지 않고, 최소한의 정보만 전송함으로써 데이터의 보안을 강화한다.
- 민감한 데이터나 개인 정보를 직접 전송하지 않고, 최소한의 정보만 전송함으로써 데이터의 보안을 강화한다.
- 데이터 일관성 유지
- 필요한 정보를 요청하는 시스템에서 직접 추출하도록 하여, 항상 최신 상태의 데이터를 확보할 수 있다. 이는 데이터의 신뢰성과 일관성을 유지하는 데 중요하다.
- 필요한 정보를 요청하는 시스템에서 직접 추출하도록 하여, 항상 최신 상태의 데이터를 확보할 수 있다. 이는 데이터의 신뢰성과 일관성을 유지하는 데 중요하다.
2. 이벤트 기반 아키텍처로 구성한 MSA프로젝트의 다이어그램
2-1. 완성된 MSA프로젝트의 다이어그램
- 아래의 다이어그램은 내가 MSA프로젝트에서 데이터 정합성을 보장하기 위해 설계한 아키텍처의 다이어그램이다.
2-2. 이번 포스트에서 다이어그램에 추가된 내용
- 이전 포스트와 비교해서 다이어그램 우측에 레시피 서버가 추가되었다.
- 멤버 서버 하단에는 레시피 서버로부터 FeignClient요청을 받아서 처리해 줄 API가 추가되었다.
지금부터 설명할 내용은 이전 포스트의 5. 비즈니스 로직에 의해 실제로 AWS의 SNS에 이벤트를 발행하는 스프링 이벤트 리스너(구독자)도 동작한다. 다음의 내용이니 꼭 이전 글을 읽어보고 오자
3. SQS는 SNS로부터 메시지를 받게 되고 이에 따라 RECIPE 서버의 SQS 리스너가 동작한다.
3-1. 그림을 통한 동작 이해하기
- SNS에서 발행한 메시지를 SQS가 받게 되면 Recipe서버에서는 @SqsListener 어노테이션을 메서드 상단에 선언해 준 SQS리스너 메서드가 이 메시지를 스프링 내부적인 메커니즘을 통해 자동으로 polling 해서 메서드를 동작시키게 된다.
3-2. 레시피 서버의 @SqsListener 설명
- 레시피 서버는 @SqsListener를 통해 SQS로부터 "닉네임 변경"이라는 메시지를 지속적으로 polling 한다. (여기서 polling은 주기적으로 SQS메시지 큐를 확인하여 새 메시지가 있는지 확인하는 과정이다.) 이게 가능한 이유는 스프링에서 @SqsListener 어노테이션을 사용하면, 이 polling 과정이 스프링에서 내부적으로 자동적으로 처리되기 때문이다.
즉, 내가 직접 SQS에 연결하여 메시지를 요청하지 않아도 되는 것이다. @SqsListener 어노테이션이 붙은 메서드는 SQS로부터 메시지를 받으면 자동으로 호출된다. 이때, @SqsListener는 AWS SQS로부터 메시지를 폴링 방식으로 받아오고, 메시지가 도착하면 해당 메서드를 실행시킨다.
3-3. 레시피 서버의 @SqsListener 코드 작성
- 내가 레시피 서버에 작성한 SQS 리스너 코드는 아래와 같다. (value에는 aws의 sqs 정보를 연결해 줄 세팅값을 application.yml에서 받아오도록 작성했다.)
@Slf4j
@RequiredArgsConstructor
@Service
public class AwsSqsListenerService {
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
@SqsListener(value = "${spring.cloud.aws.sqs.nickname-sqs-name}")
public void receiveMessage(String messageJson) throws JsonProcessingException {
JsonNode messageNode = objectMapper.readTree(messageJson);
String messageId = messageNode.get("MessageId").asText(); // 메시지 ID 추출
// SQS 메시지 처리 로직
String messageContent = messageNode.get("Message").asText();
log.info("[RECIPE] Received message from SQS with messageId: {}", messageId);
JsonNode message = objectMapper.readTree(messageContent);
log.info("Message: {}", message.toString());
// memberId 추출후 이벤트 발행
JsonNode node = objectMapper.readTree(message.toString());
Long memberId = Long.valueOf(node.get("memberId").asText());
eventPublisher.publishEvent(new NicknameChangeEvent(memberId));
}
}
4. 동작하는 SQS리스너의 메서드는 내부적으로 Spring Event를 발행한다.
4-1. 그림을 통한 동작 이해하기
- 위의 과정을 통과했다면 레시피 서버 내부의 SQS리스너 메서드가 동작할 텐데 이때 메서드 내부에서 스프링 이벤트를 발행한다. (여기서 발행하는 스프링 이벤트는 멤버 서버에 FeignClient 요청을 보내서 최신상태로 업데이트된 닉네임을 달라는 요청을 보내기 위해서 발행하는 이벤트다.)
4-2. @SqsListener 코드 확인
- 아래의 @SqsListener 메서드의 내부 코드를 살펴보면 코드 마지막 줄에 eventPublisher.publishEvent() 메서드를 호출해서 내부적으로 다시 스프링 이벤트를 발행하는 것을 확인할 수 있다.
4-3. 스프링 이벤트 (NicknameChangeEvent) 생성하기
- 나는 SQS리스너 메서드 안에서 발행시켜 줄 스프링 이벤트 객체를 만들어야 했고 아래와 같이 record 타입을 사용해서 만들어 줬다.
public record NicknameChangeEvent(
Long memberId
) {
}
4-4. 스프링 이벤트 리스너 작성
- 스프링 이벤트 클래스(NicknameChangeEvent)를 만들었다면 다음으로 @EventListener 어노테이션을 사용해서 이 이벤트를 구독하는 스프링 이벤트 리스너 메서드를 아래와 같이 만들어 주도록 하자. 그럼 이 스프링 이벤트(NicknameChangeEvent)가 발행되었을 때 아래에 작성한 이벤트 리스너가 동작할 것이다.
@Slf4j
@RequiredArgsConstructor
@Component
public class RequestFeignListener {
private final MemberFeignClient memberFeignClient;
private final RecipeRepository recipeRepository;
@Transactional
@EventListener
public void requestMemberChangedNickname(NicknameChangeEvent event) {
Long memberId = event.memberId();
NicknameDto nicknameDto = memberFeignClient.getNickname(memberId);
List<Recipe> recipeList = recipeRepository.findRecipeByMemberIdAndDelYn(memberId, "N");
// 닉네임을 변경한 사용자가 작성한 레시피들의 nickname을 변경한다.
if (!recipeList.isEmpty()) {
recipeList.forEach(recipe -> {
recipe.changeNickname(nicknameDto.nickname());
});
}
}
}
5. SQS리스너에서 발행한 스프링 이벤트를 구독하는 내부 스프링 이벤트 리스너는 FeignClient로 Member서버에 요청을 보낸다.
5-1. 그림으로 이해하기
- 아래 그림에 FeignClient 요청이 발생하는 부분을 빨간 박스로 그려놨다. 스프링 이벤트(NicknameChangeEvent)가 발행되면 이 스프링 이벤트를 구독하는 스프링 이벤트 리스너가 동작해서 메서드 내부에서 FeignClient 요청을 해서 데이터를 받아온다.
5-2. 스프링 이벤트 리스너 메서드 내부에서 FeignClient 요청이 실행된다. (레시피 서버에 FeignClient 코드를 작성해야 한다.)
- 스프링 이벤트 리스너는 아래와 같이 memberFeignClient.getNickname(memberId); 를 통해 FeignClient요청을 하게 된다.
5-3. FeignClient 요청 코드를 작성하기 전에 ZeroPayload 적용하기
- FeignClient요청 코드를 작성하기 전에 짚고 넘어가야 할 사실이 있다. 나는 ZeroPayload방식을 적용했기 때문에 memberId만을 param으로 보내야 한다. 간략하게 설명하자면 ZeroPayload 방식은 데이터 전송을 최소화하여 효율성을 높이는 전략이기 때문에, 나의 경우에는 FeignClient를 사용하여 멤버 서버에서 최신화된 닉네임 조회를 하는데 꼭 필요한 데이터(memberId)만을 요청하는 방식으로 구현했다.
5-4. 레시피 서버 내부에 FeignClient 요청 코드를 작성한다.
- 아래 코드는 레시피 서버에서 멤버 서버로 닉네임 데이터를 받아오도록 요청하는 FeignClient 코드를 작성한 것이다. (여기서 매우 중요한 사항이 있는데 Feign요청을 보내는 레시피 서버의 요청 메서드랑 이 요청을 받아서 응답하는 멤버서버의 컨트롤러 메서드 모두 파라미터에는 @RequestParam을 적어줘야만 한다. 아니면 요청에 대해서 파라미터가 없다는 오류가 발생한다.)
혹시 Feign클라이언트에 대한 정보가 필요하다면 내 팀 동료의 블로그 글을 읽어보는 것을 추천한다.
@FeignClient(name = "member-service", url = "${feign.member_url}")
public interface MemberFeignClient {
/**
* 멤버 서버에서 닉네임 변경 이벤트가 정상적으로 발행되어서 Recipe 서버의 SQS가 이 Feign을 호출하면
* 멤버 서버로부터 Id, Nickname 값을 받아온다.
*/
@RequestMapping(method = RequestMethod.POST, value = "/feign/member/getNickname")
NicknameDto getNickname(@RequestParam(name = "memberId") Long memberId);
}
5-5. Dto객체 NicknameDto 소개
- 위에서 작성한 FeignClient요청 코드에서 반환타입으로 사용 중인 NicknameDto(멤버 ID, 닉네임 데이터 전송 객체)는 아래와 같이 생성하면 된다. 나는 레시피 서버에서 필요한 정보인 memberId, nickname 값을 담고 있는 dto 객체를 생성했다. (멤버, 레시피 서버 둘 다 같은 dto객체를 만들어 줬다.)
6. 레시피 서버의 FeignClient로부터 ZeroPayload 방식으로 요청을 받은 멤버 서버는 닉네임 데이터를 레시피 서버로 보내준다.
6-1. 레시피 서버로부터 받은 FeignClient요청을 처리할 컨트롤러를 작성한다.
- 레시피 서버에서 보낸 FeignClient 요청은 멤버 서버의 컨트롤러 중 "/feign/member/getNickname" url에 매핑되어 동작한다.
@RequiredArgsConstructor
@RequestMapping("/feign/member")
@RestController
public class MemberFeignController {
private final MemberFeignService memberFeignService;
@PostMapping("/getNickname")
public NicknameDto getNickname(@RequestParam(name = "memberId") Long memberId) {
return memberFeignService.getNicknameByMemberId(memberId);
}
}
6-2. MemberFeignController에서는 getNicknameByMemberId() 메서드를 호출하여 데이터를 받아온다.
- MemberFeignController는 서비스 코드에서 아래의 getNicknameByMemberId() 메서드를 호출해서 변경된 최신의 닉네임 정보를 받아온다. 이후 이 닉네임 정보와 memberId 값을 인자로 사용해서 NicknameDto 객체를 만들고 이 객체를 레시피 서버에 반환한다.
이렇게 레시피 서버의 FeignClient로부터 멤버 서버의 controller에 닉네임 데이터에 대한 API 요청이 들어오면 멤버 서버는 가장 최신의 상태로 업데이트되어 있는 유저의 닉네임을 DTO객체로 감싸준 후에 레시피 서버에 전송하게 된다.
7. 멤버서버에서 업데이트된 닉네임을 받아온 후에 레시피 서버의 닉네임을 최신화시켜서 DB 정합성을 보장한다.
7-1. 닉네임을 변경한 유저의 memberId를 사용해서 이 유저와 관련된 모든 레시피를 리스트로 받아온다.
- FeignClient로부터 멤버서버에서 변경된 nickname 데이터를 받아온 후에는 데이터베이스에서 memberId, delYn(삭제여부)을 조건으로 넣어줘서 조건을 통과한 모든 Recipe 엔티티들을 리스트 형태로 받는다.(내가 List로 데이터를 받는 이유는 조건을 통과한 유저가 작성한 레시피가 한두 개가 아니라 엄청 많을 것이기 때문이다. 이때 모든 레시피가 가진 nickname 컬럼의 정보를 업데이트된 nickname으로 변경시켜줘야 한다.)
7-2. 리스트 내부의 모든 레시피가 가진 닉네임을 변경된 닉네임으로 수정한다.
- 유저가 꼭 레시피를 작성하지는 않을 것이므로 if(!recipeList.isEmpth()) 조건문을 통해서 리스트가 비어있지 않다면(작성한 레시피가 존재한다면) 그 리스트의 모든 데이터를 forEach를 사용해서 반복문을 통해 모두 JPA 변경감지로 닉네임을 업데이트시켰다.
8. 이 모든 기능들이 제대로 적용되었는지 검증작업 진행하기
8-1. 멤버 데이터베이스의 변경 전 닉네임 확인
- 멤버서버의 멤버 테이블에서 member_id가 2번인 jinan유저의 nickname은 NEW-NICKNAME222이다.
8-2. 레시피 데이터베이스의 레시피 테이블 내부의 멤버 닉네임 확인
- 레시피 서버의 테이블에서 member_id가 2번인 레시피의 유저 닉네임도 똑같이 NEW-NICKNAME222인 상황이다. (아직 한 번도 닉네임 변경이 된 적이 없는 상황이다.)
8-3. 멤버 서버에서 닉네임 변경 요청
- Postman을 통해 멤버 서버에 닉네임 변경 이벤트를 요청한다. 이때 나는 변경할 새로운 닉네임을 jinan으로 데이터를 보냈다. (이 글을 작성할 때 아직 요청 코드가 완성이 아니라 하드코딩으로 보냈다.)
- 아래와 같이 "닉네임 변경" 이벤트가 정상적으로 발행되어 SNS에서도 이 메시지를 잘 발행한 것을 record 테이블을 통해 확인했다.
8-4. 레시피 서버 내부의 변경된 닉네임 확인
- 레시피 서버의 Feign 요청이 마무리되었을 때 테이블 내부의 nickname이 jinan으로 바뀐 것을 확인했다. (기능이 정상 동작했다!)
이렇게 MSA에서 Message-driven 아키텍처를 적용시켜서 데이터의 정합성을 보장하도록 구현했다. 이것을 구현하면서 했던 고민 중 하나는 닉네임이 변경된 순간 Spring 이벤트를 통해서 SNS 메시지를 발행하다 보니 다른 서버에서 SQS리스너로 메서드를 동작시킬 때도 그 내부에서 계속해서 Spring Event를 호출해서 코드를 사용하는 형태로 사용하는 게 맞을지 고민이 되는 상황이었다. 우선 데이터의 정합성을 보장하도록 기능을 구현하는 것에는 성공했으니 팀원과 회의를 통해서 이벤트를 계속 사용할지 회의를 통해 옳은 방향을 찾아가려고 한다.
다음으로 Zipkin을 통해 로깅을 설정하였다.
P6spy 관련 글도 추천한다.
'Spring MSA' 카테고리의 다른 글
[Spring] Util 클래스 - static vs Bean (3) | 2023.11.18 |
---|---|
[Spring MSA] Zipkin으로 분산추적 로깅 구현하기 (0) | 2023.11.18 |
[Spring MSA] Spring Event, SNS, SQS를 사용하여 DB 정합성 보장하기 1탄 (2) | 2023.11.15 |
[Spring] SpringFramework 4.2 이후 스프링 이벤트의 변화 (2) | 2023.11.13 |
Spring - 트랜잭션 관리 (Transaction Management) (0) | 2023.08.08 |