주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 비동기 연동, 언제 어떻게 써야할까 - thought-corner/backend-roadmap 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. 비동기 연동 방법 - 메시징

  • 서로 다른 시스템 간에 비동기로 연동할 때 주로 사용하는 방식은 메시징 시스템을 사용하는 것이다.
  • 메시징 시스템에서 사용하는 용어로 생산자와 소비자가 있다.
  • 메시지를 생성해서 보내는 측을 생산자라고 하고 반대로 메시징 시스템으로부터 메시지를 받아 처리하는 측을 소비자라고 한다.

메시지 생성 측에서의 고려사항

  • 오류를 무시하기
    • 가장 쉬운 방법이지만 이 경우는 메시지가 유실된다.
  • 재시도하는 것
    • 일시적인 네트워크 불안정과 같은 오류는 재시도를 통해 해결될 수 있다. 하지만 메시지 전송을 재시도하는 과정에서 중복된 메시지가 전송될 수 있다.
    • 실제로는 전송에 성공했는데 일시적인 네트워크 오류로 전송에 실패한 것으로 인지하고 재시도를 할 수 있기 때문이다. 메시징 시스템이 중복 수신을 방지하는 기능을 제공하지 않으면 메시지 소비자가 중복 메시지를 알맞게 처리해야 한다.
  • 실패 로그를 남기는 것
    • 실패 로그는 후처리에 필요한 데이터를 담고 있어야 한다.

메시지 소비 측에서의 고려사항

  • 메시지 생산자가 같은 데이터를 가진 메시지를 메시징 시스템에 2번 전송하면 중복 처리될 가능성이 있다.
  • 소비자가 메시지를 처리하는 과정에서 오류가 발생해서 메시지를 재수신하는 경우 중복 처리될 가능성이 있다.
  • 이런 중복 처리 문제를 해결하기 위해서 수신자 입장에서 동일 데이터를 가진 중복 메시지를 처리하는 방법은 메시지에 고유한 ID를 부여해서 이미 처리했는지 여부를 추적하는 것이다.

  1. 컨슈머가 메시지를 폴링해서 가져온다.
  2. 비즈니스 로직으로 처리완료(DB 저장 등) → 만약 여기서 2번은 끝났는데 3번 전에 애플리케이션이 죽거나 리밸런싱이 일어나는 경우 재시작한 컨슈머에게 메시지를 다시 주기 때문에 이미 처리했던 것을 또 처리하게 되면서 중복 처리가 발생하게 된다.
  3. 처리 끝나고 오프셋 커밋

// 1. 메시지에 고유 ID를 부여
// 2. 이 고유 ID를 DB에 기록
// 3. DB UPSERT 혹은 Unique Constraint 제약 조건으로 같은 키를 고정해 멱등성을 보장하도록 설계
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. 비동기 연동 방법 - 트랜잭션 아웃박스 패턴

  • 트랜잭션 아웃박스 패턴은 분산 시스템에서 데이터베이스 업데이트와 메시지 발행 사이의 원자성을 보장하기 위한 설계 패턴이다.
  • MSA 환경에서 자사 서비스 DB를 업데이트한 후 다른 서비스에 알리고자 이벤트를 발행하는데 다음과 같은 장애 시나리오가 발생할 수 있다.
    • 시나리오 A : DB 업데이트 성공 후 Kafka 전송 시도 중 네트워크 오류오 인한 메시지 유실로 다른 서비스가 변경 사실 자체를 인지하지 못하는 경우
    • 시나리오 B : 메시지 전송은 성공했으나 DB 커밋 직전 서버 다운으로 트랜잭션이 롤백되면서 다른 서비스에서 존재하지 않는 데이터를 처리하는 경우
  • 메시지를 브로커로 전달하는 릴레이 프로세스는 크게 2가지 전략으로 구현한다.
    • 폴링 : 릴레이 서비스가 주기적으로 Outbox 전용 테이블을 읽어 미전송 데이터를 찾는다. 구현이 단순하나 DB에 지속적인 부하를 주고 실시간성이 다소 떨어질 수 있다.
    • CDC(Change Data Capture) : DB 트랜잭션 로그를 직접 읽어 변경 사항을 감지한다. DB에 직접 쿼리를 날리지 않아 부하가 거의 없고 매우 빠른 실시간 전송이 가능하나 인프라 설정이 상대적으로 복잡하다.

  1. 로컬 트랜잭션 시작 : 애플리케이션에서 DB 트랜잭션을 시작한다.
  2. 비즈니스 데이터 저장 : 테이블에 레코드를 저장한다.
  3. 메시지 기록(Outbox) : 동일 트랜잭션 내에서 Outbox 전용 테이블에 보낼 이벤트 메시지를 INSERT한다.
  4. 트랜잭션 커밋 : Outbox 전용 테이블에 저장 실패하면 DBMS 원자성 덕분에 전체 트랜잭션이 롤백되기에 데이터 정합성이 완벽하게 유지된다.
  5. 메시지 발행 : 별도의 독립된 프로세스가 Outbox 전용 테이블을 감시하며 새로 들어온 메시지를 읽어 메시지 브로커로 전송한다.
  6. 완료 처리 : 전송에 성공하면 Outbox 테이블의 해당 레코드를 삭제하거나 완료 상태로 변경한다.

