[백엔드] 비동기 처리 관련 필수 지식

목차

1. 비동기 연동 활용하기

1. 비동기 연동 도입조건

  • 비동기는 시스템 효율을 높이지만, 잘못 사용하면 복잡도만 키우는 양날의 검임.
  • 주의사항
    • 비동기 로직이 포함되면 오류 처리에 매우 신경 써야 함.
    • 특히, 한 트랜잭션 안에 비동기 로직이 끼어있는 상황이라면 더욱 취급에 주의해야 함.
  • 도입을 고려해볼 만한 상황
    • 시차 허용: 처리에 약간의 시차가 생겨도 무방한 경우. (예: 주문 완료 1분 뒤 판매자 푸시 발송, 게시글 등록 10초 뒤 검색 서비스 반영)
    • 실패 시 재시도 가능: 실패하더라도 재시도를 통해 결국 성공만 하면 되는 경우. (예: 푸시 발송 실패, 포인트 지급 실패 시 재시도)
    • 수동 처리 가능: 자동 연동 실패 시 추후 관리자가 수동으로 처리할 수 있는 기능이 있는 경우. (예: 검색 등록 누락 시 수동 등록)
    • 실패 무시 가능: 실패해도 서비스 핵심 운영에는 지장이 없는 경우. (예: 주문 접수 푸시 발송 실패)

2. 별도 스레드 사용하기

  • 가장 기본적인 비동기 구현 방식으로, OS 스레드를 미리 만들어둔 스레드 풀을 활용함.
  • Java Spring 프레임워크 기준, ExecutorService 구현체나 @Async 애노테이션을 활용함.
  • 가상 스레드(Virtual Thread) 활용 (Java 기준)
    • 외부 API 호출이나 DB 연동 같은 네트워크 I/O 작업이라면 가상 스레드 사용을 적극 권장함.
    • 런타임에서 관리하는 경량 스레드로 메모리 사용량이 매우 적음(수백 바이트~몇 KB).
    • 주의: CPU 집약적 작업에는 절대 쓰지 말아야 하며, 생성 비용이 저렴하므로 스레드 풀링(Pooling)을 하지 않아야 함.

2. 메시징 시스템 활용

1. 메시징 시스템의 장점

  • A시스템 → 메시징 시스템 → B시스템 구조로 데이터를 전달하여 시스템 간 결합도를 낮춤.
  • 장점
    • 영향도 격리: B시스템에 부하가 몰려도 A시스템은 메시징 시스템에 데이터만 넘기면 되므로 영향을 받지 않음.
    • 확장성: 신규 시스템 C가 추가되어도 메시징 시스템에만 연결하면 되므로 확장이 매우 용이함.
  • 주요 기술: Kafka, RabbitMQ, Redis Pub/Sub, AWS SQS 등.

2. 생산자(Producer) 주의사항

  • 세상에 장애 없는 서비스는 없으므로, 항상 메시지 유실 가능성을 고려해야 함.
  • 오류 발생 시 처리 전략
    • 무시하기: 단순 로그 등 사소한 내용은 무시함.
    • 재시도: 일시적 장애는 재시도로 해결 가능하나, 중복 메시지 전송 가능성에 대비해야 함 (고유 ID 부여 등).
    • 실패 로그 후처리: 추후 처리를 위해 필요한 정보를 로그로 남김.
  • 트랜잭션 연동 주의: DB 롤백이 발생했는데 메시지는 이미 전송되어 버리는 상황을 막기 위해, 반드시 트랜잭션이 끝난 뒤 메시지를 전송해야 함.

3. 소비자(Consumer) 주의사항

  • 중복 메시지 대비: 생산자를 100% 믿지 말고, 중복 수신 가능성을 고려해 메시지 고유 ID로 처리 여부를 추적해야 함.
  • 재수신 상황 고려: 소비자가 처리 도중 오류(예: 외부 API 타임아웃)가 발생해 메시지를 다시 받는 상황에 대비해야 함.
  • 멱등성(Idempotence) 보장: 외부 서비스는 성공했는데 내 쪽에서 실패 처리된 경우, 재처리 시 결과가 달라지지 않도록 API를 멱등하게 구현해야 함.
  • 모니터링: 소비가 안 되어 큐에 데이터가 쌓이거나 유실되지 않도록 소비 상태를 항상 감시해야 함.

4. 메시지 종류와 일관성

  • 이벤트(Event): ‘주문 발생’, ‘로그인 실패’ 등 사건의 발생을 알림. 수신자가 재량껏 처리함.
  • 커맨드(Command): ‘포인트 지급하라’, ‘로그인 차단하라’ 등 특정 행동을 지시함.
  • 궁극적 일관성(Eventual Consistency): 비동기 시스템에서는 즉시 데이터가 일치하지 않더라도, 시간이 지나면 결국 일관된 상태가 보장됨을 의미함.

3. 고급 비동기 구현 패턴

1. 트랜잭션 아웃박스 패턴

  • DB 트랜잭션과 메시지 발행을 원자적으로 처리하기 위해 고안된 패턴임.
  • 자세한 건 링크 참고

2. 배치 처리 (Batch)

  • 대량 데이터를 비동기로 연동하는 전통적인 방법임.
  • 구현 방식: 파일 전송, 일괄 API 전송, 읽기 전용 DB 접근 허용 등.
  • 필수 기능: 전송 실패에 대비한 재처리(Retry) 기능과, 수동으로 배치를 실행할 수 있는 명령어/API를 반드시 마련해야 함.

