포스트

동기와 비동기 연동 [주니어 백엔드 실무지식]

동기와 비동기 연동 [주니어 백엔드 실무지식]

동기와 비동기 연동 ⚡

저자는 기능개발을 할 때, 실행되는 순서를 떠올린다고 했다. 우리도 프로그램을 짜면서 플로우를 생각하기 때문에 이 점은 굉장히 자연스러운 것이다. 아래는 그 생각의 과정을 코드로 나타낸 것이다.

동기적 실행 방법

딱봐도 동기적인 실행 방법이라는 것을 알 수 있다.

  1. 사용자를 찾고
  2. 암호를 비교하고
  3. 포인트를 지급하고
  4. 로그인 히스토리에 추가한다

당연히 해당 코드에서는 포인트를 지급하기 전에 로그인 히스토리를 추가하지 않는다.

동기 방식은 프로그램의 흐름을 코드를 따라가보면서 직관적으로 이해하기 쉽다. 당연히 디버깅도 용이하다.

하지만, 동기 방식이 외부 연동을 만나면 고려할 점이 생긴다. 먼저 외부 연동의 실패가 전체 기능의 실패인지 알아봐야 한다. 위의 방식처럼 하면 포인트 지급에 실패하면 로그인도 실패한 것으로 처리할텐데 이게 맞을까? 당연히 아니다. 포인트 지급에 실패해도 로그인은 성공해서 나머지 기능들이 작동하도록 해야할 것이다.

외부 연동 실패 시 처리

그래서 아래 그림처럼 포인트 지급 실패 시에는 전체 로그인 기능을 실패처리해서 롤백하는게 아니고, 후처리를 통해 지급 실패 내역만 따로 남기는 식으로 코드를 개선할 수 있다.

후처리를 통한 개선

외부 연동은 4장에서도 말했던 것처럼 응답시간도 고려를 해야하는데, 연동 서비스의 응답 시간이 길어질수록 전체 응답 시간이 느려지기 때문에 이럴 때는 동기 방식 대신에 비동기 방식으로 연동하는 것을 고려해볼 필요가 있다. 물론 외부 연동 결과가 반드시 필요한 상황이라면 어쩔 수 없다.

비동기 방식은 한 작업이 끝날 때까지 기다리지 않고, 바로 다음 작업을 처리한다. 그래서 외부 연동이 끝날 때까지 기다리지 않고 바로 다음 작업을 실행할 수 있기 때문에, 사용자에게 비교적 빠른 응답이 가능하다.

비동기 vs 논블로킹

비동기는 결과, 논블로킹은 제어권이다.

비동기 vs 논블로킹

비동기와 논블로킹의 차이점

별도 스레드를 사용한 비동기 처리

위 그림은 별도의 스레드를 사용해 포인트 지급을 비동기로 처리하는 과정을 보여준다. (단일 서버는 아중화된 서버던 별도의 스레드를 이용하는 것이기 때문에 이런 그림을 그린 것 같다.)

사용자는 포인트 지급이 끝나기 전에, 로그인 응답을 받을 것이다. 로그인을 성공한 시점에 바로 포인트가 쌓이지 않으면 문제가 될 것 같지만, 사실 많은 경우에는 그렇지 않다. 로그인 성공 이후 수 초 이내에 포인트가 정상적으로 쌓인다면 문제가 되지 않을 때가 많을 뿐더러, 사용자는 포인트만 정상적으로 쌓이면 그만이기 때문에 포인트 적립이 조금 늦어지더라도 크게 문제가 되진 않을 것이다.

장점은 포인트 서비스에서 일시적인 문제가 생기더라도 로그인의 응답 시간은 증가하지 않는다. (단순히 응답 시간만이 아니라 오류가 전파되지 않는다는 점에서 사이드 이펙트가 있을 것이다.)

이처럼 생각보다 많은 연동에서 비동기 방식을 이용해도 된다. 아래는 예시가 되는 서비스들이다.

  • 푸시 알림 🔔
  • 포인트 서비스 💰
  • 검색 서비스 연동 🔍 (fanout 같은걸 얘기하는 듯)
  • 인증 메시지 발송 📱 (알림하고 비슷함)

