CS: 동기와 비동기

hyeppy·2026년 3월 30일

CS

목록 보기
12/14
post-thumbnail

주니어 백엔드 개발자가 반드시 알아야 할 실무 지식

1. 동기 방식이란?

동기 방식은 작업을 순서대로 하나씩 처리하는 방식이다. 현재 작업이 완전히 끝나야 다음 작업을 시작할 수 있으며, 코드의 작성 순서가 곧 실행 순서가 된다.

동기 방식의 가장 큰 장점은 직관성이다. 코드를 위에서 아래로 읽으면 실행 흐름을 그대로 이해할 수 있고, 디버거로 코드를 순차적으로 따라가며 분석하기 용이하다.

하지만 동기 방식에서 외부 서비스와 연동할 때는 두 가지 위험이 존재한다.

첫째로 외부 서비스의 장애가 곧 우리 서비스의 장애가 된다. 예를 들어 주문 처리 중 포인트 적립 API를 동기로 호출한다면, 포인트 서버에 장애가 발생한 순간 주문 자체가 불가능해진다. 포인트 적립이 주문의 핵심 기능이 아닌데도 불구하고 말이다.

둘째로 외부 서비스의 응답 지연이 전체 응답 시간에 직접 영향을 준다. 외부 API 응답이 3초씩 걸린다면, 우리 서버의 응답도 최소 3초가 소요된다. 트래픽이 몰리는 상황에서 이런 병목은 스레드를 고갈시켜 서버 전체가 멈추는 상황으로 이어질 수 있다.


2. 비동기 방식이란?

비동기 방식은 현재 작업의 완료를 기다리지 않고 다음 작업을 즉시 시작하는 방식이다. 외부 연동 작업을 별도로 처리하고, 주요 흐름은 계속 진행된다.

비동기 방식을 도입하면 외부 서비스가 느려지거나 장애가 발생해도 우리 서비스의 응답 시간은 영향을 받지 않는다. 그러나 비동기가 항상 정답은 아니다. 아래 조건에 해당할 때 비동기 방식 도입을 검토하는 것이 좋다.

  • 연동 결과를 즉시 알 필요가 없을 때 (약간의 시차가 허용될 때)
  • 실패 시 재시도로 복구 가능한 기능일 때
  • 실패해도 나중에 수동으로 처리할 수 있는 기능일 때
  • 연동 실패가 전체 기능에 영향을 주지 않아도 될 때

반대로, 결제 처리나 재고 차감처럼 즉각적인 결과 확인과 일관성이 중요한 작업은 비동기로 처리하면 안 된다.


3. 비동기 연동의 5가지 방식

3-1. 별도 스레드로 실행하기

가장 단순한 비동기 처리 방법이다. 현재 스레드가 아닌 별도의 스레드에서 작업을 실행하면, 호출 측은 결과를 기다리지 않고 즉시 다음 작업을 진행할 수 있다.

Spring Boot에서는 @Ansyc 어노테이션으로 이를 간단하게 구현할 수 있다.

@Service
public class NotificationService {

    // 메서드 이름에 Async를 명시해 비동기 처리임을 코드 레벨에서 드러낸다
    @Async
    public void sendEmailAsync(String email, String content) {
        // 이 메서드는 별도 스레드에서 실행된다
        externalEmailClient.send(email, content);
    }
}

주의: @Async가 동작하려면 설정 클래스에 @EnableAsync를 선언해야 한다.

메서드 이름에 Async를 명시하는 이유가 있다. 이 메서드를 호출하는 쪽에서 반환값이 없다는 사실을 인지하지 못하면, try-catch로 예외를 잡으려 하거나 반환값을 사용하려는 실수를 할 수 있다. 이를 통해 비동기 처리임을 명확히 드러내면 이런 오류를 예방할 수 있다.

별도 스레드에서 발생한 예외는 호출한 스레드로 전파되지 않는다. 따라서 비동기 메서드 내부에서 예외를 직접 처리해야 한다.

또한, 비동기 코드가 트랜잭션 범위 안에서 실행되는 경우 주의가 필요하다. @Async 메서드는 별도 스레드에서 실행되므로 호출 측의 트랜잭션 컨텍스트를 공유하지 않는다. 즉, 호출 측 트랜잭션이 롤백되더라도 비동기 메서드가 이미 실행됐다면 해당 작업은 롤백되지 않는다.

스레드와 메모리 관리

스레드는 생성 자체에 비용이 든다. 요청마다 스레드를 새로 생성하면 메모리 사용량이 통제되지 않고, 생성 시간도 낭비된다. 이를 해결하기 위해 스레드 풀(Thread Pool)을 사용한다. 스레드 풀은 미리 일정 수의 스레드를 생성해 두고 재사용하는 방식이다. 덕분에 메모리 사용량이 일정하게 유지되고, 스레드 생성 오버헤드도 제거된다. 단, 풀 스레드가 모두 사용 중이면 새 작업은 대기 상태가 된다는 점을 고려해야 한다.

