-
메인 기능, 부가 기능 분리 그리고 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 를 사용하도록 적용할 수 있다.
- 현재는 규모가 작지만 규모가 커지게 될 경우 하나의 서버에서 모든 요청을 처리하기 어려울 수 있다.
- 모듈을 분리하여 모듈 간 이벤트 처리를 하도록 한다.
- 모듈끼리 비동기로 이벤트를 처리하기 위해서는 이벤트가 적절히 전송, 소비 되어야 한다.
- 이때 고려할 수 있는 선택지는 메세지 큐, 이벤트 브로커이다.
자료출처
'개발' 카테고리의 다른 글
JPA N+1 감지 기능 구현기 (0) 2024.10.30 JOOQ 테스트 환경 분리하기 (1) 2024.09.25 테스트하기 안 좋은 코드 리팩토링하기 (0) 2024.08.20 부하테스트를 위한 더미데이터 준비 (0) 2024.08.08 WebSocket, Redis 의 테스트 코드를 작성하며 마주친 에러와 해결 (0) 2024.08.06