이 예시들은 몇가지 공통점이 있다.

  1. 연동에 약간 시차가 생겨도 별로 문제될게 없다.
    • 예를 들어, 쇼핑몰에서 주문이 완료된 후 1분 뒤에 판매자에게 푸시가 나가도 판매에는 지장이 없다.
    • 등록된 컨텐츠가 검색 결과에 10초 뒤에 나타나도 컨텐츠 등록에 문제가 되지 않는다.
  2. 일부 기능은 실패했을 때 재시도로 처리하면 된다(서비스 자체적 or 사용자가).
    • 푸시 발송에 실패했다? → 재시도로 성공시키면 그만
    • 인증번호가 sms로 오지 않아도 사용자가 다시 재시도를 해도 된다.
  3. 연동에 실패했을 때 수동으로 처리해도 되는 기능들도 많다
    • 검색 서비스 연동에 실패해서 컨텐츠가 검색에 노출되지 않을 경우, 컨텐츠 작성자가 문의했을 때 관리 툴을 사용해서 수동으로 처리해주면 된다. → 솔직히 좋은 방식은 아닌 듯하다.
  4. 연동에 실패했을 때 무시해도 되는 기능들도 있다.
    • 주문이 들어왔을 때 푸시 알림이 발송되지 않더라도 판매에 문제가 생기지는 않는다 → 라고 하긴 하는데 배달의 민족인데 사용자가 주문했는데 알림이 안가서 판매자가 모르면 ㅋㅋ 좀 그런데? 저자는 주문이 좀 늦어질 뿐이다라고 한다…. ㅋㅋㅋㅋㅋ
    • 검색이 안되는 경우도 서비스는 전체 서비스는 정상적으로 계속 할 수 있다.
    • 즉, 저자는 아마 메인 서비스나 주요 서비스에 영향이 가서 작동이 안되는 문제는 없다는 것 같다.

저자는 외부 연동이 위 4가지 중 일부에 해당하면 비동기 처리를 고려한다고 한다. 실제로 포인트 지급이나 결제 결과 반영 등 여러 기능에 비동기 연동을 적용하여 성능 개선을 했다고 한다. 또한, 연동 서비스에 장애가 생겨도 후처리를 통해서 해결해서 용이했다고 한다.

비동기 연동 구현 방식은 여러 방식으로 구현할 수 있는데, 아래는 그 예시다.

  1. 별도의 스레드로 실행 🧵
  2. 메시징 시스템 📨
  3. 트랜잭션 아웃박스 패턴 📦
  4. 배치로 연동하기 📊
  5. CDC 🔄 Change Data Capture란?

당연히 이외에도 다른 방식이 있겠지만, 초보 개발자는 이 정도만 알아도 괜찮다.

별도 스레드로 실행하기 🧵

비동기 연동을 하는 가장 쉬운 방법별도의 스레드로 실행하는 것이다. 예를 들어 푸시 알림 보내고 싶다? 그냥 새로운 스레드를 생성해서(스레드 풀에 있는 걸 가져다 쓰는 방식으로) 연동하면 된다.

별도 스레드로 실행

프레임워크가 제공하는 비동기 기능을 사용해도 된다. 스프링에서는 @Async 어노테이션이 있는데, 이걸로 특정 메서드를 비동기로 실행할 수 있다. 대신 메서드 이름을 비동기 실행과 관련된 단어를 추가해서 이 함수를 가져다 쓸 때 비동기 처리로 돌아간다는 것을 명확히 표현해줘야 한다.

@Async 어노테이션 사용

만약 모르고 위 그림처럼 try-catch 문을 써봤자 비동기로 실행되어서 catch 블록이 동작을 안한다.

별도의 스레드로 실행하면 연동 과정에서 발생한 오류 처리를 더 신경써야 한다. 익셉션을 전파해도 소용이 없기 때문에 별도의 스레드로 실행되는 코드 내부에서 적절한 오류 처리가 필요하다.

비동기 실행 시 오류 처리

메시징 📨

서로 다른 시스템 간에 비동기로 연동을 할 때 주로 사용하는 방식은 메시징 시스템을 사용하는 방법이다.

메시징 시스템 구조

요런 식으로 메시징 시스템을 이용하게 되면 구조는 더 복잡해지지만 다른 이점을 얻을 수 있다.

1. 두 시스템 간에 서로 영향을 주지 않는다 🛡️

