주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 비동기 연동, 언제 어떻게 써야할까 - 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 : 대기 / 완료 / 실패
    • 대기 상태인 메시지만을 조회하기 때문에 어떤 조건에서 실패 상태로 바꿀 것인지를 결정해야 한다.
    • 실패 횟수를 기준으로 자동으로 변경할 수도 있고 상태를 모니터링하다가 수동으로 변경할 수도 있다.
    • 실패 상태로 바뀌는 메시지는 후속 조치를 하지 않으면 시스템 간 데이터 일관성이 깨질 수 있기 때문에 별도의 컬럼을 추가해서 실패 메시지를 아웃박스 테이블에 기록하면 실패 이유를 파악하는데 도움이 된다.