한 줄 정의
다음 작업을 진행하는 데 연동 결과가 반드시 필요한 게 아니라면, 결과를 기다리지 않고 먼저 응답한 뒤 나중에 처리합니다.
쉽게 말하면
주문을 받은 직원이 커피를 다 만들어 손님 손에 쥐여 줄 때까지 다음 손님을 받지 않는 카페를 떠올리면 이 장이 한 줄로 꿰어집니다.
이 카페에서는 에스프레소 머신이 고장 나면 계산조차 되지 않습니다. 커피와 무관하게 케이크만 사려던 손님도 줄에서 못 움직입니다. 머신이 느려지면 줄은 그대로 길어집니다. 동기 연동이 정확히 이 구조입니다.
해법은 단순합니다. 주문서를 넘기고 진동벨을 준 뒤 다음 손님을 받는 것입니다. 손님은 몇 분 뒤에 커피를 받지만, 그게 문제가 되지 않는 주문이 생각보다 많습니다.
남는 질문은 주문서를 어떻게 넘기느냐 하나뿐이고, 이 장의 다섯 가지 방식이 전부 그 답입니다.
| 넘기는 방법 | 대응하는 방식 |
|---|---|
| 옆 사람에게 소리쳐 알린다 — 자리를 비웠으면 그냥 사라진다 | 별도 스레드 |
| 주문서를 레일에 꽂아 둔다 — 바 쪽이 자기 속도로 가져간다 | 메시징 |
| 장부에 먼저 적고, 적힌 것만 레일로 옮긴다 — 계산이 취소되면 장부에도 안 남는다 | 트랜잭션 아웃박스 |
| 하루치를 모아 마감 때 한 번에 넘긴다 | 배치 전송 |
| 아무도 알리지 않지만 누군가 POS 기록을 계속 들여다보며 알아서 처리한다 | CDC |
왜 중요한가?
동기 방식은 코드 순서가 곧 실행 순서라 흐름이 직관적이고 디버깅도 쉽습니다. 그럼에도 외부 연동을 만나면 두 가지 부담을 떠안습니다.
첫째는 실패의 전파 입니다. 로그인 성공 후 포인트를 지급하는 코드에서 포인트 서비스에 장애가 나면, 그 시간 동안 로그인 자체가 불가능해집니다. 포인트를 못 받는 것과 로그인을 못 하는 것은 전혀 다른 크기의 사고인데도 동기 호출은 이 둘을 하나로 묶어 버립니다.
둘째는 응답 시간의 누적 입니다. 연동 서비스가 느려진 만큼 우리 서비스의 응답 시간도 그대로 늘어납니다.
두 부담 모두 “포인트 지급이 끝나야만 로그인 응답을 보낼 수 있다”는 전제에서 나오는데, 그 전제는 대체로 사실이 아닙니다. 로그인에 성공하고 수 초 뒤에 포인트가 쌓여도 사용자는 아무 불편을 느끼지 않습니다. 전제를 걷어내는 순간 응답 시간과 장애 격리를 동시에 얻습니다.
핵심 내용
비동기로 돌려도 되는 연동인지 판별하기
모든 연동을 비동기로 바꿀 수는 없습니다. 판단 기준은 다음 네 가지이고, 일부만 해당해도 검토 대상 입니다.
| 특징 | 판단 질문 | 예시 |
|---|---|---|
| 시차 허용 | 몇 초~몇 분 늦어도 업무에 지장이 없는가 | 등록한 컨텐츠가 10초 뒤에 검색에 노출됨 |
| 재시도 가능 | 실패해도 다시 시도하면 성공하는가 | 포인트 지급 재시도, 인증번호 “다시 받기” |
| 수동 처리 가능 | 실패분을 나중에 사람이 처리할 수 있는가 | 검색 누락 컨텐츠를 관리 툴로 수동 색인 |
| 무시 가능 | 실패해도 핵심 기능이 유지되는가 | 판매자 주문 알림 푸시 누락 |
이 기준을 통과하는 연동은 실무에 널려 있습니다. 주문 시 판매자 푸시, 학습 완료 후 포인트 지급, 컨텐츠 등록 시 검색 색인, 인증번호 SMS 발송이 모두 여기에 해당합니다.
방식 1. 별도 스레드로 실행하기
가장 손쉬운 방법입니다. 스레드를 직접 만들거나(new Thread(...).start()), 매번 생성하는 대신 스레드 풀(ExecutorService)에 제출하거나, 스프링의 @Async 를 붙이면 됩니다.
쉬운 만큼 함정도 뚜렷합니다. 호출부가 비동기인지 모른다는 것 이 모든 문제의 뿌리입니다.
try {
pushService.sendPush(pushData); // 실은 @Async 메서드
} catch (Exception ex) {
// 비동기로 실행되므로 이 블록은 절대 실행되지 않는다
}익셉션이 호출 스레드로 전파되지 않으므로 catch 는 죽은 코드가 되고, 오류는 조용히 사라집니다. 그래서 메서드 이름에 sendPushAsync 처럼 비동기임을 드러내고, 오류 처리(재시도, 실패 로그 기록)는 비동기로 실행되는 메서드 안에서 직접 해야 합니다.
트랜잭션이 롤백되지 않는 사고
@Transactional안에서 호출한 메서드가 비동기로 실행되면 익셉션이 전파되지 않아, 롤백돼야 할 트랜잭션이 정상 커밋됩니다. 트랜잭션 범위 안에 비동기 코드를 넣을 때는 트랜잭션 연동 여부를 반드시 확인해야 합니다.
스레드는 공짜가 아닙니다
스레드 1개는 최소 수백 KB의 메모리를 점유합니다. 256KB라면 10만 개를 만드는 데만 약 24GB가 필요하고, 생성 시간과 스케줄링 CPU 비용도 함께 늘어납니다. 그래서 스레드 풀로 개수를 고정하는 것이고, 비동기 작업이 외부 API 호출이나 DB 연동 같은 네트워크 IO라면 자바의 가상 스레드나 Go의 고루틴처럼 런타임이 관리하는 경량 스레드가 더 나은 선택입니다.
방식 2. 메시징
서로 다른 시스템 간 비동기 연동에서 가장 널리 쓰이는 방식입니다. 시스템 A가 메시지를 만들어 메시징 시스템에 보내면, 메시징 시스템이 시스템 B에 전달합니다.
구조가 복잡해지는 대신 얻는 두 가지
첫째, 두 시스템이 서로 영향을 주지 않습니다. 메시징 시스템이 중간에서 버퍼 역할을 하므로, A의 트래픽이 급증해도 B는 자기 용량에 맞춰 처리하고 B가 느려져도 A는 영향을 받지 않습니다.
둘째, 확장이 쉽습니다. 같은 데이터를 시스템 C에도 보내야 할 때, 직접 호출 구조라면 A의 코드를 고쳐야 하지만 메시징 구조에서는 C를 메시징 시스템에 연결하기만 하면 됩니다. A는 자기 코드에 손대지 않고 소비자가 늘어납니다.
기술 선택: 유실을 어디까지 허용하는가
| 카프카 | 래빗MQ | 레디스 pub/sub | |
|---|---|---|---|
| 처리량 | 초당 백만 건 이상 | 클러스터로 증대(자원 더 필요) | 메모리 기반이라 지연 짧고 래빗MQ보다 높음 |
| 유실 | 파일 보관으로 유실 없음 | 메모리 큐 설정이면 장애 시 유실 | 구독자 없으면 유실, 영구 메시지 미지원 |
| 순서 | 파티션 단위 보장(토픽 수준은 미보장) | 큐 등록 순서대로 전송 | — |
| 전달 방식 | 풀(pull) — 소비자가 읽어 감 | 푸시(push) — 소비자가 느리면 큐 과부하 | — |
| 특징 | 수평 확장 용이, 언제든 재처리 가능 | AMQP·STOMP 지원, 요청/응답·점대점 패턴, 우선순위 지정 | 모델이 단순해 쓰기 쉬움 |
선택 기준은 명확합니다. 유실이 상관없고 장비를 적게 쓰고 싶으면 레디스, 초당 수십만~수백만의 대량 트래픽이면 카프카, 규모는 크지 않지만 순서가 정확해야 하거나 AMQP·STOMP가 필요하면 래빗MQ입니다.
유행이 아니라 조직의 역량으로 고릅니다
이미 레디스를 운영해 본 조직이 유실 허용 가능한 메시징을 원한다면 레디스가 정답입니다. 타당한 이유 없이 유행한다는 이유로 고른 기술은 두고두고 유지보수 부담으로 돌아옵니다.
생산자 측: DB 트랜잭션과 순서를 맞춘다
메시지 전송 실패에 대응하는 방법은 무시 / 재시도 / 실패 로그 세 가지입니다. 무시는 단순 로그 메시지처럼 유실이 허용될 때, 재시도는 일시적 네트워크 오류일 때 유효합니다. 다만 재시도는 중복 전송을 만들 수 있습니다. 실제로는 전송에 성공했는데 일시적 오류로 실패했다고 판단하는 경우가 있기 때문에, 메시지마다 고유 식별자를 붙여 소비자가 중복을 걸러낼 수 있게 해야 합니다.
더 중요한 것은 DB 트랜잭션과의 순서 입니다.
sequenceDiagram participant O as 주문 서비스 participant D as DB participant M as 메시징 시스템 participant N as 알림 서비스 O->>D: 1.2 DB 변경 O->>M: 1.3 메시지 전송 M->>N: 1.3.1 메시지 전파 O->>D: 1.4 DB 변경 D-->>O: 1.5 변경 실패 O->>D: 1.6 트랜잭션 롤백 N->>O: 1.3.1.1 주문 안내 푸시 발송
주문은 롤백됐는데 푸시는 이미 나간 상태가 됩니다. 고객은 주문 실패 화면을 보고 잠시 뒤에 주문 완료 푸시를 받습니다. 그래서 메시지는 트랜잭션이 완료(커밋/롤백)된 뒤에 전송해야 유효한 데이터만 전파됩니다.
글로벌 트랜잭션(2PC)이 답은 아닙니다
액티브MQ처럼 글로벌 트랜잭션을 지원하는 메시징 시스템을 쓰면 DB 수정과 메시지 전송을 한 트랜잭션으로 묶을 수 있습니다. 다만 모든 메시징 시스템이 지원하지 않고, 2단계 커밋이 커밋 과정을 길게 만들어 동시 처리량을 떨어뜨립니다. 반드시 필요한 상황이 아니라면 DB 처리와 메시지 연동을 묶지 말고, 유실 없이 보내고 싶다면 트랜잭션 아웃박스 패턴을 검토하는 편이 낫습니다.
소비자 측: 중복은 오는 게 정상이다
소비자가 같은 메시지를 두 번 처리하는 경로는 두 가지입니다. 생산자가 같은 데이터를 두 번 전송했거나, 소비자가 처리 중 오류로 메시지를 재수신한 경우입니다.
두 번째 경로가 특히 까다롭습니다. 외부 API를 호출했는데 읽기 타임아웃이 나면 소비자는 실패로 판단하고 메시지를 다시 받아 재시도하지만, 실제로는 성공했을 가능성이 있어 외부 API가 두 번 호출됩니다.
대응은 두 층으로 갑니다. 메시지에 고유 ID를 부여해 이미 처리했는지 추적하고(DB 테이블이나 메모리 집합에 기록), 그와 별개로 처리 대상 API 자체를 멱등하게 구현 합니다. 멱등성이 있으면 몇 번 호출돼도 결과가 같으므로 중복 추적이 뚫려도 데이터가 어긋나지 않습니다.
여기에 소비 속도 모니터링 이 필요합니다. 소비자가 느려지면 큐에 메시지가 쌓이고, 큐가 가득 차면 메시징 시스템에 따라 생산자가 메시지를 넣지 못하게 막기도 합니다. 소비자의 성능 저하가 생산자까지 번지는 것 입니다.
이벤트와 커맨드
메시지는 두 종류로 나뉘고, 이 구분이 소비자 확장성을 결정합니다.
| 이벤트(event) | 커맨드(command) | |
|---|---|---|
| 의미 | 어떤 일이 발생했음 | 무언가를 해 달라는 요청 |
| 예 | 주문함, 로그인에 실패함, 배송을 완료함 | 포인트 지급하기, 로그인 차단하기 |
| 수신자 | 정해져 있지 않음 — 관심 있는 소비자가 가져감 | 정해져 있음 — 기능을 실행할 쪽이 받아야 함 |
| 확장 | 소비자를 추가해도 생산자는 그대로 | 엉뚱한 소비자가 받으면 의미 없음 |
‘배송 완료함’ 이벤트는 문자를 보내라는 명령이 아니라 배송이 끝났다는 사실만 담습니다. 그래서 알림 서비스가 문자를 보내고, 주문 서비스가 주문 상태를 바꾸고, 분석 서비스가 배송 완료 시점을 기록하는 식으로 하나의 메시지에 소비자가 계속 붙을 수 있습니다. 커맨드로 설계했다면 매번 새 메시지 타입이 필요했을 일입니다.
궁극적 일관성(eventual consistency)
비동기 메시징은 일정 시간이 지난 뒤에야 일관성이 맞춰지는 특성을 갖습니다. 배송 서비스가 상태를 완료로 바꿔도 메시지가 주문 서비스에 도착하기 전까지 주문 서비스의 상태는 여전히 배송 중입니다. 비동기를 택한다는 것은 이 일시적 불일치를 받아들인다는 뜻입니다.
방식 3. 트랜잭션 아웃박스 패턴
트랜잭션이 끝난 뒤에 메시지를 보내도 완벽하지 않습니다. 그 전송 코드 자체가 실패할 수 있기 때문입니다. 재시도와 예외 처리를 넣어도 메시징 시스템 연동이 실패하면 메시지는 사라집니다.
핵심 발상은 메시지를 먼저 DB에 안전하게 저장해 두는 것 입니다. 하나의 DB 트랜잭션 안에서 업무 로직의 DB 변경과 아웃박스 테이블에 메시지 데이터 추가를 함께 수행하면, 트랜잭션이 롤백될 때 메시지 데이터도 같이 롤백되므로 유실도 없고 잘못된 메시지도 나가지 않습니다.
sequenceDiagram participant A as 시스템 A participant D as DB participant R as 메시지 중계 participant M as 메시징 시스템 A->>D: 1 트랜잭션 시작 A->>D: 2 데이터 변경 A->>D: 3 아웃박스 테이블에 메시지 저장 A->>D: 4 트랜잭션 커밋 loop 주기적 실행 R->>D: 5 대기 메시지 조회 R->>M: 6 메시지 전송 R->>D: 7 전송 완료 표기 end
중계 프로세스의 코드에서 눈여겨볼 부분은 실패 시 루프를 멈추는 처리입니다.
for (MessageData m : waitingMessages) {
try {
sendMessage(m);
markDone(m.getId());
} catch (Exception ex) {
handleError(ex);
break; // 순서대로 발송하기 위해 멈춘다
}
}10개를 읽어와 5번째에서 실패했는데 6번째부터 계속 보내면 생성 순서와 다르게 발송됩니다. 메시지 순서가 중요하다면 반드시 멈춰야 합니다.
아웃박스 테이블 구조
| 칼럼 | 타입 | 설명 |
|---|---|---|
| id | big int | 단순 증가 PK. 저장 순서대로 증가하는 값을 쓴다 |
| messageId | varchar | 메시지 고유 ID(고유키) — 소비자의 중복 판별에 사용 |
| messageType | varchar | LoginFailed, OrderPlaced 처럼 소비자가 처리를 분기하는 기준 |
| payload | clob | JSON·XML 등으로 담은 메시지 데이터 |
| status | varchar | WAITING(대기) / DONE(완료) / FAILED(실패함) |
| failCount | int | 실패 횟수 — FAILED 전환 판단에 사용 |
| occuredAt / processedAt / failedAt | timestamp | 발생 · 처리 · 마지막 실패 시간 |
전송 완료를 표시하는 방법은 상태 칼럼을 두는 방식과 마지막 전송 메시지 ID만 별도로 기록하는 방식이 있습니다. 상태 칼럼 방식이 어디까지 처리됐는지 모니터링하기 쉬워 기본으로 삼되, 중계 서비스가 2개 이상이라면 각자 자기 마지막 ID를 관리해야 하므로 후자가 적합합니다.
status 에는 실패가 아니라 수동으로 전송을 제외하고 싶을 때 쓰는 EXCLUDED 를 추가할 수도 있습니다.
실패 한 번에 FAILED로 바꾸지 않습니다
대기 상태인 메시지만 조회되므로 FAILED로 바꾸는 순간 그 메시지는 자동 발송 대상에서 빠집니다. 일시적 문제였다면 한두 차례 재시도로 성공하므로, 실패 횟수 기준(예: 5회)으로 전환하거나 모니터링을 통해 운영팀이 판단하도록 합니다. FAILED가 된 메시지는 반드시 후속 조치가 필요합니다. 방치하면 시스템 간 데이터 일관성이 깨집니다.
방식 4. 배치 전송
데이터를 비동기로 연동하는 가장 전통적인 방법입니다. 메시징이 거의 실시간이라면 배치는 일정 간격으로 모아서 보냅니다. 결제 승인 데이터를 모아 다음 날 보내거나, 택배 발송 요청을 1시간 간격으로 보내는 식입니다.
흐름은 DB 조회 → 파일 기록 → 전송(FTP·SFTP·SCP)이고, 파일 형식은 구분자 방식(값1^값2^값3), 이름=값 쌍, JSON 문자열을 주로 씁니다. 구분자 방식이 단순하고 파싱이 빨라 널리 쓰이며, JSON은 구현이 쉬운 대신 프로퍼티 이름과 따옴표·콤마 때문에 데이터 크기가 커집니다.
형식보다 먼저 합의해야 할 것은 송수신 주체와 시간, 경로 입니다.
| 항목 | 결정할 내용 |
|---|---|
| 송수신 주체 | 생산자가 업로드할지, 소비자가 다운로드할지. 정해진 규칙 없이 두 조직이 조율한다 |
| 시간 | 소비자가 데이터를 필요로 하는 시점 기준. 매월 5일까지 정산해야 한다면 그 전에 월 단위 데이터를 받아야 한다 |
| 경로·파일명 | 한 시스템이 여러 서비스로부터 받을 수 있으므로 충돌하지 않도록 규칙을 정한다 |
주체 선택에는 이해관계가 걸려 있습니다. 공격적으로 확장하는 서비스는 소비자에게 맞춰 업로드해 주는 경우가 많은 반면, 다운로드를 선호하는 쪽의 이유는 대개 보안 입니다. 생산자가 업로드하려면 소비자 시스템을 외부에 노출해야 하는데, 그 노출을 특별한 사유가 있을 때만 허용하는 조직이 있기 때문입니다.
소비자는 경로 확인 → 파일 읽기 → 없으면 후처리 → 시스템 반영 → 처리 완료 파일을 다른 폴더로 이동 순으로 동작합니다. 마지막 단계가 같은 파일의 중복 처리를 막고, 삭제 대신 이동해 두면 재처리가 필요할 때 활용할 수 있습니다.
배치에는 재처리 장치가 필수입니다. 7시에 배치가 실행되고 평균 20분 걸린다면 7시 40분쯤 성공 여부를 확인하고 실패 시 재실행하는 식으로 자동 재시도를 넣고, 재시도로도 안 되는 경우를 대비해 수동 실행 명령어나 API를 만들어 둡니다. 소비자가 오전 9시 30분과 10시 30분에만 처리하는데 생산자가 오후 2시에 파일을 보내는 상황이 실제로 발생합니다.
파일이 없는 게 정상일까
전송할 데이터가 없으면 파일을 만들지 않도록 구현했더니, 고객사는 데이터가 없어서 파일이 없는 건지 오류 때문에 없는 건지 구분할 수 없었습니다. 데이터가 없어도 빈 파일을 전송하는 편이 낫습니다. 파일의 존재 자체가 “배치가 정상 실행됐다”는 신호이기 때문입니다.
파일 대신 API로 데이터를 일괄 전송하면 파일 생성·전송·처리 과정이 없어 구현이 단순해지므로, 데이터 크기가 작거나 처리 항목이 적을 때 고려할 만합니다. 같은 조직 내에서는 읽기 전용 권한으로 DB를 직접 열어 주는 방식도 씁니다.
DB로 연동하기
배송 요청 데이터를 택배 시스템에 전송하려고 연동 문서를 요청했더니 DB IP, 계정, 테이블 명세서가 왔습니다. 해당 테이블에 직접 데이터를 추가하는 방식이었습니다. 레거시 시스템과 연동하다 보면 외부 시스템인데도 DB로 연동해야 할 때가 있는데, DB 테이블을 API 명세라고 생각하면 됩니다. 연동 구현 방식이 다를 뿐입니다.
방식 5. CDC(Change Data Capture)
Quote
변경된 데이터를 추적하고 판별해서 변경된 데이터로 작업을 수행할 수 있도록 하는 소프트웨어 설계 패턴
오라클이나 MySQL 같은 DBMS가 제공하는 변경 통지 기능을 활용해, DB → CDC 처리기 → 대상 시스템으로 변경분을 흘려보냅니다.
앞의 네 방식과 결정적으로 다른 점은 애플리케이션 코드에 연동 코드를 넣지 않는다 는 것입니다. 이것이 CDC의 존재 이유입니다.
전달되는 데이터의 성질
DB는 커밋된 데이터만 변경된 순서에 맞게 전달합니다. 롤백된 데이터는 전달되지 않고 순서가 뒤바뀌는 일도 없습니다. 전달 단위는 레코드이며, 1개를 추가하고 2개를 수정한 뒤 3개를 삭제했다면 총 6개 레코드의 변경분이 전달됩니다. 각 변경분은 추가·수정·삭제를 구분하는 플래그를 갖고, 수정이라면 칼럼의 이전 값과 이후 값이 함께 담깁니다.
CDC 처리기는 이를 두 형태로 전파합니다. 두 시스템의 데이터가 거의 1대 1 관계라면 변경분을 그대로 복제하고(회원 시스템의 이름 칼럼 변경 → 컨텐츠 시스템의 회원 테이블도 동일 변경), 그렇지 않다면 가공해서 보냅니다(주문 상태가 ‘배송 중’으로 바뀌면 통지 시스템에는 주문 ID와 상태만 전송).
전파 대상도 목적에 따라 다릅니다. 단순 동기화라면 DB↔DB로 두면 되고, 메시징 시스템으로 보내면 여러 소비자가 붙을 수 있어 확장에 유리합니다.
재시작을 견디려면 위치를 기록해야 한다
MySQL은 바이너리 로그로 CDC를 구현하고, 각 로그 항목은 변경 데이터와 로그 파일에서의 위치(포지션) 값을 갖습니다. 이 위치를 기록해 둬야 CDC 처리기를 재시작했을 때 마지막으로 읽은 지점부터 이어서 읽습니다. 기록하지 않으면 마지막 로그부터 읽게 되어 재시작하는 동안 발생한 변경 데이터를 통째로 놓칩니다.
언제 유용한가
시스템이 복잡해서 연동 코드를 넣기 부담스러울 때 입니다. 신규 주문 시스템에서 발생한 주문 데이터를 기존 주문 시스템에 반영해야 했지만, 신규 시스템 개발 조직은 일정에 여유가 없고 코드도 이미 복잡해져 연동 코드 추가에 난색을 표했습니다. CDC로 주문 DB의 변경분을 카프카에 넣고 소비자 둘이 구주문 DB와 컨텐츠 서비스로 각각 전파하는 구조를 택해, 신규 주문 시스템 코드를 한 줄도 고치지 않고 연동을 완성했습니다. 연동 대상이 둘이고 처리 속도가 서로 달라 중간에 카프카를 둔 것입니다.
CDC는 이벤트가 아닙니다
CDC는 변경분을 전달하므로 이벤트에 가깝지만 정확한 의미를 전달하지는 못합니다. ‘회원 암호 초기화함’ 이벤트는 의도를 분명히 나타내는 반면, ‘회원 테이블 데이터 변경: 이전 암호, 이후 암호’는 암호 칼럼 값이 바뀌었다는 사실만 알려 줄 뿐 회원이 변경한 건지 시스템이 초기화한 건지 드러내지 않습니다. 의미가 중요한 연동이라면 CDC가 아니라 이벤트 메시지를 써야 합니다.
비교 / 트레이드오프
| 방식 | 메시지가 사는 곳 | 유실 위험 | 코드 침투 | 적합한 상황 |
|---|---|---|---|---|
| 별도 스레드 | 프로세스 메모리 | 높음 — 프로세스가 죽으면 소멸 | 있음 | 실패해도 무시 가능한 단일 시스템 내 처리 |
| 메시징 | 메시징 시스템 | 기술 설정에 따라 다름 | 있음 | 시스템 간 실시간 비동기 연동, 소비자 확장 |
| 트랜잭션 아웃박스 | DB 아웃박스 테이블 | 낮음 — 트랜잭션으로 보장 | 있음 | DB 변경과 메시지를 반드시 함께 보장해야 할 때 |
| 배치 전송 | 파일 | 낮음 — 파일이 남음 | 있음 | 시차가 시간·일 단위로 허용되는 대량 데이터 |
| CDC | DB 로그 | 낮음 — 위치 기록 시 | 없음 | 애플리케이션 코드를 건드릴 수 없을 때 |
세로로 읽으면 선택 순서가 보입니다. 유실이 허용되면 별도 스레드로 충분하고, 유실이 곤란해지면 메시징으로 올라가며, DB 변경과의 정합성까지 필요하면 아웃박스로, 코드를 건드릴 수 없으면 CDC로 갑니다. 배치는 실시간성이 필요 없을 때 가장 단순한 답입니다.
모든 연동을 비동기로 바꿀 필요는 없습니다. 비동기는 구조를 복잡하게 만들고 두 시스템 간 데이터가 일시적으로 불일치하는 시기를 만듭니다. 그 복잡도 증가보다 성능·안정성·자율성 측면의 이점이 클 때만 택하는 것이 맞습니다.
내 생각
- 비동기 전환은 성능 최적화이기 이전에 장애 경계선 긋기입니다. “포인트 지급 실패가 로그인 실패여야 하는가”라는 질문에 답하는 순간 아키텍처가 정해지므로, 응답 시간 개선은 그 결정의 부수 효과로 보는 편이 판단이 흐려지지 않습니다.
@Async는 편해 보이지만 팀 규모가 커질수록 위험이 커집니다. 호출부에 아무 흔적이 남지 않아 리뷰에서 걸러지지 않고, 트랜잭션 안에서 쓰이면 롤백이 조용히 사라지므로 메서드 이름 규칙을 컨벤션으로 못 박아 두는 게 최소한의 방어입니다.- 아웃박스 패턴은 “메시징 시스템을 못 믿어서”가 아니라 “DB만 믿어서” 쓰는 것입니다. 이미 트랜잭션이 걸린 DB에 메시지를 얹으면 2PC 없이도 원자성을 얻으므로, 글로벌 트랜잭션을 검토하기 전에 여기부터 보는 게 순서입니다.
- 소비자 멱등성은 옵션이 아니라 전제입니다. 재시도·재수신은 언제든 일어나므로 “중복이 안 오게 만든다”가 아니라 “중복이 와도 결과가 같다”로 설계를 뒤집어야 하고, 이건 Ch04 외부 연동이 문제일 때 살펴봐야 할 것들의 재시도 논의와 정확히 같은 결론입니다.
- CDC는 강력한 만큼 결합 지점이 DB 스키마로 내려갑니다. 연동 코드를 안 넣는 대신 테이블 구조 변경이 곧 연동 장애가 되므로, 조직 간 협상이 안 될 때 쓰는 우회로로 보고 상시 연동 수단으로 삼지는 않는 게 안전합니다.
- 이벤트 vs 커맨드 구분은 지금은 사소해 보여도 2년 뒤 확장 비용을 결정합니다. ‘포인트 지급하기’로 이름 붙인 메시지에는 새 소비자를 붙일 수 없지만 ‘학습을 완료함’에는 붙일 수 있으므로, 이름 짓는 시점에 이미 아키텍처를 고르고 있는 셈입니다.
관련 개념
- Ch04 외부 연동이 문제일 때 살펴봐야 할 것들 — 동기 연동에서 타임아웃·재시도·멱등성으로 버티는 방법
- Ch03 성능을 좌우하는 DB 설계와 쿼리 — 트랜잭션 범위를 어디까지 묶을 것인가