시스템 A의 트래픽이 갑자기 증가해서 전달할 데이터가 시스템 B의 처리량을 초과한 상황을 가정해보자. A랑 B가 직접 연동되어 있으면 B의 성능 저하가 A에게 전파될 것이다. 하지만 메시징 시스템을 이용하면 이러한 성능 전파가 없다. 왜냐? 메시징 시스템이 A가 보낸 메시지를 저장하고 시스템 B의 성능에 맞게 메시지를 전달한다. 그래서 메시징 시스템이 중간에서 메시지를 보관하는 버퍼 역할을 한다. 그래서 시스템 A의 트래픽이 급증하더라도 시스템 B는 자신의 용량에 맞게 메시지를 처리할 수 있다.

2. 확장이 용이하다 📈

A가 C에도 데이터를 전송해야 한다고 가정하자. 만약 A가 직접 데이터를 전송했다면 시스템 A에 새로운 코드를 추가를 해야한다. 근데 메시징 시스템을 사용하면 C를 메시징 시스템에 연결만 해주면 된다. 그래서 A의 코드를 수정할 필요가 없다.

메시징 시스템 확장

메시징 시스템 비교

자주 사용되는 메시징 시스템은 Kafka, RabbitMQ, Redis pub/sub이 있는데 각 기술이 다른 특징을 갖고 있어 사용 목적에 맞게 기술을 선택해야 한다.

카프카 선택 시 고려 사항

카프카 특징

장점

  • 처리량이 높다(초당 백 만개 이상의 메시지 처리가 가능)
  • 메시지를 파일(디스크)에 저장해서 메시지가 유실되지 않는다
  • 1개의 토픽이 여러 개의 파티션을 가질 수 있고, 파티션 단위로 순서 보장이 된다. (토픽 수준에서는 순서 보장이 안된다)
  • 언제든지 소비자가 메시지를 재처리할 수 있다(메시지가 소비되어 없어지는게 아니다)
  • pull 모델이다. Consumer가 Polling으로 메시지를 가져간다

단점

  • 당연히 고비용이다(최소 3개 이상의 클러스터를 이용)

RabbitMQ 선택 시 고려 사항

RabbitMQ 특징

장점

  • 클러스터를 통해 처리량을 높일 수 있다. 단, 카프카보다 더 많은 자원을 필요로 한다
  • 다양한 메시지 처리 패턴(exchange 옵션)을 이용할 수 있고, 우선순위 지정이 가능하다 (책에서는 게시/구독, 요청/응답, 점대점 패턴을 지원한다고 표현)

단점

  • 메모리에만 메시지를 보관하는 큐 설정을 하면 장애 상황 시 메시지가 유실될 수 있다.
    • 이게 아마 메시지가 메모리에 넣을 수 있는 양보다 많으면 디스크에 저장하는 방식이 있는 걸로 알고 그게 아니면 그냥 메모리를 쓰는 걸로 아는데, 문제는 서버 장애, 무중단 배포 같은걸로 클러스터를 다시 띄우는 상황이 왔을 때 메시지가 사라질 수 있다고 앎
  • 큐에 등록된 순서대로 소비자에게 전송된다
  • 푸시 모델이라 Publisher의 성능이 느려지면 큐에 과부하가 걸려 전반적인 성능 저하가 발생할 수 있다

Redis 선택 시 고려 사항

Redis 특징

장점

  • 구현이 개쉽다
  • 개빠르다. pub/sub은 kafka pub/sub 보다도 빠르다
  • 모델이 단순하다

단점

  • 구독자가 없으면 메시지 유실이 발생한다
  • 영구 메시지를 지원을 안한다

정리:

  • 메시지가 유실되도 상관이 없고 빠른 성능이 중요하다면 → Redis pub/sub
  • 트래픽이 줠라 많다? → Kafka가 짱짱맨
  • 트래픽은 적당한데, 메시지 전송 방법(패턴)이 중요하면 → RabbitMQ

다른 대안으로는 AWS SQS같은거도 좋다

메시지 생성 측 고려사항

메시지 생성할 때는 메시지 유실을 고려해야 한다. 메시지 전송 과정에서 타임아웃이 발생할 수 있고, 소비자 측에서 제대로 메시지를 처리하지 못할 수도 있다. 네트워크가 개복치여서 유실은 발생 가능하다.