아웃박스 테이블 구조

칼럼 타입 설명
id bigint 단순 증가 값(PK). 저장된 순서대로 증가하는 값을 사용한다.
messageId varchar 메시지 고유 값(고유 키)
messageType varchar 메시지 타입
payload clob 메시지 데이터
status varchar 이벤트 처리 상태
failCount int 실패 횟수
occuredAt timestamp 메시지 발생 시간
processedAt timestamp 메시지 처리 시간
failedAt timestamp 마지막 실패 시간

4. 비동기 연동 방법 - 배치 전송

  • 배치 전송은 데이터를 비동기로 연동하는 가장 전통적인 방법이다.
  • 메시징 시스템이 거의 실시간으로 데이터를 연동한다면 배치는 일정 간격으로 데이터를 전송한다.
  • 파일을 지정한 시간에 전송하지 못할 때도 있다.
    • 파일을 생성하는 과정에서 실패한다거나?
    • 네트워크 상태로 인해 전송하지 못한다거나?
  • 1~2번 정도만 재시도해도 수작업으로 재처리하는 번거로움을 줄일 수 있게 된다.

5. 비동기 연동 방법 - CDC(Change Data Capture)

  • Oracle이나 MySQL같은 DBMS는 데이터가 변경되면 그 변경 내용을 통지하는 기능을 제공한다.
  • INSERT/UPDATE/DELETE 쿼리를 실행하면 DB의 데이터가 변경된다. DB는 변경된 데이터를 CDC 처리기에 전송한다.
  • DB는 커밋된 데이터만 변경된 순서에 맞게 전달한다. CDC 처리기에는 롤백된 데이터가 전달되지 않는다. 또한 잘못된 순서로 데이터가 전달되는 일도 없다.

  1. 변경 발생 : 소스 DB에서 트랜잭션이 커밋되면 그 내용이 트랜잭션 로그(MySQL binlog, PostgreSQL WAL, Oracle redo log)에 자동 기록된다.
  2. 변경 감지(Capture) : CDC 커넥터가 이 로그를 순차적으로 읽어 변경 이벤트를 뽑아낸다. 각 이벤트에는 보통 변경 전후 이미지, 연산 유형, 타임스탬프, 로그 위치가 담긴다.
  3. 직렬화 & 전송 : 이벤트를 Json 혹은 Avro와 같은 형식으로 직렬화해 Kafka와 같은 메시지 브로커로 발행한다.
  4. 소비 & 적용(Sink) : 컨슈머가 이벤트를 소비해 타겟(데이터 웨어하우스, 검색엔진, 캐시, 다른 DB 등)에 Upsert/Delete 등으로 반영한다.

변경 캡처 3가지 방식

  • 로그 기반(Log-based) : 트랜잭션 로그를 읽는다. DB 부하가 거의 없고 실시간이며 DELETE까지 정확히 잡을 수 있어 가장 권장되는 방식이다.
  • 트리거 기반(Trigger-based) : DB 트리거로 변경 내역을 별도 테이블에 기록한다. 구현은 직관적이나 쓰기 과정에서 오버헤드가 발생한다.
  • 쿼리/타임스탬프 기반(Polling) : updated_at과 같은 컬럼을 주기적으로 폴링한다. 만들기 쉽지만 DELETE 감지가 어렵고 실시간성이 떨어진다.