글 작성자: beaniejoy
Github Repo
https://github.com/beaniejoy/resetpw-outbox-scheduler

 

  • Overview
  • Eventual Consistency & Strong Consistency
  • At Least Once?
  • Outbox 패턴
  • 코드 구현

 

📌 1. Overview

프로젝트 개발 중에 비밀번호 초기화 관련한 메일 발송 기능을 구현하는 과정이 있었습니다.
여기서 개념도 익힐겸 Eventual Consistency 고려해서 ALO(At Least Once) 방식을 적용한 메일 발송 기능을 구현해봤습니다.

 

📌 2. Eventual Consistency? Strong Consistency?

위 두 개의 Consistency 개념은 Multiple Replicas of a Database 환경에서 생각해보면 꽤나 명확하게 이해할 수 있습니다.

▶ Eventual Consistency

아래 참고자료는 다른 블로그에서 가져온 것인데 EC를 가장 잘 표현하고 있는 그림이라 생각되어 가져왔습니다. 결국 Eventual Consistency의 핵심은 결과론적으로 데이터 일치성을 보장할 것이므로 Node1 DB의 write 작업이 끝나고 Node2 DB로 전파되기 전에 해당 데이터에 대해서 read를 허용한다는 것입니다. 

Eventual Consistency

제가 생각하는 Eventual Consistency의 장점은 다음과 같습니다. 

  • 데이터 갱신과 전파라는 전체 작업동안 걸리는 latency를 줄여준다는 점
  • MSA 환경에서 일련의 작업 과정 시작과 끝을 하나의 Connection으로 묶었을 때 나타날 수 있는 불필요한 rollback 발생

첫번째는 Strong Consistency의 특징에서 드러납니다. 

▶ Strong Consistency

Strong Consistency

Eventual Consistency와 다르게 Strong Consistency는 Node1 데이터 write 부터 Node2 전파 완료 때까지의 모든 과정이 끝나고 데이터 일치성이 확보된 다음에 read에 대한 응답을 보내주는 형태입니다. 위의 EC보다 latency가 상당히 커진다는 단점이 존재해 클라이언트가 기다려야하는 시간이 길어질 수 있습니다. (여기서 클라이언트는 꼭 프런트의 요청에 국한되는 것이 아니라 Spring 환경에서의 Bean 객체일 수도 있습니다.) 이러한 단점을 EC에서 보완할 수 있습니다.

또한 두 번째로 MSA 환경 내에서 EC가 필요하다는 점입니다. 예를 들어 주문 배송 서비스를 하는 프로젝트가 있다고 생각해봅시다. 클라이언트가 주문을 하면 주문 - 결제 - 물류재고처리 - 배송처리 과정으로 이루어진다고 가정했을 때 하나의 트랜잭션으로 처리한다면 어떻게 될까요. 배송처리 과정에서 문제가 생겨 롤백 시켜야 한다면 첫 단계인 주문 단계도 이전으로 롤백될텐데 만약 이미 물류재고처리를 완료한 상황이면 번거롭게 다시 복구를 해야할 것입니다. 또한 MSA 환경에서 API 통신으로 이루어지는 상황에서 트랜잭션 전파도 까다로울 것입니다. 이러한 상황에서 EC가 필요하다고 할 수 있습니다.

 

📌 3. At Least Once, At Most Once, Exactly Once

  1. At Least Once
    - 최소 한 번 메세지 전송처리 보장(최소 한 번이상)
    - 중복 메세지 발생가능성 이슈, 이에 영향받지 않는 메세지에 대해서 적용 가능
  2. At Most Once
    - 최대 한 번 메세지 전송처리
    - 수신자가 메세지를 수신했는지 여부에는 관심이 없음.
    - 메세지 유실이 발생할 수 있기 때문에 유실에 영향을 받지 않는 영역에 대해서 적용 가능
  3. Exactly Once
    - 정확히 한 번만 메세지 전송처리
    - 한 번 메세지 전송을 위해 Message Filtering해주는 미들웨어가 있어야 함
    - 미들웨어 구성도 어렵고 중요한 것은 정확히 한 번이라는 것을 보장하지 못함

이번에 적용한 것은 At Least Once입니다. 이름에서도 알 수 있듯이 적어도 한번은 메세지 전송처리를 보장한다는 것입니다. 메일 발송에 있어서 여러번 발송하는 일이 있어도 한 번도 발송되지 않는 경우는 없게끔 하는 것이 목표입니다.

 