이때 오류 처리 방법은 크게 3가지다.

  • 무시 🙈
  • 재시도 🔄
  • 실패 로그 📝

첫 번째로, 오류 무시는 당연히 쉽다. 근데 상황에 따라 At Least Once, Exactly Once가 보장되어야 한다*.

두 번째는 재시도를 하는 것이다. 문제가 생겼을 때 즉각적으로 오류를 처리해서 정상 작동해야 한다면 재시도는 필수다. 근데 이제 타임아웃 설정이나 일시적인 오류로 전송에 성공했는데 실패된 것으로 간주될 수 있어 만약 중복 처리를 하면 안되는 상황에서는 Consumer를 멱등하게 짜거나, 요청 중복 제거가 되어야 한다.

세 번째는 실패 로그나 이벤트를 DB에 담아두는 것이다. 이후 후처리를 통해 처리해주면 된다.

메시지 생산자는 DB 트랜잭션과의 연동도 고려해야 한다. DB 트랜잭션이 실패했는데, 메시지가 발송되면 잘못된 데이터가 전달될 수 있기 때문이다.

잘못된 메시지 발송

위 같은 예시로 보면 내가 주문을 넣었는데, DB에 insert 하는데에 실패했는데 주문이 완료되었다는 알림이 가면 당연히 고객은 주문이 완료된 것으로 잘못 간주할 수 있다. 그래서 이런 문제를 방지하려면 트랜잭션이 끝난 후에 메시지를 전송해야 한다.

트랜잭션 완료 후 메시지 전송

그래서 이런 경우에는 트랜잭션 커밋을 먼저 찍고 메시지를 나중에 보내는 방식을 택한다

글로벌 트랜잭션

이걸 보아하니 글로벌 트랜잭션 어우 멋있는 친구다. SAGA 패턴같이 복잡한 아키텍처를 쓰지 않고, 분산 환경에서 데이터 정합성을 맞추기 좋다. 근데 이제 모든 메시징 시스템이 지원하는 건 아니기도 하고, 이게 2단계 커밋 과정이 있다는건 당연히 처리 속도가 느려지는 것이고 그러면 처리량은 줄어든다.

메시지 소비 측 고려 사항

메시지 소비자는 아래 2가지 이유로 동일 메시지를 중복해서 처리할 수 있다.

  • Publisher가 동일한 데이터의 메시지를 메시징 시스템에 두 번 전송
  • Consumer가 메시지를 처리하는 과정에서 오류로 인해 메시지 재수신

Publisher가 같은 데이터를 가진 메시지를 두 번 이상 메시징 시스템에 전송하면 수신자가 중복해서 메시지를 처리하는 것이랑 같다. 수신자 입장에서 동일 데이터를 가진 중복 메시지를 처리하는 방법은 메시지에 고유한 ID를 부여해서 Request Deduplicate를 하는 것이다.

중복 메시지 처리

요렇게 하면 메시지 publisher가 메시지마다 고유 ID를 부여해야 한다. 그리고 처리했는지 여부는 DB 테이블에 기록을 하거나 메모리에 집합으로 관리하면 된다. 메모리로 관리할 때는 메모리 부족 에러가 발생하는 것을 막기 위해서 일정 개수의 메시지 ID만 유지한다. 뭐 적절한 TTL 설정도 좋은 것 같다.

메시지 재수신이 가능한 경우 소비자가 메시지를 처리하는 과정에서 오류가 발생하면 재처리를 위해서 다시 수신할 수 있다(개인적으로는 타임아웃 같은 문제가 많다고 본다 읽기던 연결이던).

근데 문제는 뭐냐 알고보니 성공을 했네? 그러면 이제 중복해서 호출하게 되는 것이다. 이래서 기현이 형이 어떻게 하라고 했다? → Consumer가 멱등하면 된다

메시징 처리 시스템에서 중요한건 모니터링을 잘 하는 것이다. 다른 시스템들에서도 마찬가지지만 메시징 시스템을 모니터링으로 잘 추적, 관찰하면 얻어갈 수 있는게 많다. 예를 들어 Consumer 처리 속도가 갑자기 느려지면 메시지가 계속 쌓이는데, 당연히 무한대로 쌓지는 못하니까 메시징 시스템이 죽거나 메시지가 소실될 것이다. 그리고 메시지 처리 시스템의 성능 저하로 인해 사용자가 불편함을 느끼게 된다.

