Spring에서 Server-Sent-Events(SSE) Scale Out 메세지 사용없이 MySQL Polling (Change Data Capture)

Mugeon Kim·2024년 2월 18일
1
post-thumbnail

서론


  • 안녕하세요. 이번에는 Spring으로 SSE에 대해서 알아보겠습니다. 일단 웹 애플리케이션을 개발하다보면 클라이언트 요청이 없어도 서버에서 데이터의 변경 또는 조건이 발생하면 데이터를 전달해줘야 하는 경우가 있습니다.

  • 대표적으로 알림, 랭킹 시스템, 뉴스피드 등 다양한 기능이 있고 이것에 요구사항에 실시간이라는 조건이 추가된다면 단순히 클라이언트 단에서는 setInterval()을 통하여 특정 시간마다 요청 또는 서버 측에서는 mysql에 짧은 시간동안 polling을 지속적으로 하여 재요청을 하는 방식이 있습니다.

  • 전통적인 Client-Server 모델의 HTTP 통신에는 이러한 기능을 구현하기 남감합니다. 왜냐하면 기본적으로 클라이언트의 요청이 있어야지 서버가 응답을 보낼 수 있는데 클라이언트가 변경점을 확인하지 못하기 때문입니다.

목차

  • 이번에는 여러가지 개념이 있어서 큰 목차에 대해서 먼저 말씀을 드리겠습니다.
  1. SSE에 대해서 알아보자

  2. 분산서버에서 SSE 문제가 발생하는 상황

  3. 분산서버에서 동기화 문제를 해결하기 위한 방법 (MySQL CDC Binlog)

    • 일반적으로 Redis Pub/Sub , Kafka를 통해서 문제를 해결하지만 일을 하면서 2가지의 기술을 사용하지 못하는 제약사항이 있어 MySQL Polling 방식을 이용하여 문제를 접근하였습니다.

여기서 3번을 보면 동기화 문제를 해결하는 방법이 있습니다. 그런데 일반적으로 이 문제는 Pub/Sub 또는 카프카 등 메세지 방식을 사용하면 해결할 수 있습니다. 하지만 저는 MySQL Polling방식으로 스프링이 MySQL의 변경된 이벤트를 기반으로 해결하겠습니다. 대표적인 키워드는 MySQL binlog 입니다.

본론


요구사항 해결방법

1. Shot Polling

  • 클라인언트가 서버에 주기적으로 HTTP 요청을 보내고, 서버는 즉시 응답을 반환을 한다. 이때 클라이언트는 일정한 간격으로 서버에 요청을 보내어 새로운 정보가 있는지 확인한다. 이 방식은 클라이언트의 수가 증가하면 서버에 크게 부담이 된다.
  • 간단하게 구현할 수 있으며 초기 구현에 용이하다. 또한 HTTP 프로토콜을 사용하므로 대부분 웹 브라우저에서 지원한다.
    • 변경이 발생하지 않았는데도 주기적으로 요청하기 때문에 불필요한 트래픽이 발생한다.
    • 매 요청마다 TCP Connection을 연결,해제하는 작업을 매번 수행하여 실시간으로 빠른 응답을 기대하기는 어렵다.

2. Long Polling

  • Short Polling보다 서버에서 기다리는 시간이 길어진 것
  1. 클라이언트는 서버에 요청을 보낸다.
  2. 서버는 메세지를 사용할 수 있을 때까지 응답을 보내지 않는다.
  3. 메세지를 사용할 수 있게 되고 응답을 전송한다.
  4. 클라이언트는 응답을 받으면 즉시 동일한 엔드포인트를 호출한다.
  • 연결은 가능한 오랫동안 중단되거나 열려 있는 상태로 유지되며 응답을 받으며 다시 연결한다. Short Polling에 비해서 요청의 수를 줄일 수 있고 서버로부터 응답을 받고 나면 다시 연결 요청을 하기 때문에, 상태가 빈번하게 바뀐다면 요청도 늘어나 서버에 부담이 가는건 변하지 않고, 실시간으로 처리하기에 적절하지 않다.

