이번 글에서는 분산 시스템 환경에서 데이터 전달에 대한 방법이나 문제 그리고 다음 글에서는 프로젝트에 어떻게 적용 했는지에 대해 적어보려 합니다.
분산 시스템이란?
- 목표를 달성하기 위해 여러 개의 컴퓨터 리소스를 사용하는 시스템
- 시스템은 두 개 이상의 컴포넌트로 구성되어 있다.
- 엔터프라이즈 애플리케이션
- 마이크로 서비스 아키텍처 애플리케이션
- 모놀리식 아키텍처 애플리케이션 + 검색엔진
- 네트워크를 사용하여 컴포넌트 간의 기능을 통합
분산 시스템은 네트워크를 사용하여 데이터를 전달하는 것이 특징이다.
네트워크를 통해 데이터를 전달하는 방법
네트워크를 통해 데이터를 전달하는 방법은 크게 아래 2가지 방식이 존재한다.
- Remote API(rest-api, GRPC 등)
- MessageQueue
Remote API를 사용한 데이터 전달
- 서버-클라이언트 구조가 필요
- 서버는 API를 제공하고 클라이언트는 서버가 제공하는 API를 사용해 데이터를 처리한다.
- 서버에서 데이터를 처리한다.(C/U/D)
- 클라이언트에서 서버의 데이터를 조회해서(R) 처리한다.
- 즉 데이터 처리는 양쪽에서 일어날 수 있다.
- 사용자 요청에 즉각 응답하는 API에서 주로 사용하는 방식
- 예를 들어, 게이트웨이의 경우 사용자 요청을 받아 여러 컴포넌트들의 데이터를 받아 응답한다.
- 이때 즉각적으로 응답해야 할 때 Remote API를 사용한다.
- 서버와 클라이언트만 존재해 비교적 간단하고 빠르게 개발 가능
MessageQueue를 사용한 데이터 전파
- Publisher - Consumer 구조
- 일반적으로, Publisher가 데이터를 만들어서 MessageQueue에 던지면 Conumser는 MessageQueue에서 데이터를 받아와 처리한다.
- 물론 Conumser와 연결된 다른 컴포넌트들이 있다면 컨슈머가 다른 컴포넌트들의 REST API를 호출할 수도 있고, 직접 DB에 데이터를 처리할 수 있다.
- 일반적으로 배치 작업, 비동기 작업에서 주로 사용
- Publisher, MessageQueue, Consumer 3가지로 구성되어 있어 비교적 복잡한 개발이다.
분산 시스템에서 데이터를 전달하는 효율적인 방법은?
분산 시스템에서 컴포넌트들은 네트워크로 연결되어 있다.
그래서 네트워크가 없으면 통신을 할 수 없다.
즉 네트워크는 시스템을 연결하는 유일한 수단이다.
문제는 네트워크는 신뢰할 수 없는 매체(Media)이다.
- 패킷 손실(Packet Loss)
- 네트워크 지연(Latency)
- 네트워크 다운(Network down)
그래서 데이터를 전송할 때 항상 데이터 유실에 대비해야 한다.
데이터 전달 보장 방법론
데이터 전달 보장 방법론은 REST API를 사용하거나 MessageQueue를 사용하든 간에 Endpoint부터 Endpoint까지 데이터를 전달하는 추상화된 방법론을 의미한다.
Types of messaging semantics (의미론적 메시지 타입 3가지)
At-most-once delivery(최대 한번 전달)
- Producer는 메시지를 최대 한 번만 전송
- 보내는 쪽에서 메시지를 보낸 후 받는 사람이 받았는지 안 받았는지는 확인하지 않는다.
- 그래서 일반적으로 Fire and forget이라 한다.
- Consumer는 메시지를 최대 한 번만 수신
- 컨슈머는 아래와 같은 문제로 메시지를 못 받는 경우가 발생할 수 있다.
- 네트워크 유실 문제(패킷 손실 등)
- Producer 애플리케이션에서 발생한 예외
- Consumer 애플리케이션에서 발생한 예외
- 장점
- 받는 사람이 받았는지 확인하는 작업이 없어 빠르다.(오버헤드 없음)
- 간단한 구조, 간단한 개발
- 단점
- 받는 사람이 메시지(데이터)를 받았는지 확인하지 않기에 데이터가 유실될 수 있다.
- 정확도가 제일 낮은 방식이다.
- 사용하면 좋은 경우
- 일부 데이터가 누락되어도 상관없는 Log 데이터
At-least-once delivery(최소 한번 전달)
- Producer는 메시지를 최소 한번 이상 Consumer에게 전송
- 메시지를 전송하고 최소한 하나의 메시지는 받았는지 확인한다.
- 보내는 쪽에서 메시지를 전달 후 받는 쪽에서 메시지를 잘 받았다는 답변이 올 때까지 메시지를 계속 보낸다
- 이렇게 하기 위해 발송 메시지 상태 관리에 대한 로직이 추가적으로 필요하다.
- Consumer는 메시지를 최소 한번 이상 수신
- ACK 유실로 인한 재발송
- 장점
- Producer는 메시지 발송 보장(받는 사람에게 보내는 메시지가 누락될 일이 없다.)
- 효과 대비 쉬운 개발(Producer에서 상태관리만 하면 되기에 Exactly-once 보다 상대적으로 간단하다는 의미)
- 단점
- Consumer가 중복된 메시지를 받을 수 있다.
- 그리고 순서상으로 받는다는 보장도 못한다.
- 그래서 Consumer 쪽에서 멱등성(idempotent)을 보장하도록 개발해야 한다.
- 같은 메시지를 여러 번 받아도 최종 결과는 같아야 한다는 의미다.
- 사용하면 좋은 경우
- 데이터의 누락 없이 모든 데이터를 전송해야 하는 경우,
- 중복된 값을 저장해도 상관없거나 다음 프로세스에서 중복된 값을 처리할 수 있는 경우
Exactly-least-once delivery(정확히 한번 전달)
- 메시지는 정확히 한 번만 전송한다.
- 보내는 쪽에서 받는 사람에게 메시지를 정확히 1번만 전송한다
- 장점
- 누락과 중복 없이 메시지를 전달할 수 있다.
- 단점
- 가장 어려운 개발 난이도
- Producer, Consumer에서 모든 상태 관리
- MessageQueue 기능에 의존한 개발
- MessageQueue 추가로 인한 시스템 복잡도 증가
- 사용하면 좋은 경우
- 비즈니스 적으로 정확히 한 번만 데이터가 전송되어야 하는 경우
- 예를 들어 온라인 쇼핑몰의 상품 주문 데이터 같은 경우 누락과 중복 없이 정확히 1번만 전송되어야 한다.
여러분의 애플리케이션은 데이터를 어떻게 전달하나요?
데이터 성격에 따라서 앞서 말한 3가지 방법 중에 어떤 방식을 택해서 전달하면 좋을지 고민해야 한다.
누락해도 되는 데이터인지 아닌지 등
신뢰성 있는 애플리케이션이 되려면 적어도 한번 메시지를 전달해야 한다.
우리가 Spring Boot의 기본 설정이나 Getting Started 메뉴에 나온 코드를 복붙 해서 사용한다면 최소 한번 전달하기 어렵다.
RDB를 사용하는 애플리케이션에서 전달 방법
RDB를 사용하는 애플리케이션에는 최소 한번 전달을 어떻게 구현할까?
서비스별 데이터베이스 패턴(Database per Service Pattern)
- 마이크로 서비스 아키텍처 패턴에서 가장 일반적인 패턴
- 서비스마다 독립된 DB를 가지고 있고 각 컴포넌트가 데이터를 본인이 가지고 있는 DB에 저장한다.
- 모던 애플리케이션의 일반적인 형태
어떤 컴포넌트에서 다른 컴포넌트로 데이터를 전파하는 상황을 가정해 보자.
- DB 트랜잭션을 시작해 데이터를 정상적으로 DB에 저장한다.
- 그러고 나서 REST API를 사용해서 다른 컴포넌트에 데이터를 전파한다.
예시코드
- taskRepository.save(task)
- save 메서드를 사용하여 일단 데이터를 저장한다.
- eventHandler.propagate(CreateTaskEvent.of(task))
- 그러고 나서 propagate 메서드를 사용해 다른 컴포넌트에 나의 이벤트나 메시지 혹은 REST API 콜을 하는 것을 전달한다.
- @Transactional
- 마지막으로 @Transactional이라는 어노테이션을 붙인다.
- 왜냐면 RDB를 사용하고 있고 데이터를 안전하게 저장해야 하기 때문이다.
잠시 @Transactional에 대해 살펴보자.
@Transactional
- 스프링 프레임워크에서 제공하는 어노테이션
- AOP를 사용하여 Proxy 객체 생성
- Target 객체에 추가적인 코드 삽입
- 즉 우리의 코드를 Proxy 객체가 감싸고 있다.
- transaction.aspectj.AnnotationTransactionAspect.java
그래서 @Transactional을 사용하면 실질적인 실행 순서는 아래와 같다.
- 데이터 저장
- 이벤트 전달
- 트랜잭션 커밋 혹은 롤백
위 흐름은 아래 상황에 문제가 생길 수 있다.
1. REST API를 사용해서 다른 컴포넌트에 데이터를 전달
2. DB 트랜잭션을 시작
트랜잭션을 시작하여 DB에 커밋되면 정상적인 상황인데 현실은 그렇지 않다.
어떠한 이유로 예외가 발생하생 데드락 발생 혹은 SQL에 문제가 있어 롤백이 발생할 수 있다.
3. 이슈 발생 시 롤백 진행
4. 최종적으로 REST API만 전달하는 상황
위 상황의 문제는 아래와 같다.
가장 중요한 데이터는 RDB에 저장되지 않은 상태로, 원본 데이터가 없는 상태로 다른 컴포넌트에 이벤트를 전송했다.
이러한 문제를 방지하기 위해 트랜잭션 Commit 이벤트를 사용하면 된다.
트랜잭션 Commit 이벤트를 사용하는 방법
- @TransactionalEventListener
- 위 기능을 사용해 Spring Framework에서 제공하는 Event를 이용한다.
- TransactionSynchronizationManager, TransactionSynchronization
- 위 기능을 사용해 콜백 메서드를 이용한다.
@TransactionalEventListener 예시 코드
앞서 설명한 코드에서 사용한 EventHandler의 propagate() 메서드이다.
REST API 혹은 MessageQueue를 사용해서 다른 컴포넌트에 데이터를 전달해야 하는 경우에는
@TransactionalEventListener라는 어노테이션을 propagate라는 메서드에 심어준다.
아직은 문제를 다 해결하지 못했다.
트랜잭션이 성공해도 네트워크는 신뢰할 수 없어 다른 컴포넌트로의 REST API가 실패할 수 있다.
그러면 최종적으로는 DB에만 데이터가 저장되고 다른 컴포넌트로 전파는 못하는 상황이 발생할 수 있다.
그럼 이런 문제는 어떻게 해결할까?
@TransactionalEventListener + @Retryable
@Retryable을 propagate() 메서드에 적용하면 실패했을 때 재시도를 한다.
하지만 재시도가 실패할 수 있다.
@Retryable에 maxAttempts와 backoff 속성을 사용하여 값을 설정할 수 있다.
아래 속성은 최대 3번까지 재시도를 하고 재시도 간격마다 backoff를 100ms 주겠다는 설정이다.
하지만 네트워크는 믿을 수 없기에 위 방법조차 계속 실패할 수 있다.
처리해야 하는 데이터와 이벤트가 굉장히 중요해서 애플리케이션에서 원하는 기능이 트랜잭션 처리와 이벤트 전달이 실패 없이 동시에 잘 발생하길 원한다면 아래와 같은 패턴을 이용해야 한다.
마이크로 서비스 아키텍처 패턴
- Transactional Outbox Pattern
- RDB를 Message Queue로 사용
- OLTP에 Event Message를 포함하는 패턴
- Polling Publisher Pattern
- RDB Message Queue Polling & Publishing
위 두 가지를 섞어 사용하면 데이터를 안전하게 처리할 수 있다.
Transactional Outbox Pattern
Transactional Outbox Pattern은 RDB를 Message Queue처럼 사용하는 방식이다.
예를 들어 이벤트나 메시지가 발행되면 RDB에 데이터와 같이 저장한다.(하나의 트랜잭션에)
이렇게 되면 이벤트와 데이터를 하나의 트랜잭션에서 처리하므로 둘 다 커밋되거나 혹은 둘 다 롤백이 된다.
Polling Publisher Pattern
그럼 어떻게 publish 해야 할까?
바로 Polling Publisher Pattern을 사용하면 된다.
데몬이나 스케줄러를 하나 띄워서 DB에 저장된 이벤트를 주기적으로 폴링을 하고 발행하는 로직을 추가한다.
이때, 이벤트 저장을 위해 테이블을 설계해야 하는데 중요한 필드는 아래 4종류다.
- event_id는 PK이며 이벤트 순서를 보장할 수 있는 값을 넣어야 한다.
- 가장 빠른 PK를 활용한 인덱스를 사용하면 좋다.
- create_at에는 이벤트 발생 시간을 저장한다.
- 그래야 Consumer가 이벤트를 받을 때 오래된 이벤트는 거르는 것이 가능하다.
- 이때, 이벤트를 저장할 때 Datetime를 사용할 텐데 정확도를 적어주는 게 좋다.
- ms, ns까지 저장해서 정확도를 올려주면 좋다.
- status에는 이벤트의 상태를 저장한다.
- Ready : 처리해야 될 이벤트
- Done : 처리 완료한 이벤트
- payload에는 메시지를 저장하면 된다.
Transactional Outbox 패턴을 적용한 코드
- taskRepository.save(task)
- 데이터 저장
- eventRepository.save(CreateTaskEvent.of(task))
- 이벤트 저장
Polling Publisher 패턴을 적용한 코드
@Scheduled를 사용해 5초마다 한 번씩 Polling 하고 Publisher을 실행한다.
- eventRepository.findByCreatedAtBefore(now, EventStatus.READY)
- READY상태 이벤트들을 역순으로 조회한다.
- event -> restTemplate.execute(event)
- restTemplate을 이용해 이벤트를 다른 컴포넌트로 전송한다.
- event -> event.done() / forEach(eventRepository::save)
- 정상적으로 보내진 이벤트의 상태를 done으로 바꾸고 저장한다.
publish()에 @Transactional이라는 어노테이션을 붙인 이유는 아래와 같다.
restTemplate에서 예외가 발생하거나 혹은 여러 이유로 인해 버그가 났을 때 롤백을 하기 위함이다.
그러면 10개를 처리할 때 하나에 문제가 생기면 10개를 계속 퍼다 나를 것이다.
이때, Consumer에서 멱등성 있게 코드를 잘 작성했다면 정상적인 처리가 될 것이다.
위의 모든 내용은 장단점이 존재한다.
장점
- REST API 환경에서 At-least-once를 구현할 수 있다.
단점
- Polling, Publisher 과정으로 의한 지연 처리
- 위 예시 코드처럼 5초마다 Polling 하기 때문에 지금 당장 이벤트를 발행해도 최대 5초 뒤에 처리될 수 있다.
- 혹은 Polling Pubhser 패턴에서 예외가 발생하거나 버그가 있으면 무제한으로 지연될 수 있다.
- 그렇기 때문에 실시간성이 필요한 데이터의 경우 위의 패턴을 사용할 수 없다.
- 이벤트와 데이터를 하나의 DB에 저장하므로 DB 부하가 발생한다.
- 또한 데이터 베이스 비례해서 처리속도가 결정된다.
따라서 대용량 데이터를 처리하거나 대용량의 이벤트를 발행 혹은 이벤트 개수가 하나의 트랜잭션에서 엄청나게 발생하면 Transactional Outbox 패턴을 사용하는 것을 고려해야 한다. 성능에 따라서 DB를 증설해야 할 수 있다.
Kafka를 사용한 전달 방법
Producer Confirm
kafkaTemplate.send()
- 카프카에 메시지를 발행할 수 있다.
- 응답값이 ListenableFuture이다.
- ListenableFuture 객체에 2개의 Callback을 넣어줄 수 있다.
- 첫번째는 Success Callback
- 두번째는 Failure Callback
참고한 영상에서는 ListenableFuture을 쓰고 있는데 spring framework 6(boot 가 아니다)부터 ListenableFuture 인터페이스가 deprecate 되었다. 인터페이스가 deprecate 되면서 자연히 그 구현체들도 모두 deprecate 되어 퇴출수순을 밟고있다. 다른 대체 인터페이스를 제공하는 것은 아니며 기존 JDK 에서 제공하는 CompletableFuture 를 사용하도록 했다.
KafkaTemplate 은 spring boot 2 까지는 send() 메서드 호출시 ListenableFuture 를 리턴했지만 spring boot 3부터 CompletableFuture 를 리턴하도록 됐다.
ListenableFuture 를 사용했다면 기존에는 비동기 작업 후 동작을 callback 을 등록하여 해결했을텐데 CompletableFuture 는 동일한 메서드를 제공하지 않는다. whenComplete() 메서드를 이용해야한다.
코드 예시
var future = CompletableFuture.supplyAsync(supplier);
future.whenComplete((result, ex) -> {
if(ex == null) {
System.out.println("success -> " + result);
} else {
ex.printStackTrace();
}
});
Consumer ACK - AcknowledgingMessageListener
onMessage() 메서드에서 두번째 인자인 Acknowledgement를 활용하면 Consumer ACK를 구현할 수 있다.
acknowledgment.acknowledge() 호출 시 성공한 컨슈머 ack이 나간다.
Consumer ACK - ConsumerFactory 설정
자동으로 커밋을 하는게 아니라 수동으로 ACK나 NACK를 날리기 위해서는 ENABLE_AUTO_COMMIT_CONFIG 설정을 false로 해야한다.
지금까지 분산 시스템에서 데이터를 전달하는 방법론에 대해 알아봤다.
일반적으로 Spring Boot에서 세팅된 값을 가지고는 Producer Confirm, Consumer Ack을 구현할 수 없다.
그렇기 때문에 앞서 설명한 방법을 사용해서 최소 At Least Once를 설정하여 코드를 안정성 있게 만들어야 한다.
하지만 성능상 문제가 있어 최소 한번만 전송할지 최대 한번만 발행할지 결정해야 한다.
그리고 처리해야 하는 방법이 정해져 있어야 되기 때문에 Prodcuer Confirm이나 Consumer ACK을 고려해봐야 한다.
참고
https://multifrontgarden.tistory.com/302
'프로젝트 > FitTrip' 카테고리의 다른 글
개발 기록 - 유저의 실시간 온/오프 상태 처리 기능 (1) | 2024.07.14 |
---|---|
개발 기록 - 유저의 마지막 채널 위치 기억 기능 (0) | 2024.07.13 |
트러블 슈팅 - SockJs를 사용한 웹소켓 연결 시 CORS 이슈 (0) | 2024.07.08 |
개발 기록 - WebSocket & STOMP 개발 이슈 (0) | 2024.07.05 |
개발 기록 - MongoDB auto-incremented sequence 적용 (0) | 2024.07.03 |