주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 비동기 연동, 언제 어떻게 써야할까 - 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를 부여해서 이미 처리했는지 여부를 추적하는 것이다.
- 컨슈머가 메시지를 폴링해서 가져온다.
- 비즈니스 로직으로 처리완료(DB 저장 등) → 만약 여기서 2번은 끝났는데 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에 직접 쿼리를 날리지 않아 부하가 거의 없고 매우 빠른 실시간 전송이 가능하나 인프라 설정이 상대적으로 복잡하다.
- 로컬 트랜잭션 시작 : 애플리케이션에서 DB 트랜잭션을 시작한다.
- 비즈니스 데이터 저장 : 테이블에 레코드를 저장한다.
- 메시지 기록(Outbox) : 동일 트랜잭션 내에서 Outbox 전용 테이블에 보낼 이벤트 메시지를 INSERT한다.
- 트랜잭션 커밋 : Outbox 전용 테이블에 저장 실패하면 DBMS 원자성 덕분에 전체 트랜잭션이 롤백되기에 데이터 정합성이 완벽하게 유지된다.
- 메시지 발행 : 별도의 독립된 프로세스가 Outbox 전용 테이블을 감시하며 새로 들어온 메시지를 읽어 메시지 브로커로 전송한다.
- 완료 처리 : 전송에 성공하면 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 처리기에는 롤백된 데이터가 전달되지 않는다. 또한 잘못된 순서로 데이터가 전달되는 일도 없다.
- 변경 발생 : 소스 DB에서 트랜잭션이 커밋되면 그 내용이 트랜잭션 로그(MySQL binlog, PostgreSQL WAL, Oracle redo log)에 자동 기록된다.
- 변경 감지(Capture) : CDC 커넥터가 이 로그를 순차적으로 읽어 변경 이벤트를 뽑아낸다. 각 이벤트에는 보통 변경 전후 이미지, 연산 유형, 타임스탬프, 로그 위치가 담긴다.
- 직렬화 & 전송 : 이벤트를 Json 혹은 Avro와 같은 형식으로 직렬화해 Kafka와 같은 메시지 브로커로 발행한다.
- 소비 & 적용(Sink) : 컨슈머가 이벤트를 소비해 타겟(데이터 웨어하우스, 검색엔진, 캐시, 다른 DB 등)에 Upsert/Delete 등으로 반영한다.
변경 캡처 3가지 방식
- 로그 기반(Log-based) : 트랜잭션 로그를 읽는다. DB 부하가 거의 없고 실시간이며 DELETE까지 정확히 잡을 수 있어 가장 권장되는 방식이다.
- 트리거 기반(Trigger-based) : DB 트리거로 변경 내역을 별도 테이블에 기록한다. 구현은 직관적이나 쓰기 과정에서 오버헤드가 발생한다.
- 쿼리/타임스탬프 기반(Polling) :
updated_at과 같은 컬럼을 주기적으로 폴링한다. 만들기 쉽지만 DELETE 감지가 어렵고 실시간성이 떨어진다.