3. WebSocket

  • 클라이언트와 서버는 HTTP를 통해 연결을 설정하고, websockets 핸드셰이크를 통해 연결한다. 웹 소켓의 데이터는 클라이언트와 서버가 양방향으로 전송한다.
  • 실시간 양방향 통신을 제공하므로 실시간 애플리케이션에 적합하고 작은 오버헤드로 빠른 속도를 제공한다. 하지만 HTTP 요청보다 더 많은 리소스가 필요하고 프록시 , 로드밸런서 설정이 복잡할 수 있다.

4. Server-Sent Events

  • 서버와 한번 열결을 맺고 나면 일정 시간동안 서버에서 변경이 발생할 때마다 데이터를 전송받는 방법으로 Polling 방식과 달리 서버에서 변경이 발생해도 다시 TCP Connection 재연결이 필요가 없다.

기술 선택한 이유

  • 저는 실시간 리더보드를 구현함에 따라 SSE 방식을 선택을 했습니다. polling 방식의 경우 클라이언트에서 주기적으로 요청해야 하기 때문에 요청량이 많아질 경우 비효율적으로 서버 사용량이 증가를 하며 리더보드가 24시간 화면에 띄어져 있기 때문에 24시간 계속 불필요한 요청을 발생한다.
  • 리더보드는 서버에서 클라이언트로 비동기적으로 통신을 할 수 있으면 되기 때문에 웹소켓을 선택하지 않았다.

Server-Sent Events(SSE) 알아보자

SSE란

  • SSE (Server-Sent Events)는 클라이언트와 서버 간의 1:1 관계를 유지하면서 서버에서 클라이언트로 데이터를 전송하는 기술입니다. 이벤트가 서버에서 발생할 때마다, 서버는 해당 이벤트를 클라이언트로 전송합니다. 이러한 전송 방식은 HTTP 스트리밍을 통해 이루어지며, HTML5의 표준 기술 중 하나입니다.

  • 이때 SSE의 특징 및 주의해야 되는 부분이 있습니다.

  1. 단방향 통신: SSE는 서버에서 클라이언트로의 단방향 통신을 제공합니다. 클라이언트는 서버로부터만 데이터를 수신하고, 서버로 데이터를 보낼 수 없습니다.

  2. HTTP/1.1 및 HTTP/2 프로토콜 제한: HTTP/1.1 프로토콜을 사용할 경우, 브라우저는 한 도메인당 최대 6개의 EventStream을 유지할 수 있습니다. HTTP/2 프로토콜에서는 브라우저와 서버 간의 협상에 따라 최대 100개까지 가능합니다.

  3. UTF-8 인코딩: SSE는 UTF-8 인코딩된 문자열만을 지원합니다. 따라서 서버에서 이벤트 데이터를 전송할 때, 이 데이터를 UTF-8로 인코딩하여 전송해야 합니다. 대부분의 경우, 서버는 JSON 형식으로 이벤트 데이터를 인코딩하여 전송합니다.

  4. 브라우저 지원: 현재 Internet Explorer를 제외한 대부분의 최신 브라우저에서 SSE를 지원합니다. JavaScript를 사용하여 클라이언트 측에서 EventSource를 통해 SSE 연결을 생성하고, 전송된 이벤트를 처리할 수 있습니다.

  5. Spring Framework의 지원: Spring Framework는 4.2 버전부터 SseEmitter 클래스를 제공하여 서버 측에서 SSE 통신을 구현할 수 있습니다. 이를 통해 Spring 기반의 애플리케이션에서 SSE를 쉽게 구현하고 활용할 수 있습니다.


동작 방식

1. 클라이언트에서 SSE 연결 요청을 보낸다.

GET /connect HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache
  • 이벤트의 미디어 타입은 text/event-stream을 사용하며 캐싱을 사용하지 않으며 지속적 연결을 사용한다. 또한 http 1.1에서는 keep-alive를 사용하여 지속 연결을 한다.

  • HTTP 1.0
    요청 컨텐츠마다 TCP 커넥션을 맺고 끊음을 반복한다.
    요청1 -> 응답1 -> 요청2 -> 응답2 순으로 순차적으로 진행된다. 즉, 응답을 받아야만 다음 작업을 한다.

  • HTTP 1.1
    매 요청마다 TCP 커넥션을 맺고 끊음을 반복하지 않고 keep-alive를 통해 일정 시간 동안 커넥션을 유지한다.
    클라이언트는 각 요청에 대한 응답을 기다리지 않고 여러개의 요청을 연속적으로 보낸다.(파이프라이닝) 하지만
    각 응답의 처리는 순차적으로 처리된다.


