Transactional Outbox 패턴, 필요할까?

최인준·2024년 3월 4일
3
post-thumbnail

배경

현재 진행중인 프로젝트에 알림을 보내야 하는 요구사항이 몇가지 있기에 알림 기능을 구현 해야했다.

전에 했던 프로젝트에서는 Spring의 TransactionalEventListener를 통해 알림 이벤트를 관리했다.

그리고 프로젝트가 끝난 이후 TransactionalEventListener도 문제가 발생할 수 있다는 것을 알았고

Transactional Outbox 패턴이란 개념을 새롭게 알게 되었다.

그래서 이번 프로젝트에서는 Outbox패턴을 적용해보고자 했지만 그냥 막 적용하기엔 고민이 생겨 정말 Outbox패턴이 필요할 지 비교해보았다.

결론을 미리 스포하자면 Transactional Outbox Pattern은 활용하지 않고 기존에 하던 식으로 하기로 결정했다.

이러한 결론을 내린 여정을 담아내보았다!

먼저 Outbox패턴이 어떤 안정성을 제공하는 지 간략하게 보도록 하겠다.

Transactional Outbox Pattern이란?

TransactionalEventListener에서 발생할 수 있는 문제

이 패턴이 어떤 부분에서 좋은 지 쉽게 이해하기 위해 기존 스프링 이벤트 처리 방식의 문제점을 먼저 알면 좋다.

유튜브에 좋은 그림자료가 있어 출처를 남기고 가져와보았다.

출처 : https://youtu.be/uk5fRLUsBfk?si=6tt_LY17VIxU-ySl

위 그림은 TransactionalEventListener를 활용했을 때 발생할 수 있는 문제를 보여주고 있다.

이벤트를 발행하는 쪽의 트랜잭션이 커밋된 이후에 이벤트가 실행되기 때문에 트랜잭션이 롤백됐지만 이벤트는 실행되는 문제는 방지할 수 있다.

하지만 트랜잭션은 성공하더라고 REST-API가 실패한다면 그건 막을 수 없다.

진행중인 프로젝트의 경우에도 특정 트랜잭션이 실행된 이후에 이벤트가 실행되는데 그 이벤트는 구글의 FCM api를 호출하는 방식이다.

그래서 트랜잭션과 이벤트 처리가 원자성 있게 처리될 수 있도록 하기 위해 Outbox Pattern을 활용할 수 있다.

Transactional Outbox Pattern

Transactional Outbox Pattern은 At-Least-Once-Delievery를 보장한다.

즉, 적어도 한 번 이상 메세지가 전송되도록 보장할 수 있다.

AWS 공식 문서에서 소개하는 이 패턴의 목적을 보면 이해가 빠를 것이다.

데이터베이스 업데이트 후 마이크로서비스가 이벤트 알림을 보내는 경우 데이터 일관성과 신뢰성을 보장하기 위해 이 두 작업이 원자적으로 실행되어야 합니다.

  • 데이터베이스 업데이트는 성공했지만 이벤트 알림이 실패할 경우 다운스트림 서비스는 변경 사항을 인식하지 못해 시스템이 일관되지 않은 상태가 될 수 있습니다.
  • 데이터베이스 업데이트에 실패했지만 이벤트 알림이 전송되면 데이터가 손상되어 시스템의 신뢰성에 영향을 미칠 수 있습니다.

위 글에서 볼 수 있듯이 이벤트 처리 상황에서의 문제가 발생할 수 있기 때문에 이 경우에 두 작업이 원자적으로 실행되게 하기 위함이 이 패턴의 목적이라고 소개하고 있다.

이제 어떤 프로세스인 지 살펴보도록 하겠다.

순차적으로 보도록 하자.

  1. 비즈니스 로직에 필요한 Main이란 테이블과 이벤트를 저장하는 Outbox테이블이 필요하다.

  2. 알림 발송이 필요한 서비스내 한 트랜잭션 내에 CRUD작업 수행 후 이벤트를 저장하는 작업까지 한다.

  3. Relay(현재 프로젝트 경우엔 Scheduler)에서 Polling 방식으로 특정 주기마다 Outbox테이블에서 처리 전인 이벤트를 읽어 메세지를 보낸다. 이후 처리된 이벤트의 상태는 처리 완료로 바꾼다.

이게 끝이다!(간략하게 보이지만 패턴 자체의 프로세스 자체는 간략..)

At-Least-Once-Delievery가 보장되는 이유는 3번과정 덕분이다.

Polling 방식으로 주기적으로 처리가 안된 이벤트를 처리하려 계속 시도하기 때문에 많은 요청이 오면 적어도 한번은 이벤트를 처리하게 된다.

단점

생각이 든 단점은 크게 두가지다.

  • Outbox 테이블이 별도로 필요하다.
  • 주기적으로 계속 처리해야 할 작업이 필요하다.(스케줄러, 배치..)

일단 테이블은 요구사항에 맞춰 계속 늘어날 수 있고 지금 진행중인 프로젝트는 간단한 프로젝트인 듯 하지만 이미 테이블이 복잡한 느낌이다..

그래서 요구사항에 필요한 테이블이 아닌 별도의 테이블이 필요하다는 건 조금 부담으로 다가오는 면이 있었다.