이런걸 잘 추적관찰해서 처리율 제한을 하던, Consumer를 늘리던 다양한 방법으로 해결이 가능하다.

또한, 메시지 유실이 실제로 발생하는 지 여부도 판별 가능하다. 이제 이건 정은 누나가 면접 때 겪어봤기 때문에 잘 설명해줄거다.

메시지 종류: 이벤트 & 커맨드

메시지에는 크게 2가지 종류가 있다.

  • 이벤트 📢
  • 커맨드

이벤트와 커맨드

이벤트

이벤트는 어떤 일이 발생했음을 알려주는 메시지다. 주문이 생성되었다거나, 배송이 완료되었다거나 이런게 이벤트이다. 이벤트는 상태(데이터) 변경과 관련이 있다. “주문의 상태”, “배송의 상태”가 그렇다.

로그인에 실패나 상품 정보를 조회함 같이 어떤 활동이 일어났다는 사실을 나타내는 이벤트도 있다. 예를 들어 사용자가 로그인에 실패했을 때 사용자의 데이터는 변경되지 않을 수 있다. 하지만 사용자의 활동 결과에 “로그인에 실패함” 이벤트는 발생한다.

커맨드

커맨드는 무언가를 요청하는 메시지다. 커맨드 메시지를 수신하는 소비자는 메시지로 요구한 기능을 실행한다. 예를 들어, “포인트 지급하기” 메시지를 받은 소비자는 포인트를 지급한다. “배송 완료 문자 발송하기” 메시지를 받은 소비자는 대상자에게 배송이 완료됐다는 문자를 발송한다.

커맨드 메시지

그래서 커맨드 메시지는 메시지를 수신할 측의 기능 실행에 초점이 맞춰져 있다. 즉 수신자가 정해져 있다. “포인트 지급하기” 메시지를 포인트 서비스가 아닌 게시글 서비스에서 받는다고 해보자. 게시글 서비스는 해당 메시지를 수신해도 의미 있는 기능을 수행할 수 없다.

이벤트의 소비자 확장

반면에 이벤트 메시지는 정해진 수신자가 없다. 발생한 사건에 관심이 있는 소비자가 메시지를 수신하는 방식이다. 예를 들어, “배송을 완료함” 이벤트 메시지를 생각해보자. 이 메시지는 문자를 보내라는 명령을 담고 있는 것이 아니라, 배송이 완료됐다는 사실만을 담고 있다. 따라서 배송 완료 문자를 보낼 지 여부는 메시지를 수신한 소비자 쪽에서 결정한다.

그래서 이벤트 메시지는 소비자 확장에 적합하다. 예를 들어, 배송을 완료했을 때 문자를 발송하고, 추가로 주문 상태도 변경하고 싶다면, 위 그림처럼 “배송 완료함” 메시지를 주문 서비스가 수신하도록 구성하면 된다.

주문 서비스는 “배송 완료함” 메시지를 수신하면 해당 주문의 상태를 변경하면 된다. 또, 주문부터 배송 완료까지의 과정을 분석하는 서비스를 만든다고 하면, 이 서비스 역시 “배송 완료함” 메시지를 수신해서 배송 완료 시점을 기록하고 분석에 사용할 수 있다.

궁극적 일관성

트랜잭션 아웃박스 패턴 📦

메시지 생성 시 고려 사항에서, 잘못된 메시지 발송을 막기 위해 DB 트랜잭션이 완료된 후 메시지를 전송하자고 했었다. 근데 문제는 이렇게 해도 메시징 시스템 연동에 실패할 수 있어 메시지 유실 가능성이 있다.

이게 안되게 하려면 메시지 데이터 자체가 유실되지 않도록 DB에 저장을 해두는 것이다. 그 뒤, 저장된 메시지를 읽어 메시징 시스템에 전송하면 된다. 이런식으로 데이터를 DB에 보관하는 방식이 바로 Transactional Outbox Pattern이다.