2. 서버에서는 클라이언트와 매핑되는 SSE 통신 객체를 만들고 연결 확정 응답을 보낸다.

HTTP/1.1 200
Content-Type: text/event-stream
Transfer-Encoding: chunked
  • 클라이언트에서 서버에 요청을 보내고 서버는 객체를 만들어 연결이 되었음을 확정 응답을 클라이언트에게 보낸다. 응답 미디어 타입은 text/event-stream이다. 이때 Transfer-Encoding: chunked이라고 적혀져 있다. 이것은 전송 인코딩 메너니즘의 하나로 데이터를 여러 조각으로 분할하여 전송하는데 사용한다. 청크를 통해 데이터를 전송하여 데이터 크기를 사전에 정의하지 않고 실시간으로 전송할 수 있게 한다.
    왜냐하면 서버는 동적으로 생성된 컨텐츠를 스트리밍하기 때문에 본문의 크기를 미리 알 수 없기 때문이다.

3. 서버에서 이벤트가 발생, 객체를 통해 클라이언트로 데이터를 전송

  • 서버에서 SSE 통신 객체를 유저마다 저장해놓고 해당 유저에게 응답을 보내야 할 경우 해당 객체를 활용하여 데이터를 전달한다.

4. SSE 만료

  • sse 연결 시 서버에서 만료시간을 설정을 한다. 이때 시간을 너무 길게 설정하면 서버에서 커넥션과 쓰레드가 성능의 저하 요소가 될 수 있고, 로드 밸런서도 최대 연결 시간 설정에 제한이 있기 때문에 적절한 시간을 설정하는게 좋다.
  • 클라이언트에서 만료가 되면 서버에 재요청 로직을 전송을 한다. 저희는 이 방식에서 재요청을 요청하면 sse 객체를 삭제하고 재연결을 하는 방식을 사용을 하였습니다.

트러블 슈팅

1. 503 Service Unavailable

  • 처음 sse 응답을 할 때 아무런 이벤트를 보내지 않으면 재연결 요청을 보낼때나 아니면 연결 요청 자체에서 오류가 발생한다. 그래서 처음 sse 응답을 보낼 시에는 반드시 더미를 넣어야한다.

2. 만약에 jwt를 사용하면 header에 토큰 전달

  • 이 문제는 이전에 프로젝트를 진행하면서 만났던 문제이다. SSE 연결 요청을 할 때 헤더에 JWT 토큰을 담아서 보내야한다. 그런데 EventSource 인터페이스는 기본적으로 헤더를 전달을 지원을 하지 않는다. 이때는 event-source-polyfill를 사용하여 헤더를 보낼 수 있다.

3. SSEvent의 Data 전송시 String으로만 보내야함

  • 이것을 살펴보면 즉 메시지의 데이터 필드. EventSource가 데이터로 시작하는 여러 줄의 연속 줄을 받으면, 그것들을 연결하여 각 줄 사이에 줄 바꿈 문자를 삽입합니다. 후행 줄 바꿈이 제거됩니다. 라고 나온다.
HTTP/1.1 200 OK
Content-Type: text/event-stream

data: Hello\n\n

4. JPA 사용시 Connection 고갈

  • 현재는 mybatis를 사용하고 있어서 이전에 발생한 문제를 작성한다. sse 통신을 하는 동안에는 http connection이 계속 열려있기 때문에 jpa에서 open-in-view를 false로 설정을 해야된다. 아니면 http connection이 열려있는 동안 db connection도 같이 열려있게 된다.

5. nginx

location /subscribe {
  proxy_pass http://127.0.0.1:8080/subscribe;
  proxy_set_header Connection '';
  proxy_set_header Cache-Control 'no-cache';
  proxy_set_header X-Accel-Buffering 'no';
  proxy_set_header Content-Type 'text/event-stream';
  proxy_buffering off;
  proxy_read_timeout 864000s;
  chunked_transfer_encoding on;
}