비동기로 처리할 작업이 DB 조회나 외부 API 호출처럼 대기 시간이 긴 I/O 작업이라면, Java 21의 가상 스레드(Virtual Thread) 도입을 고려해 볼 만하다. 가상 스레드는 I/O 대기 중 플랫폼 스레드를 점유하지 않으므로, 적은 자원으로 높은 동시성을 처리할 수 있다.

3-2. 메시징 시스템 이용하기

시스템 간 비동기 연동에서 가장 널리 사용되는 방식이다. 데이터를 직접 전달하는 대신 메시지 브로커(Messaging System)를 중간에 두고, 생산자(Producer)가 메시지를 발생하면 소비자(Customer)가 이를 수신해 처리한다.

직접 연동 대비 메시징 시스템이 갖는 핵심 이점은 두 가지다.

격리(Isolation): 시스템 A와 B가 메시지 브로커를 통해 분리되므로, B가 느려지거나 장애가 발생해도 A는 영향을 받지 않는다. 브로커가 메시지를 보관하는 버퍼 역할을 하기 때문이다.

확장성(Scalability): 시스템 C가 동일한 데이터를 필요로 할 때, A의 코드를 수정하지 않고 C를 브로커에 연결하기만 하면 된다.

메시징 시스템 주요 기술 비교

기술처리량메시지 보존순서 보장모델특징
Kafka매우 높음파일 영구 저장파티션 내 보장Pull대용량 스트리밍에 적합
RabbitMQ중간설정에 따라 다름큐 내 보장Push다양한 프로토콜, 라우팅 유연
Redis Pub/Sub높음없음 (메모리)보장 안 됨Push단순, 유실 허용 시 사용

기술 선택 기준을 정리하면 다음과 같다. 메시지 유실이 허용되고 지연 시간을 최소화해야 한다면 Redis Pub/Sub을, 초당 수십만 건 이상의 대용량 처리가 필요하다면 Kafka를, 정확한 전달 보장과 다양한 라우팅이 필요하다면 RabbitMQ를 선택한다.

메시지 종류

메시지는 크게 두 유형으로 나뉜다.

  • 이벤트(Event): 어떤 일이 발생했음을 알리는 메시지다. 주문이 완료됨, 배송이 시작됨처럼 과거형으로 표현한다. 수신자가 누구인지 신경 쓰지 않으며, 수신자는 이벤트를 보고 스스로 필요한 처리를 결정한다.
  • 커맨드(Command): 특정 행위를 요청하는 메시지다. 포인트를 지급하라, 문자를 발송하라처럼 수신자가 명확하고, 무엇을 해야 하는지 지시한다.

메시지 신뢰성 - 중복 처리와 멱등성

메시징 시스템에서 메시지가 정확히 한 번만 처리되는 것을 보장하기늠 매우 어렵다. 네트워크 오류나 소비자 장애로 인해 같은 메시지를 두 번 수신하는 상황이 발생할 수 있다. 이를 대비해 소비자는 멱등성(Idempotency)을 갖도록 구현해야 한다. 같은 메시지를 두 번 처리해도 결과가 달라지지 않아야 한다는 의미다.

구현 방법은 메시지에 고유 ID를 부여하고, 처리 전 해당 ID가 이미 처리됐는지 확인하는 것이다. 처리 여부는 DB 테이블에 기록하거나, 빠른 조회가 필요하다면 Redis를 활용할 수 있다.

DB 트랜잭션과 메시지 발행 시점

메시지 발행 시점도 중요하다. 트랜잭션 커밋 전에 메시지를 발행하면, 이후 트랜잭션이 롤백됐을 때 이미 발행된 메시지는 취소되지 않는다. 반드시 트랜잭션 커밋 이후에 메시지를 발행해야 한다. Spring에서는 @TransactionalEventListener를 활용하면 커밋 후에 이벤트를 발행하도록 처리할 수 있다.

3-3. 트랜잭션 아웃박스 패턴 (Transactional Outbox Pattern)

앞서 설명한 “트랜잭션 커밋 후 메시지 발행” 방식도 완전하지 않다. 트랜잭션은 커밋됐는데 메시징 시스템 연결에 실패하면 메시지가 유실되기 때문이다. 이 문제를 구조적으로 해결하는 방법이 트랜잭션 아웃박스 패턴이다.

핵심 아이디어는 간단하다. 메시지를 메시징 시스템으로 바로 보내는 대신, 같은 DB 트랜잭션 안에서 아웃박스 테이블에 저장한다. 그리고 별도의 릴레이 프로세스가 이 테이블을 주기적으로 읽어 메시징 시스템으로 전송한다.