📌 4. Outbox 패턴

MSA 환경에서 사용되는 패턴으로 Message Queue에 해당하는 부분이라고 할 수 있습니다. Outbox 패턴을 이용하면 데이터 조작과 그에 대한 다른 API 서버로의 메세지 전송간에 데이터 일관성을 유지할 수 있습니다.
(Outbox 관련 설명은 아래 링크를 통해 확인할 수 있습니다.)

Outbox 패턴의 핵심은 어떤 데이터의 DB 갱신이 이루어지면 이에 대한 이벤트를 발생시켜 해당 업데이트 내용을 이벤트로 Outbox에 저장하는 것입니다. 그리고 저장된 Outbox 내의 이벤트들을 MessageRelay가 처리해주어 메시지를 발송해줍니다.

데이터의 DB갱신과 Outbox에 해당 이벤트가 insert되기까지 과정을 하나의 Transaction에서 처리하기 때문에 MessageRelay가 메시지를 발송하는 시점의 차이가 있을 순 있지만 결국 데이터 일관성을 보장해줍니다.

이 Outbox 패턴을 이용해 Eventual Consistency와 ALO를 만족하는 메일 발송 서비스를 구현해보았습니다.

 

📌 5. 코드 구현

Part 1. DB 갱신과 Outbox insert

// UserController.java
// 비밀번호 찾기
@PostMapping("/find-password")
@ResponseStatus(HttpStatus.OK)
public ResponseDto<String> findPassword(@Valid @RequestBody UserEmailRequestDto resource) {
  // 해당 이메일이 존재하는지 여부 검토
  UserInfoDto userInfoDto = authService.findByEmail(resource.getEmail());
  userPasswordResetService.saveResetKey(userInfoDto.getUserEmail(), userInfoDto.getUserName());

  return new ResponseDto<>(0, "비밀번호 초기화를 위한 이메일을 발송하였습니다.", resource.getEmail());
}

클라이언트가 본인의 계정 이메일을 입력 후 비밀번호 초기화를 위해 이메일 발송을 요청하면 위의 api가 호출됩니다.

입력 받은 이메일을 가지고 먼저 인증 작업을 진행하고 saveResetKey를 통해 reset을 위한 key를 발급받고 DB에 insert하게 됩니다.

// UserPasswordResetService.java
@Service
@RequiredArgsConstructor
public class UserPasswordResetService {
  private final ResetPwKeyRepository resetPwKeyRepository;
  private final ApplicationEventPublisher eventPublisher;
  private final OutBoxEventBuilder<ResetPwKeyCreated> eventBuilder;

  @Transactional
  public void saveResetKey(String userEmail, String userName) {
      // key를 생성하고 DB insert
      String key = createResetKey();
      LocalDateTime now = LocalDateTime.now();
      LocalDateTime expiredTime = now.plusMinutes(20);
      ResetPwKey resetPwKey = toResetPwKeyEntity(key, expiredTime, userEmail);
      resetKeyRepository.insertResetPwKey(resetPwKey);

      // DB insert했다는 이벤트를 생성하고 publish
      eventPublisher.publishEvent(
          eventBuilder.createOutBoxEvent(
              new ResetPwKeyCreated(
                  resetPwKey.getResetPwKeyId(),
                  resetPwKey.getResetKey(),
                  resetPwKey.getExpireDate(),
                  resetPwKey.getEmail(),
                  userName)
          )
      );
  }
  
  // ...
  
}

여기서는 인증된 유저의 이메일과 사용자명을 받고 인증키를 생성해 DB에 insert해줍니다. insert 이후에 OutboxEvent로 변환해서 ApplicationEventPublisher에 의해 이벤트를 발생시키는 구조입니다. 하나의 트랜잭션으로 묶기위해 @Transactional 을 선언했습니다. 이로 인해 발생된 이벤트를 처리하는 Handler에까지 하나의 트랜잭션으로 묶을 수 있게 됩니다.
(시작점에서 Transactional Annotation을 선언해야 트랜잭션을 안정적으로 전파할 수 있습니다. 위 코드에서는 그렇지 않지만 만약  트랜잭션을 선언하지 않고 해당 트랜잭션 선언된 메소드 내에서 같은 오브젝트 내의 메소드를 호출한다면 트랜잭션 적용이 되지 않습니다. "AOP 특징")

@Component
public class OutBoxEventHandler {

    private final OutBoxRepository outBoxRepository;