WAS Scale out에서 SSE 문제

  • 위에서 clinet와 서버가 SSEmitter 객체를 기반으로 1:1 연결을 수립한다고 의미했다. 여기서 WAS Scale out 또는 분산 서버에서 특정 유저에게 전달하고 싶을 때 SSemitter 객체가 없기 때문에 동기화 문제가 발생을 한다. 일반적으로 이 문제를 해결하기 위해서는 Pub/sub 패턴 또는 kafka, RabbitMQ등을 사용하여 메세지 방식으로 문제를 해결을 한다.

현업에서 만난 문제점 및 고민한 부분

하지만 현업에서 특정 기술을 도입은 신중하게 생각을 해야되고, 현실상 기술을 학습하여 트레이드 오프를 학습하고 적용을 해도 되겠다고 생각해도 팀원의 반대 또는 유지보수 관점에서 적용이 힘들 수 있다.

그래서 나는 현업에서 Redis는 유지보수 관점 및 팀원들은 새로운 기술적인 도입보다 Polling 방식으로 문제를 해결할 수 있다고 생각하여 Redis Pub/Sub을 통해 문제를 해결하지 못하였다. 그러면 나한테 남은 선택지는 결국 분산 서버의 환경에서는 MySQL을 이용한 방식만 존재를 한다. 이때 MySQL은 관계형 데이터베이스 이기 때문에 MySQL에서 Spring으로 요청을 날릴 수 없다.

그래서 나는 이 문제를 해결하기 위해서 처음에는
1. crontab을 이용한 반복적인 검사
2. spring 스케줄러를 통한 변경 감지
3. insert를 하였을 때 특정 분산 서버에 sse를 reload하게 api call을 발생하게 작성

  • 하지만 근본적으로 이 해결법의 문제는 불필요한 요청을 계속 보낸다. 또한 was가 cpu가 증가하면 ec2가 생성되는 auto scailing 구조에서는 api call 로직을 그때마다 수정해야 된다는 문제가 발생을 하였습니다.

그래서 나는 MySQL Binlog를 사용을 하여 문제를 했다. 결국 근본적으로 Mysql에서 특정 쿼리가 발생을 하였을 때 또는 상태가 변하였을 때를 스프링이 감지할 수 있게 된다면 문제가 해결할 수 있다고 생각했다.


MySQL CDC Binlog

MySQL CDC Binlog란

  • 데이터베이스에 Change Data Capture라는 변경 이벤트들이 단일 시퀀스 레코드 저장방식을 Binary 로그 파일에 저장하고 해당 이벤트들을 스트리밍 하는 방식으로 복제하는 기술이다.

단 Select는 저장하지 않기 때문에 이벤트를 탐지를 할 수 없다.

  • 이 바이너리 로그는 일반적으로 데이터베이스 복구 (PIT)에 사용하거나 레플리케이션에 로그를 읽어 릴레이 로그로 저장한 다음 복제를 진행하는데 사용한다.
  • 일반적으로 바이너리 로그는 사람이 알아볼 수 없다.
  • 얼핏보면 쿼리문이 보이지만 관련된 내용을 알아볼 수 없다. 이것을 mysqlbinlog 명령어를 통하여 볼 수 있는데 이 중에서 Query 부분이 있다. insert를 하였을때 발생하는 부분인데 이것을 스프링에서 이벤트 기반으로 탐지할 수 있다.
# 바이너리로 보기
cat [바이너리파일]

# mysqlbinlog
mysqlbinlog [바이너리파일] [옵션]

MySQL CDC Binlog을 이용하여 분산서버에서 SSE 문제해결

Binlog를 통해서 문제를 해결해야 되는 이유

[ 상황 설명 ]

  1. 정해진 규모의 인원의 트래픽을 예측할 수 있는 상황 (90% 이상의 정확성)
  2. 서버에 Crontab으로 서버의 부화가 많아서 리소스를 최적화가 필요함
  3. 기존에 리더보드를 setInterval로 10초마다 재시작을 하여 실시간을 처리를 하려고함 (이때 1번당 쿼리는 최소 3개)

