주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 비동기 연동, 언제 어떻게 써야할까 - dnwls16071/Backend_Summary GitHub Wiki
📚 비동기 연동⭐
- 비동기(Asynchronous) 방식은 한 작업이 끝날 때까지 기다리지 않고 바로 다음 작업을 처리하는 방식을 말한다.
- 많은 곳에서 비동기 방식을 사용한다. 다음과 같은 사례가 있다.
- 쇼핑몰에서 주문이 들어오면 판매자에게 푸시 보내기
- 학습을 완료하면 학생에게 포인트 지급
- 컨텐츠를 등록할 때 검색 서비스에도 등록
- 인증 번호를 요청하면 SMS로 인증 메시지 발송
- 이런 연동 사례들에는 공통점이 있다.
- 연동에 약간의 시차가 생겨도 문제가 되지 않는다.
- 일부 기능은 실패 시 재시도가 가능하다.
- 연동 실패 시 나중에 수동으로 처리가 가능하다.
- 연동 실패 시 무시해도 되는 기능이다.
📚 별도 쓰레드를 이용한 비동기 연동⭐
새로운 쓰레드를 생성해 연동한다.
public OrderResult placeOrder(OrderRequest request) {
// ...
new Threads(() -> pushClient.sendPush(pushData)).start(); // 매번 쓰레드를 생성
return successResult();
}
쓰레드 풀을 사용한다.
ExecutorService executor = Executors.newFixedThreadPool(50);
public OrderResult placeOrder(OrderRequest request) {
// ...
executor.submit(() -> pushClient.sendPush(pushdata)); // 쓰레드 풀을 이용해 푸시를 비동기로 발송
return successResult();
}
스프링 프레임워크에서 제공하는 @Async 어노테이션을 사용한다.
@Async 어노테이션을 사용할 때는 메서드 이름에 비동기 실행과 관련된 단어를 추가하는 것이 좋다.
- 비동기로 실행되는 경우 Exception이 발생해도 catch 블록은 실행되지 않는다.
public class PushService {
@Async
public void sendPushAsync(PushData pushData) {
pushClient.sendPush(pushData);
// ...
}
}
public OrderResult placeOrder(OrderRequest request) {
try {
pushService.sendPush(pushData);
} catch (Exception e) {
// ❗sendPush()가 비동기로 실행되므로 catch 블럭은 동작하지 않는다.
// 예외 처리
}
return successResult(); // 푸시 발송 결과 안 기다리고 응답 리턴
}
- 별도 쓰레드로 실행하면 연동 과정에서 발생한 오류 처리에 더 신경을 써야 한다.
- Exception을 전파해도 소용이 없기 때문이다.
- 별도 쓰레드로 실행되는 코드의 경우 내부에서 연동 과정에서 발생한 오류를 직접 처리해야 한다.
@Async
public void sendPushAsync(PushData pushData) {
try {
pushClient.sendPush(pushData);
} catch (Exception e) {
try {
Thread.sleep(500);
} catch (Exception ex) {
// ...
}
try {
pushClient.sendPush(pushData); // 재시도 하거나
} catch (Exception e1) {
// ... // 실패를 로그로 남기거나
}
}
}
📚 메시징을 이용한 비동기 연동
Kafka
- 높은 처리량을 자랑, 초당 1,000,000개 이상의 메시지를 처리할 수 있다.
- 수평 확장이 용이하다. 서버(브로커), 파티션, 소비자를 늘리면 된다.
- 카프카는 메시지를 파일에 보관해서 메시지가 유실되지 않는다.
- 1개의 토픽은 여러 파티션을 가질 수 있는데 파티션 단위로 순서를 보장한다. 하지만 토픽 수준에서 순서는 보장할 수 없다.
- 소비자는 메시지를 언제든지 재처리할 수 있다.
- 풀(pull) 모델을 사용한다. 소비자가 카프카 브로커에서 메시지를 읽어가는 방식이다.
RabbitMQ
- 클러스터를 통해 처리량을 높일 수 있다. 단 카프카보다 더 많은 자원을 필요로 한다.
- 메모리에만 메시지를 보관하는 큐 설정을 사용하게 되면 장애 상황 발생 시 메시지가 유실될 가능성이 있다.
- 메시지는 큐에 등록된 순서대로 소비자에 전송된다.
- 메시지가 소비자에 전달됐는지 확인하는 기능을 제공한다.
- 푸시(push) 모델을 사용한다. RabbitMQ 브로커가 소비자에게 메시지를 전송한다. 소비자 성능이 느려지면 큐에 과부하가 걸려 성능 저하가 발생하게 된다.
- AMQP, STOMP 등 프로토콜을 지원한다. 게시/구독 패턴뿐만 아니라 요청/응답, P2P 패턴을 지원한다. 또한 우선순위를 지정해서 처리 순서를 변경할 수 있다.
Redis Pub/Sub
- 메모리를 사용하므로 지연 시간이 짧고 RabbitMQ 대비 처리량이 높다.
- 구독자가 없으면 메시지가 유실된다.
- 기본적으로 영구 메시지를 지원하지 않는다.
- 모델이 단순해서 사용하기 쉽다.
메시지 생성 시 고려사항⭐
- 메시지를 생성할 때 고려할 점은 메시지 유실에 대한 것이다.
- 메시지 전송 과정에서 타임아웃이 발생할 수 있다. 타임아웃 문제는 프로듀서와 브로커 간의 네트워크 연결이 불안정하면 언제든지 발생할 수 있다.
- 이 때, 오류 처리를 위해 다음과 같은 3가지 방법을 고를 수 있다.
- 오류를 무시하는 것은 메시지는 유실이 된다는 말이 된다.
- 일시적인 네트워크 불안정과 같은 오류는 재시도를 통해 충분히 해결할 수 있다. 하지만 메시지 전송을 재시도하는 과정에서 중복된 메시지가 전송될 수 있다.
- 실제로 전송에 성공했는데 일시적인 네트워크 오류로 전송에 실패한 것으로 인지하고 재시도할 수 있기 때문이다. 메시징 시스템이 중복 수신을 방지하는 기능을 제공하지 않으면 메시지 소비자가 중복 메시지를 알맞게 처리해야 한다.
- 실패 사유를 남기되 후처리에 필요한 데이터를 담고 있어야 한다.
메시지 소비 시 고려사항⭐
- 메시지를 소비할 때 다음과 같은 이유로 메시지를 중복해서 처리할 수 있다.
- 프로듀서가 같은 데이터를 가진 메시지를 두 번 전송한 경우
- 소비자가 메시지를 처리하는 과정에서 오류가 발생해 메시지를 재수신하는 경우
- 수신자 입장에서 동일 데이터를 가진 중복 메시지를 처리하는 방법은 메시지에 고유한 ID를 부여해서 이미 처리했는지 여부를 추적하는 것이다.
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record: records) {
Message m = messageConverter.convert(record.value());
if (checkAlreadyHandled(m.getId()) {
continue; // 이미 처리된거면 무시
}
handle(m);
recordHandledLog(m.getId()); // 처리 여부 기록
}
}
- 메시지 재수신에 따른 중복 처리에 대처하는 방법은 멱등성을 갖도록 API를 구현하는 것이다.
- API가 멱등성을 가지면 동일 요청을 여러 번 해도 결과가 바뀌지 않는다.
메시지 종류 - 이벤트와 커맨드
- 이벤트(event)는 어떤 일이 발생했음을 알려주는 메시지이다.
- 커맨드(command)는 무언가를 요청하는 메시지이다.
📚 트랜잭션 아웃박스 패턴⭐
- 메시지 데이터 자체가 유실되지 않도록 보장하는 방법은 먼저 해당 데이터를 DB에 안전하게 저장해두는 것이다.
- 그 뒤, 저장된 메시지를 읽어 메시징 시스템에 전송하면 된다.
- 이처럼 메시지 데이터를 DB에 보관하는 방식이 바로 트랜잭션 아웃박스 패턴(Transaction Outbox Pattern)이라고 한다.
- 트랜잭션 아웃박스 패턴은 하나의 트랜잭션 내에서 2가지 작업을 수행한다.
- 실제 업무 로직에 필요한 DB 변경 작업을 수행한다.
- 메시지 데이터를 아웃박스 전용 테이블에 추가한다.
- DB 트랜잭션 범위 내에서 데이터와 함께 아웃박스 테이블에 메시지 데이터를 추가하기에 메시지 데이터가 유실되지 않는다.
- 트랜잭션을 롤백하면 메시지 데이터도 같이 롤백되므로 잘못된 메시지 데이터가 전송될 일도 없다.
- 발송하지 않은 메시지 데이터를 조회해 메시징 시스템에 전송하고 전송 성공 시 전송 완료 처리를 한다. 이렇게 하면 같은 메시지가 2번 이상 전송되는 일을 최대한 방지할 수 있다.
public void processMessages() {
List<MessageData> waitingMessages = selectWaitingMessages(); // 아웃박스 테이블에서 대기 중인 메시지 데이터를 순서대로 조회
for (MessageData m : waitingMessages) {
try {
sendMessage(m); // 메시지를 전송
markDone(m.getId()); // 발송 완료 표시(상태 반영)
} catch (Exception e) {
handleError(e); // 메시지 발송에 실패한 경우 후속 처리
break; // 에러 발생 시 멈춤(순서대로 처리하기 위함)
}
}
}
- 발송 완료를 표시하는 방법은 크게 2가지가 있다.
- 하나는 아웃박스 전용 테이블에 발송 컬럼을 두는 것이다. 이 칼럼은 3가지 상태(발송 대기, 발송 완료, 발송 실패)를 가진다. 발송 대기 상태를 가지는 데이터를 조회해 발송에 성공하면 발송 완료로 변경한다.
- 메시지 중계 서비스가 성공적으로 전송한 마지막 메시지 ID를 별도로 기록하는 방식이다.
아웃박스 테이블 구조⭐
| 칼럼 |
타입 |
설명 |
| Id |
BigInt |
단순 증가값(PK). 저장된 순서대로 증가하는 값을 사용한다. |
| MessageId |
varchar |
메시지 고유 ID(고유키) |
| MessageType |
varchar |
메시지 타입 |
| Payload |
clob |
메시지 데이터 |
| Status |
varchar |
이벤트 처리 상태(대기,완료,실패) |
| failCount |
int |
실패 횟수 |
| occuredAt |
timestamp |
메시지 발생 시간 |
| processedAt |
timestamp |
메시지 처리 시간 |
| failedAt |
timestamp |
마지막 실패 시간 |
- messageType : 메시지 종류 구분
- payload : 메시지 데이터
- status : 대기 / 완료 / 실패
- 대기 상태인 메시지만을 조회하기 때문에 어떤 조건에서 실패 상태로 바꿀 것인지를 결정해야 한다.
- 실패 횟수를 기준으로 자동으로 변경할 수도 있고 상태를 모니터링하다가 수동으로 변경할 수도 있다.
- 실패 상태로 바뀌는 메시지는 후속 조치를 하지 않으면 시스템 간 데이터 일관성이 깨질 수 있기 때문에 별도의 컬럼을 추가해서 실패 메시지를 아웃박스 테이블에 기록하면 실패 이유를 파악하는데 도움이 된다.