그리고 별도의 스케줄러 작업이 필요하다는 점도 굳이라는 생각이 들었다.

스케줄러 자체가 문제는 되지 않지만 스케줄러가 요구사항과는 전혀 관련없고 오로지 이벤트 처리만을 위한 스케줄러가 될 것이다.

진행중인 프로젝트가 이벤트 기반 아키텍처도 아니고 이벤트 자체가 중요도가 엄청 크지 않다.

알림 이벤트의 실패율이 엄청 크지만 않다면 기존의 TransactionalEventListener를 활용하는 것이 낫다고 생각이 들었다.

이제 TransactionalEventListener를 사용했을 때 REST-API 통신이 얼마나 실패하는 지 테스트 해보자!😀

→ 네트워크 문제가 얼마나 일어나는 지 보자!

REST-API 실패율 테스트

알림 이벤트는 RestTemplate을 통해 외부 API를 호출한다.

많은 외부 API 호출에 대한 이벤트가 있을 때, REST-API 통신이 얼마나 실패하는 지 테스트 해 볼 예정이다.

테스트 환경

  • Server : 로컬
  • Spring : 3.2.0
  • 외부 API : Slack API

다음은 외부 API를 호출하는 서비스로 테스트에서 호출할 메소드들이 정의되어있다.

EventService.class

public int sendMessageAndSave(String content) {
	    String email = "dlswns661035@gmail.com";
	    String message = content;
	    String slackId = "U06CR7RDXEE";

	    return requestToSendMessage(slackId, message);
}

private int requestToSendMessage(String slackId, String message) {
		String url = "https://slack.com/api/chat.postMessage"; // slack 메세지를 보내도록 요청하는 Slack API
        // 헤더에 슬랙 토큰 삽입
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + slackToken);
        headers.add("Content-type", "application/json; charset=utf-8");

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("channel", slackId); // 채널 필드에 사용자의 슬랙 고유 ID
        jsonObject.put("text", message); // 메세지 필드에 메세지
        String body = jsonObject.toString();

        HttpEntity<String> requestEntity = new HttpEntity<>(body, headers);
        RestTemplate restTemplate = new RestTemplate();

        ResponseEntity<String> response = restTemplate.exchange(
            url,
            POST,
            requestEntity,
            String.class
        );

        if(response.getStatusCode().value() != 200){
            throw new BadRequestCustomException(FAILED_TO_CALL_API);
        } // API 통신 실패 시 예외 발생

        return response.getStatusCode().value();
}

테스트에서 호출할 클래스의 메소드들이다. sendMessageAndSave(String content) 를 테스트에서 호출하게 되면 requestToSendMessage(String slackId, String message) 를 통해 외부 api에 요청을 보내게 된다.

원래 코드에는 Response Body의 값에 따른 처리 로직이 더 있지만 이번 글의 테스트에서는 REST-API 통신 자체에 대한 성공 여부만 확인할 것이므로 상태코드를 반환하는 것으로 처리했다.

테스트 코드

@Test
@DisplayName("[1000번의 알림 api를 호출했을 때 모두 잘 성공한다. ]")
void eventFailPercent() throws InterruptedException {
        AtomicInteger errorCount = new AtomicInteger(0);
        int threadCount = 1000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    eventService.sendMessageAndSave("알림 테스트");
                } catch (BadRequestCustomException e) {
                    errorCount.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        assertThat(errorCount.get()).isZero();
    }

테스트 코드의 흐름은 다음과 같다.

  1. 동시에 1000번의 외부 API를 호출하는 로직을 수행한다.
  2. 외부 API를 호출하는 로직에서 상태코드가 200이 아닐 시 BadRequestCustomException 을 발생시킨다.
  3. 예외 발생 시 errorCount 를 증가시킨다.
  4. errorCount 가 0으로 모든 REST-API 통신이 성공했는 지 검증한다.

사실 네트워크 문제가 얼마나 발생할 지 모르기 때문에 errorCount가 0인지 아닌 지 알 수 없다.

그렇지만 외부 API 자체에만 문제가 없다면 네트워크 문제는 발생하지 않을 것이라 예상해 0으로 검증을 하였다.

그리고 결과는 성공!

결론

실제 ec2 환경이 아닌 로컬에서 외부 API를 호출한 테스트이긴 했지만 어쨋든 외부 API를 활용하는데 있어서는

로컬과 ec2의 차이가 크지 않다고 생각한다.

테스트 결과는 위에서 수행해본대로 외부 API호출에 실패하는 경우는 없었다.

물론 많은 이벤트가 발생하면 그 중에 소수건은 실패할 수 있다!

하지만 프로젝트 특성 상 알림의 유실이 중요도가 높지 않다. 반드시 확인해야 되는 알림이 있는 것은 아니다.

이벤트가 절대 유실되어선 안된다면 Outbox Pattern을 활용하는 것이 맞지만 나의 경우는 아니다.

만약 이벤트가 “결제”와 얽혀있다면 절대 유실되어선 안된다고 생각한다.

앞에서 소개한 두 작업의 단점을 비교하고 테스트를 해본 결과 미리 스포한대로 TransactionalEventListener를 활용하기로 결정했다! 😃

0개의 댓글