[문제점 및 원인 분석]

  1. 리더보드는 24시간 열려있기 때문에 밤에도 불필요한 리소스를 요청한다.
  2. 리더보드에 다양한 Trigger, Change, Event로 인하여 프론트 Reload를 자주하면 서비스 신뢰성을 주지 못한다.
  3. SSE로 분산처리를 하면 SSEmitter 객체 동기화를 하지 못한다.

[해결방안]

  1. MySQL에서 특정 쿼리가 발생하면 Spring에서 이벤트로 감지를 할 수 있어야함.
  2. Spring에서 Mysql Binlog를 감지할 수 있는 오픈소스를 사용을 해야됨 mysql-binlog-connector-java
  3. Binlog 이벤트 흐름에 따른 비즈니스 분리가 필요함

BinLog 이벤트

  • GTID를 이용해서 binlog 이벤트를 가져온다. 하지만 binlog에는 다양한 이벤트가 있다. 기본적으로 table에 대한 정보, write, update, delte 이벤트가 있다.
  • 일반적으로 이벤트에서 Xid(Transaction Id = Commit) 이벤트까지 하나의 트랜잭션 단위로 가져옵니다.

binlog에는 select 이벤트는 없다.

  • 아래와 같이 하나의 insert를 하였을 때 각각의 이벤트에 따라 데이터가 저장을 확인할 수 있다. 이때 table_id는 자동으로 1씩 증가한다.
insert into board (title, content, writer) values ('111', '111', '111t');

구현


    • 일단 관련된 내용을 자세하게는 작성할 수 없지만 실제로 구현된 상황을 예시로 들면 분산 서버에서 sse 객체의 동기화를 하지 못하는 문제를 특정 테이터가 write 작업을 할때 binlog에 적재된다. 이걸 spring에서 mysql-binlog를 사용하여 이벤트를 감지할 수 있고 비즈니스 로직에 맞게 이벤트를 감지하여 spring event로 sse 이벤트를 발생시켜 클라이언트에게 데이터를 전달하는 방식으로 사용했다.
  • mysql에 insert를 하면 동시에 spring에서 관련된 데이터를 받을 수 잇다.
//binlog
implementation 'com.github.shyiko:mysql-binlog-connector-java:0.21.0'
@EnableConfigurationProperties(BinlogConfiguration.class)
@SpringBootApplication
public class MybatisApplication {
    public static void main(String[] args) {
        SpringApplication.run(MybatisApplication.class, args);
    }
 }
@Getter
@Setter
@ConfigurationProperties(prefix = "binlog")
public class BinlogConfiguration{
    /** DB host(ipv4) */
    @Value("${binlog.host}")
    private String host;
    /** DB port */
    @Value("${binlog.port}")
    private int port;
    /** DB username */
    @Value("${binlog.user}")
    private String user;
    /** DB password */
    @Value("${binlog.password}")
    private String password;

	
    @Bean
    BinaryLogClient binaryLogClient(){

        BinaryLogClient binaryLogClient = new BinaryLogClient(
                host,
                port,
                user,
                password);

        // 받은 데이터를 BYTE 로 표현
        EventDeserializer eventDeserializer = new EventDeserializer();
        eventDeserializer.setCompatibilityMode(
                EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG,
                EventDeserializer.CompatibilityMode.CHAR_AND_BINARY_AS_BYTE_ARRAY
        );
        binaryLogClient.setEventDeserializer(eventDeserializer);

        return binaryLogClient;
    }
}

BinlogEventRunner

@Component
public class BinlogEventRunner implements ApplicationRunner, BinaryLogClient.EventListener {
    private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
    private final BinaryLogClient binaryLogClient;
    private final ObjectMapper objectMapper;
    private final ReloadEventPublisher publisher;


    public BinlogEventRunner(BinaryLogClient binaryLogClient, ObjectMapper objectMapper, ReloadEventPublisher publisher) {
        this.binaryLogClient = binaryLogClient;
        this.objectMapper = objectMapper;
        this.publisher = publisher;
    }

