Skip to Content
공부시스템 설계분산 시스템에서 데이터를 전달하는 효율적인 방법RDB를 사용하는 애플리케이션에서 전달 방법

RDB를 사용하는 애플리케이션에서 전달 방법

서비스별 데이터베이스 패턴

서비스별 데이터베이스 패턴은 마이크로서비스 아키텍처에서 가장 일반적인 패턴이다. 서비스마다 독립된 데이터 저장소를 가지고 있고, 각 컴포넌트마다 자기 데이터베이스의 데이터를 처리한다.

데이터 전파

어떤 컴포넌트에서 다른 컴포넌트로 데이터를 전파해야 한다면, DB 트랜잭션을 시작해서 데이터를 정상적으로 DB에 잘 저장하고, REST API를 사용해서 전파한다.

@Service public class CreateTaskService { @Transactional // 1. DB 트랜잭션 시작 public CreateTaskResponse createTask(CreateTaskCommand createTaskCommand) { Task task = createTaskCommand.toTask(); taskRepository.save(task); // 2. DB에 저장 eventHandler.propagate(CreateTaskEvent.of(task)); // 3. REST API로 전파 return CreateTaskResponse.of(task); } }

문제: @Transactional만 사용한 경우

@Transactional은 Spring Framework에서 제공하는 애너테이션으로, AOP를 사용해서 프록시 객체를 생성한다. 그래서 실질적으로 실행되는 순서는 데이터를 저장하고, 이벤트를 전달하고, @Transactional에 의해서 마지막으로 트랜잭션 커밋 혹은 롤백을 한다.

문제 상황

만약 Exception이 발생하거나 DeadLock이 발생하거나 SQL 문제가 있으면 롤백된다. 그럼 최종적으로 REST API만 호출되어 가장 중요하게 저장해야 되는 데이터가 RDB에 저장되지 않은 상태로 이벤트가 발생한다.

해결 방법 1: 트랜잭션 Commit 이벤트

트랜잭션 Commit 이벤트를 사용한다. Spring Framework에서는 크게 두 가지 방법이 있다:

  1. Spring Framework에서 제공하는 이벤트를 이용하는 것 (@TransactionalEventListener)
  2. Transaction Synchronization ManagerTransaction Synchronization 인터페이스를 사용하여 콜백 메서드를 호출하는 것

@TransactionalEventListener

@TransactionalEventListener를 사용하면 트랜잭션이 커밋된 후에 이벤트를 발행할 수 있다.

EventHandler.java
@Service public class EventHandler { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void propagate(CreateTaskEvent event) { // 이벤트 발생 로직 // + restTemplate.execute(...); // + rabbitTemplate.send(...); } }

REST API를 이용하거나 혹은 메시지 큐를 이용해서 다른 컴포넌트한테 데이터를 전달해야 되는 경우, @TransactionalEventListener 애너테이션을 propagate 메서드에 딱 심어주면 된다.

동작 방식

순서는 DB 트랜잭션 먼저 일어나고, 트랜잭션 커밋이 성공하면, 이벤트가 전달된다. 원본 데이터가 있을 때만 이벤트가 전파된다.

남아있는 문제

REST API 호출이 실패할 수가 있다. 왜냐하면 네트워크는 믿을 수가 없기 때문이다.

최종적으로는 DB 트랜잭션만 성공해서 내가 저장해야 되는 주요 데이터만 DB에 들어가 있고, 전파는 못하는 상황이 발생한다. 기본적으로 재시도 메커니즘이 없어서 한 번도 전달 안 될 수 있다 (At Least Once 미보장).

해결 방법 2: 재시도 적용

@Retryable 애너테이션을 propagate 메서드에다가 정의해주면, 실패했을 때 다시 시도 한다.

EventHandler.java
@Service public class EventHandler { @Retryable( maxAttempts = 3, backoff = @Backoff(delay = 100L) ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void propagate(CreateTaskEvent event) { // 이벤트 발생 로직 // + restTemplate.execute(...); // + rabbitTemplate.send(...); } }

maxAttempts는 최대 3번까지 성공할 때까지 실행할 거고, 리트라이 간격마다 backoff를 100ms를 주겠다는 설정이다. 이를 통해 일시적 네트워크 장애에는 대응할 수 있다.

남아있는 문제

하지만 계속 실패할 수가 있다. 왜냐하면 네트워크는 언제 돌아올지 우리는 알 수가 없기 때문이다. 네트워크가 오랫동안 복구되지 않으면 재시도 횟수 초과로 최종 실패한다. (At Least Once 미보장)

해결 방법 3: Transactional Outbox + Polling Publisher 패턴

트랜잭션 처리와 이벤트 전달이 실패 없이 동시에 잘 일어나기를 원한다면 Transactional Outbox PatternPolling Publisher Pattern 두 가지를 섞어 쓰는 패턴을 이용해야 한다.

  • Transactional Outbox Pattern: RDB를 Message Queue처럼 사용. 이벤트나 메시지가 발행되면 RDB에 저장한다.
  • Polling Publisher Pattern: 데몬이나 혹은 스케줄러를 하나 띄워 가지고 DB에 저장된 이벤트를 주기적으로 폴링하고 발행한다.

동작 방식

Transactional Outbox Pattern

테이블 설계

메시지 큐처럼 사용할 테이블 설계 시 중요한 4가지 필드:

  1. event_id (PK): 이벤트의 순서를 보장할 수 있는 값을 넣는다. 이벤트 순서에 따라서 처리해야 되는 경우들이 있기 때문이다. PK에 기대면 성능이 좋아진다.
  2. created_at: 이벤트 발생 시간. Consumer가 이벤트를 받을 때 오래된 이벤트는 걸러낼 수 있다. Datetime(3-6)으로 ms나 ns까지 정확도를 설정한다.
  3. status: 해당 이벤트의 상태 (Ready: 처리해야 될 이벤트, Done: 처리 완료)
  4. payload: JSON 타입의 Message payload

구현 코드

CreateTaskService.java
@Service public class CreateTaskService implements CreateTaskUserCase { @Transactional public CreateTaskResponse createTask(CreateTaskCommand createTaskCommand) { Task task = createTaskCommand.toTask(); taskRepository.save(task); // 가장 중요한 데이터 저장 eventRepository.save(CreateTaskEvent.of(task)); // 이벤트 저장 return CreateTaskResponse.of(task); } }

하나의 트랜잭션에 묶여야 되기 때문에 @Transactional 애너테이션이 잘 설정이 되어야 한다.

Polling Publisher Pattern

DB에 이벤트가 잘 저장이 되면, 이제 폴링하고 퍼다 날라야 한다.

구현 코드

EventPublisher.java
@Service public class EventPublisher { @Scheduled(cron = "0/5 * * * * *") // 5초마다 폴링 @Transactional public void publish() { LocalDateTime now = LocalDateTime.now(); eventRepository.findByCreatedAtBefore(now, EventStatus.READY) // Ready 상태 이벤트 조회 .stream() .map(event -> restTemplate.execute(event)) // REST API 호출 .map(event -> event.done()) // Done으로 업데이트 .forEach(eventRepository::save); } }

REST Template에 Exception이 발생하거나 혹은 다른 여러가지 이유로 비즈니스 로직에 버그가 있으면 Exception이 발생한다. 그때 다 롤백하기 위해서 @Transactional을 하나 선언해 놓는다.

Consumer에서 멱등성 있게 코딩을 해 놓으면 이전에 처리한 이벤트를 또 처리하더라도 문제가 없다.

장단점

장점: REST API를 사용하는 환경에서 At Least Once를 지원할 수가 있다.

단점:

  1. 지연 처리: 5초마다 폴링을 하기 때문에 지금 당장 이벤트를 발행하더라도 최대 5초 뒤에 처리가 될 수 있다. 혹은 폴링 Publisher 패턴을 적용한 코드에서 Exception이 발생하거나 버그가 있으면 무제한으로 늘어날 수가 있다.
  2. 데이터베이스 부하: 이벤트하고 데이터를 하나의 DB에다가 저장하기 때문에 데이터베이스에 부하가 간다.
  3. 처리 속도: 데이터베이스에 비례해서 처리 속도가 결정된다.

참고 자료

[NHN FORWARD 22] 분산 시스템에서 데이터를 전달하는 효율적인 방법 

Last updated on