아웃박스 패턴은 하나의 DB 트랜잭션 내에서 다음 2가지 작업을 수행한다.

  • 실제 업무 로직에 필요한 DB 변경 작업을 수행
  • 메시지 데이터를 아웃박스 테이블에 추가

그럼 이 아웃박스 테이블에 쌓인 메시지 데이터를 별도의 메시지 중계 프로세스가 주기적으로 읽어서 메시징 시스템에 전송한다.

아웃박스 패턴 구조

DB 트랜잭션 범위에서 아웃박스 테이블에 메시지를 추가하므로 메시지 데이터가 유실되지 않는다. 트랜잭션을 롤백하면 메시지 데이터도 함께 롤백되므로 잘못된 메시지 데이터가 전송될 일도 없다.

메시지 중계 시스템발송하지 않는 메시지 데이터 조회 및 메시징 시스템에 전송 전송에 성공 시 전송 완료처리(DB의 값을 바꿔준다) 요렇게하면 메시지가 두 번 이상 전송되는 일을 최대한 막을 수 있다.

메시지 중계 프로세스

위 코드에서 특정 메시지를 전송하는 데 실패하면 루프를 멈춘다. 왜냐? 순서 보장을 해야하니까 대기 메시지를 10개 읽어왔는데 중간에 5번째 메시지를 전송할 때 에러가 발생했는데, 그냥 6번째 메시지를 발생하면 당연히 순서 보장이 안된다. 꽤 많은 경우 이 순서 보장이 중요해서 유의해야 한다.

발송 완료를 표시하는 방법은 2가지가 있다.

  1. 아웃박스 테이블에 발송 상태 칼럼을 두는 것 (status : 대기, 완료, 실패)

상태 칼럼 방식

  1. 성공적으로 전송한 마지막 메시지 ID를 별도로 기록

예를 들어, 파일이나 별도의 테이블에 메시지 ID를 저장해두고, 다음 대기 메시지를 조회할 때 이 ID 이후의 메시지만 선택하는 것

필자는 Status 표기 방법을 선호한다고 하고, NHN 테크톡에서도 요렇게 하라고 했음. 이게 모니터링까지 쉬워서. 근데 이제 2개 이상의 메시지 중계 서비스가 하나의 아웃박스 테이블을 사용한다면 각 중계 서비스가 고유하게 마지막 메시지 ID를 관리해야 해서, 마지막 메시지 ID를 기록하는게 더 적합할 수 있다고 함.

아웃박스 테이블 구조

아웃박스 테이블 구조

아웃박스 테이블 예시

원한다면 여기서 알아서 변경해서 써라

  • messageType: 메시지 종류 구별
    • LoginFailed, OrderPlaced 같은 메시지 타입을 의미
  • Payload
    • Json or XML 등의 형식으로 데이터를 담음
  • Status
    • 대기, 완료, 실패
    • 대기 상태에서만 조회
    • 실패 조건을 어떨 때 실패로 할지 신경을 쓰긴 해야함 (수동으로도 가능)
      • 단 실패 상태의 메시지는 후속 조치가 필요 → 데이터 일관성이 깨질 수 있어서
      • memo나 remark 같은 칼럼을 추가해서 이유 파악에 도움을 줄 수 있음
    • 제외 상태를 추가해도 됨
      • 수동으로 특정 메시지를 전송하고 싶지 않을 때

배치 전송 📊

데이터를 비동기로 연동하는 가장 전통적인 방식. 메시징 시스템은 거의 실시간이지만, 배치는 일정 간격으로 데이터를 전송하는게 일반적. 그래서 스케줄링하고 많이 써서 혼동하기도 함.

배치 전송의 전형적인 실행 과정은 아래와 같다

  1. DB에서 전송할 데이터 조회
  2. 조회한 결과를 파일로 기록
  3. 파일을 연동 시스템에 전송

파일 전송은 FTP나 SFTP 같은 파일 전송 프로토콜이나 SCP 같은 커맨드로 수행한다.