3. CDC (Change Data Capture)

  • DB의 변경 사항(INSERT, UPDATE, DELETE)을 실시간으로 감지해 다른 시스템으로 전파하는 기술임.
  • 원리 (MySQL 기준): 모든 변경 사항이 기록되는 바이너리 로그(Binlog)를 실시간으로 파싱하여 구현함. (`binlog_format`이 `ROW`여야 함)
  • MySQL Binlog 확인 방법
    -- 1. MySQL 접속 후 로그 활성화 여부 확인
    SHOW VARIABLES LIKE 'log_bin';
    
    -- 2. Binlog 포맷 확인 (CDC 구현 시 ROW로 설정 필수)
    SHOW VARIABLES LIKE 'binlog_format';
    
    -- 3. 바이너리 로그 목록 확인
    SHOW BINARY LOGS;
    
    -- 4. 특정 로그 파일의 이벤트 확인 (최신 10개)
    SHOW BINLOG EVENTS IN 'binlog.000001' LIMIT 10;
    
  • 도구: Debezium, Canal, Maxwell 등 오픈소스와 Kafka를 조합하여 아키텍처를 구성함.
  • 활용 사례 (복잡한 시스템 연동):
    • 상황: 신규 주문 시스템을 개발 중이며, 주문 생성/변경 시 기존 레거시 주문 시스템에도 해당 데이터를 전달해야 함.
    • 문제: 일정 부족 등의 이유로 신규 시스템 내에 기존 시스템 연동 코드를 직접 작성하기 부담스러움.
    • 해결: CDC 처리기와 Kafka를 연동하여, 신규 DB에 데이터가 저장되는 즉시 변경분을 감지해 기존 시스템으로 자동 통지되도록 구성함.

4. (번외) Spring Boot 비동기 처리 구현

Spring Boot 4.0.1(Java 21+) 환경에서 CPU 집약적 작업(OS 스레드)I/O 집약적 작업(가상 스레드)을 구분하여 비동기로 처리하는 구현 예제임.

1. 설정 (Configuration)

기본적인 @Async 동작을 위한 OS 스레드 풀 설정임. CPU 연산이 많은 작업에 적합함.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    /**
     * OS 스레드 기반의 스레드 풀 설정
     * 용도: CPU 집약적인 작업, 레거시 라이브러리 사용 시
     */
    @Bean(name = "osThreadPoolTaskExecutor")
    public Executor osThreadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);  // 기본 스레드 수
        executor.setMaxPoolSize(50);   // 최대 스레드 수
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("OS-Async-");
        executor.initialize();
        return executor;
    }
}

2. 서비스 구현 (Service)

상황에 따라 OS 스레드가상 스레드(Virtual Thread)를 적절히 선택하여 사용하는 예시임.

핵심 포인트

  • OS 스레드: 미리 생성된 풀(Pool)을 사용 (`@Async` 활용).
  • 가상 스레드: 풀링(Pooling) 금지. 작업마다 `newVirtualThreadPerTaskExecutor()`로 새로 생성.
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Service
public class AsyncService {

    // 가상 스레드 실행기 (스레드 풀 아님! 매번 새로 생성됨)
    // Java 21+ 필수
    private final ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();

    /**
     * [CASE 1] CPU 집약적 작업 -> OS 스레드 풀 사용
     * @Async 애노테이션을 통해 'osThreadPoolTaskExecutor' 빈을 지정
     */
    @Async("osThreadPoolTaskExecutor")
    public void performCpuIntensiveTask() {
        System.out.println("CPU 작업 시작 (OS Thread): " + Thread.currentThread().getName());
        
        try {
            // 복잡한 연산 시뮬레이션
            Thread.sleep(100); 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /**
     * [CASE 2] 네트워크 I/O 작업 (DB, 외부 API) -> 가상 스레드 사용
     * Blocking 구간에서 스레드가 대기하지 않고 다른 작업을 처리함 (Non-blocking 효과)
     */
    public void performNetworkIoTask() {
        virtualThreadExecutor.submit(() -> {
            System.out.println("I/O 작업 시작 (Virtual Thread): " + Thread.currentThread().getName());

            try {
                // 외부 API 호출 대기 (Blocking I/O 발생)
                // 가상 스레드는 이때 OS 스레드를 점유하지 않고 양보(Unmount)함
                Thread.sleep(1500); 
                
                System.out.println("I/O 작업 완료");

            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                // Network I/O was interrupted unexpectedly.
                // 네트워크 I/O 작업이 예기치 않게 중단되었습니다.
                throw new IllegalStateException("Network I/O was interrupted unexpectedly", e);
            }
        });
    }

    /**
     * [Anti-Pattern] 가상 스레드 사용 시 주의사항
     * CPU 연산 위주의 작업을 가상 스레드에서 돌리면 오히려 성능이 저하됨
     */
    public void badPracticeWithVirtualThread() {
        virtualThreadExecutor.submit(() -> {
            // Virtual threads must not be used for heavy CPU-bound tasks.
            // 가상 스레드는 무거운 CPU 집약적 작업에 사용해서는 안 됩니다.
            throw new UnsupportedOperationException("Virtual threads must not be used for heavy CPU-bound tasks");
        });
    }
}

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