MSA 환경에서 SNS/SQS 활용하기: 서버간 DB 동기화와 제로 페이로드 방식의 효과적 구현
이번 글은 회원 서버에서 SNS를 발행하면 이를 구독하는 외부 서버의 SQS가 어떻게 동작하는지에 대해 작성해 보겠다.
이번 내용은 이전 포스팅과 이어지는 내용이기 때문에 꼭 아래 글을 읽고 와주길 바란다.
[Spring Boot]MSA 환경에서 SNS/SQS를 활용한 이벤트 처리: 이벤트 유실 문제 해결 방안
1. 이번 글에서 진행할 프로세스 간단 설명
이 프로세스는 마이크로서비스 아키텍처(MSA)에서 회원 서버와 다른 서버 간에 닉네임 정보를 동기화하는 방법을 설명한다. 이해를 돕기 위해 내용을 명확하게 정리해 보겠다.
1-1. 프로세스 개요
- 회원 서버와 레시피 서버:
- 이 시스템은 회원 정보를 처리하는 '회원 서버'와 레시피 관련 기능을 수행하는 레시피 서버로 구성되어 있다. (그 외 다른 서버도 존재한다.)
- 각 서버는 독립된 데이터베이스를 갖고 있으며, 레시피 서버는 게시글 조회 성능 향상을 위해 회원 닉네임 정보를 별도로 저장한다.
- 닉네임 변경 요청과 동기화 필요성:
- 사용자가 회원 서버에서 닉네임을 변경할 경우, 레시피 서버의 데이터베이스에도 이 변경사항이 반영되어야 한다.
1-2. 동기화 프로세스
- SNS(Simple Notification Service) 발행:
- 회원 서버에서 닉네임 변경 요청이 처리되면, 해당 회원의 memberId를 포함한 메시지를 SNS 주제인 '닉네임 변경'에 발행한다.
- SQS(Simple Queue Service) 트리거링:
- 레시피 서버에서는 '닉네임 변경' SNS를 구독하는 SQS가 설정되어 있으며, SNS 메시지가 도착하면 SQS가 트리거 된다.
- 스프링 이벤트 발행:
- SQS 메시지가 도착하면, SQS 리스너가 '닉네임 변경 이벤트'를 스프링 이벤트를 발행한다.
- 이를 통해 별도의 스프링 이벤트 리스너가 활성화되어, 비즈니스 로직의 실행을 담당한다.
- Feign 클라이언트 요청:
- 레시피 서버에서는 스프링 이벤트 리스너가 활성화되어, 이 리스너가 회원 서버에 memberId를 전달하며 닉네임을 요청한다.
- 이때 Feign 클라이언트를 사용한다.
- 닉네임 응답과 업데이트:
- 회원 서버는 요청받은 memberId에 해당하는 닉네임을 응답하고, 레시피 서버는 이 응답을 받아 자체 데이터베이스에 저장된 닉네임 정보를 업데이트한다.
이 프로세스를 통해 회원 서버와 레시피 서버 간에 닉네임 정보가 실시간으로 동기화되며, 이로 인해 누락되는 이벤트 없이 외부 서버에서도 닉네임 변경이 완벽하게 이루어진다.
이제 이 프로세스를 다이어그램으로 시각화해보자!
(Feign Client를 잘 모르겠다면 아래 글을 읽고 오면 좋다.)
[Spring Boot] Spring Boot에서 Feign 클라이언트 사용하기(@FeignClient 사용 가이드)
2. 프로세스 다이어그램으로 시각화
전체 다이어그램
- 파란색 박스는 지난 글에서 설명되었다.
- 빨간색 박스가 이제 이번 글에서부터 설명될 부분이다.
이제 빨간색 박스를 하나하나 쪼개가며 상세히 설명하겠다.
3. SQS 리스너
3-1. 닉네임 변경 관련 SQS 동작 원리
- 회원 서버에서 "닉네임 변경" SNS를 발행하면 이를 구독 중인 모든 SQS 대기열에 전달된다.
- 레시피 서버의 스프링 부트 어플리케이션에서는 @SqsListener 어노테이션이 적용된 메소드가 해당 SQS 대기열을 모니터링하고 있다.
- SQS 대기열에 새 메시지가 도착하면 @SqsListener가 이를 감지하고 해당 메소드를 활성화시킨다.
3-2. @SqsListener 클래스 실제 코드
- "닉네임 변경" SNS가 발행되고 SQS 대기열에 메시지가 도착하면 아래 메소드가 실행된다.
- @SqsListener로 표시된 이 메소드는 SQS 대기열에서 메시지가 도착할 때마다 자동으로 호출된다.
- 메시지의 내용은 JSON 문자열 형태로 전달된다.
@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));
}
}
- @SqsListener 어노테이션
- 이 어노테이션은 메소드가 SQS 메시지 리스너임을 나타낸다.
- value 속성을 사용하여 SQS 대기열 이름을 지정한다. 나는 프로퍼티 값을 사용하여 SQS 대기열 이름을 외부 설정에서 가져왔다.
- 메소드 내에서 JSON 메시지는 ObjectMapper를 사용하여 파싱 되고, 필요한 비즈니스 로직이 실행된다. 여기서는 메시지에서 memberId를 추출하고, 이를 이용하여 스프링 이벤트를 발행한다.
3-2. SQSListener에서 "닉네임 변경" 스프링 이벤트 발행
💡 여기서 잠깐!
위 코드를 보면 사실 '@SqsListener에서 바로 Feign 클라이언트 요청을 보내 닉네임 변경 로직이 있어도 되지 않나?'라는 생각이 든다. 그러나 스프링 이벤트를 사용하여 SQS 리스너에서 Feign 클라이언트 요청을 분리하는 방식은 여러 면에서 적절하다.
이 접근 방식의 장점을 살펴보자.
1. 분리된 관심사(Separation of Concerns)
- SQS 리스너는 메시지 수신과 이벤트 트리거링에 집중하고, 실제 비즈니스 로직은 다른 스프링 리스너가 처리한다. 이는 각 컴포넌트가 하나의 기능에만 집중하도록 하여 코드의 가독성과 유지보수성을 향상시킨다.
2. 확장성과 유연성
- 이벤트 기반의 접근 방식은 시스템의 확장성과 유연성을 향상시킨다. 향후 비즈니스 요구사항이 변경되거나 추가 기능이 필요할 때, 기존의 이벤트 리스너를 수정하거나 새로운 리스너를 추가하기 쉽다.
3. 비동기 처리
- 스프링 이벤트는 비동기적으로 처리될 수 있다. 이는 시스템의 성능을 향상시키고, 리소스 사용을 최적화할 수 있게 해 준다.
4. 결합도 감소
- 이벤트를 사용함으로써 SQS 리스너와 비즈니스 로직을 처리하는 스프링 리스너 간의 결합도가 감소한다. 이는 코드의 변경이 한 부분에 국한되도록 하여 다른 부분에 미치는 영향력을 최소화한다.
5. 오류 처리와 재시도 로직 구현 용이
- 스프링 이벤트를 사용하면 오류 발생 시 재시도 로직을 더 쉽게 구현할 수 있다. 이는 시스템의 안정성과 신뢰성을 향상시키는데 기여한다.
개발자의 관점에서 볼 때, 이러한 구조는 시스템의 복잡도를 관리하고, 각 컴포넌트의 역할을 명확하게 하는데 도움이 된다.
따라서, SQS 리스너와 스프링 이벤트를 통해 Feign 클라이언트 요청을 분리하는 방식은 효과적이고 합리적인 선택으로 보인다.
- SQS 리스너에서 아래와 같이 NicknameChangeEvent 이벤트를 발행한다.
3-3. NicknameChangeEvent 이벤트 실제 코드
public record NicknameChangeEvent(
Long memberId
) {
}
3-4. 다이어그램 설명
- 위 내용은 아래 다이어그램 부분에 해당한다.
4. "닉네임 변경" 스프링 이벤트 리스너
4-1. 스프링 이벤트 리스너 실제 코드
- @EventListener 어노테이션은 메소드가 특정 이벤트(NicknameChangeEvent)에 반응하는 리스너임을 나타낸다.
- SQS Listener에서 전달받은 NicknameChangeEvent에 담겨있던 memberId로 회원 서버에 Feign 클라이언트 요청 보낸다.
- 회원 서버에서 응답받은 닉네임으로 레시피 DB를 수정한다.
- 아직 레시피를 하나도 작성하지 않은 회원도 있을 수 있기 때문에 recipeList가 비어있을 때 에러를 던지지 않고, 데이터가 있을 때만 정상 작동 하도록 구현했다.
@Slf4j
@RequiredArgsConstructor
@Component
public class RequestFeignListener {
private final MemberFeignClient memberFeignClient;
private final RecipeRepository recipeRepository;
/**
* Feign 클라이언트로 Member서버에 변경된 닉네임을 요청하는 리스너
*/
@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());
});
}
}
}
- 여기서 사용된 NicknameDto는 아래와 같다.
/**
* Feign 클라이언트로 멤버의 Id, Nickname을 주고받는 데이터 전달 객체
*/
public record NicknameDto(
Long memberId,
String nickname
) {
}
4-2. MemberFeignClient 인터페이스 코드
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@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);
}
- getNickname 메소드는 memberId를 매개변수로 받아 해당 ID에 해당하는 멤버의 닉네임을 조회하는 요청을 멤버 서버에 보낸다.
- 반환 타입은 NicknameDto로, 멤버 서버로부터 닉네임 정보를 받아오는 데 사용됩니다.
💡 사실 SNS에 memberId와 변경된 nickname까지 메시지에 전부 포함해서 발행하면 이런 Feign 클라이언트 요청은 필요 없다.
그럼에도 불구하고, 왜 제로 페이로드(zero-payload) 방식을 선택했을까? 명확한 이유가 있다. 이 방식을 선택함으로써, 우리는 다음과 같은 중요한 장점들을 확보할 수 있었다.
1. 보안 강화
- 시스템에서 보안은 매우 중요하다. SNS 메시지에 memberId만 포함시킴으로써, 우리는 사용자의 닉네임과 같은 민감한 정보를 네트워크를 통해 전송하는 리스크를 줄일 수 있다. 이러한 접근은 외부 공격자에 의한 데이터 노출 가능성을 최소화하는 데 기여한다.
2. 데이터 일관성 유지
- 멤버 서버에 대한 Feign 클라이언트 요청을 통해 우리는 항상 최신의 닉네임 데이터를 확보할 수 있다. SNS 메시지가 전송되는 동안 사용자의 닉네임이 변경될 수도 있는데, 제로 페이로드 방식을 사용하면 이러한 변경 사항을 즉시 반영할 수 있다.
3. 시스템의 복잡성 관리
- 비록 추가적인 네트워크 호출이 필요하긴 하지만, 이 방식은 우리 시스템의 전체적인 복잡성을 관리하는 데 도움이 된다. 각 컴포넌트가 자신의 책임을 명확하게 가지고, 역할을 효율적으로 수행할 수 있게 만든다.
4. 확장성 및 유연성
- 미래에 시스템이 확장되거나 변경되어야 할 경우, 제로 페이로드 방식은 새로운 요구 사항에 더 쉽게 적응할 수 있게 해 준다. 예를 들어, 닉네임 외에 다른 사용자 정보가 필요해진다면, Feign 클라이언트의 요청을 쉽게 조정하여 이를 수용할 수 있다.
결론적으로, 제로 페이로드 방식은 몇 가지 추가적인 단계를 필요로 하지만, 이는 보안, 데이터 일관성, 시스템 관리 및 확장성 측면에서 큰 이점을 제공한다. 이러한 이유로, 우리는 이 방식을 선택했고, 이는 우리 시스템의 성공적인 운영에 중요한 역할을 하고 있다고 생각한다.
4-3. 다이어그램 설명
- 위 내용은 아래 다이어그램 부분에 해당한다.
5. 데이터 검증
5-1. 회원 테이블에서 변경 전 닉네임 확인
- 회원 서버에서 memberId가 2에 해당하는 회원의 닉네임은 NEW-NICKNAME222이다.
5-2. 레시피 테이블에서 변경 전 닉네임 확인
- 레시피 서버에서 memberId가 2인 회원이 작성한 레시피에 저장된 회원 닉네임 또한 NEW-NICKNAME222이다.
5-3. 회원 서버에서 닉네임 변경 요청
- postman으로 닉네임을 jinan으로 변경 요청한다.
- (아직 개발 완성 단계가 아니기 때문에 닉네임 변경 로직에 하드코딩으로 "jinan"을 적었다.)
- 회원 닉네임 변경 도메인 행위가 완료되고 SNS가 잘 발행되었는지 확인할 수 있는 이벤트 저장소 테이블을 확인해 보자.
- published = true인걸 봐서 SNS가 잘 발행된 걸 볼 수 있다.
5-4. 레시피 테이블에서 변경 후 닉네임 확인
- SNS가 잘 발행되었다면 레시피 서버의 SQS가 트리거 되었고 Feign 요청까지 잘 마무리되면 아래와 같이 레시피 테이블에도 닉네임이 변경된 모습을 확인할 수 있다.
6. 마무리
MSA에서 메시지 드리븐 아키텍처를 적용하면서 배운 중요한 교훈이 있다.
첫째, 스프링 이벤트와 제로 페이로드 방식 적용은 복잡해 보였지만, 시스템의 유연성과 데이터 보안 향상에 큰 가치가 있다는 걸 알았다. 이 방식은 서버 간 느슨한 결합을 가능하게 해 시스템의 확장성과 유지보수성을 개선했다.
둘째, 마이크로서비스 아키텍처에서 데이터 일관성과 보안 유지의 중요성을 깊이 이해했다. 이 프로젝트를 통해 민감한 데이터를 안전하게 처리하고 데이터 일관성을 유지하는 전략을 배웠다.
이번 프로젝트에서 얻은 지식과 경험은 향후 개발 작업에 큰 도움이 될 것이다. 앞으로 배운 내용을 토대로, MSA와 메시지 드리븐 아키텍처에 대한 이해를 더 깊게 해 나가야겠다.
이제 다음 글에서는 이 모든 프로세스를 Zipkin을 이용한 분산 서버 로그 관리 적용까지 다룰 예정이다.
이 포스트는 Team chillwave에서 사이드 프로젝트를 하던 중 적용했던 부분을 다시 공부하면서 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)