주로 사용하는 파일 형식은 다음과 같다.

  • 값1 + 구분자 + 값2 + 구분자 + 값3 + 구분자 + 값4
    • 데이터 크기도 작고 파싱 속도도 빠르고 구현이 쉬워서 단순하다.
  • 이름1 = 값 1, 이름2 = 값2, 이름3 = 값3, 이름4 = 값4
    • 이름이 포함되어 있어 위치 관계없이 어떤 값인지 알 수 있긴한데, 이름까지 포함되어서 첫번째 방식보다는 데이터 크기가 커진다
  • JSON 문자열
    • 대부분의 언어가 JSON 변환을 제공해서 쉽게 구현 가능하다
    • 근데 프로퍼티 이름, 형식을 지키기 위한 문자 같은게 포함되어서 데이터 크기가 커진다
    • 근데 나같으면 이거 쓸거 같긴한다.

파일로 데이터를 주고받는 시스템은 형식 외에도 아래 항목을 함께 정해야 한다

  • 송수신 주체
  • 시간
  • 경로

파일 생산자와 소비자 중 누가 전송을 담당할지는 알아서 정해야 한다.

파일 전송 방식

파일 처리 과정

시간도 중요하다. 소비자 시스템은 특정 시점에 데이터를 필요로 한다. 특정 기간에 정산을 마쳐야 한다던지 하는, 근데 정해진 시점까지 데이터를 못 받아서 업무가 지연되면? 당연히 개빡치지

배치 파일은 데이터 누락 등 오류에 대응할 수 있는 시간을 벌기 위해 근무가 시작되는 오전 시간 대에 전송을 처리할 때가 많다. 근데 생산자 시스템이 글로벌 서비스라면 다른 시간대를 기준으로 파일을 받아야 할 때도 있다. 이 경우 생산자 시스템이 보내줄 수 있는 시간에 맞춰 소비자 시스템의 처리 시간을 변경해야 한다. 경로와 파일 이름 규칙도 맞춰서 하나의 시스템이 여러 서비스로부터 파일을 받더라도 경로나 이름이 충돌하지 않도록 규칙을 잘 정해야 한다.

생산자가 소비자로 파일을 업로드할 경우 소비자는 아래와 같은 방식으로 동작한다.

  1. 지정한 경로에 파일이 존재하는 지 확인
  2. 파일이 존재하면 파일로부터 데이터를 Read
  3. 파일이 없으면 알맞게 후처리
  4. 읽어온 데이터를 시스템에 반영
  5. 처리를 완료한 파일은 다른 폴더로 옮김

파일을 업로드하는 시간을 기준으로 위 동작을 실행한다. 예를 들어 오전 8시에 생산자 시스템이 파일을 업로드 한다고 하면, 소비자 시스템은 오전 8시 반에 파일을 처리하느 식으로

소비자는 처리가 끝나면 파일을 다른 폴더로 옮긴다. 요렇게 해야 중복 처리를 막을 수 있으니까. 이게 삭제를 해버리면 나중에 재처리가 필요할 때 재사용을 못해서 옮겨두는게 좋다.

파일 얘기만 했는데, 파일 대신 API를 이용해서 데이터를 일괄로 전송할 때도 있다. API를 사용하면 파일 생성, 전송, 처리 과정이 없어서 구현이 더 단순해진다. 데이터 크기가 작거나 처리 항목이 적을 때 API를 이용하면 좋다.

배치 전송의 또 다른 방식은 읽기 전용으로 DB를 열어주는 것이다. 같은 조직 내에서 데이터를 전달할 때 쓴다. 한 조직 내에서의 데이터 전송은 외부에 전송하는 것보다는 훨씬 보안에 덜 엄격하다. 그래서 읽기 전용 권한을 줘서 DB에 직접 접근하게 할 수 도 있다. 개발 시간이 부족하면 쓸만하다.

읽기 전용 DB 접근

재처리 기능 만들기

파일을 지정한 시간대에 전송하지 못할 때가 있다. 파일 생성 과정에서의 오류라던지, 네트워크 오류라던지

어떤 이유에서든 실패를 하면 재전송을 해야 한다. 예를 들어, 7시에 배치가 실행되고 평균 20분 정도 걸린다고 치면 7시 40분쯤에 성공 여부를 확인하고 재처리를 하는 그런 방식이다. 이러면 수작업을 안해도 된다.

재시도를 했는데도 실패하는 경우가 있다. 그래서 수동으로 날릴 수 있는 API나 커맨드를 만들어 두면 좋다.

CDC (Change Data Capture) 🔄

마지막으로 CDC(Change Data Capture)다. 의미를 위키피디아에서 찾아보면 다음과 같다.

