MSA 환경에서 SNS/SQS를 활용한 이벤트 처리: 이벤트 유실 문제 해결 방안
지난 시간에서 설명한 스프링 이벤트를 이제 실제로 프로젝트에 녹이는 과정을 설명하겠다.
이 글에서 다룰 내용은 해당 YouTube 영상에서 영감을 얻었다.
(배달의 민족 - 권용근 연사님 발표 영상)
해당 영상은 MSA 아키텍처에서 하나의 시스템 안에 내부 프로세스가 어떻게 돌아가는지 설명해 준다. 나도 MSA 아키텍처는 처음 진행해 보기 때문에 이 영상에서 정말 많은 도움을 얻었고, 이를 바탕으로 내 프로젝트에 적용해 보기로 했다.
(유튜브 링크: https://youtu.be/b65zIH7sDug?si=f5zNCpJbUEZcDPpr)
1. 영상 내용 요약
위 영상은 "회원시스템 이벤트기반 아키텍처 구축하기"라는 주제로 발표된 내용이다. 발표자는 배달의 민족의 회원 시스템에서 이벤트 기반 아키텍처를 어떻게 다루고 있는지에 대해 설명한다.
이벤트 기반 아키텍처는 마이크로 서비스 아키텍처와 함께 사용되며, 각 마이크로 서비스 간의 느슨한 결합을 도와준다. 이를 위해 어떤 이벤트를 발행해야 하는지를 정의하고, 이벤트를 구독하여 처리하는 방법을 소개한다.
이벤트는 도메인 이벤트로 발행되어야 하며, 이벤트를 통해 도메인 간의 의존성을 최소화하고 강한 응집력을 갖는 시스템을 구축할 수 있다. 또한, 이벤트 저장소를 활용하여 이벤트 발행의 안정성과 신뢰성을 보장할 수 있다. 이벤트 저장소는 데이터의 특성에 맞게 선택되어야 하며, 이벤트 발행 여부를 추적하고 재발행할 수 있는 기능을 제공한다. 이러한 이벤트 기반 아키텍처를 통해 회원 시스템은 안정적이고 유연한 시스템을 구축할 수 있다.
2. 나의 MSA 프로젝트 구성 설명
나의 프로젝트는 회원 서버와 외부 다른 서버가 있는 MSA 아키텍처로 구성되어 있다. DB까지 전부 독립된 환경에서 각 서버의 빠른 조회를 위해 회원 닉네임도 테이블에 컬럼으로 존재한다.
이때, 회원 서버에서 회원이 닉네임을 변경하면 다른 서버 DB에서 사용되고 있는 회원 닉네임도 수정해줘야 한다. 각 서버끼리의 연동은 SNS, SQS를 사용하고 있던 상황에서 SNS의 발행 여부를 체크하기 위해서 중간에 SNS 발행 여부를 확인할 수 있는 Event Record용 테이블을 생성했다.
2-1. 전체적인 프로세스
- 닉네임 변경 HTTP 요청이 들어오면 해당 비즈니스 로직을 전부 수행한 후,
- 닉네임이 변경되었다는 스프링 이벤트(어플리케이션 이벤트)를 발행한다.
- 닉네임 변경 이벤트를 구독하고 있던 두 개의 리스너가 각각 트리거 된다.
- 닉네임 변경 스프링 이벤트를 구독하고 있던 'SNS 이벤트 발행 담당' 리스너는 SNS를 발행한다.
- 닉네임 변경 스프링 이벤트를 구독하고 있던 '이벤트 기록 DB 저장 담당' 리스너는 이벤트 저장소 (Event Record 테이블)에 해당 이벤트를 published = false로 저장한다. (이벤트 발행 여부 false)
- 3-1에서 SNS가 발행되었다면 해당 SNS를 구독하고 있던 외부 서버의 SQS가 실행된다.
- 그리고 동시에 회원 서버 안에서도 3-1에서 발행한 SNS를 똑같이 구독하고 있던 SQS가 이벤트 기록 DB 업데이트를 담당한다.
- 3-2에서 이벤트 저장소 (Event Record 테이블)에 저장한 row를 published = true로 업데이트한다. (이벤트 발행 여부 true)
(Event Record에 false로 남아있는 데이터는 배치 서버를 통해 주기적으로 다시 SNS를 발행하는 프로세스를 진행시키면 된다. 이 부분은 나중에 다시 설명하겠다.)
이런 프로세스로 진행하면 누락되는 이벤트 없이 완벽하게 SNS, SQS로 서버 간 통신을 보장할 수 있다.
아래는 위 내용을 간략하게 도식화시킨 다이어그램이다.
2-2. 하나의 트랜잭션으로 묶어야 하는 부분
아래 빨간 원으로 묶은 부분이 하나의 transaction으로 묶여야 하는 부분이다.
💡 만일 이벤트 저장소에 이벤트를 저장하지 않는다면 생기는 이슈 = 하나의 트랜잭션으로 묶지 않았을 때 생기는 이슈
하나의 트랜잭션으로 처리하지 않는다면 HTTP 요청이 실패하면 (SNS 발행 실패) 이벤트가 유실되면서 도메인 행위는 수행되었는데 이벤트는 발행되지 않는 불일치 상태가 발생할 수 있다. (이벤트 유실 발행)
이 문제를 해결하기 위해 이벤트 저장소가 필요했다.
💡 비즈니스 로직과 이벤트 저장소 로직을 하나의 트랜잭션으로 묶어야 하는 이유
닉네임 변경 요청이 오면 회원 DB의 닉네임을 업데이트하고 닉네임이 변경되었다는 스프링 이벤트가 발행된다. 이 스프링 이벤트가 잘 발행이 되었다면 SNS도 정상 발행되어야 한다. 하지만 어쨌든 스프링 부트에서 SNS 발행하는 구간은 HTTP 통신을 사용하기 때문에 이벤트를 발행하는 과정에 언제든지 네트워크 에러가 발생할 수 있다.
이때 SNS가 정상 발행 되지 않았다고 해서 비즈니스 로직(닉네임 변경)에 영향을 줘서는 안 된다. 다시 말해 메세징 시스템의 장애가 전체 시스템의 장애로 번지게 하면 안 된다.
따라서, SNS 이벤트 발행이 실패하여도 도메인 행위가 성공했다면 하나의 트랜잭션으로 묶인 이벤트 DB 저장 담당 리스너의 프로세스 때문에 이벤트 저장소에 이벤트가 저장되고 그렇기 때문에 어떻게든 이벤트를 재발행할 수 있다.
3. 프로세스 검증을 위한 간단 코드 구성
3-1. 닉네임 변경 요청 (Controller)
- 회원이 닉네임 변경을 요청한다.
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/member")
@RestController
public class MemberController {
private final MemberService memberService;
@PostMapping("/nicknameChange")
public String nicknameChange() {
memberService.nicknameChange();
return "success";
}
}
3-2. 비즈니스 로직 구현 후 스프링 이벤트 발행
1. NicknameChangeEvent (record)
- 닉네임 변경 이벤트 객체
- SNS 발행, 그리고 이벤트 저장소에 필요한 최소한의 데이터만 선언한 record 객체다.
/**
* 닉네임 변경 이벤트 객체
* @param memberId member pk
*/
public record NicknameChangeEvent(
Long memberId
) {
}
2. MemberService (service)
- 스프링 이벤트를 사용하기 위해 ApplicationEventPublisher를 주입받는다.
- 메소드 위에 @Transactional을 달아준다. (추후 스프링 이벤트 구독에게 해당 트랜잭션을 사용하게 하기 위함)
- 영속성에 있는 멤버 객체를 변경해 주고 닉네임 변경 스프링 이벤트 발행
- JPA 특성상 닉네임 변경이 바로 DB로 커밋되지 않고 해당 메소드가 종료될 때 커밋이 이루어진다.
@Transactional(readOnly = true)
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void nicknameChage() {
Member member = memberRepository.findById(2L).orElseThrow(() -> new MemberApplicationException(ErrorCode.DB_ERROR));
member.changeNickname("NEW-NICKNAME222");
log.info("닉네임 변경 Service [멤버 pk : {} , 변경전 닉네임 : {} , 변경할 닉네임 : {}]", member.getId(), "oldNickname", "NEW-NICKNAME222");
eventPublisher.publishEvent(new NicknameChangeEvent(member.getId()));
}
}
3-3. 닉네임 변경 이벤트 리스너
- 스프링 이벤트는 두 개의 리스너가 존재한다.
- 하나는 SNS 발행을 담당하고, 다른 하나는 이벤트 저장소에 이벤트를 저장하는 담당이다.
- 순서를 조금 바꿔 아래 있는 '이벤트 저장소 담당 리스너' 먼저 설명하겠다.
3-3-1. 이벤트를 DB에 저장하는 리스너 프로세스
- 위에서 설명한 대로 이 프로세스는 비즈니스 로직과 하나의 트랜잭션으로 묶여있어야 한다.
- 일단 이벤트 저장소를 생성한다.
1. 이벤트 저장소 테이블 설명
- 나는 Record 테이블을 아래와 같이 구성했다.
- 위 영상의 권용근 연사님께서 알려주신 것과 비슷하지만 조금 다른 부분들이 있는데 그 이유는 나는 이 프로젝트를 진행하면서 팀원과 많은 토의를 하는데 이때 우리의 프로젝트에서 필요로 하는 속성을 같이 찾고 고민해서 그것을 지정하여 테이블을 생성했다.
- 만약 이 내용을 보고 바로 따라 해보고자 하는 분이 있다면 그러지 말고 많은 고민과 생각을 해보고 필요한 부분만을 가지고 가서 본인의 프로젝트에서 사용하는 것을 추천한다.
2. 이벤트를 DB에 저장하는 리스너 로직
- @Transactional 어노테이션을 달아줌으로써 memberService에서 전달된 트랜잭션과 동일한 트랜잭션을 사용하게 된다. 이렇게 해서 비즈니스 로직과 하나의 트랜잭션으로 묶이게 된다!
💡 트랜잭션의 작동 방식
이 코드에서 서비스 계층(MemberService)과 이벤트 리스너(SpringEventRecordListener)가 하나의 트랜잭션에 묶여 있기 때문에, 실제 데이터베이스에 변경 사항이 적용되는 시점은 이벤트 리스너의 메서드가 완료된 후다.
트랜잭션 시작
- MemberService의 nicknameChange 메서드에서 트랜잭션이 시작된다. 이 시점부터 데이터베이스 변경 사항은 실제로 커밋되기 전까지 임시적인 상태로 유지된다.
변경 사항 처리
- 서비스 계층에서 닉네임 변경과 같은 비즈니스 로직을 수행한다. 이때의 변경 사항은 아직 데이터베이스에 반영되지 않았다.
이벤트 발행 및 처리
- 닉네임 변경 후, 이벤트가 발행되고 SpringEventRecordListener의 listen 메서드에서 해당 이벤트를 처리한다. 이벤트 처리 과정에서도 데이터베이스 변경 사항이 발생할 수 있다.
트랜잭션 완료
- listen 메서드가 성공적으로 완료되면, 전체 트랜잭션도 성공적으로 완료된다. 이 시점에서 모든 변경 사항이 데이터베이스에 커밋된다.
예외 발생 시 롤백
- 만약 이벤트 처리 과정에서 예외가 발생하면, 전체 트랜잭션이 롤백된다. 이 경우, nicknameChange 메서드와 listen 메서드에서 수행된 모든 데이터베이스 변경 사항은 취소된다.
이런 방식은 데이터의 일관성을 유지하는 데 매우 유용하지만, 전체 트랜잭션의 성능과 실행 시간에 영향을 줄 수 있으므로, 트랜잭션 관리에 주의해야 한다.
2-1. 이벤트 저장소에 넣을 데이터를 추출하기 위해 사용되는 Util 클래스 1 - CustomJsonBuilder
- 아래 CustomJsonBuilder 클래스는 JSON을 생성해 주는 Util 클래스다.
💡 만약 SNS 발행에 실패했을 때, 이벤트 저장소에 저장된 데이터만 갖고 SNS 재발행을 할 수 있도록 테이블에는 필수데이터가 모두 저장되어있어야 한다.
이벤트 저장소에서 attribute 컬럼은 실제로 보낼 메시지에 해당한다.
SNS를 재발행할 때 후처리 없이 바로 attribute값을 사용해서 메시지를 생성할 수 있도록 Json(key, value 쌍)으로 미리 값을 세팅해 준다.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class CustomJsonBuilder {
private Map<String, Object> values = new HashMap<>();
public CustomJsonBuilder add(String key, Object value) {
values.put(key, value);
return this;
}
public String build() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(values);
}
}
- 아래는 customJsonBuilder 클래스를 사용해 이벤트에 있던 memberId를 key, value로 생성해 주는 모습이다.
2-2. SNS ARN에서 topic name만 추출하기 위해 사용하는 Util 클래스 2 - MemberStringUtils
- 아래 MemberStringUtils 클래스는 String에서 마지막 : 문자 뒤의 값을 추출하는 코드다.
public class MemberStringUtils {
public static String extractLastPart(String input) {
int lastIndex = input.lastIndexOf(":");
if (lastIndex != -1) {
return input.substring(lastIndex + 1);
} else {
return ""; // ':'이 없으면 빈 문자열 반환
}
}
}
- 아래의 ARN을 예시로 하자면 맨 뒤의 member-test만 추출된다. (이미 삭제한 SNS라서 모자이크 처리 X)
- 아래는 MemberStringUtils를 사용해 snsTopicNicknameChangeARN에서 토픽명만 추출하는 모습이다.
3-3-2. SNS 발행을 담당하는 리스너 프로세스
1. SNS를 발행하는 코드
- 여기서는 이벤트 저장소 담당 리스너와는 다르게 @TransactionalEventListener를 사용했다.
💡 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 상세 설명
이 코드에서 사용된 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 어노테이션은 Spring의 트랜잭션 이벤트 리스닝 기능을 활용하는 것으로, 특정 트랜잭션 단계에서 이벤트를 처리할 수 있게 해 준다.
여기서 TransactionPhase.AFTER_COMMIT은 이벤트 리스너가 트랜잭션이 성공적으로 커밋된 후에 실행되도록 지정한다.
트랜잭션 커밋
- 서비스 계층에서 수행된 트랜잭션이 성공적으로 완료되고, 데이터베이스에 변경 사항이 커밋된다.
- 즉, 실제 회원 DB에 닉네임이 변경되었고, 해당 이벤트를 이벤트 저장소에 commit까지 완료된 시점이다.
이벤트 리스닝
- 트랜잭션이 커밋된 후, @TransactionalEventListener에 지정된 TransactionPhase.AFTER_COMMIT에 따라, 해당 이벤트 리스너(snsListen 메서드)가 실행된다.
독립적인 실행
- 이 리스너는 원래 트랜잭션과 독립적으로 실행된다.
- 즉, snsListen 메서드 내에서 발생하는 예외나 문제는 원래의 트랜잭션에 영향을 주지 않는다.
- 이는 리스너의 실행이 원래 트랜잭션의 성공적인 커밋 이후에 발생하기 때문이다.
@TransactionalEventListener를 사용함으로써, 트랜잭션의 성공 여부에 따라 후속 조치를 취할 수 있으며, 트랜잭션 성능에 영향을 미치지 않으면서 안정적으로 이벤트 처리를 할 수 있다.
@Slf4j
@RequiredArgsConstructor
@Component
public class SnsPublishListener {
private final SnsService snsService;
private final CustomJsonBuilder customJsonBuilder;
/**
* 이벤트를 호출한 서비스 코드의 트랜잭션과 묶여있지 않고 트랜잭션이 commit된 후에 동작한다.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void snsListen(NicknameChangeEvent event) throws JsonProcessingException {
String messageJson = customJsonBuilder.add("memberId", event.memberId().toString()).build();
snsService.publishNicknameToTopic(messageJson);
}
}
스프링 프레임워크 4.2부터 적용된 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 관련 개념은 아래 글을 참고하면 좋다.
[Spring Boot] Spring Framework 4.2 이후 버전에서 스프링 이벤트 적용하기 (SpringBoot 3 버전에 spring event 적용하기)
4. SNS 발행과 이에 따른 SQS의 동작 설명
4-1. 닉네임 변경 요청
- postman을 이용해 닉네임 변경 요청을 한다.
- 그럼 위의 과정과 같이, 닉네임 변경 스프링 이벤트가 발행되고, 두 개의 리스너들이 각각 트리거 된다.
- Status가 200으로 보아 성공을 했다는 뜻이므로 이벤트 저장소에 해당 이벤트가 published = false로 저장되어있어야 한다.
- (직접 디버깅해가면서 회원 테이블의 닉네임과 이벤트 저장소에 데이터가 저장되고 난 후에 snsListener가 실행되는 모습을 확인했다.)
- 그리고 아래와 같이 SNS를 발행한 로그도 확일 할 수 있다.
4-2. SQS 동작
- 해당 SNS를 구독하는 SQS는 외부 서버에도 존재하고, 해당 회원 서버에도 존재한다.
- 이 SQS는 내부적으로 Event Record 테이블의 published 값을 true로 업데이트해 주는 담당이다.
💡 SNS가 정상 발행 되었다면 해당 토픽을 구독하고 있던 여러 SQS가 트리거 된다.
이때 이벤트 저장소 업데이트용으로 설정된 SQS까지 트리거 되었다면 이는 SNS 발행이 성공적으로 되었다는 뜻과 동일하다. 그렇기 때문에 이제 이벤트 저장소에서 해당 토픽과 메시지가 동일한 row를 찾아 published값을 true로 업데이트해 준다.
5. 데이터 확인
- 위의 과정을 전부 거치면 최종적으로 SNS가 정상 발행되었다는 모습을 확인할 수 있다.
6. 마무리
이번 MSA 아키텍처와 이벤트 기반 시스템 프로젝트는 기술적인 지식을 넘어서는 깊은 교훈을 제공했다. SNS와 SQS를 활용한 이벤트 처리 과정에서 맞닥뜨린 도전들은 복잡한 시스템의 세세한 부분까지 이해하게 만들었다.
이 프로젝트에서 이벤트 기반 아키텍처를 적용함으로써, 각 서버 간의 느슨한 결합을 통해 유연하고 확장 가능한 시스템을 구축할 수 있었다. 이를 통해 서비스의 독립성을 강화하고, 유지보수가 용이한 구조를 경험했다.
특히, 이벤트 유실에 대한 대응 전략을 개발하며, 시스템의 안정성과 요구사항에 대한 신속한 대응 사이의 균형을 찾는 중요한 교훈을 얻었다. 이벤트 저장소에 남겨진 데이터를 재발행하는 배치 프로세스 개발은 아직 남아있지만, 이 프로젝트를 통해 복잡한 시스템을 관리하고 예기치 않은 상황에 대처하는 방법을 배웠다.
이 경험은 앞으로 진행할 프로젝트에서 큰 자산이 될 것이며, 기술적 통찰력과 동료들과의 커뮤니케이션에도 긍정적인 영향을 미쳤다.
다음 포스팅에서 이제 외부 SQS에서 실제로 어떻게 memberId만 갖고 닉네임을 수정하는지에 대해 작성해 보겠다.
이 포스트는 Team chillwave에서 사이드 프로젝트를 하던 중 적용했던 부분을 다시 공부하면서 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)