    public OutBoxEventHandler(OutBoxRepository outBoxRepository) {
        this.outBoxRepository = outBoxRepository;
    }

    @EventListener
    public void handleOutBoxEvent(OutBoxEvent event) {
        OutBox outBox = new OutBox(
                event.getAggregateId(),
                event.getAggregateType(),
                event.getEventType(),
                event.getPayload());

        outBoxRepository.insertOutBox(outBox);
    }
}

ApplicationEventPublisher에 의해 발행된 이벤트를 받아서 처리하는 부분입니다. 이벤트를 받은 OutboxEvent를 이용해 Outbox 객체를 생성해서 Outbox 테이블에 insert합니다. 

 

Part 2. Outbox 기준으로 메일 발송처리

Outbox 테이블에는 메일 발송 대상 이벤트들이 저장될 것입니다. 이를 가져다가 메일 발송처리를 해야되는데 MSA에서 Message Queue를 이용해 메시징 처리를 할 때 사용하는 MessageRelay 서비스를 활용했습니다.

크게 두가지 방법이 있었습니다.

  • 스케줄링 방식으로 메시지를 처리하는 Polling Publisher 방식 (지속적으로 Polling해서 메시지를 처리하는 방식)
  • 데이터베이스의 트랜잭션 로그로 메시지를 처리하는 Transaction Log Tailing 방식

트랜잭션 로그를 활용한 방식에 있어서 알아야 할 것도 많고 현재 제 수준에는 어렵게 다가와서 간단히 구현할 수 있는 Polling Publisher 방식을 채택했습니다. 이와 관련된 참고 소스들도 많더라구요.

@Scheduled(cron = "*/10 * * * * *")
public void schedulingResetPasswordMail() {
	List<OutBox> outBoxList = outBoxRepository.findAll();
	if (!outBoxList.isEmpty()) {
		List<Long> outBoxCompletedList = new LinkedList<>();
		outBoxList.forEach(outBox -> {
			String payload = outBox.getPayload();
			ContentDto mailContent = mailContentService.createContent(payload);

			try {
				sendMailService.sendMail(mailContent);
				// 발송완료시 완료 목록에 추가
				outBoxCompletedList.add(outBox.getOutBoxId());
			} catch (MailException e) {
				logger.error("MailSender process 과정에서 문제가 발생하였습니다.");
			}
		});

		if(!outBoxCompletedList.isEmpty())
			outBoxRepository.deleteAllByOutBoxId(outBoxCompletedList);
	}
}

10초마다 메일을 발송하는 것으로 스케줄링 설정하였고 작업 단위마다 Outbox 테이블에서 selectAll로 모든 이벤트를 조회, 메일 발송하고 성공하면 해당 Outbox id 값을 저장했다가 Outbox 테이블 내 해당 데이터를 delete 하는 방식입니다.
(다른 블로그에서 구현한 방식을 사용했습니다.)

위의 Outbox 패턴을 이용한 메일 발송 서비스를 구현한 코드들의 UML diagram 입니다.

 

📌 6. 결론

위 방식으로 ResetPwKey라는 데이터 생성과 함께 Outbox에 ResetPwKey 생성에 대한 이벤트를 저장해두고 하나의 트랜잭션으로 처리했기 때문에 데이터 일관성을 유지할 수 있습니다.

메시지 발행은 그 이후의 일이기에 데이터 생성 시점과 메일 발송 시점 간에 차이가 있을 순 있어도 최종적으로 데이터 일관성을 유지할 것이기에 "Eventual Consistency" 하다고 할 수 있습니다. 

또한 메일 발송이 실패하면 해당 이벤트에 대해서는 Outbox 테이블에서 삭제를 안하기에 최소 한 번의  메일 발송을 보장합니다. 이 관점에서 "ALO"를 구현했다고 할 수 있습니다.

 

고려해볼 만한 요소

여기서 고려할 만한 요소가 있습니다. 바로 스케줄링에 있어서 scale out 상황에서 중복 실행에 대한 처리부분입니다. 이 부분은 다음 두가지로 해결할 수 있을 것으로 보입니다.

  • ShedLock을 이용해 스케줄러에 락을 거는 방안
  • Spring batch 서버를 통해 스케줄러의 clustering 하는 방안

이 부분에 대해서도 공부해보고 정리해서 글 올려보겠습니다.

 

틀린 내용이 있을 수 있습니다. 이에 대한 코멘트 언제나 환영입니다!

 

📌 Reference