변경된 데이터를 추적하고 판별해서 변경된 데이터로 작업을 수행할 수 있도록 하는 설계 패턴

오라클, MySQL 같은 DBMS는 데이터가 변경되면 그 변경 내용을 통지하는 기능을 제공한다. CDC는 이걸 이용해서 구현한다.

CDC 구조

INSERT, UPDATE, DELETE 쿼리를 날리면 DB의 데이터가 변경된다. 이걸 CDC 처리기에 전송한다. DB는 커밋된 데이터만 변경 순서에 맞게 전달한다. CDC 처리기에는 롤백된 데이터가 전달되지 않는다. 또한, 순서 보장이 실패할 일도 없다.

변경 데이터는 레코드 단위로 전달된다. 예를 들어 1개 레코드 추가 → 2개 레코드 수정 → 3개 레코드 삭제가 일어났다? 그러면 총 6개 레코드에 대한 변경분이 CDC 처리기에 전달된다는 말이다.

이 변경분 데이터는 추가, 수정, 삭제 중 뭔지 알 수 있는 플래그를 갖는다. 수정인 경우에는 이전과 이후 값이 포함되어 있어서 어떻게 변경이 된건지 파악이 가능하다.

CDC와 데이터 위치 기록

CDC를 구현할 때 중요한 점은 데이터 변경을 어디까지 처리했는지를 기록하는 것이다. 예를 들어 MySQL은 바이너리 로그(Binary Log)를 이용해 CDC를 구현하는데, 각 로그 항목에는 변경된 데이터뿐 아니라 로그 파일 내에서의 위치(Position) 값이 함께 담겨 있다. CDC 처리기는 이 위치를 저장해 두어야 한다. 그래야 처리기를 재시작하더라도 마지막으로 읽었던 지점부터 이어서 로그를 읽을 수 있고, 그 사이 발생한 변경 데이터를 놓치지 않게 된다. 만약 이 위치를 기록하지 않는다면 마지막 로그 시점부터 다시 읽을 수밖에 없는데, 그 과정에서 재시작 시간 동안의 변경 데이터가 유실될 수 있다.

CDC가 유용한 경우

CDC가 특히 유용했던 경험이 있다. 과거 한 회사에서 신규 주문 시스템을 구축할 때, 신규 시스템에서 발생하는 주문 데이터를 기존 주문 시스템에도 반영해야 하는 요구사항이 있었다. 즉, 신규 주문이 생성되거나 변경될 때마다 그 데이터를 기존 시스템으로 전달해야 했던 것이다. 하지만 신규 시스템 개발 조직에서는 연동 코드를 직접 추가하기를 꺼려했다. 이미 코드가 복잡해져 있었고, 일정에도 여유가 없었기 때문이다.

이런 상황에서 선택한 방법이 바로 CDC 활용이었다. 신규 시스템의 변경 로그를 CDC로 추출해 기존 주문 시스템으로 전달함으로써, 코드 수정 부담 없이 데이터 연동을 구현할 수 있었다.

CDC 활용 사례

신규 주문 시스템의 코드를 수정하지 않고도 CDC를 사용해 타시스템에 관련 데이터를 전파할 수 있었다. 연동 대상 시스템이 2개였고, 서로 데이터를 처리하는 속도가 달라 중간에 메시징 시스템으로 카프카를 두었다. CDC로 연동 기능을 구현한 덕분에 신규 주문 시스템 개발 일정에 주는 영향을 최소화 할 수 있었다.

결론 🎯

동기와 비동기 연동은 각각의 장단점이 있다. 동기는 직관적이고 디버깅이 쉽지만, 외부 연동의 실패가 전체 기능에 영향을 줄 수 있다. 비동기는 성능상 이점이 크고 장애 격리가 가능하지만, 복잡성이 증가한다.

비동기 연동을 구현할 때는 메시징 시스템, 아웃박스 패턴, 배치, CDC 등 다양한 방법을 상황에 맞게 선택해야 한다. 중요한 것은 비즈니스 요구사항과 시스템 특성을 고려한 적절한 선택이다.

모든 것을 비동기로 만들 필요는 없지만, 성능과 안정성이 중요한 부분에서는 비동기 연동을 적극 고려해보자. 🚀

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.