ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 메인 기능, 부가 기능 분리 그리고 Transactional outbox pattern
    개발 2024. 9. 4. 17:16

    도서 공유 프로젝트를 진행하며 겪은 문제들과 이를 해결해나가는 과정을 담은 글입니다.


    도서의 상태가 변경되면 해당 도서를 팔로우 하고 있던 사용자들에게 알림을 보내는 기능이 있다.

    • 도서의 상태를 변경하는 메인 기능.
    • 도서를 팔로우 하던 사용자들에게 알림을 주는 부가 기능.

    하나의 클래스에서 처리하던 기능을 두 가지의 기능으로 분리하고자 했다.

     

    분리를 하는 이유

    • 메인 기능과 부가 기능이 같이 있어 코드의 의도를 파악하기에 어렵다.
    • 부가 기능의 변경으로 메인 기능이 변경되어야 할 상황이 발생할 수 있다.
    • 하나의 클래스는 하나의 책임만을 지는 것이 원칙이다.
      로버트 마틴은 하나의 클래스는 하나의 액터만을 책임져야 하며, 이를 단일 책임 원칙이라고 한다.
    • 현재의 기능은 도서의 소유자, 도서를 팔로우하고 있는 사용자. 즉 액터가 하나가 아니다. 이런 경우를 단일 책임 원칙을 위배했다고 볼 수 있다.

    • 현재는 위와 같이 하나의 클래스에 두 개 이상의 액터가 들어가있는 구조다. 이 구조를 아래와 같이 분리해야 한다.


    클래스의 분리

    단순히 Notifier 라는 클래스를 만들어 알림 기능을 구현하면 모든 일이 해결될까?

    그렇지 않다.

    @RequiredArgsConstructor
    public class BookService {
    	
    	private final Notifier notifier;
    	
    	@Transactional
    	public void update(Book book) {
    		book.update();
    		notifier.notify(book);
    	}
    }
    

    위와 같이 알림 기능을 구현한 Notifier 를 주입 받아 사용한다고 해도, 구현된 코드만 Notifier 에 있고 여전히 BookService 는 알림 기능에 대한 책임을 지고 있다.

     


    Spring Event

    이들 간의 느슨한 결합(Decoupling) 을 챙기기 위해선 분리를 해야 한다.

    느슨한 결합을 가지기 위해 도입한 것은 스프링이 제공하는 EventPublisher 이다.

    @RequiredArgsConstructor
    public class BookService {
    
    	private final ApplicationEventPublisher eventPublisher;
      
      @Transactional
    	public void update(Book book) {
    		book.update();
    		eventPublisher.publishEvent(book);
    	}
    }
    

    코드를 보았을 때 알림에 대한 코드가 없다. 단순히 상태를 변경하고 이벤트를 발행할 뿐이다.

    이전의 코드보다 결합도가 낮아졌음을 알 수 있다.

    이제 이를 처리하기 위한 EventListener 를 구현할 차례다.

    이벤트의 내구성

    우선 기존의 코드를 보자.

    @Component
    public class BookEventListener {
    
    	private final Notifier notifier;
    
    	@TransationalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    	public void handleBookEvent(Book book) {
    		notifier.notify(book);
    	}
    }
    

    이벤트를 처리하는 handleBookEvent 는 이벤트를 발행한 객체(이 상황에선 BookService의 update) 트랜잭션이 commit이 되면 이벤트가 발행되도록 설정되어 있다.

    이렇게 commit 이 완료되면 이벤트를 발행하도록 한 이유는 다음과 같다.

    • 도서의 상태를 변경하는 작업은 DB 작업으로 트랜잭션 처리가 가능하다.
      • 원자성을 보장 받는다.
      • 실패하거나 성공하거나 둘 중 하나이다.
    • 만약 트랜잭션이 commit되고 이벤트를 발행하게 하지 않는다면 DB roll back 이 일어나는 경우를 이미 발행한 이벤트에 대한 관리가 어렵다.

    위와 같은 이유로 트랜잭션이 commit 될 때 이벤트를 발행하면 해결할 수 있겠다 생각했다

    .

    .

    .

    그러나 그리 쉽게 해결할 수 있는 문제가 아니었다.

    그림을 그려 이해를 돕겠다.

    • 트랜잭션을 시작한다.
    • 도서를 조회하고 상태를 변경한다.
    • 트랜잭션을 커밋한다. 커밋이 일어나면 이벤트가 발행된다.
    • 이때 이벤트를 발행하기 전에 서버가 죽어버린다면 이벤트는 발행되지 않는다.

    즉, 신뢰성 있는 이벤트 발행을 위해서는 상태 변경과 이벤트 발행에 대해 원자성을 보장하여야 한다.


    Transactional outbox pattern

    Transactional outbox pattern 은 트랜잭션과 이벤트의 원자성을 보장해주는 패턴이다.

    아래 그림처럼 책의 상태를 업데이트 하면서 outbox 라는 곳에 이벤트를 저장한다.

    outbox 란 ?

    outbox 의 사전적 정의는 아래와 같다. 나는 트랜잭션을 처리하고 이벤트를 임시 보관함에 보관한다고 이해했다. 이제 우리의 시스템에서는 해당 outbox 를 polling 하여 이벤트를 처리한다. 책의 상태가 변경된다면 이벤트가 최소 한번은 전송됨을 보장한다. At Least Once

    고민했던 것

    책의 상태는 정상적으로 변경되는데, outbox 에 저장하는 과정에서 에러가 발생하면 모두 실패해버리게 된다. 즉 outbox 에 의존적인 기능이 되버린다.

    그럼에도 불구하고 Transactional outbox pattern 을 적용한 이유는 아래와 같다.

    어떠한 책을 빌리기 위해 상태가 변경되기 만을 기다린 사용자가 있다고 가정하자.

    • 알림 기능이 없다면 매번 확인을 해야한다. 
    • 알림을 받는다면 사용자는 보다 편하게 원하는 책에 대여 신청을 할 수 있다.

    이러한 서비스 특성 상 알림 기능은 단순한 알림 기능보다는 사용자 경험과 직결된 중요한 기능이라고 생각했다.

    그리고 같은 DB 에 작업을 한다면 에러가 발생할 확률은 줄어들 것이라고 생각했다. (에러가 난다면 book 의 상태도 변경이 안될 것이다 생각함)


    구현

    • 책의 상태를 변경하고, outbox 에 이벤트를 저장한다.
    • 이벤트를 처리할 EventFoward 라는 객체를 생성하고, 스케줄러를 통해 outbox 를 polling 하며 이벤트를 이벤트 리스너에게 전달한다.
    • 이벤트 리스너는 이벤트를 처리한다.

     

    이 경우에도 고려해야할 사항이 있다.

    At Least Once 최소 한번 전송을 보장한다면 이벤트가 중복으로 처리될 우려가 있다.

    이벤트가 중복처리 되는 문제를 예방하기 위해서는 Exactly Once 를 보장하거나, 멱등성을 보장하도록 설계하여 메시지 중복 처리의 문제를 해결해야한다.

    • outbox 에 저장될 이벤트를 고유한 ID 로 관리한다.
      • 이벤트에 대한 추적과 로깅을 용이하게 하기 위해
    • 이벤트에 상태를 나타내는 컬럼을 만든다.
    • EventForward 는 이벤트의 상태가 COMPLETE 가 아니라면 EventListener 에게 전달한다.
    • EventListener 는 이벤트의 처리 상태를 확인하고 이벤트를 처리하고 이벤트의 상태를 변경
      • 이때 outbox 에 저장된 이벤트에 잠금을 걸어야 한다.
      • polling 주기에 따라 이벤트가 중복 처리될 가능성이 있기 때문이다.
      • 비관적 락을 사용하여 잠금을 걸었다. 구현의 단순함도 있었고, 현재 해당 이벤트를 처리하는 곳은 하나 뿐이다.
      • 낙관적 락을 사용하여 버전으로 관리하더라도 근본적인 이벤트 중복 처리 문제는 해결할 수 없었다. 또한 이벤트를 처리하는 곳도 하나 밖에 없었기에 락으로 발생하는 성능 저하는 없을 것이라고 생각했다.
    • 완료된 이벤트는 즉시 삭제하거나, 배치 작업으로 주기적으로 삭제

     

    위처럼 같은 이벤트를 여러 번 읽더라도 중복으로 처리되지 않도록 멱등성을 보장해주었다.

    트랜잭션과 이벤트에 대해 원자성을 보장하고, 중복처리 되는 문제를 해결했다.

    하지만 여전히 문제는 존재한다.

    • 이벤트를 처리해야 하는데 외부 API 가 제 기능을 못한다면 알림 기능은 동작하지 않는다.
      • 써킷브레이커를 적용하여 기존의 알림 기능을 하는 API 가 먹통이라면 다른 API 를 사용하도록 적용할 수 있다.
    • 현재는 규모가 작지만 규모가 커지게 될 경우 하나의 서버에서 모든 요청을 처리하기 어려울 수 있다.
      • 모듈을 분리하여 모듈 간 이벤트 처리를 하도록 한다.
      • 모듈끼리 비동기로 이벤트를 처리하기 위해서는 이벤트가 적절히 전송, 소비 되어야 한다.
      • 이때 고려할 수 있는 선택지는 메세지 큐, 이벤트 브로커이다.

    자료출처

Designed by Tistory.