주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 비동기 연동, 언제 어떻게 써야 할까 - dnwls16071/Backend_Summary GitHub Wiki
동기 연동과 비동기 연동
- 동기(synchronous) 방식은 순차적으로 실행된다.
- 동기 방식은 한 작업이 끝날 때까지 다음 작업이 진행되지 않는다.
- 비동기(Asynchronous) 방식은 한 작업이 끝날 때까지 기다리지 않고 바로 다음 작업을 처리한다.
비동기 방식을 사용해도 되는 특징 정리
- 연동에 약간의 시차가 생겨도 문제가 되지 않는다.
- 일부 기능은 실패했을 때 재시도가 가능하다.
- 연동 실패 시 차후에 수동으로 처리할 수 있는 기능도 있다.
- 연동 실패 시 무시해도 되는 기능도 있다.
비동기 연동 1 - 별도 쓰레드로 실행하기
- 비동기 연동을 하는 가장 쉬운 방법이다.
- 푸시 서비스를 비동기로 연동하고 싶다면 새로운 쓰레드를 생성하여 연동하는 코드를 실행할 수 있다.
// 매번 쓰레드를 생성한다.
public OrderResult placeOrder(OrderRequest req) {
// 주문 생성 처리
new Thread(() -> pushClient.sendPush(pushData)).start(); // 별도 쓰레드를 이용해서 푸시를 비동기로 발송
return successResult(...);
}
// 쓰레드 풀을 사용한다.
ExecutorService executor = Executors.newFixedThreadPool(50);
public OrderResult placeOrder(OrderRequest req) {
// 주문 생성 처리
executor.submit(() -> pushClient.sendPush(pushData)).start(); // 쓰레드 풀을 이용해서 푸시를 비동기로 발송
return successResult(...);
}
- 스프링 프레임워크에서는
@Async어노테이션을 이용한 비동기 실행 기능을 제공한다. @Async어노테이션을 사용할 때는 메서드 이름에 비동기 실행과 관련된 단어를 추가하는 것이 좋다.- 비동기로 실행되기 때문에 만약 다른 개발자가 여기에
try ~ catch예외 처리를 추가하더라도catch블록은 수행되지 않는다. - 별도 쓰레드로 실행하면 연동 과정에서 발생한 오류 처리에 더 신경을 써야 한다. Exception을 던져도 소용없기 때문이다.
- 별도 쓰레드로 실행되는 코드는 그 내부에서 연동 과정에서 발생한 오류를 직접 처리해야 한다.
비동기 연동 2 - 메시징
- 서로 다른 시스템 간에 비동기로 연동할 때 주로 사용하는 방식은 메시징 시스템을 사용하는 것이다.
- 메시징 시스템에서 사용하는 용어로 생산자와 소비자가 있다.
- 메시지를 생성해서 보내는 측을 생산자라고 하고 반대로 메시징 시스템으로부터 메시지를 받아 처리하는 측을 소비자라고 한다.
메시지 생성 측에서의 고려사항
1. 오류를 무시하기
- 가장 쉬운 방법이지만 이 경우는 메시지가 유실된다.
2. 재시도하는 것
- 일시적인 네트워크 불안정과 같은 오류는 재시도를 통해 해결될 수 있다.
- 하지만 메시지 전송을 재시도하는 과정에서 중복된 메시지가 전송될 수 있다.
- 실제로는 전송에 성공했는데 일시적인 네트워크 오류로 전송에 실패한 것으로 인지하고 재시도를 할 수 있기 때문이다.
- 메시징 시스템이 중복 수신을 방지하는 기능을 제공하지 않으면 메시지 소비자가 중복 메시지를 알맞게 처리해야 한다.
3. 실패 로그를 남기는 것
- 실패 로그는 후처리에 필요한 데이터를 담고 있어야 한다.
메시지 소비 측에서의 고려사항
1. 메시지 생산자가 같은 데이터를 가진 메시지를 메시징 시스템에 2번 전송하면 중복 처리될 가능성이 있다. 2. 소비자가 메시지를 처리하는 과정에서 오류가 발생해서 메시지를 재수신하는 경우 중복 처리될 가능성이 있다.
- 이런 중복 처리 문제를 해결하기 위해서 수신자 입장에서 동일 데이터를 가진 중복 메시지를 처리하는 방법은 메시지에 고유한 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 (chechAlreadyHandled(m.getId()) {
continue;
}
handle(m);
recordHandledLog(m.getId());
}
}
비동기 연동 3 - 트랜잭션 아웃박스 패턴
- 메시지 데이터 자체가 유실되지 않도록 보장하는 방법은 먼저 해당 데이터를 DB에 안전하게 저장해두는 것이다.
- 그 뒤, 저장된 메시지를 읽어 메시징 시스템에 전송하면 된다.
- 이처럼 메시지 데이터를 DB에 보관하는 방식이 바로 트랜잭션 아웃박스 패턴이다.
- 트랜잭션 아웃박스 패턴은 하나의 DB 트랜잭션 내에서 다음 2가지 작업을 수행한다.
1. 실제 업무 로직에 필요한 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; // 순서대로 발송하기 위해 break로 중지
}
}
}
markDone(m.getId())와 같이 발송 완료를 표시하는 방법은 2가지가 있다.
1. 하나는 아웃박스 테이블에 발송 상태 컬럼을 두는 것이다.
- 이 칼럼은 3가지 상태(발송 대기, 발송 완료, 발송 실패)를 갖는다.
- 발송 대기 상태를 갖는 데이터를 조회하고 발송에 성공하면 발송 완료로 변경하는 식이다.
2. 또 다른 방법으로 메시지 중계 서비스가 성공적으로 전송한 마지막 메시지 ID를 별도로 기록하는 방식이다.
- 파일이나 별도의 테이블에 메시지 ID를 저장해두고, 다음번 대기 메시지를 조회할 때, 이 ID 이후의 메시지만 선택하는 것이다.
아웃박스 테이블 구조
| 칼럼 | 타입 | 설명 |
|---|---|---|
| id | bigint | 단순 증가 값(PK). 저장된 순서대로 증가하는 값을 사용한다. |
| messageId | varchar | 메시지 고유 값(고유 키) |
| messageType | varchar | 메시지 타입 |
| payload | clob | 메시지 데이터 |
| status | varchar | 이벤트 처리 상태 |
| failCount | int | 실패 횟수 |
| occuredAt | timestamp | 메시지 발생 시간 |
| processedAt | timestamp | 메시지 처리 시간 |
| failedAt | timestamp | 마지막 실패 시간 |
비동기 연동 4 - 배치 전송
- 배치 전송은 데이터를 비동기로 연동하는 가장 전통적인 방법이다.
- 메시징 시스템이 거의 실시간으로 데이터를 연동한다면 배치는 일정 간격으로 데이터를 전송한다.