    private static int TABLE_ID = 0;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        try {
            setBinlogClient();
        } catch (Exception e) {
            logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".run()] exitBinlogClient Exception : ", e);
            exitBinlogClient();
        }
    }
    @Override
    public void onEvent(Event event) {
        String eventStringInfo = "";
        String tableName = "";

        try {
            eventStringInfo = objectMapper.writeValueAsString(event);
        } catch (Exception e) {
            logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] exception : ", e);
        }

        if (event.getHeader().getEventType() == EventType.TABLE_MAP) {
            TableMapEventData tableMapEventData = event.getData();
            tableName = tableMapEventData.getTable();

            if (Objects.equals(tableMapEventData.getDatabase(), "db이름 작성") && Objects.equals(tableName, "테이블 이름")) {

                logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] tableMap : ", eventStringInfo);

                JsonNode rootNode = null;

                try {
                    rootNode = objectMapper.readTree(eventStringInfo);
                } catch (JsonProcessingException e) {
                    logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] exception : ", e);
                }
                JsonNode dataNode = rootNode.get("data");
                TABLE_ID = dataNode.get("tableId").asInt();
            }
        }

        if (event.getHeader().getEventType() == EventType.EXT_WRITE_ROWS) {
            JsonNode writeNode = null;
            try {
                String writeValues = objectMapper.writeValueAsString(event);
                writeNode = objectMapper.readTree(writeValues);
            } catch (Exception e) {
                logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] exception : ", e);
            }

            JsonNode dataNode = writeNode.get("data");
            int writeTableId = dataNode.get("tableId").asInt();


            if (writeTableId == TABLE_ID) {

                WriteRowsEventData data = event.getData();/                
                Serializable[] s_data = data.getRows().get(0);

                for (Serializable sDatum : s_data) {
                    if (sDatum instanceof byte[]) {
                        byte[] byteData = (byte[]) sDatum;
                        String stringData = new String(byteData, StandardCharsets.UTF_8);
                        logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] stringData : " + stringData);
                    }
                }


                try {

                    LadderCondition ladderConditionIsSessionN = ladderDAO.getLadderConditionIsSessionN();
                    logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".try onEvnet()] : ", objectMapper.writeValueAsString(ladderConditionIsSessionN));
                    publisher.sessionReloadEvent(objectMapper.writeValueAsString(ladderConditionIsSessionN));


                }catch (Exception e){
                    logger.info(" ====== /BinlogEventRunner [" + getClass().getSimpleName() + ".onEvent()] exception : ", e);
                }


            }
        }
    }

    private void setBinlogClient() throws IOException, TimeoutException {
        binaryLogClient.setServerId(binaryLogClient.getServerId() - 1);
        binaryLogClient.setKeepAlive(true);
        binaryLogClient.registerEventListener(this);
        binaryLogClient.connect(5000);
    }

    private void exitBinlogClient() throws IOException {
        try {
            binaryLogClient.unregisterEventListener(this);
        } finally {
            binaryLogClient.disconnect();
        }
    }
}

결론

[ 해결 ]

  • 이전에는 was에서 sse event가 발생을 하였을 때 클라이언트에 event를 was와 연결된 클라이언트만 전송을 하였지만 binlog를 통하여 다른 was에서도 실행하게 만들었다.

[ 부족한 부분 ]

  • 현재는 binlog를 통해서 전체 was가 event를 발생하는데 만약에 확장을 하게 된다면 이 부분은 추가적인 리펙토링을 해야된다. 현재 상황에서는 최대한의 성과를 만들기 위해서 노력을 하였지만 프로젝트가 끝나고 회고를 하면서 이 부분이 추후에 기술적인 부채가 되지 않을까 고민이 되었다.

  • 새로운 기술에 대한 trade off를 개인적으로 고려를 하여 현재 방식을 선택을 하였지만 더 좋은 방법, 비즈니스로 문제를 풀 수 없었을까? 아쉬움이 남는다.

참고


https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/

https://github.com/osheroff/mysql-binlog-connector-java

https://ridicorp.com/story/binlog-collector/

https://epozen-dt.github.io/SpringBoot-MySQL-CDC-Binlog/

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글