하나의 트랜잭션 안에서 처리되므로, 비즈니스 로직이 롤백되면 아웃박스의 메시지도 함께 롤백된다. 메시지 유실이나 잘못된 메시지 발행은 구조적으로 방지할 수 있다.

아웃박스 테이블의 기본 구조는 다음과 같다.

컬럼타입설명
idbigintPK, 순차 증가
message_idvarchar메시지 고유 ID (중복 방지용)
message_typevarchar메시지 유형 구분
payloadclob실제 메시지 데이터 (JSON)
statusvarcharwaiting / done / failed
fail_countint실패 횟수
occurred_attimestamp메시지 생성 시각
processed_attimestamp처리 완료 시각
failed_attimestamp마지막 실패 시각

릴레이 프로세스 구현 방식은 크게 두 가지다. 하나는 일정 주기마다 status='waiting인 레코드를 조회해 전송하는 폴링(Polling) 방식이고, 다른 하나는 DB의 변경 로그를 실시간으로 감지하는 CDC(Change Data Capture) 방식이다.

3-4. 배치로 연동하기

배치 전송은 데이터를 실시간이 아닌 정해진 주기마다 일괄로 전송하는 방식이다. 메시징 시스템이 이벤트 발생 즉시 데이터를 전달한다면, 배치는 매일 새벽 2시, 매시 정각 같은 특정 시점에 데이터를 모아서 보낸다.

전형적인 처리 흐름은 다음과 같다.

  1. DB에서 전송 대상 데이터를 조회한다.
  2. 조회 결과를 파일로 생성한다.
  3. FTP/SFTP 등의 프로토콜로 파일을 대상 시스템에 전송한다.
  4. 대상 시스템이 파일을 읽어 처리한다.

파일 형식은 보통 구분자(delimiter) 방식, Key=Value 방식, JSON Lines 형식 중 하나를 사용한다. 어떤 형식을 사용할지는 양측이 미리 협의해 명세로 확정해야 한다.

파일 전송 대신 배치 API 호출 방식을 쓰기도 한다. 파일 생성·전송·파싱 과정이 없어지므로 구현이 단순해진다. 같은 조직 내 시스템 간 데이터 공유라면, 읽기 전용 DB 접근 권한을 부여하는 방식도 사용한다.

배치 전송에서 반드시 고려해야 할 것은 전송 실패에 대한 재시도 로직이다. 파일 생성 오류, 네트워크 불안정 등 다양한 이유로 전송에 실패할 수 있으므로, 실패 시 일정 시간 후 자동으로 재시도하는 메커니즘을 반드시 구현해야 한다.

3-5. CDC(Change Data Capture) 이용하기

CDC는 DB에서 발생하는 데이터 변경(INSERT, UPDATE, DELETE)을 실시간으로 감지해 다른 시스템으로 전파하는 패턴이다. 애플리케이션 코드를 수정하지 않고도 데이터 변경을 다른 시스템에 전달할 수 있다는 점이 핵심이다.

MySQL, PostgreSQL 같은 RDBMS는 데이터 변경 이력을 바이너리 로그(Binary Log)에 기록한다. CDC 처리기는 이 로그를 읽어 변경 이벤트를 생성하고 대상 시스템에 전파한다. 대표적인 CDC 도구로는 Debezium이 있으며, Kafka와 연동해 많이 사용된다.

CDC가 앞서 설명한 트랜잭션 아웃박스 패턴과 함께 사용되는 경우도 많다. 아웃박스 테이블의 변경을 CDC가 감지해 메시징 시스템으로 전파하는 방식이다. 이렇게 하면 폴링 방식보다 실시간성이 높아지고, 아웃박스 테이블 조회로 인한 DB 부하도 줄일 수 있다.

변경 데이터를 대상 시스템에 전파하는 방식은 두 가지다. DB 변경 내용을 그대로 전달하거나, 변환/가공해서 전달하는 방식이다. 원천 DB 스키마를 그대로 노출하면 대상 시스템과의 결합도가 높아지므로, 일반적으로는 변환을 거쳐 전달하는 방식을 권장한다.


4. 방식별 비교 정리

방식실시간성구현 복잡도메시지 유실 위험적합한 상황
별도 스레드높음낮음있음단일 서비스 내 간단한 비동기 처리
메시징 시스템높음중간설정에 따라 다름시스템 간 비동기 연동
트랜잭션 아웃박스중간높음없음 (DB 보장)메시지 유실 불허, 정합성 중요
배치낮음낮음~중간있음 (재시도 필요)대량 데이터, 실시간성 불필요
CDC높음높음낮음애플리케이션 수정 없이 변경 전파

profile
Backend

0개의 댓글